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
53 def getmtime_nothrow(path):
55 return os.path.getmtime(path)
60 def display_error_message(msg):
61 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
63 def close(dialog, response):
65 error_dialog.connect("response", close)
69 class Dialcentral(object):
71 __pretty_app_name__ = "DialCentral"
72 __app_name__ = "dialcentral"
74 __app_magic__ = 0xdeadbeef
77 '/usr/lib/dialcentral/dialcentral.glade',
78 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
79 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
91 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
93 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
94 _user_settings = "%s/settings.ini" % _data_path
97 self._initDone = False
98 self._connection = None
100 self._clipboard = gtk.clipboard_get()
102 self._deviceIsOnline = True
103 self._credentials = ("", "")
104 self._selectedBackendId = self.NULL_BACKEND
105 self._defaultBackendId = self.GC_BACKEND
106 self._phoneBackends = None
107 self._dialpads = None
108 self._accountViews = None
109 self._messagesViews = None
110 self._recentViews = None
111 self._contactsViews = None
113 for path in self._glade_files:
114 if os.path.isfile(path):
115 self._widgetTree = gtk.glade.XML(path)
118 display_error_message("Cannot find dialcentral.glade")
122 self._window = self._widgetTree.get_widget("mainWindow")
123 self._notebook = self._widgetTree.get_widget("notebook")
124 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
125 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
128 self._isFullScreen = False
129 if hildon is not None:
130 self._app = hildon.Program()
131 oldWindow = self._window
132 self._window = hildon.Window()
133 oldWindow.get_child().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('message_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
142 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
144 for child in gtkMenu.get_children():
146 self._window.set_menu(menu)
149 self._window.connect("key-press-event", self._on_key_press)
150 self._window.connect("window-state-event", self._on_window_state_change)
152 pass # warnings.warn("No Hildon", UserWarning, 2)
154 if hildon is not None:
155 self._window.set_title("Keypad")
157 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
160 "on_dialpad_quit": self._on_close,
162 self._widgetTree.signal_autoconnect(callbackMapping)
165 self._window.connect("destroy", self._on_close)
166 self._window.show_all()
167 self._window.set_default_size(800, 300)
169 backgroundSetup = threading.Thread(target=self._idle_setup)
170 backgroundSetup.setDaemon(True)
171 backgroundSetup.start()
173 def _idle_setup(self):
175 If something can be done after the UI loads, push it here so it's not blocking the UI
177 # Barebones UI handlers
181 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
182 with gtk_toolbox.gtk_lock():
183 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
184 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
185 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
186 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
187 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
189 self._dialpads[self._selectedBackendId].enable()
190 self._accountViews[self._selectedBackendId].enable()
191 self._recentViews[self._selectedBackendId].enable()
192 self._messagesViews[self._selectedBackendId].enable()
193 self._contactsViews[self._selectedBackendId].enable()
195 # Setup maemo specifics
202 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
203 device = osso.DeviceState(self._osso)
204 device.set_device_state_callback(self._on_device_state_change, 0)
206 pass # warnings.warn("No OSSO", UserWarning)
208 # Setup maemo specifics
213 self._connection = None
214 if conic is not None:
215 self._connection = conic.Connection()
216 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
217 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
219 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
221 # Setup costly backends
229 os.makedirs(self._data_path)
233 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
234 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
235 self._defaultBackendId = self._guess_preferred_backend((
236 (self.GC_BACKEND, gcCookiePath),
237 (self.GV_BACKEND, gvCookiePath),
240 self._phoneBackends.update({
241 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
242 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
244 with gtk_toolbox.gtk_lock():
245 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
246 unifiedDialpad.set_number("")
247 self._dialpads.update({
248 self.GC_BACKEND: unifiedDialpad,
249 self.GV_BACKEND: unifiedDialpad,
251 self._accountViews.update({
252 self.GC_BACKEND: gc_views.AccountInfo(
253 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
255 self.GV_BACKEND: gc_views.AccountInfo(
256 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
259 self._recentViews.update({
260 self.GC_BACKEND: gc_views.RecentCallsView(
261 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
263 self.GV_BACKEND: gc_views.RecentCallsView(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._messagesViews.update({
268 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
269 self.GV_BACKEND: gc_views.MessagesView(
270 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
273 self._contactsViews.update({
274 self.GC_BACKEND: gc_views.ContactsView(
275 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
277 self.GV_BACKEND: gc_views.ContactsView(
278 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
282 evoBackend = evo_backend.EvolutionAddressBook()
283 fsContactsPath = os.path.join(self._data_path, "contacts")
284 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
285 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
286 self._dialpads[backendId].number_selected = self._select_action
287 self._recentViews[backendId].number_selected = self._select_action
288 self._messagesViews[backendId].number_selected = self._select_action
289 self._contactsViews[backendId].number_selected = self._select_action
292 self._phoneBackends[backendId],
296 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
297 self._contactsViews[backendId].append(mergedBook)
298 self._contactsViews[backendId].extend(addressBooks)
299 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
302 "on_paste": self._on_paste,
303 "on_refresh": self._on_refresh,
304 "on_clearcookies_clicked": self._on_clearcookies_clicked,
305 "on_notebook_switch_page": self._on_notebook_switch_page,
306 "on_about_activate": self._on_about_activate,
308 self._widgetTree.signal_autoconnect(callbackMapping)
310 self._initDone = True
312 config = ConfigParser.SafeConfigParser()
313 config.read(self._user_settings)
314 with gtk_toolbox.gtk_lock():
315 self.load_settings(config)
317 self.attempt_login(2)
319 def attempt_login(self, numOfAttempts = 10, force = False):
321 @todo Handle user notification better like attempting to login and failed login
324 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
325 assert self._initDone, "Attempting login before app is fully loaded"
326 if not self._deviceIsOnline:
327 raise RuntimeError("Unable to login, device is not online")
329 serviceId = self.NULL_BACKEND
333 self.refresh_session()
334 serviceId = self._defaultBackendId
336 except StandardError, e:
340 with gtk_toolbox.gtk_lock():
341 loggedIn, serviceId = self._login_by_user(numOfAttempts)
343 with gtk_toolbox.gtk_lock():
344 self._change_loggedin_status(serviceId)
345 except StandardError, e:
346 with gtk_toolbox.gtk_lock():
347 self._errorDisplay.push_exception(e)
349 def refresh_session(self):
350 assert self._initDone, "Attempting login before app is fully loaded"
351 if not self._deviceIsOnline:
352 raise RuntimeError("Unable to login, device is not online")
356 loggedIn = self._login_by_cookie()
358 loggedIn = self._login_by_settings()
361 raise RuntimeError("Login Failed")
363 def _login_by_cookie(self):
364 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
367 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
372 def _login_by_settings(self):
373 username, password = self._credentials
374 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
376 self._credentials = username, password
378 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
383 def _login_by_user(self, numOfAttempts):
384 loggedIn, (username, password) = False, self._credentials
385 tmpServiceId = self.NULL_BACKEND
386 for attemptCount in xrange(numOfAttempts):
389 availableServices = {
390 self.GV_BACKEND: "Google Voice",
391 self.GC_BACKEND: "Grand Central",
393 credentials = self._credentialsDialog.request_credentials_from(
394 availableServices, defaultCredentials = self._credentials
396 tmpServiceId, username, password = credentials
397 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
400 serviceId = tmpServiceId
401 self._credentials = username, password
403 "Logged into %r through user request" % self._phoneBackends[serviceId],
407 serviceId = self.NULL_BACKEND
409 return loggedIn, serviceId
411 def _select_action(self, action, number, message):
412 self.refresh_session()
413 if action == "select":
414 self._dialpads[self._selectedBackendId].set_number(number)
415 self._notebook.set_current_page(self.KEYPAD_TAB)
416 elif action == "dial":
417 self._on_dial_clicked(number)
418 elif action == "sms":
419 self._on_sms_clicked(number, message)
421 assert False, "Unknown action: %s" % action
423 def _change_loggedin_status(self, newStatus):
424 oldStatus = self._selectedBackendId
425 if oldStatus == newStatus:
428 self._dialpads[oldStatus].disable()
429 self._accountViews[oldStatus].disable()
430 self._recentViews[oldStatus].disable()
431 self._messagesViews[oldStatus].disable()
432 self._contactsViews[oldStatus].disable()
434 self._dialpads[newStatus].enable()
435 self._accountViews[newStatus].enable()
436 self._recentViews[newStatus].enable()
437 self._messagesViews[newStatus].enable()
438 self._contactsViews[newStatus].enable()
440 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
441 self._phoneBackends[self._selectedBackendId].set_sane_callback()
442 self._accountViews[self._selectedBackendId].update()
444 self._selectedBackendId = newStatus
446 def load_settings(self, config):
451 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
453 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
454 for i in xrange(len(self._credentials))
457 base64.b64decode(blob)
460 self._credentials = tuple(creds)
461 except ConfigParser.NoSectionError, e:
463 "Settings file %s is missing section %s" % (
470 for backendId, view in itertools.chain(
471 self._dialpads.iteritems(),
472 self._accountViews.iteritems(),
473 self._messagesViews.iteritems(),
474 self._recentViews.iteritems(),
475 self._contactsViews.iteritems(),
477 sectionName = "%s - %s" % (backendId, view.name())
479 view.load_settings(config, sectionName)
480 except ConfigParser.NoSectionError, e:
482 "Settings file %s is missing section %s" % (
489 def save_settings(self, config):
491 @note Thread Agnostic
493 config.add_section(self.__pretty_app_name__)
494 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
495 for i, value in enumerate(self._credentials):
496 blob = base64.b64encode(value)
497 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
498 for backendId, view in itertools.chain(
499 self._dialpads.iteritems(),
500 self._accountViews.iteritems(),
501 self._messagesViews.iteritems(),
502 self._recentViews.iteritems(),
503 self._contactsViews.iteritems(),
505 sectionName = "%s - %s" % (backendId, view.name())
506 config.add_section(sectionName)
507 view.save_settings(config, sectionName)
509 def _guess_preferred_backend(self, backendAndCookiePaths):
511 (getmtime_nothrow(path), backendId, path)
512 for backendId, path in backendAndCookiePaths
514 modTimeAndPath.sort()
515 return modTimeAndPath[-1][1]
517 def _save_settings(self):
519 @note Thread Agnostic
521 config = ConfigParser.SafeConfigParser()
522 self.save_settings(config)
523 with open(self._user_settings, "wb") as configFile:
524 config.write(configFile)
526 def _on_close(self, *args, **kwds):
528 if self._osso is not None:
532 self._save_settings()
536 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
538 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
539 For system_inactivity, we have no background tasks to pause
541 @note Hildon specific
544 for backendId in self.BACKENDS:
545 self._phoneBackends[backendId].clear_caches()
546 self._contactsViews[self._selectedBackendId].clear_caches()
549 if save_unsaved_data or shutdown:
550 self._save_settings()
552 def _on_connection_change(self, connection, event, magicIdentifier):
554 @note Hildon specific
558 status = event.get_status()
559 error = event.get_error()
560 iap_id = event.get_iap_id()
561 bearer = event.get_bearer_type()
563 if status == conic.STATUS_CONNECTED:
564 self._deviceIsOnline = True
566 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
567 backgroundLogin.setDaemon(True)
568 backgroundLogin.start()
569 elif status == conic.STATUS_DISCONNECTED:
570 self._deviceIsOnline = False
572 self._defaultBackendId = self._selectedBackendId
573 self._change_loggedin_status(self.NULL_BACKEND)
575 def _on_window_state_change(self, widget, event, *args):
577 @note Hildon specific
579 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
580 self._isFullScreen = True
582 self._isFullScreen = False
584 def _on_key_press(self, widget, event, *args):
586 @note Hildon specific
588 if event.keyval == gtk.keysyms.F6:
589 if self._isFullScreen:
590 self._window.unfullscreen()
592 self._window.fullscreen()
594 def _on_clearcookies_clicked(self, *args):
595 self._phoneBackends[self._selectedBackendId].logout()
596 self._accountViews[self._selectedBackendId].clear()
597 self._recentViews[self._selectedBackendId].clear()
598 self._messagesViews[self._selectedBackendId].clear()
599 self._contactsViews[self._selectedBackendId].clear()
600 self._change_loggedin_status(self.NULL_BACKEND)
602 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2, True])
603 backgroundLogin.setDaemon(True)
604 backgroundLogin.start()
606 def _on_notebook_switch_page(self, notebook, page, page_num):
607 if page_num == self.RECENT_TAB:
608 self._recentViews[self._selectedBackendId].update()
609 elif page_num == self.MESSAGES_TAB:
610 self._messagesViews[self._selectedBackendId].update()
611 elif page_num == self.CONTACTS_TAB:
612 self._contactsViews[self._selectedBackendId].update()
613 elif page_num == self.ACCOUNT_TAB:
614 self._accountViews[self._selectedBackendId].update()
616 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
617 if hildon is not None:
618 self._window.set_title(tabTitle)
620 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
622 def _on_sms_clicked(self, number, message):
624 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
629 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
630 except RuntimeError, e:
632 self._errorDisplay.push_exception(e)
636 self._errorDisplay.push_message(
637 "Backend link with grandcentral is not working, please try again"
643 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
645 except RuntimeError, e:
646 self._errorDisplay.push_exception(e)
647 except ValueError, e:
648 self._errorDisplay.push_exception(e)
650 def _on_dial_clicked(self, number):
652 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
656 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
657 except RuntimeError, e:
659 self._errorDisplay.push_exception(e)
663 self._errorDisplay.push_message(
664 "Backend link with grandcentral is not working, please try again"
670 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
671 self._phoneBackends[self._selectedBackendId].dial(number)
673 except RuntimeError, e:
674 self._errorDisplay.push_exception(e)
675 except ValueError, e:
676 self._errorDisplay.push_exception(e)
679 self._dialpads[self._selectedBackendId].clear()
681 def _on_refresh(self, *args):
682 page_num = self._notebook.get_current_page()
683 if page_num == self.CONTACTS_TAB:
684 self._contactsViews[self._selectedBackendId].update(force=True)
685 elif page_num == self.RECENT_TAB:
686 self._recentViews[self._selectedBackendId].update(force=True)
687 elif page_num == self.MESSAGES_TAB:
688 self._messagesViews[self._selectedBackendId].update(force=True)
690 def _on_paste(self, *args):
691 contents = self._clipboard.wait_for_text()
692 self._dialpads[self._selectedBackendId].set_number(contents)
694 def _on_about_activate(self, *args):
695 dlg = gtk.AboutDialog()
696 dlg.set_name(self.__pretty_app_name__)
697 dlg.set_version(self.__version__)
698 dlg.set_copyright("Copyright 2008 - LGPL")
699 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")
700 dlg.set_website("http://gc-dialer.garage.maemo.org/")
701 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
709 failureCount, testCount = doctest.testmod()
711 print "Tests Successful"
718 gtk.gdk.threads_init()
719 if hildon is not None:
720 gtk.set_application_name(Dialcentral.__pretty_app_name__)
721 handle = Dialcentral()
725 class DummyOptions(object):
731 if __name__ == "__main__":
732 if len(sys.argv) > 1:
738 if optparse is not None:
739 parser = optparse.OptionParser()
740 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
741 (commandOptions, commandArgs) = parser.parse_args()
743 commandOptions = DummyOptions()
746 if commandOptions.test: