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 Completely broken on Maemo
22 @todo Add storing of credentials like DoneIt
23 @todo Add logging support to make debugging issues for people a lot easier
27 from __future__ import with_statement
50 def getmtime_nothrow(path):
52 return os.path.getmtime(path)
57 def display_error_message(msg):
58 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
60 def close(dialog, response):
62 error_dialog.connect("response", close)
66 class Dialcentral(object):
68 __pretty_app_name__ = "DialCentral"
69 __app_name__ = "dialcentral"
71 __app_magic__ = 0xdeadbeef
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._deviceIsOnline = True
100 self._credentials = ("", "")
101 self._selectedBackendId = self.NULL_BACKEND
102 self._defaultBackendId = self.GC_BACKEND
103 self._phoneBackends = None
104 self._dialpads = None
105 self._accountViews = None
106 self._messagesViews = None
107 self._recentViews = None
108 self._contactsViews = 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 self._window = hildon.Window()
129 self._widgetTree.get_widget("vbox1").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('contacts_scrolledwindow'), True)
137 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
139 for child in gtkMenu.get_children():
141 self._window.set_menu(menu)
144 self._window.connect("key-press-event", self._on_key_press)
145 self._window.connect("window-state-event", self._on_window_state_change)
147 pass # warnings.warn("No Hildon", UserWarning, 2)
149 if hildon is not None:
150 self._window.set_title("Keypad")
152 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
155 "on_dialpad_quit": self._on_close,
157 self._widgetTree.signal_autoconnect(callbackMapping)
160 self._window.connect("destroy", self._on_close)
161 self._window.show_all()
162 self._window.set_default_size(800, 300)
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(Dialcentral.__app_name__, Dialcentral.__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)
207 self._connection = None
208 if conic is not None:
209 self._connection = conic.Connection()
210 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
211 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
213 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
215 # Setup costly backends
223 os.makedirs(self._data_path)
227 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
228 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
229 self._defaultBackendId = self._guess_preferred_backend((
230 (self.GC_BACKEND, gcCookiePath),
231 (self.GV_BACKEND, gvCookiePath),
234 self._phoneBackends.update({
235 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
236 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
238 with gtk_toolbox.gtk_lock():
239 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
240 unifiedDialpad.set_number("")
241 self._dialpads.update({
242 self.GC_BACKEND: unifiedDialpad,
243 self.GV_BACKEND: unifiedDialpad,
245 self._accountViews.update({
246 self.GC_BACKEND: gc_views.AccountInfo(
247 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
249 self.GV_BACKEND: gc_views.AccountInfo(
250 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
253 self._recentViews.update({
254 self.GC_BACKEND: gc_views.RecentCallsView(
255 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
257 self.GV_BACKEND: gc_views.RecentCallsView(
258 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
261 self._messagesViews.update({
262 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
263 self.GV_BACKEND: gc_views.MessagesView(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._contactsViews.update({
268 self.GC_BACKEND: gc_views.ContactsView(
269 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
271 self.GV_BACKEND: gc_views.ContactsView(
272 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
276 evoBackend = evo_backend.EvolutionAddressBook()
277 fsContactsPath = os.path.join(self._data_path, "contacts")
278 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
279 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
280 self._dialpads[backendId].dial = self._on_dial_clicked
281 self._recentViews[backendId].number_selected = self._on_number_selected
282 self._contactsViews[backendId].number_selected = self._on_number_selected
285 self._phoneBackends[backendId],
289 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
290 self._contactsViews[backendId].append(mergedBook)
291 self._contactsViews[backendId].extend(addressBooks)
292 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
295 "on_paste": self._on_paste,
296 "on_refresh": self._on_refresh,
297 "on_clearcookies_clicked": self._on_clearcookies_clicked,
298 "on_notebook_switch_page": self._on_notebook_switch_page,
299 "on_about_activate": self._on_about_activate,
301 self._widgetTree.signal_autoconnect(callbackMapping)
303 self._initDone = True
305 config = ConfigParser.SafeConfigParser()
306 config.read(self._user_settings)
307 with gtk_toolbox.gtk_lock():
308 self.load_settings(config)
310 self.attempt_login(2)
314 def attempt_login(self, numOfAttempts = 10):
316 @todo Handle user notification better like attempting to login and failed login
318 @note Not meant to be called directly, but run as a seperate thread.
320 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
322 if not self._deviceIsOnline:
323 warnings.warn("Attempted to login while device was offline")
325 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
327 "Attempted to login before initialization is complete, did an event fire early?"
333 username, password = self._credentials
334 serviceId = self._defaultBackendId
336 # Attempt using the cookies
337 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
340 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId]
343 # Attempt using the settings file
344 if not loggedIn and username and password:
345 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
348 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId]
351 # Query the user for credentials
352 for attemptCount in xrange(numOfAttempts):
355 with gtk_toolbox.gtk_lock():
356 availableServices = {
357 self.GV_BACKEND: "Google Voice",
358 self.GC_BACKEND: "Grand Central",
360 credentials = self._credentialsDialog.request_credentials_from(
361 availableServices, defaultCredentials = self._credentials
363 serviceId, username, password = credentials
365 loggedIn = self._phoneBackends[serviceId].login(username, password)
368 "Logged into %r through user request" % self._phoneBackends[serviceId]
370 except RuntimeError, e:
371 warnings.warn(traceback.format_exc())
372 self._errorDisplay.push_exception_with_lock(e)
374 with gtk_toolbox.gtk_lock():
376 self._credentials = username, password
377 self._change_loggedin_status(serviceId)
379 self._errorDisplay.push_message("Login Failed")
380 self._change_loggedin_status(self.NULL_BACKEND)
383 def _on_close(self, *args, **kwds):
385 if self._osso is not None:
389 self._save_settings()
393 def _change_loggedin_status(self, newStatus):
394 oldStatus = self._selectedBackendId
395 if oldStatus == newStatus:
398 self._dialpads[oldStatus].disable()
399 self._accountViews[oldStatus].disable()
400 self._recentViews[oldStatus].disable()
401 self._messagesViews[oldStatus].disable()
402 self._contactsViews[oldStatus].disable()
404 self._dialpads[newStatus].enable()
405 self._accountViews[newStatus].enable()
406 self._recentViews[newStatus].enable()
407 self._messagesViews[newStatus].enable()
408 self._contactsViews[newStatus].enable()
410 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
411 self._phoneBackends[self._selectedBackendId].set_sane_callback()
412 self._accountViews[self._selectedBackendId].update()
414 self._selectedBackendId = newStatus
416 def load_settings(self, config):
420 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
422 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
423 for i in xrange(len(self._credentials))
426 base64.b64decode(blob)
429 self._credentials = tuple(creds)
430 for backendId, view in itertools.chain(
431 self._dialpads.iteritems(),
432 self._accountViews.iteritems(),
433 self._messagesViews.iteritems(),
434 self._recentViews.iteritems(),
435 self._contactsViews.iteritems(),
437 sectionName = "%s - %s" % (backendId, view.name())
438 view.load_settings(config, sectionName)
440 def save_settings(self, config):
442 @note Thread Agnostic
444 config.add_section(self.__pretty_app_name__)
445 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
446 for i, value in enumerate(self._credentials):
447 blob = base64.b64encode(value)
448 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
449 for backendId, view in itertools.chain(
450 self._dialpads.iteritems(),
451 self._accountViews.iteritems(),
452 self._messagesViews.iteritems(),
453 self._recentViews.iteritems(),
454 self._contactsViews.iteritems(),
456 sectionName = "%s - %s" % (backendId, view.name())
457 config.add_section(sectionName)
458 view.save_settings(config, sectionName)
460 def _guess_preferred_backend(self, backendAndCookiePaths):
462 (getmtime_nothrow(path), backendId, path)
463 for backendId, path in backendAndCookiePaths
465 modTimeAndPath.sort()
466 return modTimeAndPath[-1][1]
468 def _save_settings(self):
470 @note Thread Agnostic
472 config = ConfigParser.SafeConfigParser()
473 self.save_settings(config)
474 with open(self._user_settings, "wb") as configFile:
475 config.write(configFile)
477 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
479 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
480 For system_inactivity, we have no background tasks to pause
482 @note Hildon specific
485 for backendId in self.BACKENDS:
486 self._phoneBackends[backendId].clear_caches()
487 self._contactsViews[self._selectedBackendId].clear_caches()
490 if save_unsaved_data or shutdown:
491 self._save_settings()
493 def _on_connection_change(self, connection, event, magicIdentifier):
495 @note Hildon specific
499 status = event.get_status()
500 error = event.get_error()
501 iap_id = event.get_iap_id()
502 bearer = event.get_bearer_type()
504 if status == conic.STATUS_CONNECTED:
505 self._deviceIsOnline = True
506 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
507 backgroundLogin.setDaemon(True)
508 backgroundLogin.start()
509 elif status == conic.STATUS_DISCONNECTED:
510 self._deviceIsOnline = False
511 self._defaultBackendId = self._selectedBackendId
512 self._change_loggedin_status(self.NULL_BACKEND)
514 def _on_window_state_change(self, widget, event, *args):
516 @note Hildon specific
518 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
519 self._isFullScreen = True
521 self._isFullScreen = False
523 def _on_key_press(self, widget, event, *args):
525 @note Hildon specific
527 if event.keyval == gtk.keysyms.F6:
528 if self._isFullScreen:
529 self._window.unfullscreen()
531 self._window.fullscreen()
533 def _on_clearcookies_clicked(self, *args):
534 self._phoneBackends[self._selectedBackendId].logout()
535 self._accountViews[self._selectedBackendId].clear()
536 self._recentViews[self._selectedBackendId].clear()
537 self._messagesViews[self._selectedBackendId].clear()
538 self._contactsViews[self._selectedBackendId].clear()
539 self._change_loggedin_status(self.NULL_BACKEND)
541 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
542 backgroundLogin.setDaemon(True)
543 backgroundLogin.start()
545 def _on_notebook_switch_page(self, notebook, page, page_num):
546 if page_num == self.RECENT_TAB:
547 self._recentViews[self._selectedBackendId].update()
548 elif page_num == self.MESSAGES_TAB:
549 self._messagesViews[self._selectedBackendId].update()
550 elif page_num == self.CONTACTS_TAB:
551 self._contactsViews[self._selectedBackendId].update()
552 elif page_num == self.ACCOUNT_TAB:
553 self._accountViews[self._selectedBackendId].update()
555 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
556 if hildon is not None:
557 self._window.set_title(tabTitle)
559 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
561 def _on_number_selected(self, action, number, message):
562 if action == "select":
563 self._dialpads[self._selectedBackendId].set_number(number)
564 self._notebook.set_current_page(self.KEYPAD_TAB)
565 elif action == "dial":
566 self._on_dial_clicked(number)
567 elif action == "sms":
568 self._on_sms_clicked(number, message)
570 assert False, "Unknown action: %s" % action
572 def _on_sms_clicked(self, number, message):
574 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
579 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
580 except RuntimeError, e:
582 self._errorDisplay.push_exception(e)
586 self._errorDisplay.push_message(
587 "Backend link with grandcentral is not working, please try again"
593 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
595 except RuntimeError, e:
596 self._errorDisplay.push_exception(e)
597 except ValueError, e:
598 self._errorDisplay.push_exception(e)
600 def _on_dial_clicked(self, number):
602 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
606 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
607 except RuntimeError, e:
609 self._errorDisplay.push_exception(e)
613 self._errorDisplay.push_message(
614 "Backend link with grandcentral is not working, please try again"
620 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
621 self._phoneBackends[self._selectedBackendId].dial(number)
623 except RuntimeError, e:
624 self._errorDisplay.push_exception(e)
625 except ValueError, e:
626 self._errorDisplay.push_exception(e)
629 self._dialpads[self._selectedBackendId].clear()
631 def _on_refresh(self, *args):
632 page_num = self._notebook.get_current_page()
633 if page_num == self.CONTACTS_TAB:
634 self._contactsViews[self._selectedBackendId].update(force=True)
635 elif page_num == self.RECENT_TAB:
636 self._recentViews[self._selectedBackendId].update(force=True)
637 elif page_num == self.MESSAGES_TAB:
638 self._messagesViews[self._selectedBackendId].update(force=True)
640 def _on_paste(self, *args):
641 contents = self._clipboard.wait_for_text()
642 self._dialpads[self._selectedBackendId].set_number(contents)
644 def _on_about_activate(self, *args):
645 dlg = gtk.AboutDialog()
646 dlg.set_name(self.__pretty_app_name__)
647 dlg.set_version(self.__version__)
648 dlg.set_copyright("Copyright 2008 - LGPL")
649 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")
650 dlg.set_website("http://gc-dialer.garage.maemo.org/")
651 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
659 failureCount, testCount = doctest.testmod()
661 print "Tests Successful"
668 gtk.gdk.threads_init()
669 if hildon is not None:
670 gtk.set_application_name(Dialcentral.__pretty_app_name__)
671 handle = Dialcentral()
675 class DummyOptions(object):
681 if __name__ == "__main__":
682 if len(sys.argv) > 1:
688 if optparse is not None:
689 parser = optparse.OptionParser()
690 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
691 (commandOptions, commandArgs) = parser.parse_args()
693 commandOptions = DummyOptions()
696 if commandOptions.test: