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
21 @todo Figure out how to integrate with the Maemo contacts app
22 @todo Look into an actor system
23 @bug Session timeouts are bad, possible solutions:
24 @li For every X minutes, if logged in, attempt login
25 @li Restructure so code handling login/dial/sms is beneath everything else and login attempts are made if those fail
26 @todo Can't text from dialpad (so can't do any arbitrary number texts)
27 @todo Add logging support to make debugging issues for people a lot easier
31 from __future__ import with_statement
55 def getmtime_nothrow(path):
57 return os.path.getmtime(path)
62 def display_error_message(msg):
63 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
65 def close(dialog, response):
67 error_dialog.connect("response", close)
71 class Dialcentral(object):
74 '/usr/lib/dialcentral/dialcentral.glade',
75 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
76 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
88 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
90 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
91 _user_settings = "%s/settings.ini" % _data_path
94 self._initDone = False
95 self._connection = None
97 self._clipboard = gtk.clipboard_get()
99 self._credentials = ("", "")
100 self._selectedBackendId = self.NULL_BACKEND
101 self._defaultBackendId = self.GC_BACKEND
102 self._phoneBackends = None
103 self._dialpads = None
104 self._accountViews = None
105 self._messagesViews = None
106 self._recentViews = None
107 self._contactsViews = None
108 self._originalCurrentLabels = []
110 for path in self._glade_files:
111 if os.path.isfile(path):
112 self._widgetTree = gtk.glade.XML(path)
115 display_error_message("Cannot find dialcentral.glade")
119 self._window = self._widgetTree.get_widget("mainWindow")
120 self._notebook = self._widgetTree.get_widget("notebook")
121 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
122 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
125 self._isFullScreen = False
126 if hildon is not None:
127 self._app = hildon.Program()
128 oldWindow = self._window
129 self._window = hildon.Window()
130 oldWindow.get_child().reparent(self._window)
131 self._app.add_window(self._window)
134 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
135 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
136 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
138 warnings.warn(e.message)
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
141 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
143 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
145 for child in gtkMenu.get_children():
147 self._window.set_menu(menu)
150 self._window.connect("key-press-event", self._on_key_press)
151 self._window.connect("window-state-event", self._on_window_state_change)
153 pass # warnings.warn("No Hildon", UserWarning, 2)
155 # If under hildon, rely on the application name being shown
157 self._window.set_title("%s" % constants.__pretty_app_name__)
160 "on_dialpad_quit": self._on_close,
162 self._widgetTree.signal_autoconnect(callbackMapping)
164 self._window.connect("destroy", self._on_close)
165 self._window.set_default_size(800, 300)
166 self._window.show_all()
168 self._loginSink = gtk_toolbox.threaded_stage(
171 gtk_toolbox.null_sink(),
175 backgroundSetup = threading.Thread(target=self._idle_setup)
176 backgroundSetup.setDaemon(True)
177 backgroundSetup.start()
179 def _idle_setup(self):
181 If something can be done after the UI loads, push it here so it's not blocking the UI
184 # Barebones UI handlers
188 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
189 with gtk_toolbox.gtk_lock():
190 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
191 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
192 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
193 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
194 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
196 self._dialpads[self._selectedBackendId].enable()
197 self._accountViews[self._selectedBackendId].enable()
198 self._recentViews[self._selectedBackendId].enable()
199 self._messagesViews[self._selectedBackendId].enable()
200 self._contactsViews[self._selectedBackendId].enable()
202 # Setup maemo specifics
209 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
210 device = osso.DeviceState(self._osso)
211 device.set_device_state_callback(self._on_device_state_change, 0)
213 pass # warnings.warn("No OSSO", UserWarning, 2)
215 # Setup maemo specifics
220 self._connection = None
221 if conic is not None:
222 self._connection = conic.Connection()
223 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
224 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
226 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
228 # Setup costly backends
236 os.makedirs(self._data_path)
240 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
241 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
242 self._defaultBackendId = self._guess_preferred_backend((
243 (self.GC_BACKEND, gcCookiePath),
244 (self.GV_BACKEND, gvCookiePath),
247 self._phoneBackends.update({
248 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
249 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
251 with gtk_toolbox.gtk_lock():
252 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
253 unifiedDialpad.set_number("")
254 self._dialpads.update({
255 self.GC_BACKEND: unifiedDialpad,
256 self.GV_BACKEND: unifiedDialpad,
258 self._accountViews.update({
259 self.GC_BACKEND: gc_views.AccountInfo(
260 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
262 self.GV_BACKEND: gc_views.AccountInfo(
263 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
266 self._recentViews.update({
267 self.GC_BACKEND: gc_views.RecentCallsView(
268 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
270 self.GV_BACKEND: gc_views.RecentCallsView(
271 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
274 self._messagesViews.update({
275 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
276 self.GV_BACKEND: gc_views.MessagesView(
277 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
280 self._contactsViews.update({
281 self.GC_BACKEND: gc_views.ContactsView(
282 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
284 self.GV_BACKEND: gc_views.ContactsView(
285 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
289 evoBackend = evo_backend.EvolutionAddressBook()
290 fsContactsPath = os.path.join(self._data_path, "contacts")
291 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
292 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
293 self._dialpads[backendId].number_selected = self._select_action
294 self._recentViews[backendId].number_selected = self._select_action
295 self._messagesViews[backendId].number_selected = self._select_action
296 self._contactsViews[backendId].number_selected = self._select_action
299 self._phoneBackends[backendId],
303 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
304 self._contactsViews[backendId].append(mergedBook)
305 self._contactsViews[backendId].extend(addressBooks)
306 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
309 "on_paste": self._on_paste,
310 "on_refresh": self._on_menu_refresh,
311 "on_clearcookies_clicked": self._on_clearcookies_clicked,
312 "on_notebook_switch_page": self._on_notebook_switch_page,
313 "on_about_activate": self._on_about_activate,
315 self._widgetTree.signal_autoconnect(callbackMapping)
317 with gtk_toolbox.gtk_lock():
318 self._originalCurrentLabels = [
319 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
320 for pageIndex in xrange(self._notebook.get_n_pages())
322 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
323 self._notebookTapHandler.enable()
324 self._notebookTapHandler.on_tap = self._reset_tab_refresh
325 self._notebookTapHandler.on_hold = self._on_tab_refresh
326 self._notebookTapHandler.on_holding = self._set_tab_refresh
327 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
329 self._initDone = True
331 config = ConfigParser.SafeConfigParser()
332 config.read(self._user_settings)
333 with gtk_toolbox.gtk_lock():
334 self.load_settings(config)
336 self._spawn_attempt_login(2)
338 with gtk_toolbox.gtk_lock():
339 self._errorDisplay.push_exception(e)
341 def attempt_login(self, numOfAttempts = 10, force = False):
343 @todo Handle user notification better like attempting to login and failed login
345 @note This must be run outside of the UI lock
348 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
349 assert self._initDone, "Attempting login before app is fully loaded"
351 serviceId = self.NULL_BACKEND
355 self.refresh_session()
356 serviceId = self._defaultBackendId
358 except StandardError, e:
359 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
362 loggedIn, serviceId = self._login_by_user(numOfAttempts)
364 with gtk_toolbox.gtk_lock():
365 self._change_loggedin_status(serviceId)
366 except StandardError, e:
367 with gtk_toolbox.gtk_lock():
368 self._errorDisplay.push_exception(e)
370 def _spawn_attempt_login(self, *args):
371 self._loginSink.send(args)
373 def refresh_session(self):
375 @note Thread agnostic
377 assert self._initDone, "Attempting login before app is fully loaded"
381 loggedIn = self._login_by_cookie()
383 loggedIn = self._login_by_settings()
386 raise RuntimeError("Login Failed")
388 def _login_by_cookie(self):
390 @note Thread agnostic
392 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
395 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
400 def _login_by_settings(self):
402 @note Thread agnostic
404 username, password = self._credentials
405 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
407 self._credentials = username, password
409 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
414 def _login_by_user(self, numOfAttempts):
416 @note This must be run outside of the UI lock
418 loggedIn, (username, password) = False, self._credentials
419 tmpServiceId = self.NULL_BACKEND
420 for attemptCount in xrange(numOfAttempts):
423 availableServices = (
424 (self.GV_BACKEND, "Google Voice"),
425 (self.GC_BACKEND, "Grand Central"),
427 with gtk_toolbox.gtk_lock():
428 credentials = self._credentialsDialog.request_credentials_from(
429 availableServices, defaultCredentials = self._credentials
431 tmpServiceId, username, password = credentials
432 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
435 serviceId = tmpServiceId
436 self._credentials = username, password
438 "Logged into %r through user request" % self._phoneBackends[serviceId],
442 serviceId = self.NULL_BACKEND
444 return loggedIn, serviceId
446 def _select_action(self, action, number, message):
447 self.refresh_session()
448 if action == "select":
449 self._dialpads[self._selectedBackendId].set_number(number)
450 self._notebook.set_current_page(self.KEYPAD_TAB)
451 elif action == "dial":
452 self._on_dial_clicked(number)
453 elif action == "sms":
454 self._on_sms_clicked(number, message)
456 assert False, "Unknown action: %s" % action
458 def _change_loggedin_status(self, newStatus):
459 oldStatus = self._selectedBackendId
460 if oldStatus == newStatus:
463 self._dialpads[oldStatus].disable()
464 self._accountViews[oldStatus].disable()
465 self._recentViews[oldStatus].disable()
466 self._messagesViews[oldStatus].disable()
467 self._contactsViews[oldStatus].disable()
469 self._dialpads[newStatus].enable()
470 self._accountViews[newStatus].enable()
471 self._recentViews[newStatus].enable()
472 self._messagesViews[newStatus].enable()
473 self._contactsViews[newStatus].enable()
475 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
476 self._phoneBackends[self._selectedBackendId].set_sane_callback()
477 self._accountViews[self._selectedBackendId].update()
479 self._selectedBackendId = newStatus
481 def load_settings(self, config):
486 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
488 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
489 for i in xrange(len(self._credentials))
492 base64.b64decode(blob)
495 self._credentials = tuple(creds)
496 except ConfigParser.NoSectionError, e:
498 "Settings file %s is missing section %s" % (
505 for backendId, view in itertools.chain(
506 self._dialpads.iteritems(),
507 self._accountViews.iteritems(),
508 self._messagesViews.iteritems(),
509 self._recentViews.iteritems(),
510 self._contactsViews.iteritems(),
512 sectionName = "%s - %s" % (backendId, view.name())
514 view.load_settings(config, sectionName)
515 except ConfigParser.NoSectionError, e:
517 "Settings file %s is missing section %s" % (
524 def save_settings(self, config):
526 @note Thread Agnostic
528 config.add_section(constants.__pretty_app_name__)
529 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
530 for i, value in enumerate(self._credentials):
531 blob = base64.b64encode(value)
532 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
533 for backendId, view in itertools.chain(
534 self._dialpads.iteritems(),
535 self._accountViews.iteritems(),
536 self._messagesViews.iteritems(),
537 self._recentViews.iteritems(),
538 self._contactsViews.iteritems(),
540 sectionName = "%s - %s" % (backendId, view.name())
541 config.add_section(sectionName)
542 view.save_settings(config, sectionName)
544 def _guess_preferred_backend(self, backendAndCookiePaths):
546 (getmtime_nothrow(path), backendId, path)
547 for backendId, path in backendAndCookiePaths
549 modTimeAndPath.sort()
550 return modTimeAndPath[-1][1]
552 def _save_settings(self):
554 @note Thread Agnostic
556 config = ConfigParser.SafeConfigParser()
557 self.save_settings(config)
558 with open(self._user_settings, "wb") as configFile:
559 config.write(configFile)
561 def _refresh_active_tab(self):
562 pageIndex = self._notebook.get_current_page()
563 if pageIndex == self.CONTACTS_TAB:
564 self._contactsViews[self._selectedBackendId].update(force=True)
565 elif pageIndex == self.RECENT_TAB:
566 self._recentViews[self._selectedBackendId].update(force=True)
567 elif pageIndex == self.MESSAGES_TAB:
568 self._messagesViews[self._selectedBackendId].update(force=True)
570 def _on_close(self, *args, **kwds):
572 if self._osso is not None:
576 self._save_settings()
580 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
582 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
583 For system_inactivity, we have no background tasks to pause
585 @note Hildon specific
588 for backendId in self.BACKENDS:
589 self._phoneBackends[backendId].clear_caches()
590 self._contactsViews[self._selectedBackendId].clear_caches()
593 if save_unsaved_data or shutdown:
594 self._save_settings()
596 def _on_connection_change(self, connection, event, magicIdentifier):
598 @note Hildon specific
602 status = event.get_status()
603 error = event.get_error()
604 iap_id = event.get_iap_id()
605 bearer = event.get_bearer_type()
607 if status == conic.STATUS_CONNECTED:
609 self._spawn_attempt_login(2)
610 elif status == conic.STATUS_DISCONNECTED:
612 self._defaultBackendId = self._selectedBackendId
613 self._change_loggedin_status(self.NULL_BACKEND)
615 def _on_window_state_change(self, widget, event, *args):
617 @note Hildon specific
619 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
620 self._isFullScreen = True
622 self._isFullScreen = False
624 def _on_key_press(self, widget, event, *args):
626 @note Hildon specific
628 if event.keyval == gtk.keysyms.F6:
629 if self._isFullScreen:
630 self._window.unfullscreen()
632 self._window.fullscreen()
634 def _on_clearcookies_clicked(self, *args):
635 self._phoneBackends[self._selectedBackendId].logout()
636 self._accountViews[self._selectedBackendId].clear()
637 self._recentViews[self._selectedBackendId].clear()
638 self._messagesViews[self._selectedBackendId].clear()
639 self._contactsViews[self._selectedBackendId].clear()
640 self._change_loggedin_status(self.NULL_BACKEND)
642 self._spawn_attempt_login(2, True)
644 def _on_notebook_switch_page(self, notebook, page, pageIndex):
645 self._reset_tab_refresh()
646 if pageIndex == self.RECENT_TAB:
647 self._recentViews[self._selectedBackendId].update()
648 elif pageIndex == self.MESSAGES_TAB:
649 self._messagesViews[self._selectedBackendId].update()
650 elif pageIndex == self.CONTACTS_TAB:
651 self._contactsViews[self._selectedBackendId].update()
652 elif pageIndex == self.ACCOUNT_TAB:
653 self._accountViews[self._selectedBackendId].update()
655 def _set_tab_refresh(self, *args):
656 pageIndex = self._notebook.get_current_page()
657 child = self._notebook.get_nth_page(pageIndex)
658 self._notebook.get_tab_label(child).set_text("Refresh?")
661 def _reset_tab_refresh(self, *args):
662 pageIndex = self._notebook.get_current_page()
663 child = self._notebook.get_nth_page(pageIndex)
664 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
667 def _on_tab_refresh(self, *args):
668 self._refresh_active_tab()
669 self._reset_tab_refresh()
672 def _on_sms_clicked(self, number, message):
673 assert number, "No number specified"
674 assert message, "Empty message"
676 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
677 except StandardError, e:
679 self._errorDisplay.push_exception(e)
683 self._errorDisplay.push_message(
684 "Backend link with grandcentral is not working, please try again"
690 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
692 except StandardError, e:
693 self._errorDisplay.push_exception(e)
694 except ValueError, e:
695 self._errorDisplay.push_exception(e)
697 def _on_dial_clicked(self, number):
698 assert number, "No number to call"
700 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
701 except StandardError, e:
703 self._errorDisplay.push_exception(e)
707 self._errorDisplay.push_message(
708 "Backend link with grandcentral is not working, please try again"
714 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
715 self._phoneBackends[self._selectedBackendId].dial(number)
717 except StandardError, e:
718 self._errorDisplay.push_exception(e)
719 except ValueError, e:
720 self._errorDisplay.push_exception(e)
723 self._dialpads[self._selectedBackendId].clear()
725 def _on_menu_refresh(self, *args):
726 self._refresh_active_tab()
728 def _on_paste(self, *args):
729 contents = self._clipboard.wait_for_text()
730 self._dialpads[self._selectedBackendId].set_number(contents)
732 def _on_about_activate(self, *args):
733 dlg = gtk.AboutDialog()
734 dlg.set_name(constants.__pretty_app_name__)
735 dlg.set_version(constants.__version__)
736 dlg.set_copyright("Copyright 2008 - LGPL")
737 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")
738 dlg.set_website("http://gc-dialer.garage.maemo.org/")
739 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
747 failureCount, testCount = doctest.testmod()
749 print "Tests Successful"
756 gtk.gdk.threads_init()
757 if hildon is not None:
758 gtk.set_application_name(constants.__pretty_app_name__)
759 handle = Dialcentral()
763 class DummyOptions(object):
769 if __name__ == "__main__":
770 if len(sys.argv) > 1:
776 if optparse is not None:
777 parser = optparse.OptionParser()
778 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
779 (commandOptions, commandArgs) = parser.parse_args()
781 commandOptions = DummyOptions()
784 if commandOptions.test: