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 SMS Dialog issues on Hildon
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 oldWindow = self._window
133 self._window = hildon.Window()
134 oldWindow.get_child().reparent(self._window)
135 self._app.add_window(self._window)
136 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
137 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
138 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
141 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
143 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
145 for child in gtkMenu.get_children():
147 self._window.set_menu(menu)
150 self._window.connect("key-press-event", self._on_key_press)
151 self._window.connect("window-state-event", self._on_window_state_change)
153 pass # warnings.warn("No Hildon", UserWarning, 2)
155 if hildon is not None:
156 self._window.set_title("Keypad")
158 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
161 "on_dialpad_quit": self._on_close,
163 self._widgetTree.signal_autoconnect(callbackMapping)
166 self._window.connect("destroy", self._on_close)
167 self._window.show_all()
168 self._window.set_default_size(800, 300)
170 backgroundSetup = threading.Thread(target=self._idle_setup)
171 backgroundSetup.setDaemon(True)
172 backgroundSetup.start()
174 def _idle_setup(self):
176 If something can be done after the UI loads, push it here so it's not blocking the UI
178 # Barebones UI handlers
182 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
183 with gtk_toolbox.gtk_lock():
184 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
185 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
186 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
187 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
188 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
190 self._dialpads[self._selectedBackendId].enable()
191 self._accountViews[self._selectedBackendId].enable()
192 self._recentViews[self._selectedBackendId].enable()
193 self._messagesViews[self._selectedBackendId].enable()
194 self._contactsViews[self._selectedBackendId].enable()
196 # Setup maemo specifics
203 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
204 device = osso.DeviceState(self._osso)
205 device.set_device_state_callback(self._on_device_state_change, 0)
207 pass # warnings.warn("No OSSO", UserWarning)
209 # Setup maemo specifics
214 self._connection = None
215 if conic is not None:
216 self._connection = conic.Connection()
217 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
218 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
220 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
222 # Setup costly backends
230 os.makedirs(self._data_path)
234 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
235 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
236 self._defaultBackendId = self._guess_preferred_backend((
237 (self.GC_BACKEND, gcCookiePath),
238 (self.GV_BACKEND, gvCookiePath),
241 self._phoneBackends.update({
242 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
243 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
245 with gtk_toolbox.gtk_lock():
246 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
247 unifiedDialpad.set_number("")
248 self._dialpads.update({
249 self.GC_BACKEND: unifiedDialpad,
250 self.GV_BACKEND: unifiedDialpad,
252 self._accountViews.update({
253 self.GC_BACKEND: gc_views.AccountInfo(
254 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
256 self.GV_BACKEND: gc_views.AccountInfo(
257 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
260 self._recentViews.update({
261 self.GC_BACKEND: gc_views.RecentCallsView(
262 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
264 self.GV_BACKEND: gc_views.RecentCallsView(
265 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
268 self._messagesViews.update({
269 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
270 self.GV_BACKEND: gc_views.MessagesView(
271 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
274 self._contactsViews.update({
275 self.GC_BACKEND: gc_views.ContactsView(
276 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
278 self.GV_BACKEND: gc_views.ContactsView(
279 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
283 evoBackend = evo_backend.EvolutionAddressBook()
284 fsContactsPath = os.path.join(self._data_path, "contacts")
285 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
286 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
287 self._dialpads[backendId].dial = self._on_dial_clicked
288 self._recentViews[backendId].number_selected = self._on_number_selected
289 self._messagesViews[backendId].number_selected = self._on_number_selected
290 self._contactsViews[backendId].number_selected = self._on_number_selected
293 self._phoneBackends[backendId],
297 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
298 self._contactsViews[backendId].append(mergedBook)
299 self._contactsViews[backendId].extend(addressBooks)
300 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
303 "on_paste": self._on_paste,
304 "on_refresh": self._on_refresh,
305 "on_clearcookies_clicked": self._on_clearcookies_clicked,
306 "on_notebook_switch_page": self._on_notebook_switch_page,
307 "on_about_activate": self._on_about_activate,
309 self._widgetTree.signal_autoconnect(callbackMapping)
311 self._initDone = True
313 config = ConfigParser.SafeConfigParser()
314 config.read(self._user_settings)
315 with gtk_toolbox.gtk_lock():
316 self.load_settings(config)
318 self.attempt_login(2)
322 def attempt_login(self, numOfAttempts = 10, force = False):
324 @todo Handle user notification better like attempting to login and failed login
326 @note Not meant to be called directly, but run as a seperate thread.
328 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
330 if self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
332 "Attempted to login before initialization is complete, did an event fire early?"
336 with gtk_toolbox.gtk_lock():
337 if not self._deviceIsOnline:
338 self._errorDisplay.push_message(
339 "Unable to login, device is not online"
345 username, password = self._credentials
346 serviceId = self._defaultBackendId
349 loggedIn, username, password = self._login_by_cookie(username, password)
351 loggedIn, username, password = self._login_by_settings(username, password)
353 loggedIn, username, password = self._login_by_user(username, password, numOfAttempts)
354 except RuntimeError, e:
355 warnings.warn(traceback.format_exc())
356 self._errorDisplay.push_exception_with_lock(e)
358 with gtk_toolbox.gtk_lock():
360 self._credentials = username, password
361 self._change_loggedin_status(serviceId)
363 self._errorDisplay.push_message("Login Failed")
364 self._change_loggedin_status(self.NULL_BACKEND)
367 def _login_by_cookie(self, username, password):
368 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
371 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
374 return loggedIn, username, password
376 def _login_by_settings(self, username, password):
377 if username and password:
378 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
381 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
384 return loggedIn, username, password
386 def _login_by_user(self, username, password, numOfAttempts):
388 for attemptCount in xrange(numOfAttempts):
391 with gtk_toolbox.gtk_lock():
392 availableServices = {
393 self.GV_BACKEND: "Google Voice",
394 self.GC_BACKEND: "Grand Central",
396 credentials = self._credentialsDialog.request_credentials_from(
397 availableServices, defaultCredentials = self._credentials
399 serviceId, username, password = credentials
401 loggedIn = self._phoneBackends[serviceId].login(username, password)
404 "Logged into %r through user request" % self._phoneBackends[serviceId],
407 return loggedIn, username, password
409 def _on_close(self, *args, **kwds):
411 if self._osso is not None:
415 self._save_settings()
419 def _change_loggedin_status(self, newStatus):
420 oldStatus = self._selectedBackendId
421 if oldStatus == newStatus:
424 self._dialpads[oldStatus].disable()
425 self._accountViews[oldStatus].disable()
426 self._recentViews[oldStatus].disable()
427 self._messagesViews[oldStatus].disable()
428 self._contactsViews[oldStatus].disable()
430 self._dialpads[newStatus].enable()
431 self._accountViews[newStatus].enable()
432 self._recentViews[newStatus].enable()
433 self._messagesViews[newStatus].enable()
434 self._contactsViews[newStatus].enable()
436 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
437 self._phoneBackends[self._selectedBackendId].set_sane_callback()
438 self._accountViews[self._selectedBackendId].update()
440 self._selectedBackendId = newStatus
442 def load_settings(self, config):
446 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
449 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
450 for i in xrange(len(self._credentials))
453 base64.b64decode(blob)
456 self._credentials = tuple(creds)
457 except ConfigParser.NoSectionError, e:
459 "Settings file %s is missing section %s" % (
466 for backendId, view in itertools.chain(
467 self._dialpads.iteritems(),
468 self._accountViews.iteritems(),
469 self._messagesViews.iteritems(),
470 self._recentViews.iteritems(),
471 self._contactsViews.iteritems(),
473 sectionName = "%s - %s" % (backendId, view.name())
475 view.load_settings(config, sectionName)
476 except ConfigParser.NoSectionError, e:
478 "Settings file %s is missing section %s" % (
485 def save_settings(self, config):
487 @note Thread Agnostic
489 config.add_section(self.__pretty_app_name__)
490 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
491 for i, value in enumerate(self._credentials):
492 blob = base64.b64encode(value)
493 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
494 for backendId, view in itertools.chain(
495 self._dialpads.iteritems(),
496 self._accountViews.iteritems(),
497 self._messagesViews.iteritems(),
498 self._recentViews.iteritems(),
499 self._contactsViews.iteritems(),
501 sectionName = "%s - %s" % (backendId, view.name())
502 config.add_section(sectionName)
503 view.save_settings(config, sectionName)
505 def _guess_preferred_backend(self, backendAndCookiePaths):
507 (getmtime_nothrow(path), backendId, path)
508 for backendId, path in backendAndCookiePaths
510 modTimeAndPath.sort()
511 return modTimeAndPath[-1][1]
513 def _save_settings(self):
515 @note Thread Agnostic
517 config = ConfigParser.SafeConfigParser()
518 self.save_settings(config)
519 with open(self._user_settings, "wb") as configFile:
520 config.write(configFile)
522 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
524 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
525 For system_inactivity, we have no background tasks to pause
527 @note Hildon specific
530 for backendId in self.BACKENDS:
531 self._phoneBackends[backendId].clear_caches()
532 self._contactsViews[self._selectedBackendId].clear_caches()
535 if save_unsaved_data or shutdown:
536 self._save_settings()
538 def _on_connection_change(self, connection, event, magicIdentifier):
540 @note Hildon specific
544 status = event.get_status()
545 error = event.get_error()
546 iap_id = event.get_iap_id()
547 bearer = event.get_bearer_type()
549 if status == conic.STATUS_CONNECTED:
550 self._deviceIsOnline = True
552 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
553 backgroundLogin.setDaemon(True)
554 backgroundLogin.start()
555 elif status == conic.STATUS_DISCONNECTED:
556 self._deviceIsOnline = False
558 self._defaultBackendId = self._selectedBackendId
559 self._change_loggedin_status(self.NULL_BACKEND)
561 def _on_window_state_change(self, widget, event, *args):
563 @note Hildon specific
565 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
566 self._isFullScreen = True
568 self._isFullScreen = False
570 def _on_key_press(self, widget, event, *args):
572 @note Hildon specific
574 if event.keyval == gtk.keysyms.F6:
575 if self._isFullScreen:
576 self._window.unfullscreen()
578 self._window.fullscreen()
580 def _on_clearcookies_clicked(self, *args):
581 self._phoneBackends[self._selectedBackendId].logout()
582 self._accountViews[self._selectedBackendId].clear()
583 self._recentViews[self._selectedBackendId].clear()
584 self._messagesViews[self._selectedBackendId].clear()
585 self._contactsViews[self._selectedBackendId].clear()
586 self._change_loggedin_status(self.NULL_BACKEND)
588 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2, True])
589 backgroundLogin.setDaemon(True)
590 backgroundLogin.start()
592 def _on_notebook_switch_page(self, notebook, page, page_num):
593 if page_num == self.RECENT_TAB:
594 self._recentViews[self._selectedBackendId].update()
595 elif page_num == self.MESSAGES_TAB:
596 self._messagesViews[self._selectedBackendId].update()
597 elif page_num == self.CONTACTS_TAB:
598 self._contactsViews[self._selectedBackendId].update()
599 elif page_num == self.ACCOUNT_TAB:
600 self._accountViews[self._selectedBackendId].update()
602 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
603 if hildon is not None:
604 self._window.set_title(tabTitle)
606 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
608 def _on_number_selected(self, action, number, message):
609 if action == "select":
610 self._dialpads[self._selectedBackendId].set_number(number)
611 self._notebook.set_current_page(self.KEYPAD_TAB)
612 elif action == "dial":
613 self._on_dial_clicked(number)
614 elif action == "sms":
615 self._on_sms_clicked(number, message)
617 assert False, "Unknown action: %s" % action
619 def _on_sms_clicked(self, number, message):
621 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
626 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
627 except RuntimeError, e:
629 self._errorDisplay.push_exception(e)
633 self._errorDisplay.push_message(
634 "Backend link with grandcentral is not working, please try again"
640 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
642 except RuntimeError, e:
643 self._errorDisplay.push_exception(e)
644 except ValueError, e:
645 self._errorDisplay.push_exception(e)
647 def _on_dial_clicked(self, number):
649 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
653 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
654 except RuntimeError, e:
656 self._errorDisplay.push_exception(e)
660 self._errorDisplay.push_message(
661 "Backend link with grandcentral is not working, please try again"
667 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
668 self._phoneBackends[self._selectedBackendId].dial(number)
670 except RuntimeError, e:
671 self._errorDisplay.push_exception(e)
672 except ValueError, e:
673 self._errorDisplay.push_exception(e)
676 self._dialpads[self._selectedBackendId].clear()
678 def _on_refresh(self, *args):
679 page_num = self._notebook.get_current_page()
680 if page_num == self.CONTACTS_TAB:
681 self._contactsViews[self._selectedBackendId].update(force=True)
682 elif page_num == self.RECENT_TAB:
683 self._recentViews[self._selectedBackendId].update(force=True)
684 elif page_num == self.MESSAGES_TAB:
685 self._messagesViews[self._selectedBackendId].update(force=True)
687 def _on_paste(self, *args):
688 contents = self._clipboard.wait_for_text()
689 self._dialpads[self._selectedBackendId].set_number(contents)
691 def _on_about_activate(self, *args):
692 dlg = gtk.AboutDialog()
693 dlg.set_name(self.__pretty_app_name__)
694 dlg.set_version(self.__version__)
695 dlg.set_copyright("Copyright 2008 - LGPL")
696 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")
697 dlg.set_website("http://gc-dialer.garage.maemo.org/")
698 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
706 failureCount, testCount = doctest.testmod()
708 print "Tests Successful"
715 gtk.gdk.threads_init()
716 if hildon is not None:
717 gtk.set_application_name(Dialcentral.__pretty_app_name__)
718 handle = Dialcentral()
722 class DummyOptions(object):
728 if __name__ == "__main__":
729 if len(sys.argv) > 1:
735 if optparse is not None:
736 parser = optparse.OptionParser()
737 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
738 (commandOptions, commandArgs) = parser.parse_args()
740 commandOptions = DummyOptions()
743 if commandOptions.test: