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
332 self.refresh_session()
333 serviceId = self._defaultBackendId
337 with gtk_toolbox.gtk_lock():
338 loggedIn, serviceId = self._login_by_user(numOfAttempts)
340 with gtk_toolbox.gtk_lock():
341 self._change_loggedin_status(serviceId)
342 except StandardError, e:
343 with gtk_toolbox.gtk_lock():
344 self._errorDisplay.push_exception(e)
346 def refresh_session(self):
347 assert self._initDone, "Attempting login before app is fully loaded"
348 if not self._deviceIsOnline:
349 raise RuntimeError("Unable to login, device is not online")
353 loggedIn = self._login_by_cookie()
355 loggedIn = self._login_by_settings()
358 raise RuntimeError("Login Failed")
360 def _login_by_cookie(self):
361 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
364 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
369 def _login_by_settings(self):
370 username, password = self._credentials
371 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
373 self._credentials = username, password
375 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
380 def _login_by_user(self, numOfAttempts):
381 loggedIn, (username, password) = False, self._credentials
382 tmpServiceId = self.NULL_BACKEND
383 for attemptCount in xrange(numOfAttempts):
386 availableServices = {
387 self.GV_BACKEND: "Google Voice",
388 self.GC_BACKEND: "Grand Central",
390 credentials = self._credentialsDialog.request_credentials_from(
391 availableServices, defaultCredentials = self._credentials
393 tmpServiceId, username, password = credentials
394 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
397 serviceId = tmpServiceId
398 self._credentials = username, password
400 "Logged into %r through user request" % self._phoneBackends[serviceId],
404 serviceId = self.NULL_BACKEND
406 return loggedIn, serviceId
408 def _select_action(self, action, number, message):
409 self.refresh_session()
410 if action == "select":
411 self._dialpads[self._selectedBackendId].set_number(number)
412 self._notebook.set_current_page(self.KEYPAD_TAB)
413 elif action == "dial":
414 self._on_dial_clicked(number)
415 elif action == "sms":
416 self._on_sms_clicked(number, message)
418 assert False, "Unknown action: %s" % action
420 def _change_loggedin_status(self, newStatus):
421 oldStatus = self._selectedBackendId
422 if oldStatus == newStatus:
425 self._dialpads[oldStatus].disable()
426 self._accountViews[oldStatus].disable()
427 self._recentViews[oldStatus].disable()
428 self._messagesViews[oldStatus].disable()
429 self._contactsViews[oldStatus].disable()
431 self._dialpads[newStatus].enable()
432 self._accountViews[newStatus].enable()
433 self._recentViews[newStatus].enable()
434 self._messagesViews[newStatus].enable()
435 self._contactsViews[newStatus].enable()
437 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
438 self._phoneBackends[self._selectedBackendId].set_sane_callback()
439 self._accountViews[self._selectedBackendId].update()
441 self._selectedBackendId = newStatus
443 def load_settings(self, config):
448 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
450 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
451 for i in xrange(len(self._credentials))
454 base64.b64decode(blob)
457 self._credentials = tuple(creds)
458 except ConfigParser.NoSectionError, e:
460 "Settings file %s is missing section %s" % (
467 for backendId, view in itertools.chain(
468 self._dialpads.iteritems(),
469 self._accountViews.iteritems(),
470 self._messagesViews.iteritems(),
471 self._recentViews.iteritems(),
472 self._contactsViews.iteritems(),
474 sectionName = "%s - %s" % (backendId, view.name())
476 view.load_settings(config, sectionName)
477 except ConfigParser.NoSectionError, e:
479 "Settings file %s is missing section %s" % (
486 def save_settings(self, config):
488 @note Thread Agnostic
490 config.add_section(self.__pretty_app_name__)
491 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
492 for i, value in enumerate(self._credentials):
493 blob = base64.b64encode(value)
494 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
495 for backendId, view in itertools.chain(
496 self._dialpads.iteritems(),
497 self._accountViews.iteritems(),
498 self._messagesViews.iteritems(),
499 self._recentViews.iteritems(),
500 self._contactsViews.iteritems(),
502 sectionName = "%s - %s" % (backendId, view.name())
503 config.add_section(sectionName)
504 view.save_settings(config, sectionName)
506 def _guess_preferred_backend(self, backendAndCookiePaths):
508 (getmtime_nothrow(path), backendId, path)
509 for backendId, path in backendAndCookiePaths
511 modTimeAndPath.sort()
512 return modTimeAndPath[-1][1]
514 def _save_settings(self):
516 @note Thread Agnostic
518 config = ConfigParser.SafeConfigParser()
519 self.save_settings(config)
520 with open(self._user_settings, "wb") as configFile:
521 config.write(configFile)
523 def _on_close(self, *args, **kwds):
525 if self._osso is not None:
529 self._save_settings()
533 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
535 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
536 For system_inactivity, we have no background tasks to pause
538 @note Hildon specific
541 for backendId in self.BACKENDS:
542 self._phoneBackends[backendId].clear_caches()
543 self._contactsViews[self._selectedBackendId].clear_caches()
546 if save_unsaved_data or shutdown:
547 self._save_settings()
549 def _on_connection_change(self, connection, event, magicIdentifier):
551 @note Hildon specific
555 status = event.get_status()
556 error = event.get_error()
557 iap_id = event.get_iap_id()
558 bearer = event.get_bearer_type()
560 if status == conic.STATUS_CONNECTED:
561 self._deviceIsOnline = True
563 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
564 backgroundLogin.setDaemon(True)
565 backgroundLogin.start()
566 elif status == conic.STATUS_DISCONNECTED:
567 self._deviceIsOnline = False
569 self._defaultBackendId = self._selectedBackendId
570 self._change_loggedin_status(self.NULL_BACKEND)
572 def _on_window_state_change(self, widget, event, *args):
574 @note Hildon specific
576 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
577 self._isFullScreen = True
579 self._isFullScreen = False
581 def _on_key_press(self, widget, event, *args):
583 @note Hildon specific
585 if event.keyval == gtk.keysyms.F6:
586 if self._isFullScreen:
587 self._window.unfullscreen()
589 self._window.fullscreen()
591 def _on_clearcookies_clicked(self, *args):
592 self._phoneBackends[self._selectedBackendId].logout()
593 self._accountViews[self._selectedBackendId].clear()
594 self._recentViews[self._selectedBackendId].clear()
595 self._messagesViews[self._selectedBackendId].clear()
596 self._contactsViews[self._selectedBackendId].clear()
597 self._change_loggedin_status(self.NULL_BACKEND)
599 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2, True])
600 backgroundLogin.setDaemon(True)
601 backgroundLogin.start()
603 def _on_notebook_switch_page(self, notebook, page, page_num):
604 if page_num == self.RECENT_TAB:
605 self._recentViews[self._selectedBackendId].update()
606 elif page_num == self.MESSAGES_TAB:
607 self._messagesViews[self._selectedBackendId].update()
608 elif page_num == self.CONTACTS_TAB:
609 self._contactsViews[self._selectedBackendId].update()
610 elif page_num == self.ACCOUNT_TAB:
611 self._accountViews[self._selectedBackendId].update()
613 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
614 if hildon is not None:
615 self._window.set_title(tabTitle)
617 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
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: