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 @bug Session timeouts are bad, possible solutions:
23 @li For every X minutes, if logged in, attempt login
24 @li Restructure so code handling login/dial/sms is beneath everything else and login attempts are made if those fail
25 @todo Force login on connect if not already done
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):
72 __pretty_app_name__ = "DialCentral"
73 __app_name__ = "dialcentral"
75 __app_magic__ = 0xdeadbeef
78 '/usr/lib/dialcentral/dialcentral.glade',
79 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
80 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
92 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
94 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
95 _user_settings = "%s/settings.ini" % _data_path
98 self._initDone = False
99 self._connection = None
101 self._clipboard = gtk.clipboard_get()
103 self._deviceIsOnline = True
104 self._credentials = ("", "")
105 self._selectedBackendId = self.NULL_BACKEND
106 self._defaultBackendId = self.GC_BACKEND
107 self._phoneBackends = None
108 self._dialpads = None
109 self._accountViews = None
110 self._messagesViews = None
111 self._recentViews = None
112 self._contactsViews = None
114 for path in self._glade_files:
115 if os.path.isfile(path):
116 self._widgetTree = gtk.glade.XML(path)
119 display_error_message("Cannot find dialcentral.glade")
123 self._window = self._widgetTree.get_widget("mainWindow")
124 self._notebook = self._widgetTree.get_widget("notebook")
125 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
126 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
129 self._isFullScreen = False
130 if hildon is not None:
131 self._app = hildon.Program()
132 self._window = hildon.Window()
133 self._widgetTree.get_widget("vbox1").reparent(self._window)
134 self._app.add_window(self._window)
135 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
136 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
137 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
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('contacts_scrolledwindow'), True)
141 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
143 for child in gtkMenu.get_children():
145 self._window.set_menu(menu)
148 self._window.connect("key-press-event", self._on_key_press)
149 self._window.connect("window-state-event", self._on_window_state_change)
151 pass # warnings.warn("No Hildon", UserWarning, 2)
153 if hildon is not None:
154 self._window.set_title("Keypad")
156 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
159 "on_dialpad_quit": self._on_close,
161 self._widgetTree.signal_autoconnect(callbackMapping)
164 self._window.connect("destroy", self._on_close)
165 self._window.show_all()
166 self._window.set_default_size(800, 300)
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
176 # Barebones UI handlers
180 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
181 with gtk_toolbox.gtk_lock():
182 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
183 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
184 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
185 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
186 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
188 self._dialpads[self._selectedBackendId].enable()
189 self._accountViews[self._selectedBackendId].enable()
190 self._recentViews[self._selectedBackendId].enable()
191 self._messagesViews[self._selectedBackendId].enable()
192 self._contactsViews[self._selectedBackendId].enable()
194 # Setup maemo specifics
201 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
202 device = osso.DeviceState(self._osso)
203 device.set_device_state_callback(self._on_device_state_change, 0)
205 pass # warnings.warn("No OSSO", UserWarning)
211 self._connection = None
212 if conic is not None:
213 self._connection = conic.Connection()
214 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
215 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
217 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
219 # Setup costly backends
227 os.makedirs(self._data_path)
231 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
232 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
233 self._defaultBackendId = self._guess_preferred_backend((
234 (self.GC_BACKEND, gcCookiePath),
235 (self.GV_BACKEND, gvCookiePath),
238 self._phoneBackends.update({
239 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
240 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
242 with gtk_toolbox.gtk_lock():
243 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
244 unifiedDialpad.set_number("")
245 self._dialpads.update({
246 self.GC_BACKEND: unifiedDialpad,
247 self.GV_BACKEND: unifiedDialpad,
249 self._accountViews.update({
250 self.GC_BACKEND: gc_views.AccountInfo(
251 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
253 self.GV_BACKEND: gc_views.AccountInfo(
254 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
257 self._recentViews.update({
258 self.GC_BACKEND: gc_views.RecentCallsView(
259 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
261 self.GV_BACKEND: gc_views.RecentCallsView(
262 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
265 self._messagesViews.update({
266 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
267 self.GV_BACKEND: gc_views.MessagesView(
268 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
271 self._contactsViews.update({
272 self.GC_BACKEND: gc_views.ContactsView(
273 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
275 self.GV_BACKEND: gc_views.ContactsView(
276 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
280 evoBackend = evo_backend.EvolutionAddressBook()
281 fsContactsPath = os.path.join(self._data_path, "contacts")
282 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
283 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
284 self._dialpads[backendId].dial = self._on_dial_clicked
285 self._recentViews[backendId].number_selected = self._on_number_selected
286 self._messagesViews[backendId].number_selected = self._on_number_selected
287 self._contactsViews[backendId].number_selected = self._on_number_selected
290 self._phoneBackends[backendId],
294 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
295 self._contactsViews[backendId].append(mergedBook)
296 self._contactsViews[backendId].extend(addressBooks)
297 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
300 "on_paste": self._on_paste,
301 "on_refresh": self._on_refresh,
302 "on_clearcookies_clicked": self._on_clearcookies_clicked,
303 "on_notebook_switch_page": self._on_notebook_switch_page,
304 "on_about_activate": self._on_about_activate,
306 self._widgetTree.signal_autoconnect(callbackMapping)
308 self._initDone = True
310 config = ConfigParser.SafeConfigParser()
311 config.read(self._user_settings)
312 with gtk_toolbox.gtk_lock():
313 self.load_settings(config)
315 self.attempt_login(2)
319 def attempt_login(self, numOfAttempts = 10):
321 @todo Handle user notification better like attempting to login and failed login
323 @note Not meant to be called directly, but run as a seperate thread.
325 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
327 if not self._deviceIsOnline:
328 warnings.warn("Attempted to login while device was offline")
330 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
332 "Attempted to login before initialization is complete, did an event fire early?"
338 username, password = self._credentials
339 serviceId = self._defaultBackendId
341 # Attempt using the cookies
342 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
345 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
349 # Attempt using the settings file
350 if not loggedIn and username and password:
351 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
354 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
358 # Query the user for credentials
359 for attemptCount in xrange(numOfAttempts):
362 with gtk_toolbox.gtk_lock():
363 availableServices = {
364 self.GV_BACKEND: "Google Voice",
365 self.GC_BACKEND: "Grand Central",
367 credentials = self._credentialsDialog.request_credentials_from(
368 availableServices, defaultCredentials = self._credentials
370 serviceId, username, password = credentials
372 loggedIn = self._phoneBackends[serviceId].login(username, password)
375 "Logged into %r through user request" % self._phoneBackends[serviceId],
378 except RuntimeError, e:
379 warnings.warn(traceback.format_exc())
380 self._errorDisplay.push_exception_with_lock(e)
382 with gtk_toolbox.gtk_lock():
384 self._credentials = username, password
385 self._change_loggedin_status(serviceId)
387 self._errorDisplay.push_message("Login Failed")
388 self._change_loggedin_status(self.NULL_BACKEND)
391 def _on_close(self, *args, **kwds):
393 if self._osso is not None:
397 self._save_settings()
401 def _change_loggedin_status(self, newStatus):
402 oldStatus = self._selectedBackendId
403 if oldStatus == newStatus:
406 self._dialpads[oldStatus].disable()
407 self._accountViews[oldStatus].disable()
408 self._recentViews[oldStatus].disable()
409 self._messagesViews[oldStatus].disable()
410 self._contactsViews[oldStatus].disable()
412 self._dialpads[newStatus].enable()
413 self._accountViews[newStatus].enable()
414 self._recentViews[newStatus].enable()
415 self._messagesViews[newStatus].enable()
416 self._contactsViews[newStatus].enable()
418 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
419 self._phoneBackends[self._selectedBackendId].set_sane_callback()
420 self._accountViews[self._selectedBackendId].update()
422 self._selectedBackendId = newStatus
424 def load_settings(self, config):
428 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
430 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
431 for i in xrange(len(self._credentials))
434 base64.b64decode(blob)
437 self._credentials = tuple(creds)
438 for backendId, view in itertools.chain(
439 self._dialpads.iteritems(),
440 self._accountViews.iteritems(),
441 self._messagesViews.iteritems(),
442 self._recentViews.iteritems(),
443 self._contactsViews.iteritems(),
445 sectionName = "%s - %s" % (backendId, view.name())
446 view.load_settings(config, sectionName)
448 def save_settings(self, config):
450 @note Thread Agnostic
452 config.add_section(self.__pretty_app_name__)
453 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
454 for i, value in enumerate(self._credentials):
455 blob = base64.b64encode(value)
456 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
457 for backendId, view in itertools.chain(
458 self._dialpads.iteritems(),
459 self._accountViews.iteritems(),
460 self._messagesViews.iteritems(),
461 self._recentViews.iteritems(),
462 self._contactsViews.iteritems(),
464 sectionName = "%s - %s" % (backendId, view.name())
465 config.add_section(sectionName)
466 view.save_settings(config, sectionName)
468 def _guess_preferred_backend(self, backendAndCookiePaths):
470 (getmtime_nothrow(path), backendId, path)
471 for backendId, path in backendAndCookiePaths
473 modTimeAndPath.sort()
474 return modTimeAndPath[-1][1]
476 def _save_settings(self):
478 @note Thread Agnostic
480 config = ConfigParser.SafeConfigParser()
481 self.save_settings(config)
482 with open(self._user_settings, "wb") as configFile:
483 config.write(configFile)
485 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
487 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
488 For system_inactivity, we have no background tasks to pause
490 @note Hildon specific
493 for backendId in self.BACKENDS:
494 self._phoneBackends[backendId].clear_caches()
495 self._contactsViews[self._selectedBackendId].clear_caches()
498 if save_unsaved_data or shutdown:
499 self._save_settings()
501 def _on_connection_change(self, connection, event, magicIdentifier):
503 @note Hildon specific
507 status = event.get_status()
508 error = event.get_error()
509 iap_id = event.get_iap_id()
510 bearer = event.get_bearer_type()
512 if status == conic.STATUS_CONNECTED:
513 self._deviceIsOnline = True
514 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
515 backgroundLogin.setDaemon(True)
516 backgroundLogin.start()
517 elif status == conic.STATUS_DISCONNECTED:
518 self._deviceIsOnline = False
519 self._defaultBackendId = self._selectedBackendId
520 self._change_loggedin_status(self.NULL_BACKEND)
522 def _on_window_state_change(self, widget, event, *args):
524 @note Hildon specific
526 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
527 self._isFullScreen = True
529 self._isFullScreen = False
531 def _on_key_press(self, widget, event, *args):
533 @note Hildon specific
535 if event.keyval == gtk.keysyms.F6:
536 if self._isFullScreen:
537 self._window.unfullscreen()
539 self._window.fullscreen()
541 def _on_clearcookies_clicked(self, *args):
542 self._phoneBackends[self._selectedBackendId].logout()
543 self._accountViews[self._selectedBackendId].clear()
544 self._recentViews[self._selectedBackendId].clear()
545 self._messagesViews[self._selectedBackendId].clear()
546 self._contactsViews[self._selectedBackendId].clear()
547 self._change_loggedin_status(self.NULL_BACKEND)
549 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
550 backgroundLogin.setDaemon(True)
551 backgroundLogin.start()
553 def _on_notebook_switch_page(self, notebook, page, page_num):
554 if page_num == self.RECENT_TAB:
555 self._recentViews[self._selectedBackendId].update()
556 elif page_num == self.MESSAGES_TAB:
557 self._messagesViews[self._selectedBackendId].update()
558 elif page_num == self.CONTACTS_TAB:
559 self._contactsViews[self._selectedBackendId].update()
560 elif page_num == self.ACCOUNT_TAB:
561 self._accountViews[self._selectedBackendId].update()
563 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
564 if hildon is not None:
565 self._window.set_title(tabTitle)
567 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
569 def _on_number_selected(self, action, number, message):
570 if action == "select":
571 self._dialpads[self._selectedBackendId].set_number(number)
572 self._notebook.set_current_page(self.KEYPAD_TAB)
573 elif action == "dial":
574 self._on_dial_clicked(number)
575 elif action == "sms":
576 self._on_sms_clicked(number, message)
578 assert False, "Unknown action: %s" % action
580 def _on_sms_clicked(self, number, message):
582 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
587 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
588 except RuntimeError, e:
590 self._errorDisplay.push_exception(e)
594 self._errorDisplay.push_message(
595 "Backend link with grandcentral is not working, please try again"
601 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
603 except RuntimeError, e:
604 self._errorDisplay.push_exception(e)
605 except ValueError, e:
606 self._errorDisplay.push_exception(e)
608 def _on_dial_clicked(self, number):
610 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
614 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
615 except RuntimeError, e:
617 self._errorDisplay.push_exception(e)
621 self._errorDisplay.push_message(
622 "Backend link with grandcentral is not working, please try again"
628 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
629 self._phoneBackends[self._selectedBackendId].dial(number)
631 except RuntimeError, e:
632 self._errorDisplay.push_exception(e)
633 except ValueError, e:
634 self._errorDisplay.push_exception(e)
637 self._dialpads[self._selectedBackendId].clear()
639 def _on_refresh(self, *args):
640 page_num = self._notebook.get_current_page()
641 if page_num == self.CONTACTS_TAB:
642 self._contactsViews[self._selectedBackendId].update(force=True)
643 elif page_num == self.RECENT_TAB:
644 self._recentViews[self._selectedBackendId].update(force=True)
645 elif page_num == self.MESSAGES_TAB:
646 self._messagesViews[self._selectedBackendId].update(force=True)
648 def _on_paste(self, *args):
649 contents = self._clipboard.wait_for_text()
650 self._dialpads[self._selectedBackendId].set_number(contents)
652 def _on_about_activate(self, *args):
653 dlg = gtk.AboutDialog()
654 dlg.set_name(self.__pretty_app_name__)
655 dlg.set_version(self.__version__)
656 dlg.set_copyright("Copyright 2008 - LGPL")
657 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")
658 dlg.set_website("http://gc-dialer.garage.maemo.org/")
659 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
667 failureCount, testCount = doctest.testmod()
669 print "Tests Successful"
676 gtk.gdk.threads_init()
677 if hildon is not None:
678 gtk.set_application_name(Dialcentral.__pretty_app_name__)
679 handle = Dialcentral()
683 class DummyOptions(object):
689 if __name__ == "__main__":
690 if len(sys.argv) > 1:
696 if optparse is not None:
697 parser = optparse.OptionParser()
698 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
699 (commandOptions, commandArgs) = parser.parse_args()
701 commandOptions = DummyOptions()
704 if commandOptions.test: