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 @bug Session timeouts are bad, possible solutions:
23 @li For every X minutes, if logged in, attempt login
24 @li Restructure so code handling login/dial/sms is beneath everything else and login attempts are made if those fail
25 @todo Can't text from dialpad (so can't do any arbitrary number texts)
26 @todo Add logging support to make debugging issues for people a lot easier
30 from __future__ import with_statement
53 def getmtime_nothrow(path):
55 return os.path.getmtime(path)
60 def display_error_message(msg):
61 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
63 def close(dialog, response):
65 error_dialog.connect("response", close)
69 class Dialcentral(object):
72 '/usr/lib/dialcentral/dialcentral.glade',
73 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
74 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
86 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
88 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
89 _user_settings = "%s/settings.ini" % _data_path
92 self._initDone = False
93 self._connection = None
95 self._clipboard = gtk.clipboard_get()
97 self._deviceIsOnline = True
98 self._credentials = ("", "")
99 self._selectedBackendId = self.NULL_BACKEND
100 self._defaultBackendId = self.GC_BACKEND
101 self._phoneBackends = None
102 self._dialpads = None
103 self._accountViews = None
104 self._messagesViews = None
105 self._recentViews = None
106 self._contactsViews = None
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)
130 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
131 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
132 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
133 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
134 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
135 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
137 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
139 for child in gtkMenu.get_children():
141 self._window.set_menu(menu)
144 self._window.connect("key-press-event", self._on_key_press)
145 self._window.connect("window-state-event", self._on_window_state_change)
147 pass # warnings.warn("No Hildon", UserWarning, 2)
149 if hildon is not None:
150 self._window.set_title("Keypad")
152 self._window.set_title("%s - Keypad" % constants.__pretty_app_name__)
155 "on_dialpad_quit": self._on_close,
157 self._widgetTree.signal_autoconnect(callbackMapping)
160 self._window.connect("destroy", self._on_close)
161 self._window.show_all()
162 self._window.set_default_size(800, 300)
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
172 # Barebones UI handlers
176 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
177 with gtk_toolbox.gtk_lock():
178 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
179 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
180 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
181 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
182 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
184 self._dialpads[self._selectedBackendId].enable()
185 self._accountViews[self._selectedBackendId].enable()
186 self._recentViews[self._selectedBackendId].enable()
187 self._messagesViews[self._selectedBackendId].enable()
188 self._contactsViews[self._selectedBackendId].enable()
190 # Setup maemo specifics
197 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
198 device = osso.DeviceState(self._osso)
199 device.set_device_state_callback(self._on_device_state_change, 0)
201 pass # warnings.warn("No OSSO", UserWarning)
203 # Setup maemo specifics
208 self._connection = None
209 if conic is not None:
210 self._connection = conic.Connection()
211 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
212 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
214 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
216 # Setup costly backends
224 os.makedirs(self._data_path)
228 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
229 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
230 self._defaultBackendId = self._guess_preferred_backend((
231 (self.GC_BACKEND, gcCookiePath),
232 (self.GV_BACKEND, gvCookiePath),
235 self._phoneBackends.update({
236 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
237 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
239 with gtk_toolbox.gtk_lock():
240 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
241 unifiedDialpad.set_number("")
242 self._dialpads.update({
243 self.GC_BACKEND: unifiedDialpad,
244 self.GV_BACKEND: unifiedDialpad,
246 self._accountViews.update({
247 self.GC_BACKEND: gc_views.AccountInfo(
248 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
250 self.GV_BACKEND: gc_views.AccountInfo(
251 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
254 self._recentViews.update({
255 self.GC_BACKEND: gc_views.RecentCallsView(
256 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
258 self.GV_BACKEND: gc_views.RecentCallsView(
259 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
262 self._messagesViews.update({
263 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
264 self.GV_BACKEND: gc_views.MessagesView(
265 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
268 self._contactsViews.update({
269 self.GC_BACKEND: gc_views.ContactsView(
270 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
272 self.GV_BACKEND: gc_views.ContactsView(
273 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
277 evoBackend = evo_backend.EvolutionAddressBook()
278 fsContactsPath = os.path.join(self._data_path, "contacts")
279 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
280 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
281 self._dialpads[backendId].number_selected = self._select_action
282 self._recentViews[backendId].number_selected = self._select_action
283 self._messagesViews[backendId].number_selected = self._select_action
284 self._contactsViews[backendId].number_selected = self._select_action
287 self._phoneBackends[backendId],
291 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
292 self._contactsViews[backendId].append(mergedBook)
293 self._contactsViews[backendId].extend(addressBooks)
294 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
297 "on_paste": self._on_paste,
298 "on_refresh": self._on_refresh,
299 "on_clearcookies_clicked": self._on_clearcookies_clicked,
300 "on_notebook_switch_page": self._on_notebook_switch_page,
301 "on_about_activate": self._on_about_activate,
303 self._widgetTree.signal_autoconnect(callbackMapping)
305 self._initDone = True
307 config = ConfigParser.SafeConfigParser()
308 config.read(self._user_settings)
309 with gtk_toolbox.gtk_lock():
310 self.load_settings(config)
312 self.attempt_login(2)
314 def attempt_login(self, numOfAttempts = 10, force = False):
316 @todo Handle user notification better like attempting to login and failed login
318 @note This must be run outside of the UI lock
321 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
322 assert self._initDone, "Attempting login before app is fully loaded"
323 if not self._deviceIsOnline:
324 raise RuntimeError("Unable to login, device is not online")
326 serviceId = self.NULL_BACKEND
330 self.refresh_session()
331 serviceId = self._defaultBackendId
333 except StandardError, e:
334 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
337 loggedIn, serviceId = self._login_by_user(numOfAttempts)
339 with gtk_toolbox.gtk_lock():
340 self._change_loggedin_status(serviceId)
341 except StandardError, e:
342 with gtk_toolbox.gtk_lock():
343 self._errorDisplay.push_exception(e)
345 def refresh_session(self):
347 @note Thread agnostic
349 assert self._initDone, "Attempting login before app is fully loaded"
350 if not self._deviceIsOnline:
351 raise RuntimeError("Unable to login, device is not online")
355 loggedIn = self._login_by_cookie()
357 loggedIn = self._login_by_settings()
360 raise RuntimeError("Login Failed")
362 def _login_by_cookie(self):
364 @note Thread agnostic
366 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
369 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
374 def _login_by_settings(self):
376 @note Thread agnostic
378 username, password = self._credentials
379 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
381 self._credentials = username, password
383 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
388 def _login_by_user(self, numOfAttempts):
390 @note This must be run outside of the UI lock
392 loggedIn, (username, password) = False, self._credentials
393 tmpServiceId = self.NULL_BACKEND
394 for attemptCount in xrange(numOfAttempts):
397 availableServices = {
398 self.GV_BACKEND: "Google Voice",
399 self.GC_BACKEND: "Grand Central",
401 with gtk_toolbox.gtk_lock():
402 credentials = self._credentialsDialog.request_credentials_from(
403 availableServices, defaultCredentials = self._credentials
405 tmpServiceId, username, password = credentials
406 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
409 serviceId = tmpServiceId
410 self._credentials = username, password
412 "Logged into %r through user request" % self._phoneBackends[serviceId],
416 serviceId = self.NULL_BACKEND
418 return loggedIn, serviceId
420 def _select_action(self, action, number, message):
421 self.refresh_session()
422 if action == "select":
423 self._dialpads[self._selectedBackendId].set_number(number)
424 self._notebook.set_current_page(self.KEYPAD_TAB)
425 elif action == "dial":
426 self._on_dial_clicked(number)
427 elif action == "sms":
428 self._on_sms_clicked(number, message)
430 assert False, "Unknown action: %s" % action
432 def _change_loggedin_status(self, newStatus):
433 oldStatus = self._selectedBackendId
434 if oldStatus == newStatus:
437 self._dialpads[oldStatus].disable()
438 self._accountViews[oldStatus].disable()
439 self._recentViews[oldStatus].disable()
440 self._messagesViews[oldStatus].disable()
441 self._contactsViews[oldStatus].disable()
443 self._dialpads[newStatus].enable()
444 self._accountViews[newStatus].enable()
445 self._recentViews[newStatus].enable()
446 self._messagesViews[newStatus].enable()
447 self._contactsViews[newStatus].enable()
449 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
450 self._phoneBackends[self._selectedBackendId].set_sane_callback()
451 self._accountViews[self._selectedBackendId].update()
453 self._selectedBackendId = newStatus
455 def load_settings(self, config):
460 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
462 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
463 for i in xrange(len(self._credentials))
466 base64.b64decode(blob)
469 self._credentials = tuple(creds)
470 except ConfigParser.NoSectionError, e:
472 "Settings file %s is missing section %s" % (
479 for backendId, view in itertools.chain(
480 self._dialpads.iteritems(),
481 self._accountViews.iteritems(),
482 self._messagesViews.iteritems(),
483 self._recentViews.iteritems(),
484 self._contactsViews.iteritems(),
486 sectionName = "%s - %s" % (backendId, view.name())
488 view.load_settings(config, sectionName)
489 except ConfigParser.NoSectionError, e:
491 "Settings file %s is missing section %s" % (
498 def save_settings(self, config):
500 @note Thread Agnostic
502 config.add_section(constants.__pretty_app_name__)
503 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
504 for i, value in enumerate(self._credentials):
505 blob = base64.b64encode(value)
506 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
507 for backendId, view in itertools.chain(
508 self._dialpads.iteritems(),
509 self._accountViews.iteritems(),
510 self._messagesViews.iteritems(),
511 self._recentViews.iteritems(),
512 self._contactsViews.iteritems(),
514 sectionName = "%s - %s" % (backendId, view.name())
515 config.add_section(sectionName)
516 view.save_settings(config, sectionName)
518 def _guess_preferred_backend(self, backendAndCookiePaths):
520 (getmtime_nothrow(path), backendId, path)
521 for backendId, path in backendAndCookiePaths
523 modTimeAndPath.sort()
524 return modTimeAndPath[-1][1]
526 def _save_settings(self):
528 @note Thread Agnostic
530 config = ConfigParser.SafeConfigParser()
531 self.save_settings(config)
532 with open(self._user_settings, "wb") as configFile:
533 config.write(configFile)
535 def _on_close(self, *args, **kwds):
537 if self._osso is not None:
541 self._save_settings()
545 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
547 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
548 For system_inactivity, we have no background tasks to pause
550 @note Hildon specific
553 for backendId in self.BACKENDS:
554 self._phoneBackends[backendId].clear_caches()
555 self._contactsViews[self._selectedBackendId].clear_caches()
558 if save_unsaved_data or shutdown:
559 self._save_settings()
561 def _on_connection_change(self, connection, event, magicIdentifier):
563 @note Hildon specific
567 status = event.get_status()
568 error = event.get_error()
569 iap_id = event.get_iap_id()
570 bearer = event.get_bearer_type()
572 if status == conic.STATUS_CONNECTED:
573 self._deviceIsOnline = True
575 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
576 backgroundLogin.setDaemon(True)
577 backgroundLogin.start()
578 elif status == conic.STATUS_DISCONNECTED:
579 self._deviceIsOnline = False
581 self._defaultBackendId = self._selectedBackendId
582 self._change_loggedin_status(self.NULL_BACKEND)
584 def _on_window_state_change(self, widget, event, *args):
586 @note Hildon specific
588 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
589 self._isFullScreen = True
591 self._isFullScreen = False
593 def _on_key_press(self, widget, event, *args):
595 @note Hildon specific
597 if event.keyval == gtk.keysyms.F6:
598 if self._isFullScreen:
599 self._window.unfullscreen()
601 self._window.fullscreen()
603 def _on_clearcookies_clicked(self, *args):
604 self._phoneBackends[self._selectedBackendId].logout()
605 self._accountViews[self._selectedBackendId].clear()
606 self._recentViews[self._selectedBackendId].clear()
607 self._messagesViews[self._selectedBackendId].clear()
608 self._contactsViews[self._selectedBackendId].clear()
609 self._change_loggedin_status(self.NULL_BACKEND)
611 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2, True])
612 backgroundLogin.setDaemon(True)
613 backgroundLogin.start()
615 def _on_notebook_switch_page(self, notebook, page, page_num):
616 if page_num == self.RECENT_TAB:
617 self._recentViews[self._selectedBackendId].update()
618 elif page_num == self.MESSAGES_TAB:
619 self._messagesViews[self._selectedBackendId].update()
620 elif page_num == self.CONTACTS_TAB:
621 self._contactsViews[self._selectedBackendId].update()
622 elif page_num == self.ACCOUNT_TAB:
623 self._accountViews[self._selectedBackendId].update()
625 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
626 if hildon is not None:
627 self._window.set_title(tabTitle)
629 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
631 def _on_sms_clicked(self, number, message):
633 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
638 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
639 except RuntimeError, e:
641 self._errorDisplay.push_exception(e)
645 self._errorDisplay.push_message(
646 "Backend link with grandcentral is not working, please try again"
652 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
654 except RuntimeError, e:
655 self._errorDisplay.push_exception(e)
656 except ValueError, e:
657 self._errorDisplay.push_exception(e)
659 def _on_dial_clicked(self, number):
661 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
665 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
666 except RuntimeError, e:
668 self._errorDisplay.push_exception(e)
672 self._errorDisplay.push_message(
673 "Backend link with grandcentral is not working, please try again"
679 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
680 self._phoneBackends[self._selectedBackendId].dial(number)
682 except RuntimeError, e:
683 self._errorDisplay.push_exception(e)
684 except ValueError, e:
685 self._errorDisplay.push_exception(e)
688 self._dialpads[self._selectedBackendId].clear()
690 def _on_refresh(self, *args):
691 page_num = self._notebook.get_current_page()
692 if page_num == self.CONTACTS_TAB:
693 self._contactsViews[self._selectedBackendId].update(force=True)
694 elif page_num == self.RECENT_TAB:
695 self._recentViews[self._selectedBackendId].update(force=True)
696 elif page_num == self.MESSAGES_TAB:
697 self._messagesViews[self._selectedBackendId].update(force=True)
699 def _on_paste(self, *args):
700 contents = self._clipboard.wait_for_text()
701 self._dialpads[self._selectedBackendId].set_number(contents)
703 def _on_about_activate(self, *args):
704 dlg = gtk.AboutDialog()
705 dlg.set_name(constants.__pretty_app_name__)
706 dlg.set_version(constants.__version__)
707 dlg.set_copyright("Copyright 2008 - LGPL")
708 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")
709 dlg.set_website("http://gc-dialer.garage.maemo.org/")
710 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
718 failureCount, testCount = doctest.testmod()
720 print "Tests Successful"
727 gtk.gdk.threads_init()
728 if hildon is not None:
729 gtk.set_application_name(constants.__pretty_app_name__)
730 handle = Dialcentral()
734 class DummyOptions(object):
740 if __name__ == "__main__":
741 if len(sys.argv) > 1:
747 if optparse is not None:
748 parser = optparse.OptionParser()
749 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
750 (commandOptions, commandArgs) = parser.parse_args()
752 commandOptions = DummyOptions()
755 if commandOptions.test: