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()
219 with gtk_toolbox.gtk_lock():
220 self._errorDisplay.push_exception()
222 if hildon is not None:
224 self._ledHandler = led_handler.LedHandler()
226 # Setup maemo specifics
231 self._connection = None
232 if conic is not None:
233 self._connection = conic.Connection()
234 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
235 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
237 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
239 # Setup costly backends
247 os.makedirs(constants._data_path_)
251 gcCookiePath = os.path.join(constants._data_path_, "gc_cookies.txt")
252 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
253 self._defaultBackendId = self._guess_preferred_backend((
254 (self.GC_BACKEND, gcCookiePath),
255 (self.GV_BACKEND, gvCookiePath),
258 self._phoneBackends.update({
259 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
260 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
262 with gtk_toolbox.gtk_lock():
263 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
264 unifiedDialpad.set_number("")
265 self._dialpads.update({
266 self.GC_BACKEND: unifiedDialpad,
267 self.GV_BACKEND: unifiedDialpad,
269 self._accountViews.update({
270 self.GC_BACKEND: gc_views.AccountInfo(
271 self._widgetTree, self._phoneBackends[self.GC_BACKEND], None, self._errorDisplay
273 self.GV_BACKEND: gc_views.AccountInfo(
274 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
277 self._accountViews[self.GC_BACKEND].save_everything = lambda *args: None
278 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
279 self._recentViews.update({
280 self.GC_BACKEND: gc_views.RecentCallsView(
281 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
283 self.GV_BACKEND: gc_views.RecentCallsView(
284 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
287 self._messagesViews.update({
288 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
289 self.GV_BACKEND: gc_views.MessagesView(
290 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
293 self._contactsViews.update({
294 self.GC_BACKEND: gc_views.ContactsView(
295 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
297 self.GV_BACKEND: gc_views.ContactsView(
298 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
302 evoBackend = evo_backend.EvolutionAddressBook()
303 fsContactsPath = os.path.join(constants._data_path_, "contacts")
304 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
305 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
306 self._dialpads[backendId].number_selected = self._select_action
307 self._recentViews[backendId].number_selected = self._select_action
308 self._messagesViews[backendId].number_selected = self._select_action
309 self._contactsViews[backendId].number_selected = self._select_action
312 self._phoneBackends[backendId],
316 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
317 self._contactsViews[backendId].append(mergedBook)
318 self._contactsViews[backendId].extend(addressBooks)
319 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
322 "on_paste": self._on_paste,
323 "on_refresh": self._on_menu_refresh,
324 "on_clearcookies_clicked": self._on_clearcookies_clicked,
325 "on_notebook_switch_page": self._on_notebook_switch_page,
326 "on_about_activate": self._on_about_activate,
328 self._widgetTree.signal_autoconnect(callbackMapping)
330 with gtk_toolbox.gtk_lock():
331 self._originalCurrentLabels = [
332 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
333 for pageIndex in xrange(self._notebook.get_n_pages())
335 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
336 self._notebookTapHandler.enable()
337 self._notebookTapHandler.on_tap = self._reset_tab_refresh
338 self._notebookTapHandler.on_hold = self._on_tab_refresh
339 self._notebookTapHandler.on_holding = self._set_tab_refresh
340 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
342 self._initDone = True
344 config = ConfigParser.SafeConfigParser()
345 config.read(constants._user_settings_)
346 with gtk_toolbox.gtk_lock():
347 self.load_settings(config)
349 self._spawn_attempt_login(2)
351 with gtk_toolbox.gtk_lock():
352 self._errorDisplay.push_exception()
354 def attempt_login(self, numOfAttempts = 10, force = False):
356 @todo Handle user notification better like attempting to login and failed login
358 @note This must be run outside of the UI lock
361 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
362 assert self._initDone, "Attempting login before app is fully loaded"
364 serviceId = self.NULL_BACKEND
368 self.refresh_session()
369 serviceId = self._defaultBackendId
371 except StandardError, e:
372 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
375 loggedIn, serviceId = self._login_by_user(numOfAttempts)
377 with gtk_toolbox.gtk_lock():
378 self._change_loggedin_status(serviceId)
379 except StandardError, e:
380 with gtk_toolbox.gtk_lock():
381 self._errorDisplay.push_exception()
383 def _spawn_attempt_login(self, *args):
384 self._loginSink.send(args)
386 def refresh_session(self):
388 @note Thread agnostic
390 assert self._initDone, "Attempting login before app is fully loaded"
394 loggedIn = self._login_by_cookie()
396 loggedIn = self._login_by_settings()
399 raise RuntimeError("Login Failed")
401 def _login_by_cookie(self):
403 @note Thread agnostic
405 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
408 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
413 def _login_by_settings(self):
415 @note Thread agnostic
417 username, password = self._credentials
418 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
420 self._credentials = username, password
422 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
427 def _login_by_user(self, numOfAttempts):
429 @note This must be run outside of the UI lock
431 loggedIn, (username, password) = False, self._credentials
432 tmpServiceId = self.NULL_BACKEND
433 for attemptCount in xrange(numOfAttempts):
436 availableServices = (
437 (self.GV_BACKEND, "Google Voice"),
438 (self.GC_BACKEND, "Grand Central"),
440 with gtk_toolbox.gtk_lock():
441 credentials = self._credentialsDialog.request_credentials_from(
442 availableServices, defaultCredentials = self._credentials
444 tmpServiceId, username, password = credentials
445 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
448 serviceId = tmpServiceId
449 self._credentials = username, password
451 "Logged into %r through user request" % self._phoneBackends[serviceId],
455 serviceId = self.NULL_BACKEND
457 return loggedIn, serviceId
459 def _select_action(self, action, number, message):
460 self.refresh_session()
461 if action == "select":
462 self._dialpads[self._selectedBackendId].set_number(number)
463 self._notebook.set_current_page(self.KEYPAD_TAB)
464 elif action == "dial":
465 self._on_dial_clicked(number)
466 elif action == "sms":
467 self._on_sms_clicked(number, message)
469 assert False, "Unknown action: %s" % action
471 def _change_loggedin_status(self, newStatus):
472 oldStatus = self._selectedBackendId
473 if oldStatus == newStatus:
476 self._dialpads[oldStatus].disable()
477 self._accountViews[oldStatus].disable()
478 self._recentViews[oldStatus].disable()
479 self._messagesViews[oldStatus].disable()
480 self._contactsViews[oldStatus].disable()
482 self._dialpads[newStatus].enable()
483 self._accountViews[newStatus].enable()
484 self._recentViews[newStatus].enable()
485 self._messagesViews[newStatus].enable()
486 self._contactsViews[newStatus].enable()
488 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
489 self._phoneBackends[self._selectedBackendId].set_sane_callback()
490 self._accountViews[self._selectedBackendId].update()
492 self._selectedBackendId = newStatus
494 def load_settings(self, config):
499 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
501 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
502 for i in xrange(len(self._credentials))
505 base64.b64decode(blob)
508 self._credentials = tuple(creds)
510 if self._alarmHandler is not None:
511 self._alarmHandler.load_settings(config, "alarm")
512 except ConfigParser.NoOptionError, e:
514 "Settings file %s is missing section %s" % (
515 constants._user_settings_,
520 except ConfigParser.NoSectionError, e:
522 "Settings file %s is missing section %s" % (
523 constants._user_settings_,
529 for backendId, view in itertools.chain(
530 self._dialpads.iteritems(),
531 self._accountViews.iteritems(),
532 self._messagesViews.iteritems(),
533 self._recentViews.iteritems(),
534 self._contactsViews.iteritems(),
536 sectionName = "%s - %s" % (backendId, view.name())
538 view.load_settings(config, sectionName)
539 except ConfigParser.NoOptionError, e:
541 "Settings file %s is missing section %s" % (
542 constants._user_settings_,
547 except ConfigParser.NoSectionError, e:
549 "Settings file %s is missing section %s" % (
550 constants._user_settings_,
556 def save_settings(self, config):
558 @note Thread Agnostic
560 config.add_section(constants.__pretty_app_name__)
561 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
562 for i, value in enumerate(self._credentials):
563 blob = base64.b64encode(value)
564 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
565 config.add_section("alarm")
566 if self._alarmHandler is not None:
567 self._alarmHandler.save_settings(config, "alarm")
569 for backendId, view in itertools.chain(
570 self._dialpads.iteritems(),
571 self._accountViews.iteritems(),
572 self._messagesViews.iteritems(),
573 self._recentViews.iteritems(),
574 self._contactsViews.iteritems(),
576 sectionName = "%s - %s" % (backendId, view.name())
577 config.add_section(sectionName)
578 view.save_settings(config, sectionName)
580 def _guess_preferred_backend(self, backendAndCookiePaths):
582 (getmtime_nothrow(path), backendId, path)
583 for backendId, path in backendAndCookiePaths
585 modTimeAndPath.sort()
586 return modTimeAndPath[-1][1]
588 def _save_settings(self):
590 @note Thread Agnostic
592 config = ConfigParser.SafeConfigParser()
593 self.save_settings(config)
594 with open(constants._user_settings_, "wb") as configFile:
595 config.write(configFile)
597 def _refresh_active_tab(self):
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 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
607 if self._ledHandler is not None:
608 self._ledHandler.off()
610 def _on_close(self, *args, **kwds):
612 if self._osso is not None:
616 self._save_settings()
620 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
622 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
623 For system_inactivity, we have no background tasks to pause
625 @note Hildon specific
628 for backendId in self.BACKENDS:
629 self._phoneBackends[backendId].clear_caches()
630 self._contactsViews[self._selectedBackendId].clear_caches()
633 if save_unsaved_data or shutdown:
634 self._save_settings()
636 def _on_connection_change(self, connection, event, magicIdentifier):
638 @note Hildon specific
642 status = event.get_status()
643 error = event.get_error()
644 iap_id = event.get_iap_id()
645 bearer = event.get_bearer_type()
647 if status == conic.STATUS_CONNECTED:
649 self._spawn_attempt_login(2)
650 elif status == conic.STATUS_DISCONNECTED:
652 self._defaultBackendId = self._selectedBackendId
653 self._change_loggedin_status(self.NULL_BACKEND)
655 def _on_window_state_change(self, widget, event, *args):
657 @note Hildon specific
659 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
660 self._isFullScreen = True
662 self._isFullScreen = False
664 def _on_key_press(self, widget, event, *args):
666 @note Hildon specific
668 if event.keyval == gtk.keysyms.F6:
669 if self._isFullScreen:
670 self._window.unfullscreen()
672 self._window.fullscreen()
674 def _on_clearcookies_clicked(self, *args):
675 self._phoneBackends[self._selectedBackendId].logout()
676 self._accountViews[self._selectedBackendId].clear()
677 self._recentViews[self._selectedBackendId].clear()
678 self._messagesViews[self._selectedBackendId].clear()
679 self._contactsViews[self._selectedBackendId].clear()
680 self._change_loggedin_status(self.NULL_BACKEND)
682 self._spawn_attempt_login(2, True)
684 def _on_notebook_switch_page(self, notebook, page, pageIndex):
685 self._reset_tab_refresh()
687 didRecentUpdate = False
688 didMessagesUpdate = False
690 if pageIndex == self.RECENT_TAB:
691 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
692 elif pageIndex == self.MESSAGES_TAB:
693 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
694 elif pageIndex == self.CONTACTS_TAB:
695 self._contactsViews[self._selectedBackendId].update()
696 elif pageIndex == self.ACCOUNT_TAB:
697 self._accountViews[self._selectedBackendId].update()
699 if didRecentUpdate or didMessagesUpdate:
700 if self._ledHandler is not None:
701 self._ledHandler.off()
703 def _set_tab_refresh(self, *args):
704 pageIndex = self._notebook.get_current_page()
705 child = self._notebook.get_nth_page(pageIndex)
706 self._notebook.get_tab_label(child).set_text("Refresh?")
709 def _reset_tab_refresh(self, *args):
710 pageIndex = self._notebook.get_current_page()
711 child = self._notebook.get_nth_page(pageIndex)
712 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
715 def _on_tab_refresh(self, *args):
716 self._refresh_active_tab()
717 self._reset_tab_refresh()
720 def _on_sms_clicked(self, number, message):
721 assert number, "No number specified"
722 assert message, "Empty message"
724 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
725 except StandardError, e:
727 self._errorDisplay.push_exception()
731 self._errorDisplay.push_message(
732 "Backend link with grandcentral is not working, please try again"
738 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
740 except StandardError, e:
741 self._errorDisplay.push_exception()
742 except ValueError, e:
743 self._errorDisplay.push_exception()
745 def _on_dial_clicked(self, number):
746 assert number, "No number to call"
748 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
749 except StandardError, e:
751 self._errorDisplay.push_exception()
755 self._errorDisplay.push_message(
756 "Backend link with grandcentral is not working, please try again"
762 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
763 self._phoneBackends[self._selectedBackendId].dial(number)
765 except StandardError, e:
766 self._errorDisplay.push_exception()
767 except ValueError, e:
768 self._errorDisplay.push_exception()
771 self._dialpads[self._selectedBackendId].clear()
773 def _on_menu_refresh(self, *args):
774 self._refresh_active_tab()
776 def _on_paste(self, *args):
777 contents = self._clipboard.wait_for_text()
778 self._dialpads[self._selectedBackendId].set_number(contents)
780 def _on_about_activate(self, *args):
781 dlg = gtk.AboutDialog()
782 dlg.set_name(constants.__pretty_app_name__)
783 dlg.set_version(constants.__version__)
784 dlg.set_copyright("Copyright 2008 - LGPL")
785 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")
786 dlg.set_website("http://gc-dialer.garage.maemo.org/")
787 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
795 failureCount, testCount = doctest.testmod()
797 print "Tests Successful"
804 _lock_file = os.path.join(constants._data_path_, ".lock")
805 #with gtk_toolbox.flock(_lock_file, 0):
806 gtk.gdk.threads_init()
808 if hildon is not None:
809 gtk.set_application_name(constants.__pretty_app_name__)
810 handle = Dialcentral()
814 class DummyOptions(object):
820 if __name__ == "__main__":
821 if len(sys.argv) > 1:
827 if optparse is not None:
828 parser = optparse.OptionParser()
829 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
830 (commandOptions, commandArgs) = parser.parse_args()
832 commandOptions = DummyOptions()
835 if commandOptions.test: