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
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)
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 self._window.set_title("%s" % constants.__pretty_app_name__)
157 "on_dialpad_quit": self._on_close,
159 self._widgetTree.signal_autoconnect(callbackMapping)
161 self._window.connect("destroy", self._on_close)
162 self._window.set_default_size(800, 300)
163 self._window.show_all()
165 backgroundSetup = threading.Thread(target=self._idle_setup)
166 backgroundSetup.setDaemon(True)
167 backgroundSetup.start()
169 def _idle_setup(self):
171 If something can be done after the UI loads, push it here so it's not blocking the UI
174 # Barebones UI handlers
178 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
179 with gtk_toolbox.gtk_lock():
180 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
181 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
182 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
183 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
184 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
186 self._dialpads[self._selectedBackendId].enable()
187 self._accountViews[self._selectedBackendId].enable()
188 self._recentViews[self._selectedBackendId].enable()
189 self._messagesViews[self._selectedBackendId].enable()
190 self._contactsViews[self._selectedBackendId].enable()
192 # Setup maemo specifics
199 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
200 device = osso.DeviceState(self._osso)
201 device.set_device_state_callback(self._on_device_state_change, 0)
203 pass # warnings.warn("No OSSO", UserWarning, 2)
205 # Setup maemo specifics
210 self._connection = None
211 if conic is not None:
212 self._connection = conic.Connection()
213 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
214 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
216 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
218 # Setup costly backends
226 os.makedirs(self._data_path)
230 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
231 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
232 self._defaultBackendId = self._guess_preferred_backend((
233 (self.GC_BACKEND, gcCookiePath),
234 (self.GV_BACKEND, gvCookiePath),
237 self._phoneBackends.update({
238 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
239 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
241 with gtk_toolbox.gtk_lock():
242 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
243 unifiedDialpad.set_number("")
244 self._dialpads.update({
245 self.GC_BACKEND: unifiedDialpad,
246 self.GV_BACKEND: unifiedDialpad,
248 self._accountViews.update({
249 self.GC_BACKEND: gc_views.AccountInfo(
250 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
252 self.GV_BACKEND: gc_views.AccountInfo(
253 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
256 self._recentViews.update({
257 self.GC_BACKEND: gc_views.RecentCallsView(
258 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
260 self.GV_BACKEND: gc_views.RecentCallsView(
261 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
264 self._messagesViews.update({
265 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
266 self.GV_BACKEND: gc_views.MessagesView(
267 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
270 self._contactsViews.update({
271 self.GC_BACKEND: gc_views.ContactsView(
272 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
274 self.GV_BACKEND: gc_views.ContactsView(
275 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
279 evoBackend = evo_backend.EvolutionAddressBook()
280 fsContactsPath = os.path.join(self._data_path, "contacts")
281 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
282 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
283 self._dialpads[backendId].number_selected = self._select_action
284 self._recentViews[backendId].number_selected = self._select_action
285 self._messagesViews[backendId].number_selected = self._select_action
286 self._contactsViews[backendId].number_selected = self._select_action
289 self._phoneBackends[backendId],
293 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
294 self._contactsViews[backendId].append(mergedBook)
295 self._contactsViews[backendId].extend(addressBooks)
296 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
299 "on_paste": self._on_paste,
300 "on_refresh": self._on_refresh,
301 "on_clearcookies_clicked": self._on_clearcookies_clicked,
302 "on_notebook_switch_page": self._on_notebook_switch_page,
303 "on_about_activate": self._on_about_activate,
305 self._widgetTree.signal_autoconnect(callbackMapping)
307 self._initDone = True
309 config = ConfigParser.SafeConfigParser()
310 config.read(self._user_settings)
311 with gtk_toolbox.gtk_lock():
312 self.load_settings(config)
314 self._spawn_attempt_login(2)
315 except StandardError, e:
316 warnings.warn(e.message, UserWarning, 2)
317 except BaseException, e:
319 warnings.warn(e.message, UserWarning, 2)
323 def attempt_login(self, numOfAttempts = 10, force = False):
325 @todo Handle user notification better like attempting to login and failed login
327 @note This must be run outside of the UI lock
330 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
331 assert self._initDone, "Attempting login before app is fully loaded"
332 if not self._deviceIsOnline:
333 raise RuntimeError("Unable to login, device is not online")
335 serviceId = self.NULL_BACKEND
339 self.refresh_session()
340 serviceId = self._defaultBackendId
342 except StandardError, e:
343 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
346 loggedIn, serviceId = self._login_by_user(numOfAttempts)
348 with gtk_toolbox.gtk_lock():
349 self._change_loggedin_status(serviceId)
350 except StandardError, e:
351 with gtk_toolbox.gtk_lock():
352 self._errorDisplay.push_exception(e)
354 def _spawn_attempt_login(self, *args):
355 backgroundLogin = threading.Thread(target=self.attempt_login, args=args)
356 backgroundLogin.setDaemon(True)
357 backgroundLogin.start()
359 def refresh_session(self):
361 @note Thread agnostic
363 assert self._initDone, "Attempting login before app is fully loaded"
364 if not self._deviceIsOnline:
365 raise RuntimeError("Unable to login, device is not online")
369 loggedIn = self._login_by_cookie()
371 loggedIn = self._login_by_settings()
374 raise RuntimeError("Login Failed")
376 def _login_by_cookie(self):
378 @note Thread agnostic
380 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
383 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
388 def _login_by_settings(self):
390 @note Thread agnostic
392 username, password = self._credentials
393 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
395 self._credentials = username, password
397 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
402 def _login_by_user(self, numOfAttempts):
404 @note This must be run outside of the UI lock
406 loggedIn, (username, password) = False, self._credentials
407 tmpServiceId = self.NULL_BACKEND
408 for attemptCount in xrange(numOfAttempts):
411 availableServices = {
412 self.GV_BACKEND: "Google Voice",
413 self.GC_BACKEND: "Grand Central",
415 with gtk_toolbox.gtk_lock():
416 credentials = self._credentialsDialog.request_credentials_from(
417 availableServices, defaultCredentials = self._credentials
419 tmpServiceId, username, password = credentials
420 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
423 serviceId = tmpServiceId
424 self._credentials = username, password
426 "Logged into %r through user request" % self._phoneBackends[serviceId],
430 serviceId = self.NULL_BACKEND
432 return loggedIn, serviceId
434 def _select_action(self, action, number, message):
435 self.refresh_session()
436 if action == "select":
437 self._dialpads[self._selectedBackendId].set_number(number)
438 self._notebook.set_current_page(self.KEYPAD_TAB)
439 elif action == "dial":
440 self._on_dial_clicked(number)
441 elif action == "sms":
442 self._on_sms_clicked(number, message)
444 assert False, "Unknown action: %s" % action
446 def _change_loggedin_status(self, newStatus):
447 oldStatus = self._selectedBackendId
448 if oldStatus == newStatus:
451 self._dialpads[oldStatus].disable()
452 self._accountViews[oldStatus].disable()
453 self._recentViews[oldStatus].disable()
454 self._messagesViews[oldStatus].disable()
455 self._contactsViews[oldStatus].disable()
457 self._dialpads[newStatus].enable()
458 self._accountViews[newStatus].enable()
459 self._recentViews[newStatus].enable()
460 self._messagesViews[newStatus].enable()
461 self._contactsViews[newStatus].enable()
463 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
464 self._phoneBackends[self._selectedBackendId].set_sane_callback()
465 self._accountViews[self._selectedBackendId].update()
467 self._selectedBackendId = newStatus
469 def load_settings(self, config):
474 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
476 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
477 for i in xrange(len(self._credentials))
480 base64.b64decode(blob)
483 self._credentials = tuple(creds)
484 except ConfigParser.NoSectionError, e:
486 "Settings file %s is missing section %s" % (
493 for backendId, view in itertools.chain(
494 self._dialpads.iteritems(),
495 self._accountViews.iteritems(),
496 self._messagesViews.iteritems(),
497 self._recentViews.iteritems(),
498 self._contactsViews.iteritems(),
500 sectionName = "%s - %s" % (backendId, view.name())
502 view.load_settings(config, sectionName)
503 except ConfigParser.NoSectionError, e:
505 "Settings file %s is missing section %s" % (
512 def save_settings(self, config):
514 @note Thread Agnostic
516 config.add_section(constants.__pretty_app_name__)
517 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
518 for i, value in enumerate(self._credentials):
519 blob = base64.b64encode(value)
520 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
521 for backendId, view in itertools.chain(
522 self._dialpads.iteritems(),
523 self._accountViews.iteritems(),
524 self._messagesViews.iteritems(),
525 self._recentViews.iteritems(),
526 self._contactsViews.iteritems(),
528 sectionName = "%s - %s" % (backendId, view.name())
529 config.add_section(sectionName)
530 view.save_settings(config, sectionName)
532 def _guess_preferred_backend(self, backendAndCookiePaths):
534 (getmtime_nothrow(path), backendId, path)
535 for backendId, path in backendAndCookiePaths
537 modTimeAndPath.sort()
538 return modTimeAndPath[-1][1]
540 def _save_settings(self):
542 @note Thread Agnostic
544 config = ConfigParser.SafeConfigParser()
545 self.save_settings(config)
546 with open(self._user_settings, "wb") as configFile:
547 config.write(configFile)
549 def _on_close(self, *args, **kwds):
551 if self._osso is not None:
555 self._save_settings()
559 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
561 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
562 For system_inactivity, we have no background tasks to pause
564 @note Hildon specific
567 for backendId in self.BACKENDS:
568 self._phoneBackends[backendId].clear_caches()
569 self._contactsViews[self._selectedBackendId].clear_caches()
572 if save_unsaved_data or shutdown:
573 self._save_settings()
575 def _on_connection_change(self, connection, event, magicIdentifier):
577 @note Hildon specific
581 status = event.get_status()
582 error = event.get_error()
583 iap_id = event.get_iap_id()
584 bearer = event.get_bearer_type()
586 if status == conic.STATUS_CONNECTED:
587 self._deviceIsOnline = True
589 self._spawn_attempt_login(2)
590 elif status == conic.STATUS_DISCONNECTED:
591 self._deviceIsOnline = False
593 self._defaultBackendId = self._selectedBackendId
594 self._change_loggedin_status(self.NULL_BACKEND)
596 def _on_window_state_change(self, widget, event, *args):
598 @note Hildon specific
600 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
601 self._isFullScreen = True
603 self._isFullScreen = False
605 def _on_key_press(self, widget, event, *args):
607 @note Hildon specific
609 if event.keyval == gtk.keysyms.F6:
610 if self._isFullScreen:
611 self._window.unfullscreen()
613 self._window.fullscreen()
615 def _on_clearcookies_clicked(self, *args):
616 self._phoneBackends[self._selectedBackendId].logout()
617 self._accountViews[self._selectedBackendId].clear()
618 self._recentViews[self._selectedBackendId].clear()
619 self._messagesViews[self._selectedBackendId].clear()
620 self._contactsViews[self._selectedBackendId].clear()
621 self._change_loggedin_status(self.NULL_BACKEND)
623 self._spawn_attempt_login(2, True)
625 def _on_notebook_switch_page(self, notebook, page, page_num):
626 if page_num == self.RECENT_TAB:
627 self._recentViews[self._selectedBackendId].update()
628 elif page_num == self.MESSAGES_TAB:
629 self._messagesViews[self._selectedBackendId].update()
630 elif page_num == self.CONTACTS_TAB:
631 self._contactsViews[self._selectedBackendId].update()
632 elif page_num == self.ACCOUNT_TAB:
633 self._accountViews[self._selectedBackendId].update()
635 def _on_sms_clicked(self, number, message):
639 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
640 except StandardError, e:
642 self._errorDisplay.push_exception(e)
646 self._errorDisplay.push_message(
647 "Backend link with grandcentral is not working, please try again"
653 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
655 except StandardError, e:
656 self._errorDisplay.push_exception(e)
657 except ValueError, e:
658 self._errorDisplay.push_exception(e)
660 def _on_dial_clicked(self, number):
663 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
664 except StandardError, e:
666 self._errorDisplay.push_exception(e)
670 self._errorDisplay.push_message(
671 "Backend link with grandcentral is not working, please try again"
677 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
678 self._phoneBackends[self._selectedBackendId].dial(number)
680 except StandardError, e:
681 self._errorDisplay.push_exception(e)
682 except ValueError, e:
683 self._errorDisplay.push_exception(e)
686 self._dialpads[self._selectedBackendId].clear()
688 def _on_refresh(self, *args):
689 page_num = self._notebook.get_current_page()
690 if page_num == self.CONTACTS_TAB:
691 self._contactsViews[self._selectedBackendId].update(force=True)
692 elif page_num == self.RECENT_TAB:
693 self._recentViews[self._selectedBackendId].update(force=True)
694 elif page_num == self.MESSAGES_TAB:
695 self._messagesViews[self._selectedBackendId].update(force=True)
697 def _on_paste(self, *args):
698 contents = self._clipboard.wait_for_text()
699 self._dialpads[self._selectedBackendId].set_number(contents)
701 def _on_about_activate(self, *args):
702 dlg = gtk.AboutDialog()
703 dlg.set_name(constants.__pretty_app_name__)
704 dlg.set_version(constants.__version__)
705 dlg.set_copyright("Copyright 2008 - LGPL")
706 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")
707 dlg.set_website("http://gc-dialer.garage.maemo.org/")
708 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
716 failureCount, testCount = doctest.testmod()
718 print "Tests Successful"
725 gtk.gdk.threads_init()
726 if hildon is not None:
727 gtk.set_application_name(constants.__pretty_app_name__)
728 handle = Dialcentral()
732 class DummyOptions(object):
738 if __name__ == "__main__":
739 if len(sys.argv) > 1:
745 if optparse is not None:
746 parser = optparse.OptionParser()
747 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
748 (commandOptions, commandArgs) = parser.parse_args()
750 commandOptions = DummyOptions()
753 if commandOptions.test: