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 @bug Need to add unit tests
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 '/usr/lib/dialcentral/dialcentral.glade',
74 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
75 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
87 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
89 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
90 _user_settings = "%s/settings.ini" % _data_path
93 self._initDone = False
94 self._connection = None
96 self._clipboard = gtk.clipboard_get()
98 self._deviceIsOnline = True
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
109 for path in self._glade_files:
110 if os.path.isfile(path):
111 self._widgetTree = gtk.glade.XML(path)
114 display_error_message("Cannot find dialcentral.glade")
118 self._window = self._widgetTree.get_widget("mainWindow")
119 self._notebook = self._widgetTree.get_widget("notebook")
120 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
121 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
124 self._isFullScreen = False
125 if hildon is not None:
126 self._app = hildon.Program()
127 oldWindow = self._window
128 self._window = hildon.Window()
129 oldWindow.get_child().reparent(self._window)
130 self._app.add_window(self._window)
131 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
132 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
133 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
134 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
135 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
136 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
138 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
140 for child in gtkMenu.get_children():
142 self._window.set_menu(menu)
145 self._window.connect("key-press-event", self._on_key_press)
146 self._window.connect("window-state-event", self._on_window_state_change)
148 pass # warnings.warn("No Hildon", UserWarning, 2)
150 if hildon is not None:
151 self._window.set_title("Keypad")
153 self._window.set_title("%s - Keypad" % constants.__pretty_app_name__)
156 "on_dialpad_quit": self._on_close,
158 self._widgetTree.signal_autoconnect(callbackMapping)
160 self._window.connect("destroy", self._on_close)
161 self._window.set_default_size(800, 300)
162 self._window.show_all()
164 backgroundSetup = threading.Thread(target=self._idle_setup)
165 backgroundSetup.setDaemon(True)
166 backgroundSetup.start()
168 def _idle_setup(self):
170 If something can be done after the UI loads, push it here so it's not blocking the UI
173 # Barebones UI handlers
177 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
178 with gtk_toolbox.gtk_lock():
179 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
180 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
181 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
182 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
183 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
185 self._dialpads[self._selectedBackendId].enable()
186 self._accountViews[self._selectedBackendId].enable()
187 self._recentViews[self._selectedBackendId].enable()
188 self._messagesViews[self._selectedBackendId].enable()
189 self._contactsViews[self._selectedBackendId].enable()
191 # Setup maemo specifics
198 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
199 device = osso.DeviceState(self._osso)
200 device.set_device_state_callback(self._on_device_state_change, 0)
202 pass # warnings.warn("No OSSO", UserWarning, 2)
204 # Setup maemo specifics
209 self._connection = None
210 if conic is not None:
211 self._connection = conic.Connection()
212 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
213 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
215 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
217 # Setup costly backends
225 os.makedirs(self._data_path)
229 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
230 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
231 self._defaultBackendId = self._guess_preferred_backend((
232 (self.GC_BACKEND, gcCookiePath),
233 (self.GV_BACKEND, gvCookiePath),
236 self._phoneBackends.update({
237 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
238 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
240 with gtk_toolbox.gtk_lock():
241 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
242 unifiedDialpad.set_number("")
243 self._dialpads.update({
244 self.GC_BACKEND: unifiedDialpad,
245 self.GV_BACKEND: unifiedDialpad,
247 self._accountViews.update({
248 self.GC_BACKEND: gc_views.AccountInfo(
249 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
251 self.GV_BACKEND: gc_views.AccountInfo(
252 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
255 self._recentViews.update({
256 self.GC_BACKEND: gc_views.RecentCallsView(
257 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
259 self.GV_BACKEND: gc_views.RecentCallsView(
260 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
263 self._messagesViews.update({
264 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
265 self.GV_BACKEND: gc_views.MessagesView(
266 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
269 self._contactsViews.update({
270 self.GC_BACKEND: gc_views.ContactsView(
271 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
273 self.GV_BACKEND: gc_views.ContactsView(
274 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
278 evoBackend = evo_backend.EvolutionAddressBook()
279 fsContactsPath = os.path.join(self._data_path, "contacts")
280 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
281 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
282 self._dialpads[backendId].number_selected = self._select_action
283 self._recentViews[backendId].number_selected = self._select_action
284 self._messagesViews[backendId].number_selected = self._select_action
285 self._contactsViews[backendId].number_selected = self._select_action
288 self._phoneBackends[backendId],
292 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
293 self._contactsViews[backendId].append(mergedBook)
294 self._contactsViews[backendId].extend(addressBooks)
295 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
298 "on_paste": self._on_paste,
299 "on_refresh": self._on_refresh,
300 "on_clearcookies_clicked": self._on_clearcookies_clicked,
301 "on_notebook_switch_page": self._on_notebook_switch_page,
302 "on_about_activate": self._on_about_activate,
304 self._widgetTree.signal_autoconnect(callbackMapping)
306 self._initDone = True
308 config = ConfigParser.SafeConfigParser()
309 config.read(self._user_settings)
310 with gtk_toolbox.gtk_lock():
311 self.load_settings(config)
313 gtk_toolbox.asynchronous_gtk_message(self._spawn_attempt_login)(2)
314 except StandardError, e:
315 warnings.warn(e.message, UserWarning, 2)
316 except BaseException, e:
318 warnings.warn(e.message, UserWarning, 2)
322 def attempt_login(self, numOfAttempts = 10, force = False):
324 @todo Handle user notification better like attempting to login and failed login
326 @note This must be run outside of the UI lock
329 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
330 assert self._initDone, "Attempting login before app is fully loaded"
331 if not self._deviceIsOnline:
332 raise RuntimeError("Unable to login, device is not online")
334 serviceId = self.NULL_BACKEND
338 self.refresh_session()
339 serviceId = self._defaultBackendId
341 except StandardError, e:
342 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
345 loggedIn, serviceId = self._login_by_user(numOfAttempts)
347 with gtk_toolbox.gtk_lock():
348 self._change_loggedin_status(serviceId)
349 except StandardError, e:
350 with gtk_toolbox.gtk_lock():
351 self._errorDisplay.push_exception(e)
353 def _spawn_attempt_login(self, *args):
354 backgroundLogin = threading.Thread(target=self.attempt_login, args=args)
355 backgroundLogin.setDaemon(True)
356 backgroundLogin.start()
358 def refresh_session(self):
360 @note Thread agnostic
362 assert self._initDone, "Attempting login before app is fully loaded"
363 if not self._deviceIsOnline:
364 raise RuntimeError("Unable to login, device is not online")
368 loggedIn = self._login_by_cookie()
370 loggedIn = self._login_by_settings()
373 raise RuntimeError("Login Failed")
375 def _login_by_cookie(self):
377 @note Thread agnostic
379 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
382 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
387 def _login_by_settings(self):
389 @note Thread agnostic
391 username, password = self._credentials
392 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
394 self._credentials = username, password
396 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
401 def _login_by_user(self, numOfAttempts):
403 @note This must be run outside of the UI lock
405 loggedIn, (username, password) = False, self._credentials
406 tmpServiceId = self.NULL_BACKEND
407 for attemptCount in xrange(numOfAttempts):
410 availableServices = {
411 self.GV_BACKEND: "Google Voice",
412 self.GC_BACKEND: "Grand Central",
414 with gtk_toolbox.gtk_lock():
415 credentials = self._credentialsDialog.request_credentials_from(
416 availableServices, defaultCredentials = self._credentials
418 tmpServiceId, username, password = credentials
419 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
422 serviceId = tmpServiceId
423 self._credentials = username, password
425 "Logged into %r through user request" % self._phoneBackends[serviceId],
429 serviceId = self.NULL_BACKEND
431 return loggedIn, serviceId
433 def _select_action(self, action, number, message):
434 self.refresh_session()
435 if action == "select":
436 self._dialpads[self._selectedBackendId].set_number(number)
437 self._notebook.set_current_page(self.KEYPAD_TAB)
438 elif action == "dial":
439 self._on_dial_clicked(number)
440 elif action == "sms":
441 self._on_sms_clicked(number, message)
443 assert False, "Unknown action: %s" % action
445 def _change_loggedin_status(self, newStatus):
446 oldStatus = self._selectedBackendId
447 if oldStatus == newStatus:
450 self._dialpads[oldStatus].disable()
451 self._accountViews[oldStatus].disable()
452 self._recentViews[oldStatus].disable()
453 self._messagesViews[oldStatus].disable()
454 self._contactsViews[oldStatus].disable()
456 self._dialpads[newStatus].enable()
457 self._accountViews[newStatus].enable()
458 self._recentViews[newStatus].enable()
459 self._messagesViews[newStatus].enable()
460 self._contactsViews[newStatus].enable()
462 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
463 self._phoneBackends[self._selectedBackendId].set_sane_callback()
464 self._accountViews[self._selectedBackendId].update()
466 self._selectedBackendId = newStatus
468 def load_settings(self, config):
473 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
475 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
476 for i in xrange(len(self._credentials))
479 base64.b64decode(blob)
482 self._credentials = tuple(creds)
483 except ConfigParser.NoSectionError, e:
485 "Settings file %s is missing section %s" % (
492 for backendId, view in itertools.chain(
493 self._dialpads.iteritems(),
494 self._accountViews.iteritems(),
495 self._messagesViews.iteritems(),
496 self._recentViews.iteritems(),
497 self._contactsViews.iteritems(),
499 sectionName = "%s - %s" % (backendId, view.name())
501 view.load_settings(config, sectionName)
502 except ConfigParser.NoSectionError, e:
504 "Settings file %s is missing section %s" % (
511 def save_settings(self, config):
513 @note Thread Agnostic
515 config.add_section(constants.__pretty_app_name__)
516 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
517 for i, value in enumerate(self._credentials):
518 blob = base64.b64encode(value)
519 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
520 for backendId, view in itertools.chain(
521 self._dialpads.iteritems(),
522 self._accountViews.iteritems(),
523 self._messagesViews.iteritems(),
524 self._recentViews.iteritems(),
525 self._contactsViews.iteritems(),
527 sectionName = "%s - %s" % (backendId, view.name())
528 config.add_section(sectionName)
529 view.save_settings(config, sectionName)
531 def _guess_preferred_backend(self, backendAndCookiePaths):
533 (getmtime_nothrow(path), backendId, path)
534 for backendId, path in backendAndCookiePaths
536 modTimeAndPath.sort()
537 return modTimeAndPath[-1][1]
539 def _save_settings(self):
541 @note Thread Agnostic
543 config = ConfigParser.SafeConfigParser()
544 self.save_settings(config)
545 with open(self._user_settings, "wb") as configFile:
546 config.write(configFile)
548 def _on_close(self, *args, **kwds):
550 if self._osso is not None:
554 self._save_settings()
558 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
560 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
561 For system_inactivity, we have no background tasks to pause
563 @note Hildon specific
566 for backendId in self.BACKENDS:
567 self._phoneBackends[backendId].clear_caches()
568 self._contactsViews[self._selectedBackendId].clear_caches()
571 if save_unsaved_data or shutdown:
572 self._save_settings()
574 def _on_connection_change(self, connection, event, magicIdentifier):
576 @note Hildon specific
580 status = event.get_status()
581 error = event.get_error()
582 iap_id = event.get_iap_id()
583 bearer = event.get_bearer_type()
585 if status == conic.STATUS_CONNECTED:
586 self._deviceIsOnline = True
588 self._spawn_attempt_login(2)
589 elif status == conic.STATUS_DISCONNECTED:
590 self._deviceIsOnline = False
592 self._defaultBackendId = self._selectedBackendId
593 self._change_loggedin_status(self.NULL_BACKEND)
595 def _on_window_state_change(self, widget, event, *args):
597 @note Hildon specific
599 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
600 self._isFullScreen = True
602 self._isFullScreen = False
604 def _on_key_press(self, widget, event, *args):
606 @note Hildon specific
608 if event.keyval == gtk.keysyms.F6:
609 if self._isFullScreen:
610 self._window.unfullscreen()
612 self._window.fullscreen()
614 def _on_clearcookies_clicked(self, *args):
615 self._phoneBackends[self._selectedBackendId].logout()
616 self._accountViews[self._selectedBackendId].clear()
617 self._recentViews[self._selectedBackendId].clear()
618 self._messagesViews[self._selectedBackendId].clear()
619 self._contactsViews[self._selectedBackendId].clear()
620 self._change_loggedin_status(self.NULL_BACKEND)
622 self._spawn_attempt_login(2, True)
624 def _on_notebook_switch_page(self, notebook, page, page_num):
625 if page_num == self.RECENT_TAB:
626 self._recentViews[self._selectedBackendId].update()
627 elif page_num == self.MESSAGES_TAB:
628 self._messagesViews[self._selectedBackendId].update()
629 elif page_num == self.CONTACTS_TAB:
630 self._contactsViews[self._selectedBackendId].update()
631 elif page_num == self.ACCOUNT_TAB:
632 self._accountViews[self._selectedBackendId].update()
634 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
635 if hildon is not None:
636 self._window.set_title(tabTitle)
638 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
640 def _on_sms_clicked(self, number, message):
644 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
645 except RuntimeError, e:
647 self._errorDisplay.push_exception(e)
651 self._errorDisplay.push_message(
652 "Backend link with grandcentral is not working, please try again"
658 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
660 except RuntimeError, e:
661 self._errorDisplay.push_exception(e)
662 except ValueError, e:
663 self._errorDisplay.push_exception(e)
665 def _on_dial_clicked(self, number):
668 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
669 except RuntimeError, e:
671 self._errorDisplay.push_exception(e)
675 self._errorDisplay.push_message(
676 "Backend link with grandcentral is not working, please try again"
682 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
683 self._phoneBackends[self._selectedBackendId].dial(number)
685 except RuntimeError, e:
686 self._errorDisplay.push_exception(e)
687 except ValueError, e:
688 self._errorDisplay.push_exception(e)
691 self._dialpads[self._selectedBackendId].clear()
693 def _on_refresh(self, *args):
694 page_num = self._notebook.get_current_page()
695 if page_num == self.CONTACTS_TAB:
696 self._contactsViews[self._selectedBackendId].update(force=True)
697 elif page_num == self.RECENT_TAB:
698 self._recentViews[self._selectedBackendId].update(force=True)
699 elif page_num == self.MESSAGES_TAB:
700 self._messagesViews[self._selectedBackendId].update(force=True)
702 def _on_paste(self, *args):
703 contents = self._clipboard.wait_for_text()
704 self._dialpads[self._selectedBackendId].set_number(contents)
706 def _on_about_activate(self, *args):
707 dlg = gtk.AboutDialog()
708 dlg.set_name(constants.__pretty_app_name__)
709 dlg.set_version(constants.__version__)
710 dlg.set_copyright("Copyright 2008 - LGPL")
711 dlg.set_comments("Dialer is designed to interface with your Google Grandcentral account. This application is not affiliated with Google or Grandcentral in any way")
712 dlg.set_website("http://gc-dialer.garage.maemo.org/")
713 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
721 failureCount, testCount = doctest.testmod()
723 print "Tests Successful"
730 gtk.gdk.threads_init()
731 if hildon is not None:
732 gtk.set_application_name(constants.__pretty_app_name__)
733 handle = Dialcentral()
737 class DummyOptions(object):
743 if __name__ == "__main__":
744 if len(sys.argv) > 1:
750 if optparse is not None:
751 parser = optparse.OptionParser()
752 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
753 (commandOptions, commandArgs) = parser.parse_args()
755 commandOptions = DummyOptions()
758 if commandOptions.test: