4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008 Mark Bergman bergman AT merctech DOT com
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 Lesser General Public License for more details.
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
23 from __future__ import with_statement
46 def getmtime_nothrow(path):
48 return os.path.getmtime(path)
53 def display_error_message(msg):
54 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
56 def close(dialog, response):
58 error_dialog.connect("response", close)
62 class Dialcentral(object):
65 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
66 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
67 '/usr/lib/dialcentral/dialcentral.glade',
79 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
82 self._initDone = False
83 self._connection = None
85 self._clipboard = gtk.clipboard_get()
87 self._credentials = ("", "")
88 self._selectedBackendId = self.NULL_BACKEND
89 self._defaultBackendId = self.GC_BACKEND
90 self._phoneBackends = None
92 self._accountViews = None
93 self._messagesViews = None
94 self._recentViews = None
95 self._contactsViews = None
96 self._alarmHandler = None
97 self._ledHandler = None
98 self._originalCurrentLabels = []
100 for path in self._glade_files:
101 if os.path.isfile(path):
102 self._widgetTree = gtk.glade.XML(path)
105 display_error_message("Cannot find dialcentral.glade")
109 self._window = self._widgetTree.get_widget("mainWindow")
110 self._notebook = self._widgetTree.get_widget("notebook")
111 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
112 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
115 self._isFullScreen = False
116 if hildon is not None:
117 self._app = hildon.Program()
118 oldWindow = self._window
119 self._window = hildon.Window()
120 oldWindow.get_child().reparent(self._window)
121 self._app.add_window(self._window)
124 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
125 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
126 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
128 warnings.warn(e.message)
129 for scrollingWidget in (
130 'recent_scrolledwindow',
131 'message_scrolledwindow',
132 'contacts_scrolledwindow',
133 "phoneSelectionMessage_scrolledwindow",
134 "phonetypes_scrolledwindow",
135 "smsMessage_scrolledwindow",
136 "smsMessage_scrolledEntry",
138 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget(scrollingWidget), True)
140 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
142 for child in gtkMenu.get_children():
144 self._window.set_menu(menu)
147 self._window.connect("key-press-event", self._on_key_press)
148 self._window.connect("window-state-event", self._on_window_state_change)
150 pass # warnings.warn("No Hildon", UserWarning, 2)
152 # If under hildon, rely on the application name being shown
154 self._window.set_title("%s" % constants.__pretty_app_name__)
157 "on_dialpad_quit": self._on_close,
159 self._widgetTree.signal_autoconnect(callbackMapping)
161 self._window.connect("destroy", self._on_close)
162 self._window.set_default_size(800, 300)
163 self._window.show_all()
165 self._loginSink = gtk_toolbox.threaded_stage(
168 gtk_toolbox.null_sink(),
172 backgroundSetup = threading.Thread(target=self._idle_setup)
173 backgroundSetup.setDaemon(True)
174 backgroundSetup.start()
176 def _idle_setup(self):
178 If something can be done after the UI loads, push it here so it's not blocking the UI
181 # Barebones UI handlers
185 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
186 with gtk_toolbox.gtk_lock():
187 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
188 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
189 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
190 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
191 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
193 self._dialpads[self._selectedBackendId].enable()
194 self._accountViews[self._selectedBackendId].enable()
195 self._recentViews[self._selectedBackendId].enable()
196 self._messagesViews[self._selectedBackendId].enable()
197 self._contactsViews[self._selectedBackendId].enable()
199 # Setup maemo specifics
206 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
207 device = osso.DeviceState(self._osso)
208 device.set_device_state_callback(self._on_device_state_change, 0)
210 pass # warnings.warn("No OSSO", UserWarning, 2)
214 self._alarmHandler = alarm_handler.AlarmHandler()
218 with gtk_toolbox.gtk_lock():
219 self._errorDisplay.push_exception()
221 if hildon is not None:
223 self._ledHandler = led_handler.LedHandler()
225 # Setup maemo specifics
230 self._connection = None
231 if conic is not None:
232 self._connection = conic.Connection()
233 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
234 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
236 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
238 # Setup costly backends
245 os.makedirs(constants._data_path_)
249 gcCookiePath = os.path.join(constants._data_path_, "gc_cookies.txt")
250 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
251 self._defaultBackendId = self._guess_preferred_backend((
252 (self.GC_BACKEND, gcCookiePath),
253 (self.GV_BACKEND, gvCookiePath),
256 self._phoneBackends.update({
257 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
258 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
260 with gtk_toolbox.gtk_lock():
261 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
262 unifiedDialpad.set_number("")
263 self._dialpads.update({
264 self.GC_BACKEND: unifiedDialpad,
265 self.GV_BACKEND: unifiedDialpad,
267 self._accountViews.update({
268 self.GC_BACKEND: gc_views.AccountInfo(
269 self._widgetTree, self._phoneBackends[self.GC_BACKEND], None, self._errorDisplay
271 self.GV_BACKEND: gc_views.AccountInfo(
272 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
275 self._accountViews[self.GC_BACKEND].save_everything = lambda *args: None
276 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
277 self._recentViews.update({
278 self.GC_BACKEND: gc_views.RecentCallsView(
279 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
281 self.GV_BACKEND: gc_views.RecentCallsView(
282 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
285 self._messagesViews.update({
286 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
287 self.GV_BACKEND: gc_views.MessagesView(
288 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
291 self._contactsViews.update({
292 self.GC_BACKEND: gc_views.ContactsView(
293 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
295 self.GV_BACKEND: gc_views.ContactsView(
296 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
300 fsContactsPath = os.path.join(constants._data_path_, "contacts")
301 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
302 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
303 self._dialpads[backendId].number_selected = self._select_action
304 self._recentViews[backendId].number_selected = self._select_action
305 self._messagesViews[backendId].number_selected = self._select_action
306 self._contactsViews[backendId].number_selected = self._select_action
309 self._phoneBackends[backendId],
312 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
313 self._contactsViews[backendId].append(mergedBook)
314 self._contactsViews[backendId].extend(addressBooks)
315 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
318 "on_paste": self._on_paste,
319 "on_refresh": self._on_menu_refresh,
320 "on_clearcookies_clicked": self._on_clearcookies_clicked,
321 "on_notebook_switch_page": self._on_notebook_switch_page,
322 "on_about_activate": self._on_about_activate,
324 self._widgetTree.signal_autoconnect(callbackMapping)
326 with gtk_toolbox.gtk_lock():
327 self._originalCurrentLabels = [
328 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
329 for pageIndex in xrange(self._notebook.get_n_pages())
331 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
332 self._notebookTapHandler.enable()
333 self._notebookTapHandler.on_tap = self._reset_tab_refresh
334 self._notebookTapHandler.on_hold = self._on_tab_refresh
335 self._notebookTapHandler.on_holding = self._set_tab_refresh
336 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
338 self._initDone = True
340 config = ConfigParser.SafeConfigParser()
341 config.read(constants._user_settings_)
342 with gtk_toolbox.gtk_lock():
343 self.load_settings(config)
345 self._spawn_attempt_login(2)
347 with gtk_toolbox.gtk_lock():
348 self._errorDisplay.push_exception()
350 def attempt_login(self, numOfAttempts = 10, force = False):
352 @todo Handle user notification better like attempting to login and failed login
354 @note This must be run outside of the UI lock
357 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
358 assert self._initDone, "Attempting login before app is fully loaded"
360 serviceId = self.NULL_BACKEND
364 self.refresh_session()
365 serviceId = self._defaultBackendId
367 except StandardError, e:
368 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
371 loggedIn, serviceId = self._login_by_user(numOfAttempts)
373 with gtk_toolbox.gtk_lock():
374 self._change_loggedin_status(serviceId)
375 except StandardError, e:
376 with gtk_toolbox.gtk_lock():
377 self._errorDisplay.push_exception()
379 def _spawn_attempt_login(self, *args):
380 self._loginSink.send(args)
382 def refresh_session(self):
384 @note Thread agnostic
386 assert self._initDone, "Attempting login before app is fully loaded"
390 loggedIn = self._login_by_cookie()
392 loggedIn = self._login_by_settings()
395 raise RuntimeError("Login Failed")
397 def _login_by_cookie(self):
399 @note Thread agnostic
401 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
404 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
409 def _login_by_settings(self):
411 @note Thread agnostic
413 username, password = self._credentials
414 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
416 self._credentials = username, password
418 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
423 def _login_by_user(self, numOfAttempts):
425 @note This must be run outside of the UI lock
427 loggedIn, (username, password) = False, self._credentials
428 tmpServiceId = self.NULL_BACKEND
429 for attemptCount in xrange(numOfAttempts):
432 availableServices = (
433 (self.GV_BACKEND, "Google Voice"),
434 (self.GC_BACKEND, "Grand Central"),
436 with gtk_toolbox.gtk_lock():
437 credentials = self._credentialsDialog.request_credentials_from(
438 availableServices, defaultCredentials = self._credentials
440 tmpServiceId, username, password = credentials
441 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
444 serviceId = tmpServiceId
445 self._credentials = username, password
447 "Logged into %r through user request" % self._phoneBackends[serviceId],
451 serviceId = self.NULL_BACKEND
453 return loggedIn, serviceId
455 def _select_action(self, action, number, message):
456 self.refresh_session()
457 if action == "select":
458 self._dialpads[self._selectedBackendId].set_number(number)
459 self._notebook.set_current_page(self.KEYPAD_TAB)
460 elif action == "dial":
461 self._on_dial_clicked(number)
462 elif action == "sms":
463 self._on_sms_clicked(number, message)
465 assert False, "Unknown action: %s" % action
467 def _change_loggedin_status(self, newStatus):
468 oldStatus = self._selectedBackendId
469 if oldStatus == newStatus:
472 self._dialpads[oldStatus].disable()
473 self._accountViews[oldStatus].disable()
474 self._recentViews[oldStatus].disable()
475 self._messagesViews[oldStatus].disable()
476 self._contactsViews[oldStatus].disable()
478 self._dialpads[newStatus].enable()
479 self._accountViews[newStatus].enable()
480 self._recentViews[newStatus].enable()
481 self._messagesViews[newStatus].enable()
482 self._contactsViews[newStatus].enable()
484 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
485 self._phoneBackends[self._selectedBackendId].set_sane_callback()
486 self._accountViews[self._selectedBackendId].update()
488 self._selectedBackendId = newStatus
490 def load_settings(self, config):
495 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
497 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
498 for i in xrange(len(self._credentials))
501 base64.b64decode(blob)
504 self._credentials = tuple(creds)
506 if self._alarmHandler is not None:
507 self._alarmHandler.load_settings(config, "alarm")
508 except ConfigParser.NoOptionError, e:
510 "Settings file %s is missing section %s" % (
511 constants._user_settings_,
516 except ConfigParser.NoSectionError, e:
518 "Settings file %s is missing section %s" % (
519 constants._user_settings_,
525 for backendId, view in itertools.chain(
526 self._dialpads.iteritems(),
527 self._accountViews.iteritems(),
528 self._messagesViews.iteritems(),
529 self._recentViews.iteritems(),
530 self._contactsViews.iteritems(),
532 sectionName = "%s - %s" % (backendId, view.name())
534 view.load_settings(config, sectionName)
535 except ConfigParser.NoOptionError, e:
537 "Settings file %s is missing section %s" % (
538 constants._user_settings_,
543 except ConfigParser.NoSectionError, e:
545 "Settings file %s is missing section %s" % (
546 constants._user_settings_,
552 def save_settings(self, config):
554 @note Thread Agnostic
556 config.add_section(constants.__pretty_app_name__)
557 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
558 for i, value in enumerate(self._credentials):
559 blob = base64.b64encode(value)
560 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
561 config.add_section("alarm")
562 if self._alarmHandler is not None:
563 self._alarmHandler.save_settings(config, "alarm")
565 for backendId, view in itertools.chain(
566 self._dialpads.iteritems(),
567 self._accountViews.iteritems(),
568 self._messagesViews.iteritems(),
569 self._recentViews.iteritems(),
570 self._contactsViews.iteritems(),
572 sectionName = "%s - %s" % (backendId, view.name())
573 config.add_section(sectionName)
574 view.save_settings(config, sectionName)
576 def _guess_preferred_backend(self, backendAndCookiePaths):
578 (getmtime_nothrow(path), backendId, path)
579 for backendId, path in backendAndCookiePaths
581 modTimeAndPath.sort()
582 return modTimeAndPath[-1][1]
584 def _save_settings(self):
586 @note Thread Agnostic
588 config = ConfigParser.SafeConfigParser()
589 self.save_settings(config)
590 with open(constants._user_settings_, "wb") as configFile:
591 config.write(configFile)
593 def _refresh_active_tab(self):
594 pageIndex = self._notebook.get_current_page()
595 if pageIndex == self.CONTACTS_TAB:
596 self._contactsViews[self._selectedBackendId].update(force=True)
597 elif pageIndex == self.RECENT_TAB:
598 self._recentViews[self._selectedBackendId].update(force=True)
599 elif pageIndex == self.MESSAGES_TAB:
600 self._messagesViews[self._selectedBackendId].update(force=True)
602 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
603 if self._ledHandler is not None:
604 self._ledHandler.off()
606 def _on_close(self, *args, **kwds):
608 if self._osso is not None:
612 self._save_settings()
616 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
618 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
619 For system_inactivity, we have no background tasks to pause
621 @note Hildon specific
624 for backendId in self.BACKENDS:
625 self._phoneBackends[backendId].clear_caches()
626 self._contactsViews[self._selectedBackendId].clear_caches()
629 if save_unsaved_data or shutdown:
630 self._save_settings()
632 def _on_connection_change(self, connection, event, magicIdentifier):
634 @note Hildon specific
638 status = event.get_status()
639 error = event.get_error()
640 iap_id = event.get_iap_id()
641 bearer = event.get_bearer_type()
643 if status == conic.STATUS_CONNECTED:
645 self._spawn_attempt_login(2)
646 elif status == conic.STATUS_DISCONNECTED:
648 self._defaultBackendId = self._selectedBackendId
649 self._change_loggedin_status(self.NULL_BACKEND)
651 def _on_window_state_change(self, widget, event, *args):
653 @note Hildon specific
655 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
656 self._isFullScreen = True
658 self._isFullScreen = False
660 def _on_key_press(self, widget, event, *args):
662 @note Hildon specific
664 if event.keyval == gtk.keysyms.F6:
665 if self._isFullScreen:
666 self._window.unfullscreen()
668 self._window.fullscreen()
670 def _on_clearcookies_clicked(self, *args):
671 self._phoneBackends[self._selectedBackendId].logout()
672 self._accountViews[self._selectedBackendId].clear()
673 self._recentViews[self._selectedBackendId].clear()
674 self._messagesViews[self._selectedBackendId].clear()
675 self._contactsViews[self._selectedBackendId].clear()
676 self._change_loggedin_status(self.NULL_BACKEND)
678 self._spawn_attempt_login(2, True)
680 def _on_notebook_switch_page(self, notebook, page, pageIndex):
681 self._reset_tab_refresh()
683 didRecentUpdate = False
684 didMessagesUpdate = False
686 if pageIndex == self.RECENT_TAB:
687 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
688 elif pageIndex == self.MESSAGES_TAB:
689 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
690 elif pageIndex == self.CONTACTS_TAB:
691 self._contactsViews[self._selectedBackendId].update()
692 elif pageIndex == self.ACCOUNT_TAB:
693 self._accountViews[self._selectedBackendId].update()
695 if didRecentUpdate or didMessagesUpdate:
696 if self._ledHandler is not None:
697 self._ledHandler.off()
699 def _set_tab_refresh(self, *args):
700 pageIndex = self._notebook.get_current_page()
701 child = self._notebook.get_nth_page(pageIndex)
702 self._notebook.get_tab_label(child).set_text("Refresh?")
705 def _reset_tab_refresh(self, *args):
706 pageIndex = self._notebook.get_current_page()
707 child = self._notebook.get_nth_page(pageIndex)
708 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
711 def _on_tab_refresh(self, *args):
712 self._refresh_active_tab()
713 self._reset_tab_refresh()
716 def _on_sms_clicked(self, number, message):
717 assert number, "No number specified"
718 assert message, "Empty message"
720 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
721 except StandardError, e:
723 self._errorDisplay.push_exception()
727 self._errorDisplay.push_message(
728 "Backend link with grandcentral is not working, please try again"
734 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
736 except StandardError, e:
737 self._errorDisplay.push_exception()
738 except ValueError, e:
739 self._errorDisplay.push_exception()
742 self._dialpads[self._selectedBackendId].clear()
744 def _on_dial_clicked(self, number):
745 assert number, "No number to call"
747 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
748 except StandardError, e:
750 self._errorDisplay.push_exception()
754 self._errorDisplay.push_message(
755 "Backend link with grandcentral is not working, please try again"
761 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
762 self._phoneBackends[self._selectedBackendId].dial(number)
764 except StandardError, e:
765 self._errorDisplay.push_exception()
766 except ValueError, e:
767 self._errorDisplay.push_exception()
770 self._dialpads[self._selectedBackendId].clear()
772 def _on_menu_refresh(self, *args):
773 self._refresh_active_tab()
775 def _on_paste(self, *args):
776 contents = self._clipboard.wait_for_text()
777 self._dialpads[self._selectedBackendId].set_number(contents)
779 def _on_about_activate(self, *args):
780 dlg = gtk.AboutDialog()
781 dlg.set_name(constants.__pretty_app_name__)
782 dlg.set_version(constants.__version__)
783 dlg.set_copyright("Copyright 2008 - LGPL")
784 dlg.set_comments("Dialcentral is a touch screen enhanced interface to your GoogleVoice/Grandcentral account. This application is not affiliated with Google in any way")
785 dlg.set_website("http://gc-dialer.garage.maemo.org/")
786 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
794 failureCount, testCount = doctest.testmod()
796 print "Tests Successful"
803 _lock_file = os.path.join(constants._data_path_, ".lock")
804 #with gtk_toolbox.flock(_lock_file, 0):
805 gtk.gdk.threads_init()
807 if hildon is not None:
808 gtk.set_application_name(constants.__pretty_app_name__)
809 handle = Dialcentral()
813 class DummyOptions(object):
819 if __name__ == "__main__":
820 if len(sys.argv) > 1:
826 if optparse is not None:
827 parser = optparse.OptionParser()
828 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
829 (commandOptions, commandArgs) = parser.parse_args()
831 commandOptions = DummyOptions()
834 if commandOptions.test: