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 For every X minutes, if logged in, attempt login
23 @todo Force login on connect if not already done
24 @todo Can't text from dialpad (so can't do any arbitrary number texts)
25 @todo Add logging support to make debugging issues for people a lot easier
29 from __future__ import with_statement
52 def getmtime_nothrow(path):
54 return os.path.getmtime(path)
59 def display_error_message(msg):
60 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
62 def close(dialog, response):
64 error_dialog.connect("response", close)
68 class Dialcentral(object):
70 __pretty_app_name__ = "DialCentral"
71 __app_name__ = "dialcentral"
73 __app_magic__ = 0xdeadbeef
76 '/usr/lib/dialcentral/dialcentral.glade',
77 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
78 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
90 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
92 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
93 _user_settings = "%s/settings.ini" % _data_path
96 self._initDone = False
97 self._connection = None
99 self._clipboard = gtk.clipboard_get()
101 self._deviceIsOnline = True
102 self._credentials = ("", "")
103 self._selectedBackendId = self.NULL_BACKEND
104 self._defaultBackendId = self.GC_BACKEND
105 self._phoneBackends = None
106 self._dialpads = None
107 self._accountViews = None
108 self._messagesViews = None
109 self._recentViews = None
110 self._contactsViews = None
112 for path in self._glade_files:
113 if os.path.isfile(path):
114 self._widgetTree = gtk.glade.XML(path)
117 display_error_message("Cannot find dialcentral.glade")
121 self._window = self._widgetTree.get_widget("mainWindow")
122 self._notebook = self._widgetTree.get_widget("notebook")
123 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
124 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
127 self._isFullScreen = False
128 if hildon is not None:
129 self._app = hildon.Program()
130 self._window = hildon.Window()
131 self._widgetTree.get_widget("vbox1").reparent(self._window)
132 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))
136 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
137 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
139 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
141 for child in gtkMenu.get_children():
143 self._window.set_menu(menu)
146 self._window.connect("key-press-event", self._on_key_press)
147 self._window.connect("window-state-event", self._on_window_state_change)
149 pass # warnings.warn("No Hildon", UserWarning, 2)
151 if hildon is not None:
152 self._window.set_title("Keypad")
154 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
157 "on_dialpad_quit": self._on_close,
159 self._widgetTree.signal_autoconnect(callbackMapping)
162 self._window.connect("destroy", self._on_close)
163 self._window.show_all()
164 self._window.set_default_size(800, 300)
166 backgroundSetup = threading.Thread(target=self._idle_setup)
167 backgroundSetup.setDaemon(True)
168 backgroundSetup.start()
170 def _idle_setup(self):
172 If something can be done after the UI loads, push it here so it's not blocking the UI
174 # Barebones UI handlers
178 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
179 with gtk_toolbox.gtk_lock():
180 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
181 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
182 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
183 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
184 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
186 self._dialpads[self._selectedBackendId].enable()
187 self._accountViews[self._selectedBackendId].enable()
188 self._recentViews[self._selectedBackendId].enable()
189 self._messagesViews[self._selectedBackendId].enable()
190 self._contactsViews[self._selectedBackendId].enable()
192 # Setup maemo specifics
199 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
200 device = osso.DeviceState(self._osso)
201 device.set_device_state_callback(self._on_device_state_change, 0)
203 pass # warnings.warn("No OSSO", UserWarning)
209 self._connection = None
210 if conic is not None:
211 self._connection = conic.Connection()
212 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
213 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
215 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
217 # Setup costly backends
225 os.makedirs(self._data_path)
229 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
230 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
231 self._defaultBackendId = self._guess_preferred_backend((
232 (self.GC_BACKEND, gcCookiePath),
233 (self.GV_BACKEND, gvCookiePath),
236 self._phoneBackends.update({
237 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
238 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
240 with gtk_toolbox.gtk_lock():
241 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
242 unifiedDialpad.set_number("")
243 self._dialpads.update({
244 self.GC_BACKEND: unifiedDialpad,
245 self.GV_BACKEND: unifiedDialpad,
247 self._accountViews.update({
248 self.GC_BACKEND: gc_views.AccountInfo(
249 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
251 self.GV_BACKEND: gc_views.AccountInfo(
252 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
255 self._recentViews.update({
256 self.GC_BACKEND: gc_views.RecentCallsView(
257 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
259 self.GV_BACKEND: gc_views.RecentCallsView(
260 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
263 self._messagesViews.update({
264 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
265 self.GV_BACKEND: gc_views.MessagesView(
266 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
269 self._contactsViews.update({
270 self.GC_BACKEND: gc_views.ContactsView(
271 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
273 self.GV_BACKEND: gc_views.ContactsView(
274 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
278 evoBackend = evo_backend.EvolutionAddressBook()
279 fsContactsPath = os.path.join(self._data_path, "contacts")
280 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
281 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
282 self._dialpads[backendId].dial = self._on_dial_clicked
283 self._recentViews[backendId].number_selected = self._on_number_selected
284 self._contactsViews[backendId].number_selected = self._on_number_selected
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 self.attempt_login(2)
316 def attempt_login(self, numOfAttempts = 10):
318 @todo Handle user notification better like attempting to login and failed login
320 @note Not meant to be called directly, but run as a seperate thread.
322 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
324 if not self._deviceIsOnline:
325 warnings.warn("Attempted to login while device was offline")
327 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
329 "Attempted to login before initialization is complete, did an event fire early?"
335 username, password = self._credentials
336 serviceId = self._defaultBackendId
338 # Attempt using the cookies
339 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
342 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
346 # Attempt using the settings file
347 if not loggedIn and username and password:
348 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
351 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
355 # Query the user for credentials
356 for attemptCount in xrange(numOfAttempts):
359 with gtk_toolbox.gtk_lock():
360 availableServices = {
361 self.GV_BACKEND: "Google Voice",
362 self.GC_BACKEND: "Grand Central",
364 credentials = self._credentialsDialog.request_credentials_from(
365 availableServices, defaultCredentials = self._credentials
367 serviceId, username, password = credentials
369 loggedIn = self._phoneBackends[serviceId].login(username, password)
372 "Logged into %r through user request" % self._phoneBackends[serviceId],
375 except RuntimeError, e:
376 warnings.warn(traceback.format_exc())
377 self._errorDisplay.push_exception_with_lock(e)
379 with gtk_toolbox.gtk_lock():
381 self._credentials = username, password
382 self._change_loggedin_status(serviceId)
384 self._errorDisplay.push_message("Login Failed")
385 self._change_loggedin_status(self.NULL_BACKEND)
388 def _on_close(self, *args, **kwds):
390 if self._osso is not None:
394 self._save_settings()
398 def _change_loggedin_status(self, newStatus):
399 oldStatus = self._selectedBackendId
400 if oldStatus == newStatus:
403 self._dialpads[oldStatus].disable()
404 self._accountViews[oldStatus].disable()
405 self._recentViews[oldStatus].disable()
406 self._messagesViews[oldStatus].disable()
407 self._contactsViews[oldStatus].disable()
409 self._dialpads[newStatus].enable()
410 self._accountViews[newStatus].enable()
411 self._recentViews[newStatus].enable()
412 self._messagesViews[newStatus].enable()
413 self._contactsViews[newStatus].enable()
415 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
416 self._phoneBackends[self._selectedBackendId].set_sane_callback()
417 self._accountViews[self._selectedBackendId].update()
419 self._selectedBackendId = newStatus
421 def load_settings(self, config):
425 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
427 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
428 for i in xrange(len(self._credentials))
431 base64.b64decode(blob)
434 self._credentials = tuple(creds)
435 for backendId, view in itertools.chain(
436 self._dialpads.iteritems(),
437 self._accountViews.iteritems(),
438 self._messagesViews.iteritems(),
439 self._recentViews.iteritems(),
440 self._contactsViews.iteritems(),
442 sectionName = "%s - %s" % (backendId, view.name())
443 view.load_settings(config, sectionName)
445 def save_settings(self, config):
447 @note Thread Agnostic
449 config.add_section(self.__pretty_app_name__)
450 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
451 for i, value in enumerate(self._credentials):
452 blob = base64.b64encode(value)
453 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
454 for backendId, view in itertools.chain(
455 self._dialpads.iteritems(),
456 self._accountViews.iteritems(),
457 self._messagesViews.iteritems(),
458 self._recentViews.iteritems(),
459 self._contactsViews.iteritems(),
461 sectionName = "%s - %s" % (backendId, view.name())
462 config.add_section(sectionName)
463 view.save_settings(config, sectionName)
465 def _guess_preferred_backend(self, backendAndCookiePaths):
467 (getmtime_nothrow(path), backendId, path)
468 for backendId, path in backendAndCookiePaths
470 modTimeAndPath.sort()
471 return modTimeAndPath[-1][1]
473 def _save_settings(self):
475 @note Thread Agnostic
477 config = ConfigParser.SafeConfigParser()
478 self.save_settings(config)
479 with open(self._user_settings, "wb") as configFile:
480 config.write(configFile)
482 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
484 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
485 For system_inactivity, we have no background tasks to pause
487 @note Hildon specific
490 for backendId in self.BACKENDS:
491 self._phoneBackends[backendId].clear_caches()
492 self._contactsViews[self._selectedBackendId].clear_caches()
495 if save_unsaved_data or shutdown:
496 self._save_settings()
498 def _on_connection_change(self, connection, event, magicIdentifier):
500 @note Hildon specific
504 status = event.get_status()
505 error = event.get_error()
506 iap_id = event.get_iap_id()
507 bearer = event.get_bearer_type()
509 if status == conic.STATUS_CONNECTED:
510 self._deviceIsOnline = True
511 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
512 backgroundLogin.setDaemon(True)
513 backgroundLogin.start()
514 elif status == conic.STATUS_DISCONNECTED:
515 self._deviceIsOnline = False
516 self._defaultBackendId = self._selectedBackendId
517 self._change_loggedin_status(self.NULL_BACKEND)
519 def _on_window_state_change(self, widget, event, *args):
521 @note Hildon specific
523 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
524 self._isFullScreen = True
526 self._isFullScreen = False
528 def _on_key_press(self, widget, event, *args):
530 @note Hildon specific
532 if event.keyval == gtk.keysyms.F6:
533 if self._isFullScreen:
534 self._window.unfullscreen()
536 self._window.fullscreen()
538 def _on_clearcookies_clicked(self, *args):
539 self._phoneBackends[self._selectedBackendId].logout()
540 self._accountViews[self._selectedBackendId].clear()
541 self._recentViews[self._selectedBackendId].clear()
542 self._messagesViews[self._selectedBackendId].clear()
543 self._contactsViews[self._selectedBackendId].clear()
544 self._change_loggedin_status(self.NULL_BACKEND)
546 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
547 backgroundLogin.setDaemon(True)
548 backgroundLogin.start()
550 def _on_notebook_switch_page(self, notebook, page, page_num):
551 if page_num == self.RECENT_TAB:
552 self._recentViews[self._selectedBackendId].update()
553 elif page_num == self.MESSAGES_TAB:
554 self._messagesViews[self._selectedBackendId].update()
555 elif page_num == self.CONTACTS_TAB:
556 self._contactsViews[self._selectedBackendId].update()
557 elif page_num == self.ACCOUNT_TAB:
558 self._accountViews[self._selectedBackendId].update()
560 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
561 if hildon is not None:
562 self._window.set_title(tabTitle)
564 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
566 def _on_number_selected(self, action, number, message):
567 if action == "select":
568 self._dialpads[self._selectedBackendId].set_number(number)
569 self._notebook.set_current_page(self.KEYPAD_TAB)
570 elif action == "dial":
571 self._on_dial_clicked(number)
572 elif action == "sms":
573 self._on_sms_clicked(number, message)
575 assert False, "Unknown action: %s" % action
577 def _on_sms_clicked(self, number, message):
579 @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 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
600 except RuntimeError, e:
601 self._errorDisplay.push_exception(e)
602 except ValueError, e:
603 self._errorDisplay.push_exception(e)
605 def _on_dial_clicked(self, number):
607 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
611 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
612 except RuntimeError, e:
614 self._errorDisplay.push_exception(e)
618 self._errorDisplay.push_message(
619 "Backend link with grandcentral is not working, please try again"
625 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
626 self._phoneBackends[self._selectedBackendId].dial(number)
628 except RuntimeError, e:
629 self._errorDisplay.push_exception(e)
630 except ValueError, e:
631 self._errorDisplay.push_exception(e)
634 self._dialpads[self._selectedBackendId].clear()
636 def _on_refresh(self, *args):
637 page_num = self._notebook.get_current_page()
638 if page_num == self.CONTACTS_TAB:
639 self._contactsViews[self._selectedBackendId].update(force=True)
640 elif page_num == self.RECENT_TAB:
641 self._recentViews[self._selectedBackendId].update(force=True)
642 elif page_num == self.MESSAGES_TAB:
643 self._messagesViews[self._selectedBackendId].update(force=True)
645 def _on_paste(self, *args):
646 contents = self._clipboard.wait_for_text()
647 self._dialpads[self._selectedBackendId].set_number(contents)
649 def _on_about_activate(self, *args):
650 dlg = gtk.AboutDialog()
651 dlg.set_name(self.__pretty_app_name__)
652 dlg.set_version(self.__version__)
653 dlg.set_copyright("Copyright 2008 - LGPL")
654 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")
655 dlg.set_website("http://gc-dialer.garage.maemo.org/")
656 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
664 failureCount, testCount = doctest.testmod()
666 print "Tests Successful"
673 gtk.gdk.threads_init()
674 if hildon is not None:
675 gtk.set_application_name(Dialcentral.__pretty_app_name__)
676 handle = Dialcentral()
680 class DummyOptions(object):
686 if __name__ == "__main__":
687 if len(sys.argv) > 1:
693 if optparse is not None:
694 parser = optparse.OptionParser()
695 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
696 (commandOptions, commandArgs) = parser.parse_args()
698 commandOptions = DummyOptions()
701 if commandOptions.test: