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 if hildon is not None:
155 self._window.set_title("Keypad")
157 self._window.set_title("%s - Keypad" % 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 backgroundSetup = threading.Thread(target=self._idle_setup)
169 backgroundSetup.setDaemon(True)
170 backgroundSetup.start()
172 def _idle_setup(self):
174 If something can be done after the UI loads, push it here so it's not blocking the UI
177 # Barebones UI handlers
181 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
182 with gtk_toolbox.gtk_lock():
183 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
184 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
185 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
186 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
187 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
189 self._dialpads[self._selectedBackendId].enable()
190 self._accountViews[self._selectedBackendId].enable()
191 self._recentViews[self._selectedBackendId].enable()
192 self._messagesViews[self._selectedBackendId].enable()
193 self._contactsViews[self._selectedBackendId].enable()
195 # Setup maemo specifics
202 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
203 device = osso.DeviceState(self._osso)
204 device.set_device_state_callback(self._on_device_state_change, 0)
206 pass # warnings.warn("No OSSO", UserWarning, 2)
208 # Setup maemo specifics
213 self._connection = None
214 if conic is not None:
215 self._connection = conic.Connection()
216 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
217 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
219 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
221 # Setup costly backends
229 os.makedirs(self._data_path)
233 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
234 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
235 self._defaultBackendId = self._guess_preferred_backend((
236 (self.GC_BACKEND, gcCookiePath),
237 (self.GV_BACKEND, gvCookiePath),
240 self._phoneBackends.update({
241 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
242 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
244 with gtk_toolbox.gtk_lock():
245 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
246 unifiedDialpad.set_number("")
247 self._dialpads.update({
248 self.GC_BACKEND: unifiedDialpad,
249 self.GV_BACKEND: unifiedDialpad,
251 self._accountViews.update({
252 self.GC_BACKEND: gc_views.AccountInfo(
253 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
255 self.GV_BACKEND: gc_views.AccountInfo(
256 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
259 self._recentViews.update({
260 self.GC_BACKEND: gc_views.RecentCallsView(
261 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
263 self.GV_BACKEND: gc_views.RecentCallsView(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._messagesViews.update({
268 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
269 self.GV_BACKEND: gc_views.MessagesView(
270 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
273 self._contactsViews.update({
274 self.GC_BACKEND: gc_views.ContactsView(
275 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
277 self.GV_BACKEND: gc_views.ContactsView(
278 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
282 evoBackend = evo_backend.EvolutionAddressBook()
283 fsContactsPath = os.path.join(self._data_path, "contacts")
284 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
285 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
286 self._dialpads[backendId].number_selected = self._select_action
287 self._recentViews[backendId].number_selected = self._select_action
288 self._messagesViews[backendId].number_selected = self._select_action
289 self._contactsViews[backendId].number_selected = self._select_action
292 self._phoneBackends[backendId],
296 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
297 self._contactsViews[backendId].append(mergedBook)
298 self._contactsViews[backendId].extend(addressBooks)
299 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
302 "on_paste": self._on_paste,
303 "on_refresh": self._on_refresh,
304 "on_clearcookies_clicked": self._on_clearcookies_clicked,
305 "on_notebook_switch_page": self._on_notebook_switch_page,
306 "on_about_activate": self._on_about_activate,
308 self._widgetTree.signal_autoconnect(callbackMapping)
310 self._initDone = True
312 config = ConfigParser.SafeConfigParser()
313 config.read(self._user_settings)
314 with gtk_toolbox.gtk_lock():
315 self.load_settings(config)
317 gtk_toolbox.asynchronous_gtk_message(self._spawn_attempt_login)(2)
318 except StandardError, e:
319 warnings.warn(e.message, UserWarning, 2)
320 except BaseException, e:
322 warnings.warn(e.message, UserWarning, 2)
326 def attempt_login(self, numOfAttempts = 10, force = False):
328 @todo Handle user notification better like attempting to login and failed login
330 @note This must be run outside of the UI lock
333 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
334 assert self._initDone, "Attempting login before app is fully loaded"
335 if not self._deviceIsOnline:
336 raise RuntimeError("Unable to login, device is not online")
338 serviceId = self.NULL_BACKEND
342 self.refresh_session()
343 serviceId = self._defaultBackendId
345 except StandardError, e:
346 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
349 loggedIn, serviceId = self._login_by_user(numOfAttempts)
351 with gtk_toolbox.gtk_lock():
352 self._change_loggedin_status(serviceId)
353 except StandardError, e:
354 with gtk_toolbox.gtk_lock():
355 self._errorDisplay.push_exception(e)
357 def _spawn_attempt_login(self, *args):
358 backgroundLogin = threading.Thread(target=self.attempt_login, args=args)
359 backgroundLogin.setDaemon(True)
360 backgroundLogin.start()
362 def refresh_session(self):
364 @note Thread agnostic
366 assert self._initDone, "Attempting login before app is fully loaded"
367 if not self._deviceIsOnline:
368 raise RuntimeError("Unable to login, device is not online")
372 loggedIn = self._login_by_cookie()
374 loggedIn = self._login_by_settings()
377 raise RuntimeError("Login Failed")
379 def _login_by_cookie(self):
381 @note Thread agnostic
383 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
386 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
391 def _login_by_settings(self):
393 @note Thread agnostic
395 username, password = self._credentials
396 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
398 self._credentials = username, password
400 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
405 def _login_by_user(self, numOfAttempts):
407 @note This must be run outside of the UI lock
409 loggedIn, (username, password) = False, self._credentials
410 tmpServiceId = self.NULL_BACKEND
411 for attemptCount in xrange(numOfAttempts):
414 availableServices = {
415 self.GV_BACKEND: "Google Voice",
416 self.GC_BACKEND: "Grand Central",
418 with gtk_toolbox.gtk_lock():
419 credentials = self._credentialsDialog.request_credentials_from(
420 availableServices, defaultCredentials = self._credentials
422 tmpServiceId, username, password = credentials
423 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
426 serviceId = tmpServiceId
427 self._credentials = username, password
429 "Logged into %r through user request" % self._phoneBackends[serviceId],
433 serviceId = self.NULL_BACKEND
435 return loggedIn, serviceId
437 def _select_action(self, action, number, message):
438 self.refresh_session()
439 if action == "select":
440 self._dialpads[self._selectedBackendId].set_number(number)
441 self._notebook.set_current_page(self.KEYPAD_TAB)
442 elif action == "dial":
443 self._on_dial_clicked(number)
444 elif action == "sms":
445 self._on_sms_clicked(number, message)
447 assert False, "Unknown action: %s" % action
449 def _change_loggedin_status(self, newStatus):
450 oldStatus = self._selectedBackendId
451 if oldStatus == newStatus:
454 self._dialpads[oldStatus].disable()
455 self._accountViews[oldStatus].disable()
456 self._recentViews[oldStatus].disable()
457 self._messagesViews[oldStatus].disable()
458 self._contactsViews[oldStatus].disable()
460 self._dialpads[newStatus].enable()
461 self._accountViews[newStatus].enable()
462 self._recentViews[newStatus].enable()
463 self._messagesViews[newStatus].enable()
464 self._contactsViews[newStatus].enable()
466 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
467 self._phoneBackends[self._selectedBackendId].set_sane_callback()
468 self._accountViews[self._selectedBackendId].update()
470 self._selectedBackendId = newStatus
472 def load_settings(self, config):
477 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
479 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
480 for i in xrange(len(self._credentials))
483 base64.b64decode(blob)
486 self._credentials = tuple(creds)
487 except ConfigParser.NoSectionError, e:
489 "Settings file %s is missing section %s" % (
496 for backendId, view in itertools.chain(
497 self._dialpads.iteritems(),
498 self._accountViews.iteritems(),
499 self._messagesViews.iteritems(),
500 self._recentViews.iteritems(),
501 self._contactsViews.iteritems(),
503 sectionName = "%s - %s" % (backendId, view.name())
505 view.load_settings(config, sectionName)
506 except ConfigParser.NoSectionError, e:
508 "Settings file %s is missing section %s" % (
515 def save_settings(self, config):
517 @note Thread Agnostic
519 config.add_section(constants.__pretty_app_name__)
520 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
521 for i, value in enumerate(self._credentials):
522 blob = base64.b64encode(value)
523 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
524 for backendId, view in itertools.chain(
525 self._dialpads.iteritems(),
526 self._accountViews.iteritems(),
527 self._messagesViews.iteritems(),
528 self._recentViews.iteritems(),
529 self._contactsViews.iteritems(),
531 sectionName = "%s - %s" % (backendId, view.name())
532 config.add_section(sectionName)
533 view.save_settings(config, sectionName)
535 def _guess_preferred_backend(self, backendAndCookiePaths):
537 (getmtime_nothrow(path), backendId, path)
538 for backendId, path in backendAndCookiePaths
540 modTimeAndPath.sort()
541 return modTimeAndPath[-1][1]
543 def _save_settings(self):
545 @note Thread Agnostic
547 config = ConfigParser.SafeConfigParser()
548 self.save_settings(config)
549 with open(self._user_settings, "wb") as configFile:
550 config.write(configFile)
552 def _on_close(self, *args, **kwds):
554 if self._osso is not None:
558 self._save_settings()
562 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
564 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
565 For system_inactivity, we have no background tasks to pause
567 @note Hildon specific
570 for backendId in self.BACKENDS:
571 self._phoneBackends[backendId].clear_caches()
572 self._contactsViews[self._selectedBackendId].clear_caches()
575 if save_unsaved_data or shutdown:
576 self._save_settings()
578 def _on_connection_change(self, connection, event, magicIdentifier):
580 @note Hildon specific
584 status = event.get_status()
585 error = event.get_error()
586 iap_id = event.get_iap_id()
587 bearer = event.get_bearer_type()
589 if status == conic.STATUS_CONNECTED:
590 self._deviceIsOnline = True
592 self._spawn_attempt_login(2)
593 elif status == conic.STATUS_DISCONNECTED:
594 self._deviceIsOnline = False
596 self._defaultBackendId = self._selectedBackendId
597 self._change_loggedin_status(self.NULL_BACKEND)
599 def _on_window_state_change(self, widget, event, *args):
601 @note Hildon specific
603 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
604 self._isFullScreen = True
606 self._isFullScreen = False
608 def _on_key_press(self, widget, event, *args):
610 @note Hildon specific
612 if event.keyval == gtk.keysyms.F6:
613 if self._isFullScreen:
614 self._window.unfullscreen()
616 self._window.fullscreen()
618 def _on_clearcookies_clicked(self, *args):
619 self._phoneBackends[self._selectedBackendId].logout()
620 self._accountViews[self._selectedBackendId].clear()
621 self._recentViews[self._selectedBackendId].clear()
622 self._messagesViews[self._selectedBackendId].clear()
623 self._contactsViews[self._selectedBackendId].clear()
624 self._change_loggedin_status(self.NULL_BACKEND)
626 self._spawn_attempt_login(2, True)
628 def _on_notebook_switch_page(self, notebook, page, page_num):
629 if page_num == self.RECENT_TAB:
630 self._recentViews[self._selectedBackendId].update()
631 elif page_num == self.MESSAGES_TAB:
632 self._messagesViews[self._selectedBackendId].update()
633 elif page_num == self.CONTACTS_TAB:
634 self._contactsViews[self._selectedBackendId].update()
635 elif page_num == self.ACCOUNT_TAB:
636 self._accountViews[self._selectedBackendId].update()
638 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
639 if hildon is not None:
640 self._window.set_title(tabTitle)
642 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
644 def _on_sms_clicked(self, number, message):
648 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
649 except StandardError, e:
651 self._errorDisplay.push_exception(e)
655 self._errorDisplay.push_message(
656 "Backend link with grandcentral is not working, please try again"
662 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
664 except StandardError, e:
665 self._errorDisplay.push_exception(e)
666 except ValueError, e:
667 self._errorDisplay.push_exception(e)
669 def _on_dial_clicked(self, number):
672 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
673 except StandardError, e:
675 self._errorDisplay.push_exception(e)
679 self._errorDisplay.push_message(
680 "Backend link with grandcentral is not working, please try again"
686 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
687 self._phoneBackends[self._selectedBackendId].dial(number)
689 except StandardError, e:
690 self._errorDisplay.push_exception(e)
691 except ValueError, e:
692 self._errorDisplay.push_exception(e)
695 self._dialpads[self._selectedBackendId].clear()
697 def _on_refresh(self, *args):
698 page_num = self._notebook.get_current_page()
699 if page_num == self.CONTACTS_TAB:
700 self._contactsViews[self._selectedBackendId].update(force=True)
701 elif page_num == self.RECENT_TAB:
702 self._recentViews[self._selectedBackendId].update(force=True)
703 elif page_num == self.MESSAGES_TAB:
704 self._messagesViews[self._selectedBackendId].update(force=True)
706 def _on_paste(self, *args):
707 contents = self._clipboard.wait_for_text()
708 self._dialpads[self._selectedBackendId].set_number(contents)
710 def _on_about_activate(self, *args):
711 dlg = gtk.AboutDialog()
712 dlg.set_name(constants.__pretty_app_name__)
713 dlg.set_version(constants.__version__)
714 dlg.set_copyright("Copyright 2008 - LGPL")
715 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")
716 dlg.set_website("http://gc-dialer.garage.maemo.org/")
717 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
725 failureCount, testCount = doctest.testmod()
727 print "Tests Successful"
734 gtk.gdk.threads_init()
735 if hildon is not None:
736 gtk.set_application_name(constants.__pretty_app_name__)
737 handle = Dialcentral()
741 class DummyOptions(object):
747 if __name__ == "__main__":
748 if len(sys.argv) > 1:
754 if optparse is not None:
755 parser = optparse.OptionParser()
756 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
757 (commandOptions, commandArgs) = parser.parse_args()
759 commandOptions = DummyOptions()
762 if commandOptions.test: