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 Look into an actor system
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)
132 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
133 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
134 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
136 warnings.warn(e.message)
137 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
138 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
141 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
143 for child in gtkMenu.get_children():
145 self._window.set_menu(menu)
148 self._window.connect("key-press-event", self._on_key_press)
149 self._window.connect("window-state-event", self._on_window_state_change)
151 pass # warnings.warn("No Hildon", UserWarning, 2)
153 if hildon is not None:
154 self._window.set_title("Keypad")
156 self._window.set_title("%s - Keypad" % 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 backgroundSetup = threading.Thread(target=self._idle_setup)
168 backgroundSetup.setDaemon(True)
169 backgroundSetup.start()
171 def _idle_setup(self):
173 If something can be done after the UI loads, push it here so it's not blocking the UI
176 # Barebones UI handlers
180 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
181 with gtk_toolbox.gtk_lock():
182 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
183 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
184 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
185 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
186 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
188 self._dialpads[self._selectedBackendId].enable()
189 self._accountViews[self._selectedBackendId].enable()
190 self._recentViews[self._selectedBackendId].enable()
191 self._messagesViews[self._selectedBackendId].enable()
192 self._contactsViews[self._selectedBackendId].enable()
194 # Setup maemo specifics
201 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
202 device = osso.DeviceState(self._osso)
203 device.set_device_state_callback(self._on_device_state_change, 0)
205 pass # warnings.warn("No OSSO", UserWarning, 2)
207 # Setup maemo specifics
212 self._connection = None
213 if conic is not None:
214 self._connection = conic.Connection()
215 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
216 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
218 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
220 # Setup costly backends
228 os.makedirs(self._data_path)
232 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
233 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
234 self._defaultBackendId = self._guess_preferred_backend((
235 (self.GC_BACKEND, gcCookiePath),
236 (self.GV_BACKEND, gvCookiePath),
239 self._phoneBackends.update({
240 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
241 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
243 with gtk_toolbox.gtk_lock():
244 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
245 unifiedDialpad.set_number("")
246 self._dialpads.update({
247 self.GC_BACKEND: unifiedDialpad,
248 self.GV_BACKEND: unifiedDialpad,
250 self._accountViews.update({
251 self.GC_BACKEND: gc_views.AccountInfo(
252 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
254 self.GV_BACKEND: gc_views.AccountInfo(
255 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
258 self._recentViews.update({
259 self.GC_BACKEND: gc_views.RecentCallsView(
260 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
262 self.GV_BACKEND: gc_views.RecentCallsView(
263 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
266 self._messagesViews.update({
267 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
268 self.GV_BACKEND: gc_views.MessagesView(
269 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
272 self._contactsViews.update({
273 self.GC_BACKEND: gc_views.ContactsView(
274 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
276 self.GV_BACKEND: gc_views.ContactsView(
277 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
281 evoBackend = evo_backend.EvolutionAddressBook()
282 fsContactsPath = os.path.join(self._data_path, "contacts")
283 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
284 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
285 self._dialpads[backendId].number_selected = self._select_action
286 self._recentViews[backendId].number_selected = self._select_action
287 self._messagesViews[backendId].number_selected = self._select_action
288 self._contactsViews[backendId].number_selected = self._select_action
291 self._phoneBackends[backendId],
295 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
296 self._contactsViews[backendId].append(mergedBook)
297 self._contactsViews[backendId].extend(addressBooks)
298 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
301 "on_paste": self._on_paste,
302 "on_refresh": self._on_refresh,
303 "on_clearcookies_clicked": self._on_clearcookies_clicked,
304 "on_notebook_switch_page": self._on_notebook_switch_page,
305 "on_about_activate": self._on_about_activate,
307 self._widgetTree.signal_autoconnect(callbackMapping)
309 self._initDone = True
311 config = ConfigParser.SafeConfigParser()
312 config.read(self._user_settings)
313 with gtk_toolbox.gtk_lock():
314 self.load_settings(config)
316 gtk_toolbox.asynchronous_gtk_message(self._spawn_attempt_login)(2)
317 except StandardError, e:
318 warnings.warn(e.message, UserWarning, 2)
319 except BaseException, e:
321 warnings.warn(e.message, UserWarning, 2)
325 def attempt_login(self, numOfAttempts = 10, force = False):
327 @todo Handle user notification better like attempting to login and failed login
329 @note This must be run outside of the UI lock
332 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
333 assert self._initDone, "Attempting login before app is fully loaded"
334 if not self._deviceIsOnline:
335 raise RuntimeError("Unable to login, device is not online")
337 serviceId = self.NULL_BACKEND
341 self.refresh_session()
342 serviceId = self._defaultBackendId
344 except StandardError, e:
345 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
348 loggedIn, serviceId = self._login_by_user(numOfAttempts)
350 with gtk_toolbox.gtk_lock():
351 self._change_loggedin_status(serviceId)
352 except StandardError, e:
353 with gtk_toolbox.gtk_lock():
354 self._errorDisplay.push_exception(e)
356 def _spawn_attempt_login(self, *args):
357 backgroundLogin = threading.Thread(target=self.attempt_login, args=args)
358 backgroundLogin.setDaemon(True)
359 backgroundLogin.start()
361 def refresh_session(self):
363 @note Thread agnostic
365 assert self._initDone, "Attempting login before app is fully loaded"
366 if not self._deviceIsOnline:
367 raise RuntimeError("Unable to login, device is not online")
371 loggedIn = self._login_by_cookie()
373 loggedIn = self._login_by_settings()
376 raise RuntimeError("Login Failed")
378 def _login_by_cookie(self):
380 @note Thread agnostic
382 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
385 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
390 def _login_by_settings(self):
392 @note Thread agnostic
394 username, password = self._credentials
395 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
397 self._credentials = username, password
399 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
404 def _login_by_user(self, numOfAttempts):
406 @note This must be run outside of the UI lock
408 loggedIn, (username, password) = False, self._credentials
409 tmpServiceId = self.NULL_BACKEND
410 for attemptCount in xrange(numOfAttempts):
413 availableServices = {
414 self.GV_BACKEND: "Google Voice",
415 self.GC_BACKEND: "Grand Central",
417 with gtk_toolbox.gtk_lock():
418 credentials = self._credentialsDialog.request_credentials_from(
419 availableServices, defaultCredentials = self._credentials
421 tmpServiceId, username, password = credentials
422 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
425 serviceId = tmpServiceId
426 self._credentials = username, password
428 "Logged into %r through user request" % self._phoneBackends[serviceId],
432 serviceId = self.NULL_BACKEND
434 return loggedIn, serviceId
436 def _select_action(self, action, number, message):
437 self.refresh_session()
438 if action == "select":
439 self._dialpads[self._selectedBackendId].set_number(number)
440 self._notebook.set_current_page(self.KEYPAD_TAB)
441 elif action == "dial":
442 self._on_dial_clicked(number)
443 elif action == "sms":
444 self._on_sms_clicked(number, message)
446 assert False, "Unknown action: %s" % action
448 def _change_loggedin_status(self, newStatus):
449 oldStatus = self._selectedBackendId
450 if oldStatus == newStatus:
453 self._dialpads[oldStatus].disable()
454 self._accountViews[oldStatus].disable()
455 self._recentViews[oldStatus].disable()
456 self._messagesViews[oldStatus].disable()
457 self._contactsViews[oldStatus].disable()
459 self._dialpads[newStatus].enable()
460 self._accountViews[newStatus].enable()
461 self._recentViews[newStatus].enable()
462 self._messagesViews[newStatus].enable()
463 self._contactsViews[newStatus].enable()
465 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
466 self._phoneBackends[self._selectedBackendId].set_sane_callback()
467 self._accountViews[self._selectedBackendId].update()
469 self._selectedBackendId = newStatus
471 def load_settings(self, config):
476 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
478 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
479 for i in xrange(len(self._credentials))
482 base64.b64decode(blob)
485 self._credentials = tuple(creds)
486 except ConfigParser.NoSectionError, e:
488 "Settings file %s is missing section %s" % (
495 for backendId, view in itertools.chain(
496 self._dialpads.iteritems(),
497 self._accountViews.iteritems(),
498 self._messagesViews.iteritems(),
499 self._recentViews.iteritems(),
500 self._contactsViews.iteritems(),
502 sectionName = "%s - %s" % (backendId, view.name())
504 view.load_settings(config, sectionName)
505 except ConfigParser.NoSectionError, e:
507 "Settings file %s is missing section %s" % (
514 def save_settings(self, config):
516 @note Thread Agnostic
518 config.add_section(constants.__pretty_app_name__)
519 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
520 for i, value in enumerate(self._credentials):
521 blob = base64.b64encode(value)
522 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
523 for backendId, view in itertools.chain(
524 self._dialpads.iteritems(),
525 self._accountViews.iteritems(),
526 self._messagesViews.iteritems(),
527 self._recentViews.iteritems(),
528 self._contactsViews.iteritems(),
530 sectionName = "%s - %s" % (backendId, view.name())
531 config.add_section(sectionName)
532 view.save_settings(config, sectionName)
534 def _guess_preferred_backend(self, backendAndCookiePaths):
536 (getmtime_nothrow(path), backendId, path)
537 for backendId, path in backendAndCookiePaths
539 modTimeAndPath.sort()
540 return modTimeAndPath[-1][1]
542 def _save_settings(self):
544 @note Thread Agnostic
546 config = ConfigParser.SafeConfigParser()
547 self.save_settings(config)
548 with open(self._user_settings, "wb") as configFile:
549 config.write(configFile)
551 def _on_close(self, *args, **kwds):
553 if self._osso is not None:
557 self._save_settings()
561 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
563 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
564 For system_inactivity, we have no background tasks to pause
566 @note Hildon specific
569 for backendId in self.BACKENDS:
570 self._phoneBackends[backendId].clear_caches()
571 self._contactsViews[self._selectedBackendId].clear_caches()
574 if save_unsaved_data or shutdown:
575 self._save_settings()
577 def _on_connection_change(self, connection, event, magicIdentifier):
579 @note Hildon specific
583 status = event.get_status()
584 error = event.get_error()
585 iap_id = event.get_iap_id()
586 bearer = event.get_bearer_type()
588 if status == conic.STATUS_CONNECTED:
589 self._deviceIsOnline = True
591 self._spawn_attempt_login(2)
592 elif status == conic.STATUS_DISCONNECTED:
593 self._deviceIsOnline = False
595 self._defaultBackendId = self._selectedBackendId
596 self._change_loggedin_status(self.NULL_BACKEND)
598 def _on_window_state_change(self, widget, event, *args):
600 @note Hildon specific
602 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
603 self._isFullScreen = True
605 self._isFullScreen = False
607 def _on_key_press(self, widget, event, *args):
609 @note Hildon specific
611 if event.keyval == gtk.keysyms.F6:
612 if self._isFullScreen:
613 self._window.unfullscreen()
615 self._window.fullscreen()
617 def _on_clearcookies_clicked(self, *args):
618 self._phoneBackends[self._selectedBackendId].logout()
619 self._accountViews[self._selectedBackendId].clear()
620 self._recentViews[self._selectedBackendId].clear()
621 self._messagesViews[self._selectedBackendId].clear()
622 self._contactsViews[self._selectedBackendId].clear()
623 self._change_loggedin_status(self.NULL_BACKEND)
625 self._spawn_attempt_login(2, True)
627 def _on_notebook_switch_page(self, notebook, page, page_num):
628 if page_num == self.RECENT_TAB:
629 self._recentViews[self._selectedBackendId].update()
630 elif page_num == self.MESSAGES_TAB:
631 self._messagesViews[self._selectedBackendId].update()
632 elif page_num == self.CONTACTS_TAB:
633 self._contactsViews[self._selectedBackendId].update()
634 elif page_num == self.ACCOUNT_TAB:
635 self._accountViews[self._selectedBackendId].update()
637 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
638 if hildon is not None:
639 self._window.set_title(tabTitle)
641 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
643 def _on_sms_clicked(self, number, message):
647 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
648 except RuntimeError, e:
650 self._errorDisplay.push_exception(e)
654 self._errorDisplay.push_message(
655 "Backend link with grandcentral is not working, please try again"
661 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
663 except RuntimeError, e:
664 self._errorDisplay.push_exception(e)
665 except ValueError, e:
666 self._errorDisplay.push_exception(e)
668 def _on_dial_clicked(self, number):
671 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
672 except RuntimeError, e:
674 self._errorDisplay.push_exception(e)
678 self._errorDisplay.push_message(
679 "Backend link with grandcentral is not working, please try again"
685 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
686 self._phoneBackends[self._selectedBackendId].dial(number)
688 except RuntimeError, e:
689 self._errorDisplay.push_exception(e)
690 except ValueError, e:
691 self._errorDisplay.push_exception(e)
694 self._dialpads[self._selectedBackendId].clear()
696 def _on_refresh(self, *args):
697 page_num = self._notebook.get_current_page()
698 if page_num == self.CONTACTS_TAB:
699 self._contactsViews[self._selectedBackendId].update(force=True)
700 elif page_num == self.RECENT_TAB:
701 self._recentViews[self._selectedBackendId].update(force=True)
702 elif page_num == self.MESSAGES_TAB:
703 self._messagesViews[self._selectedBackendId].update(force=True)
705 def _on_paste(self, *args):
706 contents = self._clipboard.wait_for_text()
707 self._dialpads[self._selectedBackendId].set_number(contents)
709 def _on_about_activate(self, *args):
710 dlg = gtk.AboutDialog()
711 dlg.set_name(constants.__pretty_app_name__)
712 dlg.set_version(constants.__version__)
713 dlg.set_copyright("Copyright 2008 - LGPL")
714 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")
715 dlg.set_website("http://gc-dialer.garage.maemo.org/")
716 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
724 failureCount, testCount = doctest.testmod()
726 print "Tests Successful"
733 gtk.gdk.threads_init()
734 if hildon is not None:
735 gtk.set_application_name(constants.__pretty_app_name__)
736 handle = Dialcentral()
740 class DummyOptions(object):
746 if __name__ == "__main__":
747 if len(sys.argv) > 1:
753 if optparse is not None:
754 parser = optparse.OptionParser()
755 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
756 (commandOptions, commandArgs) = parser.parse_args()
758 commandOptions = DummyOptions()
761 if commandOptions.test: