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
162 # 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 with gtk_toolbox.gtk_lock():
182 self._errorDisplay.push_exception()
184 # Setup maemo specifics
188 except (ImportError, OSError):
192 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
193 device = osso.DeviceState(self._osso)
194 device.set_device_state_callback(self._on_device_state_change, 0)
196 logging.warning("No device state support")
200 self._alarmHandler = alarm_handler.AlarmHandler()
201 except (ImportError, OSError):
204 with gtk_toolbox.gtk_lock():
205 self._errorDisplay.push_exception()
207 logging.warning("No notification support")
208 if hildonize.IS_HILDON:
210 self._ledHandler = led_handler.LedHandler()
214 except (ImportError, OSError):
216 self._connection = None
217 if conic is not None:
218 self._connection = conic.Connection()
219 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
220 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
222 logging.warning("No connection support")
224 with gtk_toolbox.gtk_lock():
225 self._errorDisplay.push_exception()
227 # Setup costly backends
234 os.makedirs(constants._data_path_)
238 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
240 self._phoneBackends.update({
241 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
243 with gtk_toolbox.gtk_lock():
244 unifiedDialpad = gv_views.Dialpad(self._widgetTree, self._errorDisplay)
245 self._dialpads.update({
246 self.GV_BACKEND: unifiedDialpad,
248 self._accountViews.update({
249 self.GV_BACKEND: gv_views.AccountInfo(
250 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
253 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
254 self._recentViews.update({
255 self.GV_BACKEND: gv_views.RecentCallsView(
256 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
259 self._messagesViews.update({
260 self.GV_BACKEND: gv_views.MessagesView(
261 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
264 self._contactsViews.update({
265 self.GV_BACKEND: gv_views.ContactsView(
266 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
270 fsContactsPath = os.path.join(constants._data_path_, "contacts")
271 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
273 self._dialpads[self.GV_BACKEND].number_selected = self._select_action
274 self._recentViews[self.GV_BACKEND].number_selected = self._select_action
275 self._messagesViews[self.GV_BACKEND].number_selected = self._select_action
276 self._contactsViews[self.GV_BACKEND].number_selected = self._select_action
279 self._phoneBackends[self.GV_BACKEND],
282 mergedBook = gv_views.MergedAddressBook(addressBooks, gv_views.MergedAddressBook.advanced_lastname_sorter)
283 self._contactsViews[self.GV_BACKEND].append(mergedBook)
284 self._contactsViews[self.GV_BACKEND].extend(addressBooks)
285 self._contactsViews[self.GV_BACKEND].open_addressbook(*self._contactsViews[self.GV_BACKEND].get_addressbooks().next()[0][0:2])
288 "on_paste": self._on_paste,
289 "on_refresh": self._on_menu_refresh,
290 "on_rotate": self._on_menu_rotate,
291 "on_clearcookies_clicked": self._on_clearcookies_clicked,
292 "on_notebook_switch_page": self._on_notebook_switch_page,
293 "on_about_activate": self._on_about_activate,
295 self._widgetTree.signal_autoconnect(callbackMapping)
297 with gtk_toolbox.gtk_lock():
298 self._originalCurrentLabels = [
299 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
300 for pageIndex in xrange(self._notebook.get_n_pages())
302 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
303 self._notebookTapHandler.enable()
304 self._notebookTapHandler.on_tap = self._reset_tab_refresh
305 self._notebookTapHandler.on_hold = self._on_tab_refresh
306 self._notebookTapHandler.on_holding = self._set_tab_refresh
307 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
309 self._initDone = True
311 config = ConfigParser.SafeConfigParser()
312 config.read(constants._user_settings_)
313 with gtk_toolbox.gtk_lock():
314 self.load_settings(config)
316 with gtk_toolbox.gtk_lock():
317 self._errorDisplay.push_exception()
319 self._spawn_attempt_login(2)
321 def attempt_login(self, numOfAttempts = 10, force = False):
323 @todo Handle user notification better like attempting to login and failed login
325 @note This must be run outside of the UI lock
328 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
329 assert self._initDone, "Attempting login before app is fully loaded"
331 serviceId = self.NULL_BACKEND
335 self.refresh_session()
336 serviceId = self._defaultBackendId
339 logging.exception('Session refresh failed with the following message "%s"' % e.message)
342 loggedIn, serviceId = self._login_by_user(numOfAttempts)
344 with gtk_toolbox.gtk_lock():
345 self._change_loggedin_status(serviceId)
347 with gtk_toolbox.gtk_lock():
348 self._errorDisplay.push_exception()
350 def _spawn_attempt_login(self, *args):
351 self._loginSink.send(args)
353 def refresh_session(self):
355 @note Thread agnostic
357 assert self._initDone, "Attempting login before app is fully loaded"
361 loggedIn = self._login_by_cookie()
363 loggedIn = self._login_by_settings()
366 raise RuntimeError("Login Failed")
368 def _login_by_cookie(self):
370 @note Thread agnostic
372 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
374 logging.info("Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId])
377 def _login_by_settings(self):
379 @note Thread agnostic
381 username, password = self._credentials
382 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
384 self._credentials = username, password
385 logging.info("Logged into %r through settings" % self._phoneBackends[self._defaultBackendId])
388 def _login_by_user(self, numOfAttempts):
390 @note This must be run outside of the UI lock
392 loggedIn, (username, password) = False, self._credentials
393 tmpServiceId = self.GV_BACKEND
394 for attemptCount in xrange(numOfAttempts):
397 with gtk_toolbox.gtk_lock():
398 credentials = self._credentialsDialog.request_credentials(
399 defaultCredentials = self._credentials
401 username, password = credentials
402 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
405 serviceId = tmpServiceId
406 self._credentials = username, password
407 logging.info("Logged into %r through user request" % self._phoneBackends[serviceId])
409 serviceId = self.NULL_BACKEND
411 return loggedIn, serviceId
413 def _select_action(self, action, number, message):
414 self.refresh_session()
415 if action == "select":
416 self._dialpads[self._selectedBackendId].set_number(number)
417 self._notebook.set_current_page(self.KEYPAD_TAB)
418 elif action == "dial":
419 self._on_dial_clicked(number)
420 elif action == "sms":
421 self._on_sms_clicked(number, message)
423 assert False, "Unknown action: %s" % action
425 def _change_loggedin_status(self, newStatus):
426 oldStatus = self._selectedBackendId
427 if oldStatus == newStatus:
430 self._dialpads[oldStatus].disable()
431 self._accountViews[oldStatus].disable()
432 self._recentViews[oldStatus].disable()
433 self._messagesViews[oldStatus].disable()
434 self._contactsViews[oldStatus].disable()
436 self._dialpads[newStatus].enable()
437 self._accountViews[newStatus].enable()
438 self._recentViews[newStatus].enable()
439 self._messagesViews[newStatus].enable()
440 self._contactsViews[newStatus].enable()
442 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
443 self._phoneBackends[self._selectedBackendId].set_sane_callback()
444 self._accountViews[self._selectedBackendId].update()
446 self._selectedBackendId = newStatus
448 def load_settings(self, config):
453 self._defaultBackendId = config.getint(constants.__pretty_app_name__, "active")
455 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
456 for i in xrange(len(self._credentials))
459 base64.b64decode(blob)
462 self._credentials = tuple(creds)
464 if self._alarmHandler is not None:
465 self._alarmHandler.load_settings(config, "alarm")
467 previousOrientation = config.getint(constants.__pretty_app_name__, "orientation")
468 if previousOrientation == gtk.ORIENTATION_HORIZONTAL:
469 hildonize.window_to_landscape(self._window)
470 elif previousOrientation == gtk.ORIENTATION_VERTICAL:
471 hildonize.window_to_portrait(self._window)
472 except ConfigParser.NoOptionError, e:
474 "Settings file %s is missing section %s" % (
475 constants._user_settings_,
479 except ConfigParser.NoSectionError, e:
481 "Settings file %s is missing section %s" % (
482 constants._user_settings_,
487 for backendId, view in itertools.chain(
488 self._dialpads.iteritems(),
489 self._accountViews.iteritems(),
490 self._messagesViews.iteritems(),
491 self._recentViews.iteritems(),
492 self._contactsViews.iteritems(),
494 sectionName = "%s - %s" % (backendId, view.name())
496 view.load_settings(config, sectionName)
497 except ConfigParser.NoOptionError, e:
499 "Settings file %s is missing section %s" % (
500 constants._user_settings_,
504 except ConfigParser.NoSectionError, e:
506 "Settings file %s is missing section %s" % (
507 constants._user_settings_,
512 def save_settings(self, config):
514 @note Thread Agnostic
516 config.add_section(constants.__pretty_app_name__)
517 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
518 config.set(constants.__pretty_app_name__, "orientation", str(int(gtk_toolbox.get_screen_orientation())))
519 for i, value in enumerate(self._credentials):
520 blob = base64.b64encode(value)
521 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
522 config.add_section("alarm")
523 if self._alarmHandler is not None:
524 self._alarmHandler.save_settings(config, "alarm")
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 _save_settings(self):
539 @note Thread Agnostic
541 config = ConfigParser.SafeConfigParser()
542 self.save_settings(config)
543 with open(constants._user_settings_, "wb") as configFile:
544 config.write(configFile)
546 def _refresh_active_tab(self):
547 pageIndex = self._notebook.get_current_page()
548 if pageIndex == self.CONTACTS_TAB:
549 self._contactsViews[self._selectedBackendId].update(force=True)
550 elif pageIndex == self.RECENT_TAB:
551 self._recentViews[self._selectedBackendId].update(force=True)
552 elif pageIndex == self.MESSAGES_TAB:
553 self._messagesViews[self._selectedBackendId].update(force=True)
555 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
556 if self._ledHandler is not None:
557 self._ledHandler.off()
559 def _on_close(self, *args, **kwds):
561 if self._osso is not None:
565 self._save_settings()
569 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
571 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
572 For system_inactivity, we have no background tasks to pause
574 @note Hildon specific
578 for backendId in self.BACKENDS:
579 self._phoneBackends[backendId].clear_caches()
580 self._contactsViews[self._selectedBackendId].clear_caches()
583 if save_unsaved_data or shutdown:
584 self._save_settings()
586 self._errorDisplay.push_exception()
588 def _on_connection_change(self, connection, event, magicIdentifier):
590 @note Hildon specific
595 status = event.get_status()
596 error = event.get_error()
597 iap_id = event.get_iap_id()
598 bearer = event.get_bearer_type()
600 if status == conic.STATUS_CONNECTED:
602 self._spawn_attempt_login(2)
603 elif status == conic.STATUS_DISCONNECTED:
605 self._defaultBackendId = self._selectedBackendId
606 self._change_loggedin_status(self.NULL_BACKEND)
608 self._errorDisplay.push_exception()
610 def _on_window_state_change(self, widget, event, *args):
612 @note Hildon specific
615 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
616 self._isFullScreen = True
618 self._isFullScreen = False
620 self._errorDisplay.push_exception()
622 def _on_key_press(self, widget, event, *args):
624 @note Hildon specific
627 if event.keyval == gtk.keysyms.F6:
628 if self._isFullScreen:
629 self._window.unfullscreen()
631 self._window.fullscreen()
633 self._errorDisplay.push_exception()
635 def _on_clearcookies_clicked(self, *args):
637 self._phoneBackends[self._selectedBackendId].logout()
638 self._accountViews[self._selectedBackendId].clear()
639 self._recentViews[self._selectedBackendId].clear()
640 self._messagesViews[self._selectedBackendId].clear()
641 self._contactsViews[self._selectedBackendId].clear()
642 self._change_loggedin_status(self.NULL_BACKEND)
644 self._spawn_attempt_login(2, True)
646 self._errorDisplay.push_exception()
648 def _on_notebook_switch_page(self, notebook, page, pageIndex):
650 self._reset_tab_refresh()
652 didRecentUpdate = False
653 didMessagesUpdate = False
655 if pageIndex == self.RECENT_TAB:
656 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
657 elif pageIndex == self.MESSAGES_TAB:
658 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
659 elif pageIndex == self.CONTACTS_TAB:
660 self._contactsViews[self._selectedBackendId].update()
661 elif pageIndex == self.ACCOUNT_TAB:
662 self._accountViews[self._selectedBackendId].update()
664 if didRecentUpdate or didMessagesUpdate:
665 if self._ledHandler is not None:
666 self._ledHandler.off()
668 self._errorDisplay.push_exception()
670 def _set_tab_refresh(self, *args):
672 pageIndex = self._notebook.get_current_page()
673 child = self._notebook.get_nth_page(pageIndex)
674 self._notebook.get_tab_label(child).set_text("Refresh?")
676 self._errorDisplay.push_exception()
679 def _reset_tab_refresh(self, *args):
681 pageIndex = self._notebook.get_current_page()
682 child = self._notebook.get_nth_page(pageIndex)
683 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
685 self._errorDisplay.push_exception()
688 def _on_tab_refresh(self, *args):
690 self._refresh_active_tab()
691 self._reset_tab_refresh()
693 self._errorDisplay.push_exception()
696 def _on_sms_clicked(self, number, message):
698 assert number, "No number specified"
699 assert message, "Empty message"
701 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
704 self._errorDisplay.push_exception()
708 self._errorDisplay.push_message(
709 "Backend link with grandcentral is not working, please try again"
715 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
718 self._errorDisplay.push_exception()
721 self._dialpads[self._selectedBackendId].clear()
723 self._errorDisplay.push_exception()
725 def _on_dial_clicked(self, number):
727 assert number, "No number to call"
729 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
732 self._errorDisplay.push_exception()
736 self._errorDisplay.push_message(
737 "Backend link with grandcentral is not working, please try again"
743 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
744 self._phoneBackends[self._selectedBackendId].dial(number)
747 self._errorDisplay.push_exception()
750 self._dialpads[self._selectedBackendId].clear()
752 self._errorDisplay.push_exception()
754 def _on_menu_refresh(self, *args):
756 self._refresh_active_tab()
758 self._errorDisplay.push_exception()
760 def _on_menu_rotate(self, *args):
762 orientation = gtk_toolbox.get_screen_orientation()
763 if orientation == gtk.ORIENTATION_HORIZONTAL:
764 hildonize.window_to_portrait(self._window)
765 elif orientation == gtk.ORIENTATION_VERTICAL:
766 hildonize.window_to_landscape(self._window)
768 self._errorDisplay.push_exception()
770 def _on_paste(self, *args):
772 contents = self._clipboard.wait_for_text()
773 self._dialpads[self._selectedBackendId].set_number(contents)
775 self._errorDisplay.push_exception()
777 def _on_about_activate(self, *args):
779 dlg = gtk.AboutDialog()
780 dlg.set_name(constants.__pretty_app_name__)
781 dlg.set_version("%s-%d" % (constants.__version__, constants.__build__))
782 dlg.set_copyright("Copyright 2008 - LGPL")
783 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")
784 dlg.set_website("http://gc-dialer.garage.maemo.org/")
785 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
789 self._errorDisplay.push_exception()
795 failureCount, testCount = doctest.testmod()
797 print "Tests Successful"
804 _lock_file = os.path.join(constants._data_path_, ".lock")
805 #with gtk_toolbox.flock(_lock_file, 0):
806 gtk.gdk.threads_init()
808 if hildonize.IS_HILDON:
809 gtk.set_application_name(constants.__pretty_app_name__)
810 handle = Dialcentral()
814 class DummyOptions(object):
820 if __name__ == "__main__":
821 logging.basicConfig(level=logging.DEBUG)
823 if len(sys.argv) > 1:
829 if optparse is not None:
830 parser = optparse.OptionParser()
831 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
832 (commandOptions, commandArgs) = parser.parse_args()
834 commandOptions = DummyOptions()
837 if commandOptions.test: