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
108 self._tabHoldTimeoutId = None
110 for path in self._glade_files:
111 if os.path.isfile(path):
112 self._widgetTree = gtk.glade.XML(path)
115 display_error_message("Cannot find dialcentral.glade")
119 self._window = self._widgetTree.get_widget("mainWindow")
120 self._notebook = self._widgetTree.get_widget("notebook")
121 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
122 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
125 self._isFullScreen = False
126 if hildon is not None:
127 self._app = hildon.Program()
128 oldWindow = self._window
129 self._window = hildon.Window()
130 oldWindow.get_child().reparent(self._window)
131 self._app.add_window(self._window)
134 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
135 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
136 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
138 warnings.warn(e.message)
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
141 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
143 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
145 for child in gtkMenu.get_children():
147 self._window.set_menu(menu)
150 self._window.connect("key-press-event", self._on_key_press)
151 self._window.connect("window-state-event", self._on_window_state_change)
153 pass # warnings.warn("No Hildon", UserWarning, 2)
155 # If under hildon, rely on the application name being shown
157 self._window.set_title("%s" % constants.__pretty_app_name__)
160 "on_dialpad_quit": self._on_close,
162 self._widgetTree.signal_autoconnect(callbackMapping)
164 self._window.connect("destroy", self._on_close)
165 self._window.set_default_size(800, 300)
166 self._window.show_all()
168 self._loginSink = gtk_toolbox.threaded_stage(
171 gtk_toolbox.null_sink(),
175 backgroundSetup = threading.Thread(target=self._idle_setup)
176 backgroundSetup.setDaemon(True)
177 backgroundSetup.start()
179 def _idle_setup(self):
181 If something can be done after the UI loads, push it here so it's not blocking the UI
184 # Barebones UI handlers
188 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
189 with gtk_toolbox.gtk_lock():
190 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
191 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
192 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
193 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
194 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
196 self._dialpads[self._selectedBackendId].enable()
197 self._accountViews[self._selectedBackendId].enable()
198 self._recentViews[self._selectedBackendId].enable()
199 self._messagesViews[self._selectedBackendId].enable()
200 self._contactsViews[self._selectedBackendId].enable()
202 # Setup maemo specifics
209 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
210 device = osso.DeviceState(self._osso)
211 device.set_device_state_callback(self._on_device_state_change, 0)
213 pass # warnings.warn("No OSSO", UserWarning, 2)
215 # Setup maemo specifics
220 self._connection = None
221 if conic is not None:
222 self._connection = conic.Connection()
223 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
224 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
226 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
228 # Setup costly backends
236 os.makedirs(self._data_path)
240 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
241 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
242 self._defaultBackendId = self._guess_preferred_backend((
243 (self.GC_BACKEND, gcCookiePath),
244 (self.GV_BACKEND, gvCookiePath),
247 self._phoneBackends.update({
248 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
249 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
251 with gtk_toolbox.gtk_lock():
252 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
253 unifiedDialpad.set_number("")
254 self._dialpads.update({
255 self.GC_BACKEND: unifiedDialpad,
256 self.GV_BACKEND: unifiedDialpad,
258 self._accountViews.update({
259 self.GC_BACKEND: gc_views.AccountInfo(
260 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
262 self.GV_BACKEND: gc_views.AccountInfo(
263 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
266 self._recentViews.update({
267 self.GC_BACKEND: gc_views.RecentCallsView(
268 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
270 self.GV_BACKEND: gc_views.RecentCallsView(
271 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
274 self._messagesViews.update({
275 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
276 self.GV_BACKEND: gc_views.MessagesView(
277 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
280 self._contactsViews.update({
281 self.GC_BACKEND: gc_views.ContactsView(
282 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
284 self.GV_BACKEND: gc_views.ContactsView(
285 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
289 evoBackend = evo_backend.EvolutionAddressBook()
290 fsContactsPath = os.path.join(self._data_path, "contacts")
291 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
292 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
293 self._dialpads[backendId].number_selected = self._select_action
294 self._recentViews[backendId].number_selected = self._select_action
295 self._messagesViews[backendId].number_selected = self._select_action
296 self._contactsViews[backendId].number_selected = self._select_action
299 self._phoneBackends[backendId],
303 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
304 self._contactsViews[backendId].append(mergedBook)
305 self._contactsViews[backendId].extend(addressBooks)
306 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
309 "on_paste": self._on_paste,
310 "on_refresh": self._on_menu_refresh,
311 "on_clearcookies_clicked": self._on_clearcookies_clicked,
312 "on_notebook_switch_page": self._on_notebook_switch_page,
313 "on_about_activate": self._on_about_activate,
315 self._widgetTree.signal_autoconnect(callbackMapping)
316 self._notebook.connect("button-press-event", self._on_tab_press)
317 self._notebook.connect("button-release-event", self._on_tab_release)
319 self._initDone = True
321 config = ConfigParser.SafeConfigParser()
322 config.read(self._user_settings)
323 with gtk_toolbox.gtk_lock():
324 self.load_settings(config)
326 self._spawn_attempt_login(2)
327 except StandardError, e:
328 warnings.warn(e.message, UserWarning, 2)
329 except BaseException, e:
331 warnings.warn(e.message, UserWarning, 2)
335 def attempt_login(self, numOfAttempts = 10, force = False):
337 @todo Handle user notification better like attempting to login and failed login
339 @note This must be run outside of the UI lock
342 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
343 assert self._initDone, "Attempting login before app is fully loaded"
345 serviceId = self.NULL_BACKEND
349 self.refresh_session()
350 serviceId = self._defaultBackendId
352 except StandardError, e:
353 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
356 loggedIn, serviceId = self._login_by_user(numOfAttempts)
358 with gtk_toolbox.gtk_lock():
359 self._change_loggedin_status(serviceId)
360 except StandardError, e:
361 with gtk_toolbox.gtk_lock():
362 self._errorDisplay.push_exception(e)
364 def _spawn_attempt_login(self, *args):
365 self._loginSink.send(args)
367 def refresh_session(self):
369 @note Thread agnostic
371 assert self._initDone, "Attempting login before app is fully loaded"
375 loggedIn = self._login_by_cookie()
377 loggedIn = self._login_by_settings()
380 raise RuntimeError("Login Failed")
382 def _login_by_cookie(self):
384 @note Thread agnostic
386 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
389 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
394 def _login_by_settings(self):
396 @note Thread agnostic
398 username, password = self._credentials
399 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
401 self._credentials = username, password
403 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
408 def _login_by_user(self, numOfAttempts):
410 @note This must be run outside of the UI lock
412 loggedIn, (username, password) = False, self._credentials
413 tmpServiceId = self.NULL_BACKEND
414 for attemptCount in xrange(numOfAttempts):
417 availableServices = (
418 (self.GV_BACKEND, "Google Voice"),
419 (self.GC_BACKEND, "Grand Central"),
421 with gtk_toolbox.gtk_lock():
422 credentials = self._credentialsDialog.request_credentials_from(
423 availableServices, defaultCredentials = self._credentials
425 tmpServiceId, username, password = credentials
426 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
429 serviceId = tmpServiceId
430 self._credentials = username, password
432 "Logged into %r through user request" % self._phoneBackends[serviceId],
436 serviceId = self.NULL_BACKEND
438 return loggedIn, serviceId
440 def _select_action(self, action, number, message):
441 self.refresh_session()
442 if action == "select":
443 self._dialpads[self._selectedBackendId].set_number(number)
444 self._notebook.set_current_page(self.KEYPAD_TAB)
445 elif action == "dial":
446 self._on_dial_clicked(number)
447 elif action == "sms":
448 self._on_sms_clicked(number, message)
450 assert False, "Unknown action: %s" % action
452 def _change_loggedin_status(self, newStatus):
453 oldStatus = self._selectedBackendId
454 if oldStatus == newStatus:
457 self._dialpads[oldStatus].disable()
458 self._accountViews[oldStatus].disable()
459 self._recentViews[oldStatus].disable()
460 self._messagesViews[oldStatus].disable()
461 self._contactsViews[oldStatus].disable()
463 self._dialpads[newStatus].enable()
464 self._accountViews[newStatus].enable()
465 self._recentViews[newStatus].enable()
466 self._messagesViews[newStatus].enable()
467 self._contactsViews[newStatus].enable()
469 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
470 self._phoneBackends[self._selectedBackendId].set_sane_callback()
471 self._accountViews[self._selectedBackendId].update()
473 self._selectedBackendId = newStatus
475 def load_settings(self, config):
480 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
482 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
483 for i in xrange(len(self._credentials))
486 base64.b64decode(blob)
489 self._credentials = tuple(creds)
490 except ConfigParser.NoSectionError, e:
492 "Settings file %s is missing section %s" % (
499 for backendId, view in itertools.chain(
500 self._dialpads.iteritems(),
501 self._accountViews.iteritems(),
502 self._messagesViews.iteritems(),
503 self._recentViews.iteritems(),
504 self._contactsViews.iteritems(),
506 sectionName = "%s - %s" % (backendId, view.name())
508 view.load_settings(config, sectionName)
509 except ConfigParser.NoSectionError, e:
511 "Settings file %s is missing section %s" % (
518 def save_settings(self, config):
520 @note Thread Agnostic
522 config.add_section(constants.__pretty_app_name__)
523 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
524 for i, value in enumerate(self._credentials):
525 blob = base64.b64encode(value)
526 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
527 for backendId, view in itertools.chain(
528 self._dialpads.iteritems(),
529 self._accountViews.iteritems(),
530 self._messagesViews.iteritems(),
531 self._recentViews.iteritems(),
532 self._contactsViews.iteritems(),
534 sectionName = "%s - %s" % (backendId, view.name())
535 config.add_section(sectionName)
536 view.save_settings(config, sectionName)
538 def _guess_preferred_backend(self, backendAndCookiePaths):
540 (getmtime_nothrow(path), backendId, path)
541 for backendId, path in backendAndCookiePaths
543 modTimeAndPath.sort()
544 return modTimeAndPath[-1][1]
546 def _save_settings(self):
548 @note Thread Agnostic
550 config = ConfigParser.SafeConfigParser()
551 self.save_settings(config)
552 with open(self._user_settings, "wb") as configFile:
553 config.write(configFile)
555 def _refresh_active_tab(self):
556 page_num = self._notebook.get_current_page()
557 if page_num == self.CONTACTS_TAB:
558 self._contactsViews[self._selectedBackendId].update(force=True)
559 elif page_num == self.RECENT_TAB:
560 self._recentViews[self._selectedBackendId].update(force=True)
561 elif page_num == self.MESSAGES_TAB:
562 self._messagesViews[self._selectedBackendId].update(force=True)
564 def _on_close(self, *args, **kwds):
566 if self._osso is not None:
570 self._save_settings()
574 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
576 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
577 For system_inactivity, we have no background tasks to pause
579 @note Hildon specific
582 for backendId in self.BACKENDS:
583 self._phoneBackends[backendId].clear_caches()
584 self._contactsViews[self._selectedBackendId].clear_caches()
587 if save_unsaved_data or shutdown:
588 self._save_settings()
590 def _on_connection_change(self, connection, event, magicIdentifier):
592 @note Hildon specific
596 status = event.get_status()
597 error = event.get_error()
598 iap_id = event.get_iap_id()
599 bearer = event.get_bearer_type()
601 if status == conic.STATUS_CONNECTED:
603 self._spawn_attempt_login(2)
604 elif status == conic.STATUS_DISCONNECTED:
606 self._defaultBackendId = self._selectedBackendId
607 self._change_loggedin_status(self.NULL_BACKEND)
609 def _on_window_state_change(self, widget, event, *args):
611 @note Hildon specific
613 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
614 self._isFullScreen = True
616 self._isFullScreen = False
618 def _on_key_press(self, widget, event, *args):
620 @note Hildon specific
622 if event.keyval == gtk.keysyms.F6:
623 if self._isFullScreen:
624 self._window.unfullscreen()
626 self._window.fullscreen()
628 def _on_clearcookies_clicked(self, *args):
629 self._phoneBackends[self._selectedBackendId].logout()
630 self._accountViews[self._selectedBackendId].clear()
631 self._recentViews[self._selectedBackendId].clear()
632 self._messagesViews[self._selectedBackendId].clear()
633 self._contactsViews[self._selectedBackendId].clear()
634 self._change_loggedin_status(self.NULL_BACKEND)
636 self._spawn_attempt_login(2, True)
638 def _on_notebook_switch_page(self, notebook, page, page_num):
639 if page_num == self.RECENT_TAB:
640 self._recentViews[self._selectedBackendId].update()
641 elif page_num == self.MESSAGES_TAB:
642 self._messagesViews[self._selectedBackendId].update()
643 elif page_num == self.CONTACTS_TAB:
644 self._contactsViews[self._selectedBackendId].update()
645 elif page_num == self.ACCOUNT_TAB:
646 self._accountViews[self._selectedBackendId].update()
648 def _on_tab_press(self, *args):
649 self._tabHoldTimeoutId = gobject.timeout_add(1000, self._on_tab_refresh)
651 def _on_tab_release(self, *args):
652 if self._tabHoldTimeoutId is not None:
653 gobject.source_remove(self._tabHoldTimeoutId)
654 self._tabHoldTimeoutId = None
656 def _on_tab_refresh(self, *args):
657 self._tabHoldTimeoutId = None
658 self._refresh_active_tab()
661 def _on_sms_clicked(self, number, message):
665 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
666 except StandardError, e:
668 self._errorDisplay.push_exception(e)
672 self._errorDisplay.push_message(
673 "Backend link with grandcentral is not working, please try again"
679 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
681 except StandardError, e:
682 self._errorDisplay.push_exception(e)
683 except ValueError, e:
684 self._errorDisplay.push_exception(e)
686 def _on_dial_clicked(self, number):
687 assert number, "No number to call"
689 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
690 except StandardError, e:
692 self._errorDisplay.push_exception(e)
696 self._errorDisplay.push_message(
697 "Backend link with grandcentral is not working, please try again"
703 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
704 self._phoneBackends[self._selectedBackendId].dial(number)
706 except StandardError, e:
707 self._errorDisplay.push_exception(e)
708 except ValueError, e:
709 self._errorDisplay.push_exception(e)
712 self._dialpads[self._selectedBackendId].clear()
714 def _on_menu_refresh(self, *args):
715 self._refresh_active_tab()
717 def _on_paste(self, *args):
718 contents = self._clipboard.wait_for_text()
719 self._dialpads[self._selectedBackendId].set_number(contents)
721 def _on_about_activate(self, *args):
722 dlg = gtk.AboutDialog()
723 dlg.set_name(constants.__pretty_app_name__)
724 dlg.set_version(constants.__version__)
725 dlg.set_copyright("Copyright 2008 - LGPL")
726 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")
727 dlg.set_website("http://gc-dialer.garage.maemo.org/")
728 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
736 failureCount, testCount = doctest.testmod()
738 print "Tests Successful"
745 gtk.gdk.threads_init()
746 if hildon is not None:
747 gtk.set_application_name(constants.__pretty_app_name__)
748 handle = Dialcentral()
752 class DummyOptions(object):
758 if __name__ == "__main__":
759 if len(sys.argv) > 1:
765 if optparse is not None:
766 parser = optparse.OptionParser()
767 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
768 (commandOptions, commandArgs) = parser.parse_args()
770 commandOptions = DummyOptions()
773 if commandOptions.test: