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._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)
133 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
134 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
135 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
137 warnings.warn(e.message)
138 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
142 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
144 for child in gtkMenu.get_children():
146 self._window.set_menu(menu)
149 self._window.connect("key-press-event", self._on_key_press)
150 self._window.connect("window-state-event", self._on_window_state_change)
152 pass # warnings.warn("No Hildon", UserWarning, 2)
154 # If under hildon, rely on the application name being shown
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)
316 self._originalCurrentLabel = ""
317 with gtk_toolbox.gtk_lock():
318 self._backup_tab_name()
319 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
320 self._notebookTapHandler.enable()
321 self._notebookTapHandler.on_tap = self._reset_tab_refresh
322 self._notebookTapHandler.on_hold = self._on_tab_refresh
323 self._notebookTapHandler.on_holding = self._set_tab_refresh
324 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
326 self._initDone = True
328 config = ConfigParser.SafeConfigParser()
329 config.read(self._user_settings)
330 with gtk_toolbox.gtk_lock():
331 self.load_settings(config)
333 self._spawn_attempt_login(2)
334 except StandardError, e:
335 warnings.warn(e.message, UserWarning, 2)
336 except BaseException, e:
338 warnings.warn(e.message, UserWarning, 2)
342 def attempt_login(self, numOfAttempts = 10, force = False):
344 @todo Handle user notification better like attempting to login and failed login
346 @note This must be run outside of the UI lock
349 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
350 assert self._initDone, "Attempting login before app is fully loaded"
352 serviceId = self.NULL_BACKEND
356 self.refresh_session()
357 serviceId = self._defaultBackendId
359 except StandardError, e:
360 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
363 loggedIn, serviceId = self._login_by_user(numOfAttempts)
365 with gtk_toolbox.gtk_lock():
366 self._change_loggedin_status(serviceId)
367 except StandardError, e:
368 with gtk_toolbox.gtk_lock():
369 self._errorDisplay.push_exception(e)
371 def _spawn_attempt_login(self, *args):
372 self._loginSink.send(args)
374 def refresh_session(self):
376 @note Thread agnostic
378 assert self._initDone, "Attempting login before app is fully loaded"
382 loggedIn = self._login_by_cookie()
384 loggedIn = self._login_by_settings()
387 raise RuntimeError("Login Failed")
389 def _login_by_cookie(self):
391 @note Thread agnostic
393 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
396 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
401 def _login_by_settings(self):
403 @note Thread agnostic
405 username, password = self._credentials
406 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
408 self._credentials = username, password
410 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
415 def _login_by_user(self, numOfAttempts):
417 @note This must be run outside of the UI lock
419 loggedIn, (username, password) = False, self._credentials
420 tmpServiceId = self.NULL_BACKEND
421 for attemptCount in xrange(numOfAttempts):
424 availableServices = (
425 (self.GV_BACKEND, "Google Voice"),
426 (self.GC_BACKEND, "Grand Central"),
428 with gtk_toolbox.gtk_lock():
429 credentials = self._credentialsDialog.request_credentials_from(
430 availableServices, defaultCredentials = self._credentials
432 tmpServiceId, username, password = credentials
433 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
436 serviceId = tmpServiceId
437 self._credentials = username, password
439 "Logged into %r through user request" % self._phoneBackends[serviceId],
443 serviceId = self.NULL_BACKEND
445 return loggedIn, serviceId
447 def _select_action(self, action, number, message):
448 self.refresh_session()
449 if action == "select":
450 self._dialpads[self._selectedBackendId].set_number(number)
451 self._notebook.set_current_page(self.KEYPAD_TAB)
452 elif action == "dial":
453 self._on_dial_clicked(number)
454 elif action == "sms":
455 self._on_sms_clicked(number, message)
457 assert False, "Unknown action: %s" % action
459 def _change_loggedin_status(self, newStatus):
460 oldStatus = self._selectedBackendId
461 if oldStatus == newStatus:
464 self._dialpads[oldStatus].disable()
465 self._accountViews[oldStatus].disable()
466 self._recentViews[oldStatus].disable()
467 self._messagesViews[oldStatus].disable()
468 self._contactsViews[oldStatus].disable()
470 self._dialpads[newStatus].enable()
471 self._accountViews[newStatus].enable()
472 self._recentViews[newStatus].enable()
473 self._messagesViews[newStatus].enable()
474 self._contactsViews[newStatus].enable()
476 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
477 self._phoneBackends[self._selectedBackendId].set_sane_callback()
478 self._accountViews[self._selectedBackendId].update()
480 self._selectedBackendId = newStatus
482 def load_settings(self, config):
487 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
489 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
490 for i in xrange(len(self._credentials))
493 base64.b64decode(blob)
496 self._credentials = tuple(creds)
497 except ConfigParser.NoSectionError, e:
499 "Settings file %s is missing section %s" % (
506 for backendId, view in itertools.chain(
507 self._dialpads.iteritems(),
508 self._accountViews.iteritems(),
509 self._messagesViews.iteritems(),
510 self._recentViews.iteritems(),
511 self._contactsViews.iteritems(),
513 sectionName = "%s - %s" % (backendId, view.name())
515 view.load_settings(config, sectionName)
516 except ConfigParser.NoSectionError, e:
518 "Settings file %s is missing section %s" % (
525 def save_settings(self, config):
527 @note Thread Agnostic
529 config.add_section(constants.__pretty_app_name__)
530 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
531 for i, value in enumerate(self._credentials):
532 blob = base64.b64encode(value)
533 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
534 for backendId, view in itertools.chain(
535 self._dialpads.iteritems(),
536 self._accountViews.iteritems(),
537 self._messagesViews.iteritems(),
538 self._recentViews.iteritems(),
539 self._contactsViews.iteritems(),
541 sectionName = "%s - %s" % (backendId, view.name())
542 config.add_section(sectionName)
543 view.save_settings(config, sectionName)
545 def _guess_preferred_backend(self, backendAndCookiePaths):
547 (getmtime_nothrow(path), backendId, path)
548 for backendId, path in backendAndCookiePaths
550 modTimeAndPath.sort()
551 return modTimeAndPath[-1][1]
553 def _save_settings(self):
555 @note Thread Agnostic
557 config = ConfigParser.SafeConfigParser()
558 self.save_settings(config)
559 with open(self._user_settings, "wb") as configFile:
560 config.write(configFile)
562 def _refresh_active_tab(self):
563 pageIndex = self._notebook.get_current_page()
564 if pageIndex == self.CONTACTS_TAB:
565 self._contactsViews[self._selectedBackendId].update(force=True)
566 elif pageIndex == self.RECENT_TAB:
567 self._recentViews[self._selectedBackendId].update(force=True)
568 elif pageIndex == self.MESSAGES_TAB:
569 self._messagesViews[self._selectedBackendId].update(force=True)
571 def _on_close(self, *args, **kwds):
573 if self._osso is not None:
577 self._save_settings()
581 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
583 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
584 For system_inactivity, we have no background tasks to pause
586 @note Hildon specific
589 for backendId in self.BACKENDS:
590 self._phoneBackends[backendId].clear_caches()
591 self._contactsViews[self._selectedBackendId].clear_caches()
594 if save_unsaved_data or shutdown:
595 self._save_settings()
597 def _on_connection_change(self, connection, event, magicIdentifier):
599 @note Hildon specific
603 status = event.get_status()
604 error = event.get_error()
605 iap_id = event.get_iap_id()
606 bearer = event.get_bearer_type()
608 if status == conic.STATUS_CONNECTED:
610 self._spawn_attempt_login(2)
611 elif status == conic.STATUS_DISCONNECTED:
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, pageIndex):
646 self._reset_tab_refresh()
647 self._backup_tab_name(pageIndex)
648 if pageIndex == self.RECENT_TAB:
649 self._recentViews[self._selectedBackendId].update()
650 elif pageIndex == self.MESSAGES_TAB:
651 self._messagesViews[self._selectedBackendId].update()
652 elif pageIndex == self.CONTACTS_TAB:
653 self._contactsViews[self._selectedBackendId].update()
654 elif pageIndex == self.ACCOUNT_TAB:
655 self._accountViews[self._selectedBackendId].update()
657 def _backup_tab_name(self, pageIndex = -1):
659 pageIndex = self._notebook.get_current_page()
660 child = self._notebook.get_nth_page(pageIndex)
661 self._originalCurrentLabel = self._notebook.get_tab_label(child).get_text()
663 def _set_tab_refresh(self, *args):
664 pageIndex = self._notebook.get_current_page()
665 child = self._notebook.get_nth_page(pageIndex)
666 self._notebook.get_tab_label(child).set_text("Refresh?")
669 def _reset_tab_refresh(self, *args):
670 pageIndex = self._notebook.get_current_page()
671 child = self._notebook.get_nth_page(pageIndex)
672 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabel)
675 def _on_tab_refresh(self, *args):
676 self._refresh_active_tab()
677 self._reset_tab_refresh()
680 def _on_sms_clicked(self, number, message):
681 assert number, "No number specified"
682 assert message, "Empty message"
684 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
685 except StandardError, e:
687 self._errorDisplay.push_exception(e)
691 self._errorDisplay.push_message(
692 "Backend link with grandcentral is not working, please try again"
698 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
700 except StandardError, e:
701 self._errorDisplay.push_exception(e)
702 except ValueError, e:
703 self._errorDisplay.push_exception(e)
705 def _on_dial_clicked(self, number):
706 assert number, "No number to call"
708 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
709 except StandardError, e:
711 self._errorDisplay.push_exception(e)
715 self._errorDisplay.push_message(
716 "Backend link with grandcentral is not working, please try again"
722 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
723 self._phoneBackends[self._selectedBackendId].dial(number)
725 except StandardError, e:
726 self._errorDisplay.push_exception(e)
727 except ValueError, e:
728 self._errorDisplay.push_exception(e)
731 self._dialpads[self._selectedBackendId].clear()
733 def _on_menu_refresh(self, *args):
734 self._refresh_active_tab()
736 def _on_paste(self, *args):
737 contents = self._clipboard.wait_for_text()
738 self._dialpads[self._selectedBackendId].set_number(contents)
740 def _on_about_activate(self, *args):
741 dlg = gtk.AboutDialog()
742 dlg.set_name(constants.__pretty_app_name__)
743 dlg.set_version(constants.__version__)
744 dlg.set_copyright("Copyright 2008 - LGPL")
745 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")
746 dlg.set_website("http://gc-dialer.garage.maemo.org/")
747 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
755 failureCount, testCount = doctest.testmod()
757 print "Tests Successful"
764 gtk.gdk.threads_init()
765 if hildon is not None:
766 gtk.set_application_name(constants.__pretty_app_name__)
767 handle = Dialcentral()
771 class DummyOptions(object):
777 if __name__ == "__main__":
778 if len(sys.argv) > 1:
784 if optparse is not None:
785 parser = optparse.OptionParser()
786 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
787 (commandOptions, commandArgs) = parser.parse_args()
789 commandOptions = DummyOptions()
792 if commandOptions.test: