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 @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)
131 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
132 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
133 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
134 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
135 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
136 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
138 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
140 for child in gtkMenu.get_children():
142 self._window.set_menu(menu)
145 self._window.connect("key-press-event", self._on_key_press)
146 self._window.connect("window-state-event", self._on_window_state_change)
148 pass # warnings.warn("No Hildon", UserWarning, 2)
150 if hildon is not None:
151 self._window.set_title("Keypad")
153 self._window.set_title("%s - Keypad" % constants.__pretty_app_name__)
156 "on_dialpad_quit": self._on_close,
158 self._widgetTree.signal_autoconnect(callbackMapping)
160 self._window.connect("destroy", self._on_close)
161 self._window.set_default_size(800, 300)
162 self._window.show_all()
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, 2)
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 gtk_toolbox.asynchronous_gtk_message(self._spawn_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 _spawn_attempt_login(self, *args):
346 backgroundLogin = threading.Thread(target=self.attempt_login, args=args)
347 backgroundLogin.setDaemon(True)
348 backgroundLogin.start()
350 def refresh_session(self):
352 @note Thread agnostic
354 assert self._initDone, "Attempting login before app is fully loaded"
355 if not self._deviceIsOnline:
356 raise RuntimeError("Unable to login, device is not online")
360 loggedIn = self._login_by_cookie()
362 loggedIn = self._login_by_settings()
365 raise RuntimeError("Login Failed")
367 def _login_by_cookie(self):
369 @note Thread agnostic
371 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
374 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
379 def _login_by_settings(self):
381 @note Thread agnostic
383 username, password = self._credentials
384 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
386 self._credentials = username, password
388 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
393 def _login_by_user(self, numOfAttempts):
395 @note This must be run outside of the UI lock
397 loggedIn, (username, password) = False, self._credentials
398 tmpServiceId = self.NULL_BACKEND
399 for attemptCount in xrange(numOfAttempts):
402 availableServices = {
403 self.GV_BACKEND: "Google Voice",
404 self.GC_BACKEND: "Grand Central",
406 with gtk_toolbox.gtk_lock():
407 credentials = self._credentialsDialog.request_credentials_from(
408 availableServices, defaultCredentials = self._credentials
410 tmpServiceId, username, password = credentials
411 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
414 serviceId = tmpServiceId
415 self._credentials = username, password
417 "Logged into %r through user request" % self._phoneBackends[serviceId],
421 serviceId = self.NULL_BACKEND
423 return loggedIn, serviceId
425 def _select_action(self, action, number, message):
426 self.refresh_session()
427 if action == "select":
428 self._dialpads[self._selectedBackendId].set_number(number)
429 self._notebook.set_current_page(self.KEYPAD_TAB)
430 elif action == "dial":
431 self._on_dial_clicked(number)
432 elif action == "sms":
433 self._on_sms_clicked(number, message)
435 assert False, "Unknown action: %s" % action
437 def _change_loggedin_status(self, newStatus):
438 oldStatus = self._selectedBackendId
439 if oldStatus == newStatus:
442 self._dialpads[oldStatus].disable()
443 self._accountViews[oldStatus].disable()
444 self._recentViews[oldStatus].disable()
445 self._messagesViews[oldStatus].disable()
446 self._contactsViews[oldStatus].disable()
448 self._dialpads[newStatus].enable()
449 self._accountViews[newStatus].enable()
450 self._recentViews[newStatus].enable()
451 self._messagesViews[newStatus].enable()
452 self._contactsViews[newStatus].enable()
454 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
455 self._phoneBackends[self._selectedBackendId].set_sane_callback()
456 self._accountViews[self._selectedBackendId].update()
458 self._selectedBackendId = newStatus
460 def load_settings(self, config):
465 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
467 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
468 for i in xrange(len(self._credentials))
471 base64.b64decode(blob)
474 self._credentials = tuple(creds)
475 except ConfigParser.NoSectionError, e:
477 "Settings file %s is missing section %s" % (
484 for backendId, view in itertools.chain(
485 self._dialpads.iteritems(),
486 self._accountViews.iteritems(),
487 self._messagesViews.iteritems(),
488 self._recentViews.iteritems(),
489 self._contactsViews.iteritems(),
491 sectionName = "%s - %s" % (backendId, view.name())
493 view.load_settings(config, sectionName)
494 except ConfigParser.NoSectionError, e:
496 "Settings file %s is missing section %s" % (
503 def save_settings(self, config):
505 @note Thread Agnostic
507 config.add_section(constants.__pretty_app_name__)
508 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
509 for i, value in enumerate(self._credentials):
510 blob = base64.b64encode(value)
511 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
512 for backendId, view in itertools.chain(
513 self._dialpads.iteritems(),
514 self._accountViews.iteritems(),
515 self._messagesViews.iteritems(),
516 self._recentViews.iteritems(),
517 self._contactsViews.iteritems(),
519 sectionName = "%s - %s" % (backendId, view.name())
520 config.add_section(sectionName)
521 view.save_settings(config, sectionName)
523 def _guess_preferred_backend(self, backendAndCookiePaths):
525 (getmtime_nothrow(path), backendId, path)
526 for backendId, path in backendAndCookiePaths
528 modTimeAndPath.sort()
529 return modTimeAndPath[-1][1]
531 def _save_settings(self):
533 @note Thread Agnostic
535 config = ConfigParser.SafeConfigParser()
536 self.save_settings(config)
537 with open(self._user_settings, "wb") as configFile:
538 config.write(configFile)
540 def _on_close(self, *args, **kwds):
542 if self._osso is not None:
546 self._save_settings()
550 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
552 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
553 For system_inactivity, we have no background tasks to pause
555 @note Hildon specific
558 for backendId in self.BACKENDS:
559 self._phoneBackends[backendId].clear_caches()
560 self._contactsViews[self._selectedBackendId].clear_caches()
563 if save_unsaved_data or shutdown:
564 self._save_settings()
566 def _on_connection_change(self, connection, event, magicIdentifier):
568 @note Hildon specific
572 status = event.get_status()
573 error = event.get_error()
574 iap_id = event.get_iap_id()
575 bearer = event.get_bearer_type()
577 if status == conic.STATUS_CONNECTED:
578 self._deviceIsOnline = True
580 self._spawn_attempt_login(2)
581 elif status == conic.STATUS_DISCONNECTED:
582 self._deviceIsOnline = False
584 self._defaultBackendId = self._selectedBackendId
585 self._change_loggedin_status(self.NULL_BACKEND)
587 def _on_window_state_change(self, widget, event, *args):
589 @note Hildon specific
591 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
592 self._isFullScreen = True
594 self._isFullScreen = False
596 def _on_key_press(self, widget, event, *args):
598 @note Hildon specific
600 if event.keyval == gtk.keysyms.F6:
601 if self._isFullScreen:
602 self._window.unfullscreen()
604 self._window.fullscreen()
606 def _on_clearcookies_clicked(self, *args):
607 self._phoneBackends[self._selectedBackendId].logout()
608 self._accountViews[self._selectedBackendId].clear()
609 self._recentViews[self._selectedBackendId].clear()
610 self._messagesViews[self._selectedBackendId].clear()
611 self._contactsViews[self._selectedBackendId].clear()
612 self._change_loggedin_status(self.NULL_BACKEND)
614 self._spawn_attempt_login(2, True)
616 def _on_notebook_switch_page(self, notebook, page, page_num):
617 if page_num == self.RECENT_TAB:
618 self._recentViews[self._selectedBackendId].update()
619 elif page_num == self.MESSAGES_TAB:
620 self._messagesViews[self._selectedBackendId].update()
621 elif page_num == self.CONTACTS_TAB:
622 self._contactsViews[self._selectedBackendId].update()
623 elif page_num == self.ACCOUNT_TAB:
624 self._accountViews[self._selectedBackendId].update()
626 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
627 if hildon is not None:
628 self._window.set_title(tabTitle)
630 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
632 def _on_sms_clicked(self, number, message):
636 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
637 except RuntimeError, e:
639 self._errorDisplay.push_exception(e)
643 self._errorDisplay.push_message(
644 "Backend link with grandcentral is not working, please try again"
650 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
652 except RuntimeError, e:
653 self._errorDisplay.push_exception(e)
654 except ValueError, e:
655 self._errorDisplay.push_exception(e)
657 def _on_dial_clicked(self, number):
660 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
661 except RuntimeError, e:
663 self._errorDisplay.push_exception(e)
667 self._errorDisplay.push_message(
668 "Backend link with grandcentral is not working, please try again"
674 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
675 self._phoneBackends[self._selectedBackendId].dial(number)
677 except RuntimeError, e:
678 self._errorDisplay.push_exception(e)
679 except ValueError, e:
680 self._errorDisplay.push_exception(e)
683 self._dialpads[self._selectedBackendId].clear()
685 def _on_refresh(self, *args):
686 page_num = self._notebook.get_current_page()
687 if page_num == self.CONTACTS_TAB:
688 self._contactsViews[self._selectedBackendId].update(force=True)
689 elif page_num == self.RECENT_TAB:
690 self._recentViews[self._selectedBackendId].update(force=True)
691 elif page_num == self.MESSAGES_TAB:
692 self._messagesViews[self._selectedBackendId].update(force=True)
694 def _on_paste(self, *args):
695 contents = self._clipboard.wait_for_text()
696 self._dialpads[self._selectedBackendId].set_number(contents)
698 def _on_about_activate(self, *args):
699 dlg = gtk.AboutDialog()
700 dlg.set_name(constants.__pretty_app_name__)
701 dlg.set_version(constants.__version__)
702 dlg.set_copyright("Copyright 2008 - LGPL")
703 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")
704 dlg.set_website("http://gc-dialer.garage.maemo.org/")
705 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
713 failureCount, testCount = doctest.testmod()
715 print "Tests Successful"
722 gtk.gdk.threads_init()
723 if hildon is not None:
724 gtk.set_application_name(constants.__pretty_app_name__)
725 handle = Dialcentral()
729 class DummyOptions(object):
735 if __name__ == "__main__":
736 if len(sys.argv) > 1:
742 if optparse is not None:
743 parser = optparse.OptionParser()
744 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
745 (commandOptions, commandArgs) = parser.parse_args()
747 commandOptions = DummyOptions()
750 if commandOptions.test: