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
23 from __future__ import with_statement
42 def getmtime_nothrow(path):
44 return os.path.getmtime(path)
49 def display_error_message(msg):
50 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
52 def close(dialog, response):
54 error_dialog.connect("response", close)
58 class Dialcentral(object):
61 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
62 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
63 '/usr/lib/dialcentral/dialcentral.glade',
74 BACKENDS = (NULL_BACKEND, GV_BACKEND)
77 self._initDone = False
78 self._connection = None
80 self._clipboard = gtk.clipboard_get()
82 self._credentials = ("", "")
83 self._selectedBackendId = self.NULL_BACKEND
84 self._defaultBackendId = self.GV_BACKEND
85 self._phoneBackends = None
87 self._accountViews = None
88 self._messagesViews = None
89 self._recentViews = None
90 self._contactsViews = None
91 self._alarmHandler = None
92 self._ledHandler = None
93 self._originalCurrentLabels = []
95 for path in self._glade_files:
96 if os.path.isfile(path):
97 self._widgetTree = gtk.glade.XML(path)
100 display_error_message("Cannot find dialcentral.glade")
104 self._window = self._widgetTree.get_widget("mainWindow")
105 self._notebook = self._widgetTree.get_widget("notebook")
106 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
107 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
109 self._isFullScreen = False
110 self._app = hildonize.get_app_class()()
111 self._window = hildonize.hildonize_window(self._app, self._window)
112 hildonize.hildonize_text_entry(self._widgetTree.get_widget("usernameentry"))
113 hildonize.hildonize_password_entry(self._widgetTree.get_widget("passwordentry"))
114 hildonize.hildonize_combo_entry(self._widgetTree.get_widget("callbackcombo").get_child())
116 for scrollingWidget in (
117 'recent_scrolledwindow',
118 'message_scrolledwindow',
119 'contacts_scrolledwindow',
120 "phoneSelectionMessage_scrolledwindow",
121 "phonetypes_scrolledwindow",
122 "smsMessage_scrolledwindow",
123 "smsMessage_scrolledEntry",
125 hildonize.set_thumb_scrollbar(self._widgetTree.get_widget(scrollingWidget))
127 hildonize.hildonize_menu(self._window, self._widgetTree.get_widget("dialpad_menubar"))
129 if hildonize.IS_HILDON:
130 self._window.connect("key-press-event", self._on_key_press)
131 self._window.connect("window-state-event", self._on_window_state_change)
133 logging.warning("No hildonization support")
136 hildonize.set_application_title(self._window, "%s" % constants.__pretty_app_name__)
139 "on_dialpad_quit": self._on_close,
141 self._widgetTree.signal_autoconnect(callbackMapping)
143 self._window.connect("destroy", self._on_close)
144 self._window.set_default_size(800, 300)
145 self._window.show_all()
147 self._loginSink = gtk_toolbox.threaded_stage(
150 gtk_toolbox.null_sink(),
154 backgroundSetup = threading.Thread(target=self._idle_setup)
155 backgroundSetup.setDaemon(True)
156 backgroundSetup.start()
158 def _idle_setup(self):
160 If something can be done after the UI loads, push it here so it's not blocking the UI
163 # Barebones UI handlers
167 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
168 with gtk_toolbox.gtk_lock():
169 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
170 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
171 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
172 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
173 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
175 self._dialpads[self._selectedBackendId].enable()
176 self._accountViews[self._selectedBackendId].enable()
177 self._recentViews[self._selectedBackendId].enable()
178 self._messagesViews[self._selectedBackendId].enable()
179 self._contactsViews[self._selectedBackendId].enable()
181 # Setup maemo specifics
184 except (ImportError, OSError):
188 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
189 device = osso.DeviceState(self._osso)
190 device.set_device_state_callback(self._on_device_state_change, 0)
192 logging.warning("No device state support")
196 self._alarmHandler = alarm_handler.AlarmHandler()
197 except (ImportError, OSError):
200 with gtk_toolbox.gtk_lock():
201 self._errorDisplay.push_exception()
203 logging.warning("No notification support")
204 if hildonize.IS_HILDON:
206 self._ledHandler = led_handler.LedHandler()
208 # Setup maemo specifics
211 except (ImportError, OSError):
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, constants.__app_magic__)
217 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
219 logging.warning("No connection support")
221 # Setup costly backends
227 os.makedirs(constants._data_path_)
231 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
233 self._phoneBackends.update({
234 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
236 with gtk_toolbox.gtk_lock():
237 unifiedDialpad = gv_views.Dialpad(self._widgetTree, self._errorDisplay)
238 self._dialpads.update({
239 self.GV_BACKEND: unifiedDialpad,
241 self._accountViews.update({
242 self.GV_BACKEND: gv_views.AccountInfo(
243 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
246 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
247 self._recentViews.update({
248 self.GV_BACKEND: gv_views.RecentCallsView(
249 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
252 self._messagesViews.update({
253 self.GV_BACKEND: gv_views.MessagesView(
254 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
257 self._contactsViews.update({
258 self.GV_BACKEND: gv_views.ContactsView(
259 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
263 fsContactsPath = os.path.join(constants._data_path_, "contacts")
264 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
266 self._dialpads[self.GV_BACKEND].number_selected = self._select_action
267 self._recentViews[self.GV_BACKEND].number_selected = self._select_action
268 self._messagesViews[self.GV_BACKEND].number_selected = self._select_action
269 self._contactsViews[self.GV_BACKEND].number_selected = self._select_action
272 self._phoneBackends[self.GV_BACKEND],
275 mergedBook = gv_views.MergedAddressBook(addressBooks, gv_views.MergedAddressBook.advanced_lastname_sorter)
276 self._contactsViews[self.GV_BACKEND].append(mergedBook)
277 self._contactsViews[self.GV_BACKEND].extend(addressBooks)
278 self._contactsViews[self.GV_BACKEND].open_addressbook(*self._contactsViews[self.GV_BACKEND].get_addressbooks().next()[0][0:2])
281 "on_paste": self._on_paste,
282 "on_refresh": self._on_menu_refresh,
283 "on_rotate": self._on_menu_rotate,
284 "on_clearcookies_clicked": self._on_clearcookies_clicked,
285 "on_notebook_switch_page": self._on_notebook_switch_page,
286 "on_about_activate": self._on_about_activate,
288 self._widgetTree.signal_autoconnect(callbackMapping)
290 with gtk_toolbox.gtk_lock():
291 self._originalCurrentLabels = [
292 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
293 for pageIndex in xrange(self._notebook.get_n_pages())
295 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
296 self._notebookTapHandler.enable()
297 self._notebookTapHandler.on_tap = self._reset_tab_refresh
298 self._notebookTapHandler.on_hold = self._on_tab_refresh
299 self._notebookTapHandler.on_holding = self._set_tab_refresh
300 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
302 self._initDone = True
304 config = ConfigParser.SafeConfigParser()
305 config.read(constants._user_settings_)
306 with gtk_toolbox.gtk_lock():
307 self.load_settings(config)
309 self._spawn_attempt_login(2)
311 with gtk_toolbox.gtk_lock():
312 self._errorDisplay.push_exception()
314 def attempt_login(self, numOfAttempts = 10, force = False):
316 @todo Handle user notification better like attempting to login and failed login
318 @note This must be run outside of the UI lock
321 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
322 assert self._initDone, "Attempting login before app is fully loaded"
324 serviceId = self.NULL_BACKEND
328 self.refresh_session()
329 serviceId = self._defaultBackendId
332 logging.exception('Session refresh failed with the following message "%s"' % e.message)
335 loggedIn, serviceId = self._login_by_user(numOfAttempts)
337 with gtk_toolbox.gtk_lock():
338 self._change_loggedin_status(serviceId)
340 with gtk_toolbox.gtk_lock():
341 self._errorDisplay.push_exception()
343 def _spawn_attempt_login(self, *args):
344 self._loginSink.send(args)
346 def refresh_session(self):
348 @note Thread agnostic
350 assert self._initDone, "Attempting login before app is fully loaded"
354 loggedIn = self._login_by_cookie()
356 loggedIn = self._login_by_settings()
359 raise RuntimeError("Login Failed")
361 def _login_by_cookie(self):
363 @note Thread agnostic
365 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
367 logging.info("Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId])
370 def _login_by_settings(self):
372 @note Thread agnostic
374 username, password = self._credentials
375 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
377 self._credentials = username, password
378 logging.info("Logged into %r through settings" % self._phoneBackends[self._defaultBackendId])
381 def _login_by_user(self, numOfAttempts):
383 @note This must be run outside of the UI lock
385 loggedIn, (username, password) = False, self._credentials
386 tmpServiceId = self.GV_BACKEND
387 for attemptCount in xrange(numOfAttempts):
390 with gtk_toolbox.gtk_lock():
391 credentials = self._credentialsDialog.request_credentials(
392 defaultCredentials = self._credentials
394 username, password = credentials
395 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
398 serviceId = tmpServiceId
399 self._credentials = username, password
400 logging.info("Logged into %r through user request" % self._phoneBackends[serviceId])
402 serviceId = self.NULL_BACKEND
404 return loggedIn, serviceId
406 def _select_action(self, action, number, message):
407 self.refresh_session()
408 if action == "select":
409 self._dialpads[self._selectedBackendId].set_number(number)
410 self._notebook.set_current_page(self.KEYPAD_TAB)
411 elif action == "dial":
412 self._on_dial_clicked(number)
413 elif action == "sms":
414 self._on_sms_clicked(number, message)
416 assert False, "Unknown action: %s" % action
418 def _change_loggedin_status(self, newStatus):
419 oldStatus = self._selectedBackendId
420 if oldStatus == newStatus:
423 self._dialpads[oldStatus].disable()
424 self._accountViews[oldStatus].disable()
425 self._recentViews[oldStatus].disable()
426 self._messagesViews[oldStatus].disable()
427 self._contactsViews[oldStatus].disable()
429 self._dialpads[newStatus].enable()
430 self._accountViews[newStatus].enable()
431 self._recentViews[newStatus].enable()
432 self._messagesViews[newStatus].enable()
433 self._contactsViews[newStatus].enable()
435 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
436 self._phoneBackends[self._selectedBackendId].set_sane_callback()
437 self._accountViews[self._selectedBackendId].update()
439 self._selectedBackendId = newStatus
441 def load_settings(self, config):
446 self._defaultBackendId = config.getint(constants.__pretty_app_name__, "active")
448 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
449 for i in xrange(len(self._credentials))
452 base64.b64decode(blob)
455 self._credentials = tuple(creds)
457 if self._alarmHandler is not None:
458 self._alarmHandler.load_settings(config, "alarm")
460 previousOrientation = config.getint(constants.__pretty_app_name__, "orientation")
461 if previousOrientation == gtk.ORIENTATION_HORIZONTAL:
462 hildonize.window_to_landscape(self._window)
463 elif previousOrientation == gtk.ORIENTATION_VERTICAL:
464 hildonize.window_to_portrait(self._window)
465 except ConfigParser.NoOptionError, e:
467 "Settings file %s is missing section %s" % (
468 constants._user_settings_,
472 except ConfigParser.NoSectionError, e:
474 "Settings file %s is missing section %s" % (
475 constants._user_settings_,
480 for backendId, view in itertools.chain(
481 self._dialpads.iteritems(),
482 self._accountViews.iteritems(),
483 self._messagesViews.iteritems(),
484 self._recentViews.iteritems(),
485 self._contactsViews.iteritems(),
487 sectionName = "%s - %s" % (backendId, view.name())
489 view.load_settings(config, sectionName)
490 except ConfigParser.NoOptionError, e:
492 "Settings file %s is missing section %s" % (
493 constants._user_settings_,
497 except ConfigParser.NoSectionError, e:
499 "Settings file %s is missing section %s" % (
500 constants._user_settings_,
505 def save_settings(self, config):
507 @note Thread Agnostic
509 config.add_section(constants.__pretty_app_name__)
510 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
511 config.set(constants.__pretty_app_name__, "orientation", str(int(gtk_toolbox.get_screen_orientation())))
512 for i, value in enumerate(self._credentials):
513 blob = base64.b64encode(value)
514 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
515 config.add_section("alarm")
516 if self._alarmHandler is not None:
517 self._alarmHandler.save_settings(config, "alarm")
519 for backendId, view in itertools.chain(
520 self._dialpads.iteritems(),
521 self._accountViews.iteritems(),
522 self._messagesViews.iteritems(),
523 self._recentViews.iteritems(),
524 self._contactsViews.iteritems(),
526 sectionName = "%s - %s" % (backendId, view.name())
527 config.add_section(sectionName)
528 view.save_settings(config, sectionName)
530 def _save_settings(self):
532 @note Thread Agnostic
534 config = ConfigParser.SafeConfigParser()
535 self.save_settings(config)
536 with open(constants._user_settings_, "wb") as configFile:
537 config.write(configFile)
539 def _refresh_active_tab(self):
540 pageIndex = self._notebook.get_current_page()
541 if pageIndex == self.CONTACTS_TAB:
542 self._contactsViews[self._selectedBackendId].update(force=True)
543 elif pageIndex == self.RECENT_TAB:
544 self._recentViews[self._selectedBackendId].update(force=True)
545 elif pageIndex == self.MESSAGES_TAB:
546 self._messagesViews[self._selectedBackendId].update(force=True)
548 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
549 if self._ledHandler is not None:
550 self._ledHandler.off()
552 def _on_close(self, *args, **kwds):
554 if self._osso is not None:
558 self._save_settings()
562 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
564 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
565 For system_inactivity, we have no background tasks to pause
567 @note Hildon specific
571 for backendId in self.BACKENDS:
572 self._phoneBackends[backendId].clear_caches()
573 self._contactsViews[self._selectedBackendId].clear_caches()
576 if save_unsaved_data or shutdown:
577 self._save_settings()
579 self._errorDisplay.push_exception()
581 def _on_connection_change(self, connection, event, magicIdentifier):
583 @note Hildon specific
588 status = event.get_status()
589 error = event.get_error()
590 iap_id = event.get_iap_id()
591 bearer = event.get_bearer_type()
593 if status == conic.STATUS_CONNECTED:
595 self._spawn_attempt_login(2)
596 elif status == conic.STATUS_DISCONNECTED:
598 self._defaultBackendId = self._selectedBackendId
599 self._change_loggedin_status(self.NULL_BACKEND)
601 self._errorDisplay.push_exception()
603 def _on_window_state_change(self, widget, event, *args):
605 @note Hildon specific
608 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
609 self._isFullScreen = True
611 self._isFullScreen = False
613 self._errorDisplay.push_exception()
615 def _on_key_press(self, widget, event, *args):
617 @note Hildon specific
620 if event.keyval == gtk.keysyms.F6:
621 if self._isFullScreen:
622 self._window.unfullscreen()
624 self._window.fullscreen()
626 self._errorDisplay.push_exception()
628 def _on_clearcookies_clicked(self, *args):
630 self._phoneBackends[self._selectedBackendId].logout()
631 self._accountViews[self._selectedBackendId].clear()
632 self._recentViews[self._selectedBackendId].clear()
633 self._messagesViews[self._selectedBackendId].clear()
634 self._contactsViews[self._selectedBackendId].clear()
635 self._change_loggedin_status(self.NULL_BACKEND)
637 self._spawn_attempt_login(2, True)
639 self._errorDisplay.push_exception()
641 def _on_notebook_switch_page(self, notebook, page, pageIndex):
643 self._reset_tab_refresh()
645 didRecentUpdate = False
646 didMessagesUpdate = False
648 if pageIndex == self.RECENT_TAB:
649 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
650 elif pageIndex == self.MESSAGES_TAB:
651 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
652 elif pageIndex == self.CONTACTS_TAB:
653 self._contactsViews[self._selectedBackendId].update()
654 elif pageIndex == self.ACCOUNT_TAB:
655 self._accountViews[self._selectedBackendId].update()
657 if didRecentUpdate or didMessagesUpdate:
658 if self._ledHandler is not None:
659 self._ledHandler.off()
661 self._errorDisplay.push_exception()
663 def _set_tab_refresh(self, *args):
665 pageIndex = self._notebook.get_current_page()
666 child = self._notebook.get_nth_page(pageIndex)
667 self._notebook.get_tab_label(child).set_text("Refresh?")
669 self._errorDisplay.push_exception()
672 def _reset_tab_refresh(self, *args):
674 pageIndex = self._notebook.get_current_page()
675 child = self._notebook.get_nth_page(pageIndex)
676 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
678 self._errorDisplay.push_exception()
681 def _on_tab_refresh(self, *args):
683 self._refresh_active_tab()
684 self._reset_tab_refresh()
686 self._errorDisplay.push_exception()
689 def _on_sms_clicked(self, number, message):
691 assert number, "No number specified"
692 assert message, "Empty message"
694 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
697 self._errorDisplay.push_exception()
701 self._errorDisplay.push_message(
702 "Backend link with grandcentral is not working, please try again"
708 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
711 self._errorDisplay.push_exception()
714 self._dialpads[self._selectedBackendId].clear()
716 self._errorDisplay.push_exception()
718 def _on_dial_clicked(self, number):
720 assert number, "No number to call"
722 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
725 self._errorDisplay.push_exception()
729 self._errorDisplay.push_message(
730 "Backend link with grandcentral is not working, please try again"
736 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
737 self._phoneBackends[self._selectedBackendId].dial(number)
740 self._errorDisplay.push_exception()
743 self._dialpads[self._selectedBackendId].clear()
745 self._errorDisplay.push_exception()
747 def _on_menu_refresh(self, *args):
749 self._refresh_active_tab()
751 self._errorDisplay.push_exception()
753 def _on_menu_rotate(self, *args):
755 orientation = gtk_toolbox.get_screen_orientation()
756 if orientation == gtk.ORIENTATION_HORIZONTAL:
757 hildonize.window_to_portrait(self._window)
758 elif orientation == gtk.ORIENTATION_VERTICAL:
759 hildonize.window_to_landscape(self._window)
761 self._errorDisplay.push_exception()
763 def _on_paste(self, *args):
765 contents = self._clipboard.wait_for_text()
766 self._dialpads[self._selectedBackendId].set_number(contents)
768 self._errorDisplay.push_exception()
770 def _on_about_activate(self, *args):
772 dlg = gtk.AboutDialog()
773 dlg.set_name(constants.__pretty_app_name__)
774 dlg.set_version("%s-%d" % (constants.__version__, constants.__build__))
775 dlg.set_copyright("Copyright 2008 - LGPL")
776 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")
777 dlg.set_website("http://gc-dialer.garage.maemo.org/")
778 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
782 self._errorDisplay.push_exception()
788 failureCount, testCount = doctest.testmod()
790 print "Tests Successful"
797 _lock_file = os.path.join(constants._data_path_, ".lock")
798 #with gtk_toolbox.flock(_lock_file, 0):
799 gtk.gdk.threads_init()
801 if hildonize.IS_HILDON:
802 gtk.set_application_name(constants.__pretty_app_name__)
803 handle = Dialcentral()
807 class DummyOptions(object):
813 if __name__ == "__main__":
814 logging.basicConfig(level=logging.DEBUG)
816 if len(sys.argv) > 1:
822 if optparse is not None:
823 parser = optparse.OptionParser()
824 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
825 (commandOptions, commandArgs) = parser.parse_args()
827 commandOptions = DummyOptions()
830 if commandOptions.test: