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
54 def getmtime_nothrow(path):
56 return os.path.getmtime(path)
61 def display_error_message(msg):
62 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
64 def close(dialog, response):
66 error_dialog.connect("response", close)
70 class Dialcentral(object):
73 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
74 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
75 '/usr/lib/dialcentral/dialcentral.glade',
87 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
90 self._initDone = False
91 self._connection = None
93 self._clipboard = gtk.clipboard_get()
95 self._credentials = ("", "")
96 self._selectedBackendId = self.NULL_BACKEND
97 self._defaultBackendId = self.GC_BACKEND
98 self._phoneBackends = None
100 self._accountViews = None
101 self._messagesViews = None
102 self._recentViews = None
103 self._contactsViews = None
104 self._alarmHandler = None
105 self._ledHandler = None
106 self._originalCurrentLabels = []
108 for path in self._glade_files:
109 if os.path.isfile(path):
110 self._widgetTree = gtk.glade.XML(path)
113 display_error_message("Cannot find dialcentral.glade")
117 self._window = self._widgetTree.get_widget("mainWindow")
118 self._notebook = self._widgetTree.get_widget("notebook")
119 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
120 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
123 self._isFullScreen = False
124 if hildon is not None:
125 self._app = hildon.Program()
126 oldWindow = self._window
127 self._window = hildon.Window()
128 oldWindow.get_child().reparent(self._window)
129 self._app.add_window(self._window)
132 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
133 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
134 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
136 warnings.warn(e.message)
137 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
138 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
141 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
143 for child in gtkMenu.get_children():
145 self._window.set_menu(menu)
148 self._window.connect("key-press-event", self._on_key_press)
149 self._window.connect("window-state-event", self._on_window_state_change)
151 pass # warnings.warn("No Hildon", UserWarning, 2)
153 # If under hildon, rely on the application name being shown
155 self._window.set_title("%s" % constants.__pretty_app_name__)
158 "on_dialpad_quit": self._on_close,
160 self._widgetTree.signal_autoconnect(callbackMapping)
162 self._window.connect("destroy", self._on_close)
163 self._window.set_default_size(800, 300)
164 self._window.show_all()
166 self._loginSink = gtk_toolbox.threaded_stage(
169 gtk_toolbox.null_sink(),
173 backgroundSetup = threading.Thread(target=self._idle_setup)
174 backgroundSetup.setDaemon(True)
175 backgroundSetup.start()
177 def _idle_setup(self):
179 If something can be done after the UI loads, push it here so it's not blocking the UI
182 # Barebones UI handlers
186 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
187 with gtk_toolbox.gtk_lock():
188 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
189 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
190 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
191 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
192 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
194 self._dialpads[self._selectedBackendId].enable()
195 self._accountViews[self._selectedBackendId].enable()
196 self._recentViews[self._selectedBackendId].enable()
197 self._messagesViews[self._selectedBackendId].enable()
198 self._contactsViews[self._selectedBackendId].enable()
200 # Setup maemo specifics
207 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
208 device = osso.DeviceState(self._osso)
209 device.set_device_state_callback(self._on_device_state_change, 0)
211 pass # warnings.warn("No OSSO", UserWarning, 2)
215 self._alarmHandler = alarm_handler.AlarmHandler()
218 if hildon is not None:
220 self._ledHandler = led_handler.LedHandler()
221 self._ledHandler.off()
223 # Setup maemo specifics
228 self._connection = None
229 if conic is not None:
230 self._connection = conic.Connection()
231 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
232 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
234 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
236 # Setup costly backends
244 os.makedirs(constants._data_path_)
248 gcCookiePath = os.path.join(constants._data_path_, "gc_cookies.txt")
249 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
250 self._defaultBackendId = self._guess_preferred_backend((
251 (self.GC_BACKEND, gcCookiePath),
252 (self.GV_BACKEND, gvCookiePath),
255 self._phoneBackends.update({
256 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
257 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
259 with gtk_toolbox.gtk_lock():
260 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
261 unifiedDialpad.set_number("")
262 self._dialpads.update({
263 self.GC_BACKEND: unifiedDialpad,
264 self.GV_BACKEND: unifiedDialpad,
266 self._accountViews.update({
267 self.GC_BACKEND: gc_views.AccountInfo(
268 self._widgetTree, self._phoneBackends[self.GC_BACKEND], None, self._errorDisplay
270 self.GV_BACKEND: gc_views.AccountInfo(
271 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
274 self._accountViews[self.GC_BACKEND].save_everything = lambda *args: None
275 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
276 self._recentViews.update({
277 self.GC_BACKEND: gc_views.RecentCallsView(
278 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
280 self.GV_BACKEND: gc_views.RecentCallsView(
281 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
284 self._messagesViews.update({
285 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
286 self.GV_BACKEND: gc_views.MessagesView(
287 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
290 self._contactsViews.update({
291 self.GC_BACKEND: gc_views.ContactsView(
292 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
294 self.GV_BACKEND: gc_views.ContactsView(
295 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
299 evoBackend = evo_backend.EvolutionAddressBook()
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],
313 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
314 self._contactsViews[backendId].append(mergedBook)
315 self._contactsViews[backendId].extend(addressBooks)
316 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
319 "on_paste": self._on_paste,
320 "on_refresh": self._on_menu_refresh,
321 "on_clearcookies_clicked": self._on_clearcookies_clicked,
322 "on_notebook_switch_page": self._on_notebook_switch_page,
323 "on_about_activate": self._on_about_activate,
325 self._widgetTree.signal_autoconnect(callbackMapping)
327 with gtk_toolbox.gtk_lock():
328 self._originalCurrentLabels = [
329 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
330 for pageIndex in xrange(self._notebook.get_n_pages())
332 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
333 self._notebookTapHandler.enable()
334 self._notebookTapHandler.on_tap = self._reset_tab_refresh
335 self._notebookTapHandler.on_hold = self._on_tab_refresh
336 self._notebookTapHandler.on_holding = self._set_tab_refresh
337 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
339 self._initDone = True
341 config = ConfigParser.SafeConfigParser()
342 config.read(constants._user_settings_)
343 with gtk_toolbox.gtk_lock():
344 self.load_settings(config)
346 self._spawn_attempt_login(2)
348 with gtk_toolbox.gtk_lock():
349 self._errorDisplay.push_exception()
351 def attempt_login(self, numOfAttempts = 10, force = False):
353 @todo Handle user notification better like attempting to login and failed login
355 @note This must be run outside of the UI lock
358 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
359 assert self._initDone, "Attempting login before app is fully loaded"
361 serviceId = self.NULL_BACKEND
365 self.refresh_session()
366 serviceId = self._defaultBackendId
368 except StandardError, e:
369 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
372 loggedIn, serviceId = self._login_by_user(numOfAttempts)
374 with gtk_toolbox.gtk_lock():
375 self._change_loggedin_status(serviceId)
376 except StandardError, e:
377 with gtk_toolbox.gtk_lock():
378 self._errorDisplay.push_exception()
380 def _spawn_attempt_login(self, *args):
381 self._loginSink.send(args)
383 def refresh_session(self):
385 @note Thread agnostic
387 assert self._initDone, "Attempting login before app is fully loaded"
391 loggedIn = self._login_by_cookie()
393 loggedIn = self._login_by_settings()
396 raise RuntimeError("Login Failed")
398 def _login_by_cookie(self):
400 @note Thread agnostic
402 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
405 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
410 def _login_by_settings(self):
412 @note Thread agnostic
414 username, password = self._credentials
415 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
417 self._credentials = username, password
419 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
424 def _login_by_user(self, numOfAttempts):
426 @note This must be run outside of the UI lock
428 loggedIn, (username, password) = False, self._credentials
429 tmpServiceId = self.NULL_BACKEND
430 for attemptCount in xrange(numOfAttempts):
433 availableServices = (
434 (self.GV_BACKEND, "Google Voice"),
435 (self.GC_BACKEND, "Grand Central"),
437 with gtk_toolbox.gtk_lock():
438 credentials = self._credentialsDialog.request_credentials_from(
439 availableServices, defaultCredentials = self._credentials
441 tmpServiceId, username, password = credentials
442 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
445 serviceId = tmpServiceId
446 self._credentials = username, password
448 "Logged into %r through user request" % self._phoneBackends[serviceId],
452 serviceId = self.NULL_BACKEND
454 return loggedIn, serviceId
456 def _select_action(self, action, number, message):
457 self.refresh_session()
458 if action == "select":
459 self._dialpads[self._selectedBackendId].set_number(number)
460 self._notebook.set_current_page(self.KEYPAD_TAB)
461 elif action == "dial":
462 self._on_dial_clicked(number)
463 elif action == "sms":
464 self._on_sms_clicked(number, message)
466 assert False, "Unknown action: %s" % action
468 def _change_loggedin_status(self, newStatus):
469 oldStatus = self._selectedBackendId
470 if oldStatus == newStatus:
473 self._dialpads[oldStatus].disable()
474 self._accountViews[oldStatus].disable()
475 self._recentViews[oldStatus].disable()
476 self._messagesViews[oldStatus].disable()
477 self._contactsViews[oldStatus].disable()
479 self._dialpads[newStatus].enable()
480 self._accountViews[newStatus].enable()
481 self._recentViews[newStatus].enable()
482 self._messagesViews[newStatus].enable()
483 self._contactsViews[newStatus].enable()
485 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
486 self._phoneBackends[self._selectedBackendId].set_sane_callback()
487 self._accountViews[self._selectedBackendId].update()
489 self._selectedBackendId = newStatus
491 def load_settings(self, config):
496 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
498 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
499 for i in xrange(len(self._credentials))
502 base64.b64decode(blob)
505 self._credentials = tuple(creds)
507 if self._alarmHandler is not None:
508 self._alarmHandler.load_settings(config, "alarm")
509 except ConfigParser.NoOptionError, e:
511 "Settings file %s is missing section %s" % (
512 constants._user_settings_,
517 except ConfigParser.NoSectionError, e:
519 "Settings file %s is missing section %s" % (
520 constants._user_settings_,
526 for backendId, view in itertools.chain(
527 self._dialpads.iteritems(),
528 self._accountViews.iteritems(),
529 self._messagesViews.iteritems(),
530 self._recentViews.iteritems(),
531 self._contactsViews.iteritems(),
533 sectionName = "%s - %s" % (backendId, view.name())
535 view.load_settings(config, sectionName)
536 except ConfigParser.NoOptionError, e:
538 "Settings file %s is missing section %s" % (
539 constants._user_settings_,
544 except ConfigParser.NoSectionError, e:
546 "Settings file %s is missing section %s" % (
547 constants._user_settings_,
553 def save_settings(self, config):
555 @note Thread Agnostic
557 config.add_section(constants.__pretty_app_name__)
558 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
559 for i, value in enumerate(self._credentials):
560 blob = base64.b64encode(value)
561 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
562 config.add_section("alarm")
563 if self._alarmHandler is not None:
564 self._alarmHandler.save_settings(config, "alarm")
566 for backendId, view in itertools.chain(
567 self._dialpads.iteritems(),
568 self._accountViews.iteritems(),
569 self._messagesViews.iteritems(),
570 self._recentViews.iteritems(),
571 self._contactsViews.iteritems(),
573 sectionName = "%s - %s" % (backendId, view.name())
574 config.add_section(sectionName)
575 view.save_settings(config, sectionName)
577 def _guess_preferred_backend(self, backendAndCookiePaths):
579 (getmtime_nothrow(path), backendId, path)
580 for backendId, path in backendAndCookiePaths
582 modTimeAndPath.sort()
583 return modTimeAndPath[-1][1]
585 def _save_settings(self):
587 @note Thread Agnostic
589 config = ConfigParser.SafeConfigParser()
590 self.save_settings(config)
591 with open(constants._user_settings_, "wb") as configFile:
592 config.write(configFile)
594 def _refresh_active_tab(self):
595 if self._ledHandler is not None:
596 self._ledHandler.off()
598 pageIndex = self._notebook.get_current_page()
599 if pageIndex == self.CONTACTS_TAB:
600 self._contactsViews[self._selectedBackendId].update(force=True)
601 elif pageIndex == self.RECENT_TAB:
602 self._recentViews[self._selectedBackendId].update(force=True)
603 elif pageIndex == self.MESSAGES_TAB:
604 self._messagesViews[self._selectedBackendId].update(force=True)
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()
682 if pageIndex == self.RECENT_TAB:
683 self._recentViews[self._selectedBackendId].update()
684 elif pageIndex == self.MESSAGES_TAB:
685 self._messagesViews[self._selectedBackendId].update()
686 elif pageIndex == self.CONTACTS_TAB:
687 self._contactsViews[self._selectedBackendId].update()
688 elif pageIndex == self.ACCOUNT_TAB:
689 self._accountViews[self._selectedBackendId].update()
691 def _set_tab_refresh(self, *args):
692 pageIndex = self._notebook.get_current_page()
693 child = self._notebook.get_nth_page(pageIndex)
694 self._notebook.get_tab_label(child).set_text("Refresh?")
697 def _reset_tab_refresh(self, *args):
698 pageIndex = self._notebook.get_current_page()
699 child = self._notebook.get_nth_page(pageIndex)
700 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
703 def _on_tab_refresh(self, *args):
704 self._refresh_active_tab()
705 self._reset_tab_refresh()
708 def _on_sms_clicked(self, number, message):
709 assert number, "No number specified"
710 assert message, "Empty message"
712 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
713 except StandardError, e:
715 self._errorDisplay.push_exception()
719 self._errorDisplay.push_message(
720 "Backend link with grandcentral is not working, please try again"
726 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
728 except StandardError, e:
729 self._errorDisplay.push_exception()
730 except ValueError, e:
731 self._errorDisplay.push_exception()
733 def _on_dial_clicked(self, number):
734 assert number, "No number to call"
736 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
737 except StandardError, e:
739 self._errorDisplay.push_exception()
743 self._errorDisplay.push_message(
744 "Backend link with grandcentral is not working, please try again"
750 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
751 self._phoneBackends[self._selectedBackendId].dial(number)
753 except StandardError, e:
754 self._errorDisplay.push_exception()
755 except ValueError, e:
756 self._errorDisplay.push_exception()
759 self._dialpads[self._selectedBackendId].clear()
761 def _on_menu_refresh(self, *args):
762 self._refresh_active_tab()
764 def _on_paste(self, *args):
765 contents = self._clipboard.wait_for_text()
766 self._dialpads[self._selectedBackendId].set_number(contents)
768 def _on_about_activate(self, *args):
769 dlg = gtk.AboutDialog()
770 dlg.set_name(constants.__pretty_app_name__)
771 dlg.set_version(constants.__version__)
772 dlg.set_copyright("Copyright 2008 - LGPL")
773 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")
774 dlg.set_website("http://gc-dialer.garage.maemo.org/")
775 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
783 failureCount, testCount = doctest.testmod()
785 print "Tests Successful"
792 _lock_file = os.path.join(constants._data_path_, ".lock")
793 with gtk_toolbox.flock(_lock_file, 0):
794 gtk.gdk.threads_init()
796 if hildon is not None:
797 gtk.set_application_name(constants.__pretty_app_name__)
798 handle = Dialcentral()
802 class DummyOptions(object):
808 if __name__ == "__main__":
809 if len(sys.argv) > 1:
815 if optparse is not None:
816 parser = optparse.OptionParser()
817 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
818 (commandOptions, commandArgs) = parser.parse_args()
820 commandOptions = DummyOptions()
823 if commandOptions.test: