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._deviceIsOnline = True
100 self._credentials = ("", "")
101 self._selectedBackendId = self.NULL_BACKEND
102 self._defaultBackendId = self.GC_BACKEND
103 self._phoneBackends = None
104 self._dialpads = None
105 self._accountViews = None
106 self._messagesViews = None
107 self._recentViews = None
108 self._contactsViews = None
109 self._tabHoldTimeoutId = None
111 for path in self._glade_files:
112 if os.path.isfile(path):
113 self._widgetTree = gtk.glade.XML(path)
116 display_error_message("Cannot find dialcentral.glade")
120 self._window = self._widgetTree.get_widget("mainWindow")
121 self._notebook = self._widgetTree.get_widget("notebook")
122 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
123 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
126 self._isFullScreen = False
127 if hildon is not None:
128 self._app = hildon.Program()
129 oldWindow = self._window
130 self._window = hildon.Window()
131 oldWindow.get_child().reparent(self._window)
132 self._app.add_window(self._window)
135 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
136 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
137 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
139 warnings.warn(e.message)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
141 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
142 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
144 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
146 for child in gtkMenu.get_children():
148 self._window.set_menu(menu)
151 self._window.connect("key-press-event", self._on_key_press)
152 self._window.connect("window-state-event", self._on_window_state_change)
154 pass # warnings.warn("No Hildon", UserWarning, 2)
156 self._window.set_title("%s" % constants.__pretty_app_name__)
159 "on_dialpad_quit": self._on_close,
161 self._widgetTree.signal_autoconnect(callbackMapping)
163 self._window.connect("destroy", self._on_close)
164 self._window.set_default_size(800, 300)
165 self._window.show_all()
167 self._loginSink = gtk_toolbox.threaded_stage(
170 gtk_toolbox.null_sink(),
174 backgroundSetup = threading.Thread(target=self._idle_setup)
175 backgroundSetup.setDaemon(True)
176 backgroundSetup.start()
178 def _idle_setup(self):
180 If something can be done after the UI loads, push it here so it's not blocking the UI
183 # Barebones UI handlers
187 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
188 with gtk_toolbox.gtk_lock():
189 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
190 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
191 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
192 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
193 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
195 self._dialpads[self._selectedBackendId].enable()
196 self._accountViews[self._selectedBackendId].enable()
197 self._recentViews[self._selectedBackendId].enable()
198 self._messagesViews[self._selectedBackendId].enable()
199 self._contactsViews[self._selectedBackendId].enable()
201 # Setup maemo specifics
208 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
209 device = osso.DeviceState(self._osso)
210 device.set_device_state_callback(self._on_device_state_change, 0)
212 pass # warnings.warn("No OSSO", UserWarning, 2)
214 # Setup maemo specifics
219 self._connection = None
220 if conic is not None:
221 self._connection = conic.Connection()
222 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
223 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
225 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
227 # Setup costly backends
235 os.makedirs(self._data_path)
239 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
240 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
241 self._defaultBackendId = self._guess_preferred_backend((
242 (self.GC_BACKEND, gcCookiePath),
243 (self.GV_BACKEND, gvCookiePath),
246 self._phoneBackends.update({
247 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
248 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
250 with gtk_toolbox.gtk_lock():
251 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
252 unifiedDialpad.set_number("")
253 self._dialpads.update({
254 self.GC_BACKEND: unifiedDialpad,
255 self.GV_BACKEND: unifiedDialpad,
257 self._accountViews.update({
258 self.GC_BACKEND: gc_views.AccountInfo(
259 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
261 self.GV_BACKEND: gc_views.AccountInfo(
262 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
265 self._recentViews.update({
266 self.GC_BACKEND: gc_views.RecentCallsView(
267 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
269 self.GV_BACKEND: gc_views.RecentCallsView(
270 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
273 self._messagesViews.update({
274 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
275 self.GV_BACKEND: gc_views.MessagesView(
276 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
279 self._contactsViews.update({
280 self.GC_BACKEND: gc_views.ContactsView(
281 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
283 self.GV_BACKEND: gc_views.ContactsView(
284 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
288 evoBackend = evo_backend.EvolutionAddressBook()
289 fsContactsPath = os.path.join(self._data_path, "contacts")
290 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
291 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
292 self._dialpads[backendId].number_selected = self._select_action
293 self._recentViews[backendId].number_selected = self._select_action
294 self._messagesViews[backendId].number_selected = self._select_action
295 self._contactsViews[backendId].number_selected = self._select_action
298 self._phoneBackends[backendId],
302 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
303 self._contactsViews[backendId].append(mergedBook)
304 self._contactsViews[backendId].extend(addressBooks)
305 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
308 "on_paste": self._on_paste,
309 "on_refresh": self._on_menu_refresh,
310 "on_clearcookies_clicked": self._on_clearcookies_clicked,
311 "on_notebook_switch_page": self._on_notebook_switch_page,
312 "on_about_activate": self._on_about_activate,
314 self._widgetTree.signal_autoconnect(callbackMapping)
315 self._notebook.connect("button-press-event", self._on_tab_press)
316 self._notebook.connect("button-release-event", self._on_tab_release)
318 self._initDone = True
320 config = ConfigParser.SafeConfigParser()
321 config.read(self._user_settings)
322 with gtk_toolbox.gtk_lock():
323 self.load_settings(config)
325 self._spawn_attempt_login(2)
326 except StandardError, e:
327 warnings.warn(e.message, UserWarning, 2)
328 except BaseException, e:
330 warnings.warn(e.message, UserWarning, 2)
334 def attempt_login(self, numOfAttempts = 10, force = False):
336 @todo Handle user notification better like attempting to login and failed login
338 @note This must be run outside of the UI lock
341 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
342 assert self._initDone, "Attempting login before app is fully loaded"
343 if not self._deviceIsOnline:
344 raise RuntimeError("Unable to login, device is not online")
346 serviceId = self.NULL_BACKEND
350 self.refresh_session()
351 serviceId = self._defaultBackendId
353 except StandardError, e:
354 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
357 loggedIn, serviceId = self._login_by_user(numOfAttempts)
359 with gtk_toolbox.gtk_lock():
360 self._change_loggedin_status(serviceId)
361 except StandardError, e:
362 with gtk_toolbox.gtk_lock():
363 self._errorDisplay.push_exception(e)
365 def _spawn_attempt_login(self, *args):
366 self._loginSink.send(args)
368 def refresh_session(self):
370 @note Thread agnostic
372 assert self._initDone, "Attempting login before app is fully loaded"
373 if not self._deviceIsOnline:
374 raise RuntimeError("Unable to login, device is not online")
378 loggedIn = self._login_by_cookie()
380 loggedIn = self._login_by_settings()
383 raise RuntimeError("Login Failed")
385 def _login_by_cookie(self):
387 @note Thread agnostic
389 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
392 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
397 def _login_by_settings(self):
399 @note Thread agnostic
401 username, password = self._credentials
402 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
404 self._credentials = username, password
406 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
411 def _login_by_user(self, numOfAttempts):
413 @note This must be run outside of the UI lock
415 loggedIn, (username, password) = False, self._credentials
416 tmpServiceId = self.NULL_BACKEND
417 for attemptCount in xrange(numOfAttempts):
420 availableServices = (
421 (self.GV_BACKEND, "Google Voice"),
422 (self.GC_BACKEND, "Grand Central"),
424 with gtk_toolbox.gtk_lock():
425 credentials = self._credentialsDialog.request_credentials_from(
426 availableServices, defaultCredentials = self._credentials
428 tmpServiceId, username, password = credentials
429 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
432 serviceId = tmpServiceId
433 self._credentials = username, password
435 "Logged into %r through user request" % self._phoneBackends[serviceId],
439 serviceId = self.NULL_BACKEND
441 return loggedIn, serviceId
443 def _select_action(self, action, number, message):
444 self.refresh_session()
445 if action == "select":
446 self._dialpads[self._selectedBackendId].set_number(number)
447 self._notebook.set_current_page(self.KEYPAD_TAB)
448 elif action == "dial":
449 self._on_dial_clicked(number)
450 elif action == "sms":
451 self._on_sms_clicked(number, message)
453 assert False, "Unknown action: %s" % action
455 def _change_loggedin_status(self, newStatus):
456 oldStatus = self._selectedBackendId
457 if oldStatus == newStatus:
460 self._dialpads[oldStatus].disable()
461 self._accountViews[oldStatus].disable()
462 self._recentViews[oldStatus].disable()
463 self._messagesViews[oldStatus].disable()
464 self._contactsViews[oldStatus].disable()
466 self._dialpads[newStatus].enable()
467 self._accountViews[newStatus].enable()
468 self._recentViews[newStatus].enable()
469 self._messagesViews[newStatus].enable()
470 self._contactsViews[newStatus].enable()
472 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
473 self._phoneBackends[self._selectedBackendId].set_sane_callback()
474 self._accountViews[self._selectedBackendId].update()
476 self._selectedBackendId = newStatus
478 def load_settings(self, config):
483 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
485 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
486 for i in xrange(len(self._credentials))
489 base64.b64decode(blob)
492 self._credentials = tuple(creds)
493 except ConfigParser.NoSectionError, e:
495 "Settings file %s is missing section %s" % (
502 for backendId, view in itertools.chain(
503 self._dialpads.iteritems(),
504 self._accountViews.iteritems(),
505 self._messagesViews.iteritems(),
506 self._recentViews.iteritems(),
507 self._contactsViews.iteritems(),
509 sectionName = "%s - %s" % (backendId, view.name())
511 view.load_settings(config, sectionName)
512 except ConfigParser.NoSectionError, e:
514 "Settings file %s is missing section %s" % (
521 def save_settings(self, config):
523 @note Thread Agnostic
525 config.add_section(constants.__pretty_app_name__)
526 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
527 for i, value in enumerate(self._credentials):
528 blob = base64.b64encode(value)
529 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
530 for backendId, view in itertools.chain(
531 self._dialpads.iteritems(),
532 self._accountViews.iteritems(),
533 self._messagesViews.iteritems(),
534 self._recentViews.iteritems(),
535 self._contactsViews.iteritems(),
537 sectionName = "%s - %s" % (backendId, view.name())
538 config.add_section(sectionName)
539 view.save_settings(config, sectionName)
541 def _guess_preferred_backend(self, backendAndCookiePaths):
543 (getmtime_nothrow(path), backendId, path)
544 for backendId, path in backendAndCookiePaths
546 modTimeAndPath.sort()
547 return modTimeAndPath[-1][1]
549 def _save_settings(self):
551 @note Thread Agnostic
553 config = ConfigParser.SafeConfigParser()
554 self.save_settings(config)
555 with open(self._user_settings, "wb") as configFile:
556 config.write(configFile)
558 def _refresh_active_tab(self):
559 page_num = self._notebook.get_current_page()
560 if page_num == self.CONTACTS_TAB:
561 self._contactsViews[self._selectedBackendId].update(force=True)
562 elif page_num == self.RECENT_TAB:
563 self._recentViews[self._selectedBackendId].update(force=True)
564 elif page_num == self.MESSAGES_TAB:
565 self._messagesViews[self._selectedBackendId].update(force=True)
567 def _on_close(self, *args, **kwds):
569 if self._osso is not None:
573 self._save_settings()
577 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
579 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
580 For system_inactivity, we have no background tasks to pause
582 @note Hildon specific
585 for backendId in self.BACKENDS:
586 self._phoneBackends[backendId].clear_caches()
587 self._contactsViews[self._selectedBackendId].clear_caches()
590 if save_unsaved_data or shutdown:
591 self._save_settings()
593 def _on_connection_change(self, connection, event, magicIdentifier):
595 @note Hildon specific
599 status = event.get_status()
600 error = event.get_error()
601 iap_id = event.get_iap_id()
602 bearer = event.get_bearer_type()
604 if status == conic.STATUS_CONNECTED:
605 self._deviceIsOnline = True
607 self._spawn_attempt_login(2)
608 elif status == conic.STATUS_DISCONNECTED:
609 self._deviceIsOnline = False
611 self._defaultBackendId = self._selectedBackendId
612 self._change_loggedin_status(self.NULL_BACKEND)
614 def _on_window_state_change(self, widget, event, *args):
616 @note Hildon specific
618 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
619 self._isFullScreen = True
621 self._isFullScreen = False
623 def _on_key_press(self, widget, event, *args):
625 @note Hildon specific
627 if event.keyval == gtk.keysyms.F6:
628 if self._isFullScreen:
629 self._window.unfullscreen()
631 self._window.fullscreen()
633 def _on_clearcookies_clicked(self, *args):
634 self._phoneBackends[self._selectedBackendId].logout()
635 self._accountViews[self._selectedBackendId].clear()
636 self._recentViews[self._selectedBackendId].clear()
637 self._messagesViews[self._selectedBackendId].clear()
638 self._contactsViews[self._selectedBackendId].clear()
639 self._change_loggedin_status(self.NULL_BACKEND)
641 self._spawn_attempt_login(2, True)
643 def _on_notebook_switch_page(self, notebook, page, page_num):
644 if page_num == self.RECENT_TAB:
645 self._recentViews[self._selectedBackendId].update()
646 elif page_num == self.MESSAGES_TAB:
647 self._messagesViews[self._selectedBackendId].update()
648 elif page_num == self.CONTACTS_TAB:
649 self._contactsViews[self._selectedBackendId].update()
650 elif page_num == self.ACCOUNT_TAB:
651 self._accountViews[self._selectedBackendId].update()
653 def _on_tab_press(self, *args):
654 self._tabHoldTimeoutId = gobject.timeout_add(1000, self._on_tab_refresh)
656 def _on_tab_release(self, *args):
657 if self._tabHoldTimeoutId is not None:
658 gobject.source_remove(self._tabHoldTimeoutId)
659 self._tabHoldTimeoutId = None
661 def _on_tab_refresh(self, *args):
662 self._tabHoldTimeoutId = None
663 self._refresh_active_tab()
666 def _on_sms_clicked(self, number, message):
670 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
671 except StandardError, e:
673 self._errorDisplay.push_exception(e)
677 self._errorDisplay.push_message(
678 "Backend link with grandcentral is not working, please try again"
684 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
686 except StandardError, e:
687 self._errorDisplay.push_exception(e)
688 except ValueError, e:
689 self._errorDisplay.push_exception(e)
691 def _on_dial_clicked(self, number):
692 assert number, "No number to call"
694 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
695 except StandardError, e:
697 self._errorDisplay.push_exception(e)
701 self._errorDisplay.push_message(
702 "Backend link with grandcentral is not working, please try again"
708 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
709 self._phoneBackends[self._selectedBackendId].dial(number)
711 except StandardError, e:
712 self._errorDisplay.push_exception(e)
713 except ValueError, e:
714 self._errorDisplay.push_exception(e)
717 self._dialpads[self._selectedBackendId].clear()
719 def _on_menu_refresh(self, *args):
720 self._refresh_active_tab()
722 def _on_paste(self, *args):
723 contents = self._clipboard.wait_for_text()
724 self._dialpads[self._selectedBackendId].set_number(contents)
726 def _on_about_activate(self, *args):
727 dlg = gtk.AboutDialog()
728 dlg.set_name(constants.__pretty_app_name__)
729 dlg.set_version(constants.__version__)
730 dlg.set_copyright("Copyright 2008 - LGPL")
731 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")
732 dlg.set_website("http://gc-dialer.garage.maemo.org/")
733 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
741 failureCount, testCount = doctest.testmod()
743 print "Tests Successful"
750 gtk.gdk.threads_init()
751 if hildon is not None:
752 gtk.set_application_name(constants.__pretty_app_name__)
753 handle = Dialcentral()
757 class DummyOptions(object):
763 if __name__ == "__main__":
764 if len(sys.argv) > 1:
770 if optparse is not None:
771 parser = optparse.OptionParser()
772 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
773 (commandOptions, commandArgs) = parser.parse_args()
775 commandOptions = DummyOptions()
778 if commandOptions.test: