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 # If under hildon, rely on the application name being shown
158 self._window.set_title("%s" % constants.__pretty_app_name__)
161 "on_dialpad_quit": self._on_close,
163 self._widgetTree.signal_autoconnect(callbackMapping)
165 self._window.connect("destroy", self._on_close)
166 self._window.set_default_size(800, 300)
167 self._window.show_all()
169 self._loginSink = gtk_toolbox.threaded_stage(
172 gtk_toolbox.null_sink(),
176 backgroundSetup = threading.Thread(target=self._idle_setup)
177 backgroundSetup.setDaemon(True)
178 backgroundSetup.start()
180 def _idle_setup(self):
182 If something can be done after the UI loads, push it here so it's not blocking the UI
185 # Barebones UI handlers
189 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
190 with gtk_toolbox.gtk_lock():
191 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
192 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
193 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
194 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
195 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
197 self._dialpads[self._selectedBackendId].enable()
198 self._accountViews[self._selectedBackendId].enable()
199 self._recentViews[self._selectedBackendId].enable()
200 self._messagesViews[self._selectedBackendId].enable()
201 self._contactsViews[self._selectedBackendId].enable()
203 # Setup maemo specifics
210 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
211 device = osso.DeviceState(self._osso)
212 device.set_device_state_callback(self._on_device_state_change, 0)
214 pass # warnings.warn("No OSSO", UserWarning, 2)
216 # Setup maemo specifics
221 self._connection = None
222 if conic is not None:
223 self._connection = conic.Connection()
224 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
225 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
227 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
229 # Setup costly backends
237 os.makedirs(self._data_path)
241 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
242 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
243 self._defaultBackendId = self._guess_preferred_backend((
244 (self.GC_BACKEND, gcCookiePath),
245 (self.GV_BACKEND, gvCookiePath),
248 self._phoneBackends.update({
249 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
250 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
252 with gtk_toolbox.gtk_lock():
253 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
254 unifiedDialpad.set_number("")
255 self._dialpads.update({
256 self.GC_BACKEND: unifiedDialpad,
257 self.GV_BACKEND: unifiedDialpad,
259 self._accountViews.update({
260 self.GC_BACKEND: gc_views.AccountInfo(
261 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
263 self.GV_BACKEND: gc_views.AccountInfo(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._recentViews.update({
268 self.GC_BACKEND: gc_views.RecentCallsView(
269 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
271 self.GV_BACKEND: gc_views.RecentCallsView(
272 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
275 self._messagesViews.update({
276 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
277 self.GV_BACKEND: gc_views.MessagesView(
278 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
281 self._contactsViews.update({
282 self.GC_BACKEND: gc_views.ContactsView(
283 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
285 self.GV_BACKEND: gc_views.ContactsView(
286 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
290 evoBackend = evo_backend.EvolutionAddressBook()
291 fsContactsPath = os.path.join(self._data_path, "contacts")
292 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
293 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
294 self._dialpads[backendId].number_selected = self._select_action
295 self._recentViews[backendId].number_selected = self._select_action
296 self._messagesViews[backendId].number_selected = self._select_action
297 self._contactsViews[backendId].number_selected = self._select_action
300 self._phoneBackends[backendId],
304 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
305 self._contactsViews[backendId].append(mergedBook)
306 self._contactsViews[backendId].extend(addressBooks)
307 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
310 "on_paste": self._on_paste,
311 "on_refresh": self._on_menu_refresh,
312 "on_clearcookies_clicked": self._on_clearcookies_clicked,
313 "on_notebook_switch_page": self._on_notebook_switch_page,
314 "on_about_activate": self._on_about_activate,
316 self._widgetTree.signal_autoconnect(callbackMapping)
317 self._notebook.connect("button-press-event", self._on_tab_press)
318 self._notebook.connect("button-release-event", self._on_tab_release)
320 self._initDone = True
322 config = ConfigParser.SafeConfigParser()
323 config.read(self._user_settings)
324 with gtk_toolbox.gtk_lock():
325 self.load_settings(config)
327 self._spawn_attempt_login(2)
328 except StandardError, e:
329 warnings.warn(e.message, UserWarning, 2)
330 except BaseException, e:
332 warnings.warn(e.message, UserWarning, 2)
336 def attempt_login(self, numOfAttempts = 10, force = False):
338 @todo Handle user notification better like attempting to login and failed login
340 @note This must be run outside of the UI lock
343 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
344 assert self._initDone, "Attempting login before app is fully loaded"
345 if not self._deviceIsOnline:
346 raise RuntimeError("Unable to login, device is not online")
348 serviceId = self.NULL_BACKEND
352 self.refresh_session()
353 serviceId = self._defaultBackendId
355 except StandardError, e:
356 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
359 loggedIn, serviceId = self._login_by_user(numOfAttempts)
361 with gtk_toolbox.gtk_lock():
362 self._change_loggedin_status(serviceId)
363 except StandardError, e:
364 with gtk_toolbox.gtk_lock():
365 self._errorDisplay.push_exception(e)
367 def _spawn_attempt_login(self, *args):
368 self._loginSink.send(args)
370 def refresh_session(self):
372 @note Thread agnostic
374 assert self._initDone, "Attempting login before app is fully loaded"
375 if not self._deviceIsOnline:
376 raise RuntimeError("Unable to login, device is not online")
380 loggedIn = self._login_by_cookie()
382 loggedIn = self._login_by_settings()
385 raise RuntimeError("Login Failed")
387 def _login_by_cookie(self):
389 @note Thread agnostic
391 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
394 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
399 def _login_by_settings(self):
401 @note Thread agnostic
403 username, password = self._credentials
404 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
406 self._credentials = username, password
408 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
413 def _login_by_user(self, numOfAttempts):
415 @note This must be run outside of the UI lock
417 loggedIn, (username, password) = False, self._credentials
418 tmpServiceId = self.NULL_BACKEND
419 for attemptCount in xrange(numOfAttempts):
422 availableServices = (
423 (self.GV_BACKEND, "Google Voice"),
424 (self.GC_BACKEND, "Grand Central"),
426 with gtk_toolbox.gtk_lock():
427 credentials = self._credentialsDialog.request_credentials_from(
428 availableServices, defaultCredentials = self._credentials
430 tmpServiceId, username, password = credentials
431 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
434 serviceId = tmpServiceId
435 self._credentials = username, password
437 "Logged into %r through user request" % self._phoneBackends[serviceId],
441 serviceId = self.NULL_BACKEND
443 return loggedIn, serviceId
445 def _select_action(self, action, number, message):
446 self.refresh_session()
447 if action == "select":
448 self._dialpads[self._selectedBackendId].set_number(number)
449 self._notebook.set_current_page(self.KEYPAD_TAB)
450 elif action == "dial":
451 self._on_dial_clicked(number)
452 elif action == "sms":
453 self._on_sms_clicked(number, message)
455 assert False, "Unknown action: %s" % action
457 def _change_loggedin_status(self, newStatus):
458 oldStatus = self._selectedBackendId
459 if oldStatus == newStatus:
462 self._dialpads[oldStatus].disable()
463 self._accountViews[oldStatus].disable()
464 self._recentViews[oldStatus].disable()
465 self._messagesViews[oldStatus].disable()
466 self._contactsViews[oldStatus].disable()
468 self._dialpads[newStatus].enable()
469 self._accountViews[newStatus].enable()
470 self._recentViews[newStatus].enable()
471 self._messagesViews[newStatus].enable()
472 self._contactsViews[newStatus].enable()
474 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
475 self._phoneBackends[self._selectedBackendId].set_sane_callback()
476 self._accountViews[self._selectedBackendId].update()
478 self._selectedBackendId = newStatus
480 def load_settings(self, config):
485 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
487 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
488 for i in xrange(len(self._credentials))
491 base64.b64decode(blob)
494 self._credentials = tuple(creds)
495 except ConfigParser.NoSectionError, e:
497 "Settings file %s is missing section %s" % (
504 for backendId, view in itertools.chain(
505 self._dialpads.iteritems(),
506 self._accountViews.iteritems(),
507 self._messagesViews.iteritems(),
508 self._recentViews.iteritems(),
509 self._contactsViews.iteritems(),
511 sectionName = "%s - %s" % (backendId, view.name())
513 view.load_settings(config, sectionName)
514 except ConfigParser.NoSectionError, e:
516 "Settings file %s is missing section %s" % (
523 def save_settings(self, config):
525 @note Thread Agnostic
527 config.add_section(constants.__pretty_app_name__)
528 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
529 for i, value in enumerate(self._credentials):
530 blob = base64.b64encode(value)
531 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
532 for backendId, view in itertools.chain(
533 self._dialpads.iteritems(),
534 self._accountViews.iteritems(),
535 self._messagesViews.iteritems(),
536 self._recentViews.iteritems(),
537 self._contactsViews.iteritems(),
539 sectionName = "%s - %s" % (backendId, view.name())
540 config.add_section(sectionName)
541 view.save_settings(config, sectionName)
543 def _guess_preferred_backend(self, backendAndCookiePaths):
545 (getmtime_nothrow(path), backendId, path)
546 for backendId, path in backendAndCookiePaths
548 modTimeAndPath.sort()
549 return modTimeAndPath[-1][1]
551 def _save_settings(self):
553 @note Thread Agnostic
555 config = ConfigParser.SafeConfigParser()
556 self.save_settings(config)
557 with open(self._user_settings, "wb") as configFile:
558 config.write(configFile)
560 def _refresh_active_tab(self):
561 page_num = self._notebook.get_current_page()
562 if page_num == self.CONTACTS_TAB:
563 self._contactsViews[self._selectedBackendId].update(force=True)
564 elif page_num == self.RECENT_TAB:
565 self._recentViews[self._selectedBackendId].update(force=True)
566 elif page_num == self.MESSAGES_TAB:
567 self._messagesViews[self._selectedBackendId].update(force=True)
569 def _on_close(self, *args, **kwds):
571 if self._osso is not None:
575 self._save_settings()
579 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
581 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
582 For system_inactivity, we have no background tasks to pause
584 @note Hildon specific
587 for backendId in self.BACKENDS:
588 self._phoneBackends[backendId].clear_caches()
589 self._contactsViews[self._selectedBackendId].clear_caches()
592 if save_unsaved_data or shutdown:
593 self._save_settings()
595 def _on_connection_change(self, connection, event, magicIdentifier):
597 @note Hildon specific
601 status = event.get_status()
602 error = event.get_error()
603 iap_id = event.get_iap_id()
604 bearer = event.get_bearer_type()
606 if status == conic.STATUS_CONNECTED:
607 self._deviceIsOnline = True
609 self._spawn_attempt_login(2)
610 elif status == conic.STATUS_DISCONNECTED:
611 self._deviceIsOnline = False
613 self._defaultBackendId = self._selectedBackendId
614 self._change_loggedin_status(self.NULL_BACKEND)
616 def _on_window_state_change(self, widget, event, *args):
618 @note Hildon specific
620 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
621 self._isFullScreen = True
623 self._isFullScreen = False
625 def _on_key_press(self, widget, event, *args):
627 @note Hildon specific
629 if event.keyval == gtk.keysyms.F6:
630 if self._isFullScreen:
631 self._window.unfullscreen()
633 self._window.fullscreen()
635 def _on_clearcookies_clicked(self, *args):
636 self._phoneBackends[self._selectedBackendId].logout()
637 self._accountViews[self._selectedBackendId].clear()
638 self._recentViews[self._selectedBackendId].clear()
639 self._messagesViews[self._selectedBackendId].clear()
640 self._contactsViews[self._selectedBackendId].clear()
641 self._change_loggedin_status(self.NULL_BACKEND)
643 self._spawn_attempt_login(2, True)
645 def _on_notebook_switch_page(self, notebook, page, page_num):
646 if page_num == self.RECENT_TAB:
647 self._recentViews[self._selectedBackendId].update()
648 elif page_num == self.MESSAGES_TAB:
649 self._messagesViews[self._selectedBackendId].update()
650 elif page_num == self.CONTACTS_TAB:
651 self._contactsViews[self._selectedBackendId].update()
652 elif page_num == self.ACCOUNT_TAB:
653 self._accountViews[self._selectedBackendId].update()
655 def _on_tab_press(self, *args):
656 self._tabHoldTimeoutId = gobject.timeout_add(1000, self._on_tab_refresh)
658 def _on_tab_release(self, *args):
659 if self._tabHoldTimeoutId is not None:
660 gobject.source_remove(self._tabHoldTimeoutId)
661 self._tabHoldTimeoutId = None
663 def _on_tab_refresh(self, *args):
664 self._tabHoldTimeoutId = None
665 self._refresh_active_tab()
668 def _on_sms_clicked(self, number, message):
672 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
673 except StandardError, e:
675 self._errorDisplay.push_exception(e)
679 self._errorDisplay.push_message(
680 "Backend link with grandcentral is not working, please try again"
686 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
688 except StandardError, e:
689 self._errorDisplay.push_exception(e)
690 except ValueError, e:
691 self._errorDisplay.push_exception(e)
693 def _on_dial_clicked(self, number):
694 assert number, "No number to call"
696 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
697 except StandardError, e:
699 self._errorDisplay.push_exception(e)
703 self._errorDisplay.push_message(
704 "Backend link with grandcentral is not working, please try again"
710 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
711 self._phoneBackends[self._selectedBackendId].dial(number)
713 except StandardError, e:
714 self._errorDisplay.push_exception(e)
715 except ValueError, e:
716 self._errorDisplay.push_exception(e)
719 self._dialpads[self._selectedBackendId].clear()
721 def _on_menu_refresh(self, *args):
722 self._refresh_active_tab()
724 def _on_paste(self, *args):
725 contents = self._clipboard.wait_for_text()
726 self._dialpads[self._selectedBackendId].set_number(contents)
728 def _on_about_activate(self, *args):
729 dlg = gtk.AboutDialog()
730 dlg.set_name(constants.__pretty_app_name__)
731 dlg.set_version(constants.__version__)
732 dlg.set_copyright("Copyright 2008 - LGPL")
733 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")
734 dlg.set_website("http://gc-dialer.garage.maemo.org/")
735 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
743 failureCount, testCount = doctest.testmod()
745 print "Tests Successful"
752 gtk.gdk.threads_init()
753 if hildon is not None:
754 gtk.set_application_name(constants.__pretty_app_name__)
755 handle = Dialcentral()
759 class DummyOptions(object):
765 if __name__ == "__main__":
766 if len(sys.argv) > 1:
772 if optparse is not None:
773 parser = optparse.OptionParser()
774 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
775 (commandOptions, commandArgs) = parser.parse_args()
777 commandOptions = DummyOptions()
780 if commandOptions.test: