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 @todo Figure out how to integrate with the Maemo contacts app
22 @todo Look into an actor system
23 @bug Session timeouts are bad, possible solutions:
24 @li For every X minutes, if logged in, attempt login
25 @li Restructure so code handling login/dial/sms is beneath everything else and login attempts are made if those fail
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):
73 '/usr/lib/dialcentral/dialcentral.glade',
74 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
75 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
87 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
89 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
90 _user_settings = "%s/settings.ini" % _data_path
93 self._initDone = False
94 self._connection = None
96 self._clipboard = gtk.clipboard_get()
98 self._deviceIsOnline = True
99 self._credentials = ("", "")
100 self._selectedBackendId = self.NULL_BACKEND
101 self._defaultBackendId = self.GC_BACKEND
102 self._phoneBackends = None
103 self._dialpads = None
104 self._accountViews = None
105 self._messagesViews = None
106 self._recentViews = None
107 self._contactsViews = None
109 for path in self._glade_files:
110 if os.path.isfile(path):
111 self._widgetTree = gtk.glade.XML(path)
114 display_error_message("Cannot find dialcentral.glade")
118 self._window = self._widgetTree.get_widget("mainWindow")
119 self._notebook = self._widgetTree.get_widget("notebook")
120 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
121 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
124 self._isFullScreen = False
125 if hildon is not None:
126 self._app = hildon.Program()
127 oldWindow = self._window
128 self._window = hildon.Window()
129 oldWindow.get_child().reparent(self._window)
130 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))
137 warnings.warn(e.message)
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 self._window.set_title("%s" % constants.__pretty_app_name__)
157 "on_dialpad_quit": self._on_close,
159 self._widgetTree.signal_autoconnect(callbackMapping)
161 self._window.connect("destroy", self._on_close)
162 self._window.set_default_size(800, 300)
163 self._window.show_all()
165 self._loginSink = gtk_toolbox.threaded_stage(
168 gtk_toolbox.null_sink(),
172 backgroundSetup = threading.Thread(target=self._idle_setup)
173 backgroundSetup.setDaemon(True)
174 backgroundSetup.start()
176 def _idle_setup(self):
178 If something can be done after the UI loads, push it here so it's not blocking the UI
181 # Barebones UI handlers
185 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
186 with gtk_toolbox.gtk_lock():
187 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
188 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
189 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
190 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
191 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
193 self._dialpads[self._selectedBackendId].enable()
194 self._accountViews[self._selectedBackendId].enable()
195 self._recentViews[self._selectedBackendId].enable()
196 self._messagesViews[self._selectedBackendId].enable()
197 self._contactsViews[self._selectedBackendId].enable()
199 # Setup maemo specifics
206 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
207 device = osso.DeviceState(self._osso)
208 device.set_device_state_callback(self._on_device_state_change, 0)
210 pass # warnings.warn("No OSSO", UserWarning, 2)
212 # Setup maemo specifics
217 self._connection = None
218 if conic is not None:
219 self._connection = conic.Connection()
220 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
221 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
223 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
225 # Setup costly backends
233 os.makedirs(self._data_path)
237 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
238 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
239 self._defaultBackendId = self._guess_preferred_backend((
240 (self.GC_BACKEND, gcCookiePath),
241 (self.GV_BACKEND, gvCookiePath),
244 self._phoneBackends.update({
245 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
246 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
248 with gtk_toolbox.gtk_lock():
249 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
250 unifiedDialpad.set_number("")
251 self._dialpads.update({
252 self.GC_BACKEND: unifiedDialpad,
253 self.GV_BACKEND: unifiedDialpad,
255 self._accountViews.update({
256 self.GC_BACKEND: gc_views.AccountInfo(
257 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
259 self.GV_BACKEND: gc_views.AccountInfo(
260 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
263 self._recentViews.update({
264 self.GC_BACKEND: gc_views.RecentCallsView(
265 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
267 self.GV_BACKEND: gc_views.RecentCallsView(
268 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
271 self._messagesViews.update({
272 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
273 self.GV_BACKEND: gc_views.MessagesView(
274 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
277 self._contactsViews.update({
278 self.GC_BACKEND: gc_views.ContactsView(
279 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
281 self.GV_BACKEND: gc_views.ContactsView(
282 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
286 evoBackend = evo_backend.EvolutionAddressBook()
287 fsContactsPath = os.path.join(self._data_path, "contacts")
288 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
289 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
290 self._dialpads[backendId].number_selected = self._select_action
291 self._recentViews[backendId].number_selected = self._select_action
292 self._messagesViews[backendId].number_selected = self._select_action
293 self._contactsViews[backendId].number_selected = self._select_action
296 self._phoneBackends[backendId],
300 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
301 self._contactsViews[backendId].append(mergedBook)
302 self._contactsViews[backendId].extend(addressBooks)
303 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
306 "on_paste": self._on_paste,
307 "on_refresh": self._on_refresh,
308 "on_clearcookies_clicked": self._on_clearcookies_clicked,
309 "on_notebook_switch_page": self._on_notebook_switch_page,
310 "on_about_activate": self._on_about_activate,
312 self._widgetTree.signal_autoconnect(callbackMapping)
314 self._initDone = True
316 config = ConfigParser.SafeConfigParser()
317 config.read(self._user_settings)
318 with gtk_toolbox.gtk_lock():
319 self.load_settings(config)
321 self._spawn_attempt_login(2)
322 except StandardError, e:
323 warnings.warn(e.message, UserWarning, 2)
324 except BaseException, e:
326 warnings.warn(e.message, UserWarning, 2)
330 def attempt_login(self, numOfAttempts = 10, force = False):
332 @todo Handle user notification better like attempting to login and failed login
334 @note This must be run outside of the UI lock
337 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
338 assert self._initDone, "Attempting login before app is fully loaded"
339 if not self._deviceIsOnline:
340 raise RuntimeError("Unable to login, device is not online")
342 serviceId = self.NULL_BACKEND
346 self.refresh_session()
347 serviceId = self._defaultBackendId
349 except StandardError, e:
350 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
353 loggedIn, serviceId = self._login_by_user(numOfAttempts)
355 with gtk_toolbox.gtk_lock():
356 self._change_loggedin_status(serviceId)
357 except StandardError, e:
358 with gtk_toolbox.gtk_lock():
359 self._errorDisplay.push_exception(e)
361 def _spawn_attempt_login(self, *args):
362 self._loginSink.send(args)
364 def refresh_session(self):
366 @note Thread agnostic
368 assert self._initDone, "Attempting login before app is fully loaded"
369 if not self._deviceIsOnline:
370 raise RuntimeError("Unable to login, device is not online")
374 loggedIn = self._login_by_cookie()
376 loggedIn = self._login_by_settings()
379 raise RuntimeError("Login Failed")
381 def _login_by_cookie(self):
383 @note Thread agnostic
385 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
388 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
393 def _login_by_settings(self):
395 @note Thread agnostic
397 username, password = self._credentials
398 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
400 self._credentials = username, password
402 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
407 def _login_by_user(self, numOfAttempts):
409 @note This must be run outside of the UI lock
411 loggedIn, (username, password) = False, self._credentials
412 tmpServiceId = self.NULL_BACKEND
413 for attemptCount in xrange(numOfAttempts):
416 availableServices = {
417 self.GV_BACKEND: "Google Voice",
418 self.GC_BACKEND: "Grand Central",
420 with gtk_toolbox.gtk_lock():
421 credentials = self._credentialsDialog.request_credentials_from(
422 availableServices, defaultCredentials = self._credentials
424 tmpServiceId, username, password = credentials
425 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
428 serviceId = tmpServiceId
429 self._credentials = username, password
431 "Logged into %r through user request" % self._phoneBackends[serviceId],
435 serviceId = self.NULL_BACKEND
437 return loggedIn, serviceId
439 def _select_action(self, action, number, message):
440 self.refresh_session()
441 if action == "select":
442 self._dialpads[self._selectedBackendId].set_number(number)
443 self._notebook.set_current_page(self.KEYPAD_TAB)
444 elif action == "dial":
445 self._on_dial_clicked(number)
446 elif action == "sms":
447 self._on_sms_clicked(number, message)
449 assert False, "Unknown action: %s" % action
451 def _change_loggedin_status(self, newStatus):
452 oldStatus = self._selectedBackendId
453 if oldStatus == newStatus:
456 self._dialpads[oldStatus].disable()
457 self._accountViews[oldStatus].disable()
458 self._recentViews[oldStatus].disable()
459 self._messagesViews[oldStatus].disable()
460 self._contactsViews[oldStatus].disable()
462 self._dialpads[newStatus].enable()
463 self._accountViews[newStatus].enable()
464 self._recentViews[newStatus].enable()
465 self._messagesViews[newStatus].enable()
466 self._contactsViews[newStatus].enable()
468 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
469 self._phoneBackends[self._selectedBackendId].set_sane_callback()
470 self._accountViews[self._selectedBackendId].update()
472 self._selectedBackendId = newStatus
474 def load_settings(self, config):
479 self._defaultBackendId = int(config.get(constants.__pretty_app_name__, "active"))
481 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
482 for i in xrange(len(self._credentials))
485 base64.b64decode(blob)
488 self._credentials = tuple(creds)
489 except ConfigParser.NoSectionError, e:
491 "Settings file %s is missing section %s" % (
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())
507 view.load_settings(config, sectionName)
508 except ConfigParser.NoSectionError, e:
510 "Settings file %s is missing section %s" % (
517 def save_settings(self, config):
519 @note Thread Agnostic
521 config.add_section(constants.__pretty_app_name__)
522 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
523 for i, value in enumerate(self._credentials):
524 blob = base64.b64encode(value)
525 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
526 for backendId, view in itertools.chain(
527 self._dialpads.iteritems(),
528 self._accountViews.iteritems(),
529 self._messagesViews.iteritems(),
530 self._recentViews.iteritems(),
531 self._contactsViews.iteritems(),
533 sectionName = "%s - %s" % (backendId, view.name())
534 config.add_section(sectionName)
535 view.save_settings(config, sectionName)
537 def _guess_preferred_backend(self, backendAndCookiePaths):
539 (getmtime_nothrow(path), backendId, path)
540 for backendId, path in backendAndCookiePaths
542 modTimeAndPath.sort()
543 return modTimeAndPath[-1][1]
545 def _save_settings(self):
547 @note Thread Agnostic
549 config = ConfigParser.SafeConfigParser()
550 self.save_settings(config)
551 with open(self._user_settings, "wb") as configFile:
552 config.write(configFile)
554 def _on_close(self, *args, **kwds):
556 if self._osso is not None:
560 self._save_settings()
564 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
566 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
567 For system_inactivity, we have no background tasks to pause
569 @note Hildon specific
572 for backendId in self.BACKENDS:
573 self._phoneBackends[backendId].clear_caches()
574 self._contactsViews[self._selectedBackendId].clear_caches()
577 if save_unsaved_data or shutdown:
578 self._save_settings()
580 def _on_connection_change(self, connection, event, magicIdentifier):
582 @note Hildon specific
586 status = event.get_status()
587 error = event.get_error()
588 iap_id = event.get_iap_id()
589 bearer = event.get_bearer_type()
591 if status == conic.STATUS_CONNECTED:
592 self._deviceIsOnline = True
594 self._spawn_attempt_login(2)
595 elif status == conic.STATUS_DISCONNECTED:
596 self._deviceIsOnline = False
598 self._defaultBackendId = self._selectedBackendId
599 self._change_loggedin_status(self.NULL_BACKEND)
601 def _on_window_state_change(self, widget, event, *args):
603 @note Hildon specific
605 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
606 self._isFullScreen = True
608 self._isFullScreen = False
610 def _on_key_press(self, widget, event, *args):
612 @note Hildon specific
614 if event.keyval == gtk.keysyms.F6:
615 if self._isFullScreen:
616 self._window.unfullscreen()
618 self._window.fullscreen()
620 def _on_clearcookies_clicked(self, *args):
621 self._phoneBackends[self._selectedBackendId].logout()
622 self._accountViews[self._selectedBackendId].clear()
623 self._recentViews[self._selectedBackendId].clear()
624 self._messagesViews[self._selectedBackendId].clear()
625 self._contactsViews[self._selectedBackendId].clear()
626 self._change_loggedin_status(self.NULL_BACKEND)
628 self._spawn_attempt_login(2, True)
630 def _on_notebook_switch_page(self, notebook, page, page_num):
631 if page_num == self.RECENT_TAB:
632 self._recentViews[self._selectedBackendId].update()
633 elif page_num == self.MESSAGES_TAB:
634 self._messagesViews[self._selectedBackendId].update()
635 elif page_num == self.CONTACTS_TAB:
636 self._contactsViews[self._selectedBackendId].update()
637 elif page_num == self.ACCOUNT_TAB:
638 self._accountViews[self._selectedBackendId].update()
640 def _on_sms_clicked(self, number, message):
644 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
645 except StandardError, e:
647 self._errorDisplay.push_exception(e)
651 self._errorDisplay.push_message(
652 "Backend link with grandcentral is not working, please try again"
658 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
660 except StandardError, e:
661 self._errorDisplay.push_exception(e)
662 except ValueError, e:
663 self._errorDisplay.push_exception(e)
665 def _on_dial_clicked(self, number):
668 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
669 except StandardError, e:
671 self._errorDisplay.push_exception(e)
675 self._errorDisplay.push_message(
676 "Backend link with grandcentral is not working, please try again"
682 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
683 self._phoneBackends[self._selectedBackendId].dial(number)
685 except StandardError, e:
686 self._errorDisplay.push_exception(e)
687 except ValueError, e:
688 self._errorDisplay.push_exception(e)
691 self._dialpads[self._selectedBackendId].clear()
693 def _on_refresh(self, *args):
694 page_num = self._notebook.get_current_page()
695 if page_num == self.CONTACTS_TAB:
696 self._contactsViews[self._selectedBackendId].update(force=True)
697 elif page_num == self.RECENT_TAB:
698 self._recentViews[self._selectedBackendId].update(force=True)
699 elif page_num == self.MESSAGES_TAB:
700 self._messagesViews[self._selectedBackendId].update(force=True)
702 def _on_paste(self, *args):
703 contents = self._clipboard.wait_for_text()
704 self._dialpads[self._selectedBackendId].set_number(contents)
706 def _on_about_activate(self, *args):
707 dlg = gtk.AboutDialog()
708 dlg.set_name(constants.__pretty_app_name__)
709 dlg.set_version(constants.__version__)
710 dlg.set_copyright("Copyright 2008 - LGPL")
711 dlg.set_comments("Dialcentral is a touch screen enhanced interface to your GoogleVoice/Grandcentral account. This application is not affiliated with Google in any way")
712 dlg.set_website("http://gc-dialer.garage.maemo.org/")
713 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
721 failureCount, testCount = doctest.testmod()
723 print "Tests Successful"
730 gtk.gdk.threads_init()
731 if hildon is not None:
732 gtk.set_application_name(constants.__pretty_app_name__)
733 handle = Dialcentral()
737 class DummyOptions(object):
743 if __name__ == "__main__":
744 if len(sys.argv) > 1:
750 if optparse is not None:
751 parser = optparse.OptionParser()
752 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
753 (commandOptions, commandArgs) = parser.parse_args()
755 commandOptions = DummyOptions()
758 if commandOptions.test: