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 if self._phoneBackends[self._defaultBackendId].is_authed():
334 serviceId = self._defaultBackendId
336 username, password = self._credentials
337 for x in xrange(numOfAttempts):
340 with gtk_toolbox.gtk_lock():
341 availableServices = {
342 self.GV_BACKEND: "Google Voice",
343 self.GC_BACKEND: "Grand Central",
345 credentials = self._credentialsDialog.request_credentials_from(
346 availableServices, defaultCredentials = self._credentials
348 serviceId, username, password = credentials
350 loggedIn = self._phoneBackends[serviceId].login(username, password)
351 except RuntimeError, e:
352 warnings.warn(traceback.format_exc())
353 self._errorDisplay.push_exception_with_lock(e)
355 with gtk_toolbox.gtk_lock():
357 self._credentials = username, password
359 self._errorDisplay.push_message("Login Failed")
360 self._change_loggedin_status(serviceId if loggedIn else self.NULL_BACKEND)
363 def _on_close(self, *args, **kwds):
365 if self._osso is not None:
369 self._save_settings()
373 def _change_loggedin_status(self, newStatus):
374 oldStatus = self._selectedBackendId
375 if oldStatus == newStatus:
378 self._dialpads[oldStatus].disable()
379 self._accountViews[oldStatus].disable()
380 self._recentViews[oldStatus].disable()
381 self._messagesViews[oldStatus].disable()
382 self._contactsViews[oldStatus].disable()
384 self._dialpads[newStatus].enable()
385 self._accountViews[newStatus].enable()
386 self._recentViews[newStatus].enable()
387 self._messagesViews[newStatus].enable()
388 self._contactsViews[newStatus].enable()
390 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
391 self._phoneBackends[self._selectedBackendId].set_sane_callback()
392 self._accountViews[self._selectedBackendId].update()
394 self._selectedBackendId = newStatus
396 def load_settings(self, config):
400 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
402 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
403 for i in xrange(len(self._credentials))
406 base64.b64decode(blob)
409 self._credentials = tuple(creds)
410 for backendId, view in itertools.chain(
411 self._dialpads.iteritems(),
412 self._accountViews.iteritems(),
413 self._messagesViews.iteritems(),
414 self._recentViews.iteritems(),
415 self._contactsViews.iteritems(),
417 sectionName = "%s - %s" % (backendId, view.name())
418 view.load_settings(config, sectionName)
420 def save_settings(self, config):
422 @note Thread Agnostic
424 config.add_section(self.__pretty_app_name__)
425 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
426 for i, value in enumerate(self._credentials):
427 blob = base64.b64encode(value)
428 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
429 for backendId, view in itertools.chain(
430 self._dialpads.iteritems(),
431 self._accountViews.iteritems(),
432 self._messagesViews.iteritems(),
433 self._recentViews.iteritems(),
434 self._contactsViews.iteritems(),
436 sectionName = "%s - %s" % (backendId, view.name())
437 config.add_section(sectionName)
438 view.save_settings(config, sectionName)
440 def _guess_preferred_backend(self, backendAndCookiePaths):
442 (getmtime_nothrow(path), backendId, path)
443 for backendId, path in backendAndCookiePaths
445 modTimeAndPath.sort()
446 return modTimeAndPath[-1][1]
448 def _save_settings(self):
450 @note Thread Agnostic
452 config = ConfigParser.SafeConfigParser()
453 self.save_settings(config)
454 with open(self._user_settings, "wb") as configFile:
455 config.write(configFile)
457 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
459 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
460 For system_inactivity, we have no background tasks to pause
462 @note Hildon specific
465 for backendId in self.BACKENDS:
466 self._phoneBackends[backendId].clear_caches()
467 self._contactsViews[self._selectedBackendId].clear_caches()
470 if save_unsaved_data or shutdown:
471 self._save_settings()
473 def _on_connection_change(self, connection, event, magicIdentifier):
475 @note Hildon specific
479 status = event.get_status()
480 error = event.get_error()
481 iap_id = event.get_iap_id()
482 bearer = event.get_bearer_type()
484 if status == conic.STATUS_CONNECTED:
485 self._deviceIsOnline = True
486 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
487 backgroundLogin.setDaemon(True)
488 backgroundLogin.start()
489 elif status == conic.STATUS_DISCONNECTED:
490 self._deviceIsOnline = False
491 self._defaultBackendId = self._selectedBackendId
492 self._change_loggedin_status(self.NULL_BACKEND)
494 def _on_window_state_change(self, widget, event, *args):
496 @note Hildon specific
498 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
499 self._isFullScreen = True
501 self._isFullScreen = False
503 def _on_key_press(self, widget, event, *args):
505 @note Hildon specific
507 if event.keyval == gtk.keysyms.F6:
508 if self._isFullScreen:
509 self._window.unfullscreen()
511 self._window.fullscreen()
513 def _on_clearcookies_clicked(self, *args):
514 self._phoneBackends[self._selectedBackendId].logout()
515 self._accountViews[self._selectedBackendId].clear()
516 self._recentViews[self._selectedBackendId].clear()
517 self._messagesViews[self._selectedBackendId].clear()
518 self._contactsViews[self._selectedBackendId].clear()
519 self._change_loggedin_status(self.NULL_BACKEND)
521 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
522 backgroundLogin.setDaemon(True)
523 backgroundLogin.start()
525 def _on_notebook_switch_page(self, notebook, page, page_num):
526 if page_num == self.CONTACTS_TAB:
527 self._contactsViews[self._selectedBackendId].update()
528 elif page_num == self.RECENT_TAB:
529 self._recentViews[self._selectedBackendId].update()
530 elif page_num == self.MESSAGES_TAB:
531 self._messagesViews[self._selectedBackendId].update()
533 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
534 if hildon is not None:
535 self._window.set_title(tabTitle)
537 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
539 def _on_number_selected(self, action, number, message):
540 if action == "select":
541 self._dialpads[self._selectedBackendId].set_number(number)
542 self._notebook.set_current_page(self.KEYPAD_TAB)
543 elif action == "dial":
544 self._on_dial_clicked(number)
545 elif action == "sms":
546 self._on_sms_clicked(number, message)
548 assert False, "Unknown action: %s" % action
550 def _on_sms_clicked(self, number, message):
552 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
557 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
558 except RuntimeError, e:
560 self._errorDisplay.push_exception(e)
564 self._errorDisplay.push_message(
565 "Backend link with grandcentral is not working, please try again"
571 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
573 except RuntimeError, e:
574 self._errorDisplay.push_exception(e)
575 except ValueError, e:
576 self._errorDisplay.push_exception(e)
578 def _on_dial_clicked(self, number):
580 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
584 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
585 except RuntimeError, e:
587 self._errorDisplay.push_exception(e)
591 self._errorDisplay.push_message(
592 "Backend link with grandcentral is not working, please try again"
598 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
599 self._phoneBackends[self._selectedBackendId].dial(number)
601 except RuntimeError, e:
602 self._errorDisplay.push_exception(e)
603 except ValueError, e:
604 self._errorDisplay.push_exception(e)
607 self._dialpads[self._selectedBackendId].clear()
609 def _on_refresh(self, *args):
610 page_num = self._notebook.get_current_page()
611 if page_num == self.CONTACTS_TAB:
612 self._contactsViews[self._selectedBackendId].update(force=True)
613 elif page_num == self.RECENT_TAB:
614 self._recentViews[self._selectedBackendId].update(force=True)
615 elif page_num == self.MESSAGES_TAB:
616 self._messagesViews[self._selectedBackendId].update(force=True)
618 def _on_paste(self, *args):
619 contents = self._clipboard.wait_for_text()
620 self._dialpads[self._selectedBackendId].set_number(contents)
622 def _on_about_activate(self, *args):
623 dlg = gtk.AboutDialog()
624 dlg.set_name(self.__pretty_app_name__)
625 dlg.set_version(self.__version__)
626 dlg.set_copyright("Copyright 2008 - LGPL")
627 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")
628 dlg.set_website("http://gc-dialer.garage.maemo.org/")
629 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
637 failureCount, testCount = doctest.testmod()
639 print "Tests Successful"
646 gtk.gdk.threads_init()
647 if hildon is not None:
648 gtk.set_application_name(Dialcentral.__pretty_app_name__)
649 handle = Dialcentral()
653 class DummyOptions(object):
659 if __name__ == "__main__":
660 if len(sys.argv) > 1:
666 if optparse is not None:
667 parser = optparse.OptionParser()
668 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
669 (commandOptions, commandArgs) = parser.parse_args()
671 commandOptions = DummyOptions()
674 if commandOptions.test: