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:
211 self._ledHandler = led_handler.LedHandler()
213 logging.exception('LED Handling failed: "%s"' % str(e))
214 self._ledHandler = None
216 self._ledHandler = None
220 except (ImportError, OSError):
222 self._connection = None
223 if conic is not None:
224 self._connection = conic.Connection()
225 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
226 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
228 logging.warning("No connection support")
230 with gtk_toolbox.gtk_lock():
231 self._errorDisplay.push_exception()
233 # Setup costly backends
240 os.makedirs(constants._data_path_)
244 gvCookiePath = os.path.join(constants._data_path_, "gv_cookies.txt")
246 self._phoneBackends.update({
247 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
249 with gtk_toolbox.gtk_lock():
250 unifiedDialpad = gv_views.Dialpad(self._widgetTree, self._errorDisplay)
251 self._dialpads.update({
252 self.GV_BACKEND: unifiedDialpad,
254 self._accountViews.update({
255 self.GV_BACKEND: gv_views.AccountInfo(
256 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._alarmHandler, self._errorDisplay
259 self._accountViews[self.GV_BACKEND].save_everything = self._save_settings
260 self._recentViews.update({
261 self.GV_BACKEND: gv_views.RecentCallsView(
262 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
265 self._messagesViews.update({
266 self.GV_BACKEND: gv_views.MessagesView(
267 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
270 self._contactsViews.update({
271 self.GV_BACKEND: gv_views.ContactsView(
272 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
276 fsContactsPath = os.path.join(constants._data_path_, "contacts")
277 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
279 self._dialpads[self.GV_BACKEND].number_selected = self._select_action
280 self._recentViews[self.GV_BACKEND].number_selected = self._select_action
281 self._messagesViews[self.GV_BACKEND].number_selected = self._select_action
282 self._contactsViews[self.GV_BACKEND].number_selected = self._select_action
285 self._phoneBackends[self.GV_BACKEND],
288 mergedBook = gv_views.MergedAddressBook(addressBooks, gv_views.MergedAddressBook.advanced_lastname_sorter)
289 self._contactsViews[self.GV_BACKEND].append(mergedBook)
290 self._contactsViews[self.GV_BACKEND].extend(addressBooks)
291 self._contactsViews[self.GV_BACKEND].open_addressbook(*self._contactsViews[self.GV_BACKEND].get_addressbooks().next()[0][0:2])
294 "on_paste": self._on_paste,
295 "on_refresh": self._on_menu_refresh,
296 "on_rotate": self._on_menu_rotate,
297 "on_clearcookies_clicked": self._on_clearcookies_clicked,
298 "on_notebook_switch_page": self._on_notebook_switch_page,
299 "on_about_activate": self._on_about_activate,
301 self._widgetTree.signal_autoconnect(callbackMapping)
303 with gtk_toolbox.gtk_lock():
304 self._originalCurrentLabels = [
305 self._notebook.get_tab_label(self._notebook.get_nth_page(pageIndex)).get_text()
306 for pageIndex in xrange(self._notebook.get_n_pages())
308 self._notebookTapHandler = gtk_toolbox.TapOrHold(self._notebook)
309 self._notebookTapHandler.enable()
310 self._notebookTapHandler.on_tap = self._reset_tab_refresh
311 self._notebookTapHandler.on_hold = self._on_tab_refresh
312 self._notebookTapHandler.on_holding = self._set_tab_refresh
313 self._notebookTapHandler.on_cancel = self._reset_tab_refresh
315 self._initDone = True
317 config = ConfigParser.SafeConfigParser()
318 config.read(constants._user_settings_)
319 with gtk_toolbox.gtk_lock():
320 self.load_settings(config)
322 with gtk_toolbox.gtk_lock():
323 self._errorDisplay.push_exception()
325 self._spawn_attempt_login(2)
327 def attempt_login(self, numOfAttempts = 10, force = False):
329 @todo Handle user notification better like attempting to login and failed login
331 @note This must be run outside of the UI lock
334 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
335 assert self._initDone, "Attempting login before app is fully loaded"
337 serviceId = self.NULL_BACKEND
341 self.refresh_session()
342 serviceId = self._defaultBackendId
345 logging.exception('Session refresh failed with the following message "%s"' % str(e))
348 loggedIn, serviceId = self._login_by_user(numOfAttempts)
350 with gtk_toolbox.gtk_lock():
351 self._change_loggedin_status(serviceId)
353 with gtk_toolbox.gtk_lock():
354 self._errorDisplay.push_exception()
356 def _spawn_attempt_login(self, *args):
357 self._loginSink.send(args)
359 def refresh_session(self):
361 @note Thread agnostic
363 assert self._initDone, "Attempting login before app is fully loaded"
367 loggedIn = self._login_by_cookie()
369 loggedIn = self._login_by_settings()
372 raise RuntimeError("Login Failed")
374 def _login_by_cookie(self):
376 @note Thread agnostic
378 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
380 logging.info("Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId])
383 def _login_by_settings(self):
385 @note Thread agnostic
387 username, password = self._credentials
388 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
390 self._credentials = username, password
391 logging.info("Logged into %r through settings" % self._phoneBackends[self._defaultBackendId])
394 def _login_by_user(self, numOfAttempts):
396 @note This must be run outside of the UI lock
398 loggedIn, (username, password) = False, self._credentials
399 tmpServiceId = self.GV_BACKEND
400 for attemptCount in xrange(numOfAttempts):
403 with gtk_toolbox.gtk_lock():
404 credentials = self._credentialsDialog.request_credentials(
405 defaultCredentials = self._credentials
407 username, password = credentials
408 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
411 serviceId = tmpServiceId
412 self._credentials = username, password
413 logging.info("Logged into %r through user request" % self._phoneBackends[serviceId])
415 serviceId = self.NULL_BACKEND
417 return loggedIn, serviceId
419 def _select_action(self, action, number, message):
420 self.refresh_session()
421 if action == "select":
422 self._dialpads[self._selectedBackendId].set_number(number)
423 self._notebook.set_current_page(self.KEYPAD_TAB)
424 elif action == "dial":
425 self._on_dial_clicked(number)
426 elif action == "sms":
427 self._on_sms_clicked(number, message)
429 assert False, "Unknown action: %s" % action
431 def _change_loggedin_status(self, newStatus):
432 oldStatus = self._selectedBackendId
433 if oldStatus == newStatus:
436 self._dialpads[oldStatus].disable()
437 self._accountViews[oldStatus].disable()
438 self._recentViews[oldStatus].disable()
439 self._messagesViews[oldStatus].disable()
440 self._contactsViews[oldStatus].disable()
442 self._dialpads[newStatus].enable()
443 self._accountViews[newStatus].enable()
444 self._recentViews[newStatus].enable()
445 self._messagesViews[newStatus].enable()
446 self._contactsViews[newStatus].enable()
448 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
449 self._phoneBackends[self._selectedBackendId].set_sane_callback()
450 self._accountViews[self._selectedBackendId].update()
452 self._selectedBackendId = newStatus
454 def load_settings(self, config):
459 self._defaultBackendId = config.getint(constants.__pretty_app_name__, "active")
461 config.get(constants.__pretty_app_name__, "bin_blob_%i" % i)
462 for i in xrange(len(self._credentials))
465 base64.b64decode(blob)
468 self._credentials = tuple(creds)
470 if self._alarmHandler is not None:
471 self._alarmHandler.load_settings(config, "alarm")
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 # @todo down here till this issue is fixed
514 previousOrientation = config.getint(constants.__pretty_app_name__, "orientation")
515 if previousOrientation == gtk.ORIENTATION_HORIZONTAL:
516 hildonize.window_to_landscape(self._window)
517 elif previousOrientation == gtk.ORIENTATION_VERTICAL:
518 hildonize.window_to_portrait(self._window)
519 except ConfigParser.NoOptionError, e:
521 "Settings file %s is missing section %s" % (
522 constants._user_settings_,
526 except ConfigParser.NoSectionError, e:
528 "Settings file %s is missing section %s" % (
529 constants._user_settings_,
534 def save_settings(self, config):
536 @note Thread Agnostic
538 config.add_section(constants.__pretty_app_name__)
539 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
540 config.set(constants.__pretty_app_name__, "orientation", str(int(gtk_toolbox.get_screen_orientation())))
541 for i, value in enumerate(self._credentials):
542 blob = base64.b64encode(value)
543 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
544 config.add_section("alarm")
545 if self._alarmHandler is not None:
546 self._alarmHandler.save_settings(config, "alarm")
548 for backendId, view in itertools.chain(
549 self._dialpads.iteritems(),
550 self._accountViews.iteritems(),
551 self._messagesViews.iteritems(),
552 self._recentViews.iteritems(),
553 self._contactsViews.iteritems(),
555 sectionName = "%s - %s" % (backendId, view.name())
556 config.add_section(sectionName)
557 view.save_settings(config, sectionName)
559 def _save_settings(self):
561 @note Thread Agnostic
563 config = ConfigParser.SafeConfigParser()
564 self.save_settings(config)
565 with open(constants._user_settings_, "wb") as configFile:
566 config.write(configFile)
568 def _refresh_active_tab(self):
569 pageIndex = self._notebook.get_current_page()
570 if pageIndex == self.CONTACTS_TAB:
571 self._contactsViews[self._selectedBackendId].update(force=True)
572 elif pageIndex == self.RECENT_TAB:
573 self._recentViews[self._selectedBackendId].update(force=True)
574 elif pageIndex == self.MESSAGES_TAB:
575 self._messagesViews[self._selectedBackendId].update(force=True)
577 if pageIndex in (self.RECENT_TAB, self.MESSAGES_TAB):
578 if self._ledHandler is not None:
579 self._ledHandler.off()
581 def _on_close(self, *args, **kwds):
583 if self._osso is not None:
587 self._save_settings()
591 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
593 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
594 For system_inactivity, we have no background tasks to pause
596 @note Hildon specific
600 for backendId in self.BACKENDS:
601 self._phoneBackends[backendId].clear_caches()
602 self._contactsViews[self._selectedBackendId].clear_caches()
605 if save_unsaved_data or shutdown:
606 self._save_settings()
608 self._errorDisplay.push_exception()
610 def _on_connection_change(self, connection, event, magicIdentifier):
612 @note Hildon specific
617 status = event.get_status()
618 error = event.get_error()
619 iap_id = event.get_iap_id()
620 bearer = event.get_bearer_type()
622 if status == conic.STATUS_CONNECTED:
624 self._spawn_attempt_login(2)
625 elif status == conic.STATUS_DISCONNECTED:
627 self._defaultBackendId = self._selectedBackendId
628 self._change_loggedin_status(self.NULL_BACKEND)
630 self._errorDisplay.push_exception()
632 def _on_window_state_change(self, widget, event, *args):
634 @note Hildon specific
637 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
638 self._isFullScreen = True
640 self._isFullScreen = False
642 self._errorDisplay.push_exception()
644 def _on_key_press(self, widget, event, *args):
646 @note Hildon specific
649 if event.keyval == gtk.keysyms.F6:
650 if self._isFullScreen:
651 self._window.unfullscreen()
653 self._window.fullscreen()
655 self._errorDisplay.push_exception()
657 def _on_clearcookies_clicked(self, *args):
659 self._phoneBackends[self._selectedBackendId].logout()
660 self._accountViews[self._selectedBackendId].clear()
661 self._recentViews[self._selectedBackendId].clear()
662 self._messagesViews[self._selectedBackendId].clear()
663 self._contactsViews[self._selectedBackendId].clear()
664 self._change_loggedin_status(self.NULL_BACKEND)
666 self._spawn_attempt_login(2, True)
668 self._errorDisplay.push_exception()
670 def _on_notebook_switch_page(self, notebook, page, pageIndex):
672 self._reset_tab_refresh()
674 didRecentUpdate = False
675 didMessagesUpdate = False
677 if pageIndex == self.RECENT_TAB:
678 didRecentUpdate = self._recentViews[self._selectedBackendId].update()
679 elif pageIndex == self.MESSAGES_TAB:
680 didMessagesUpdate = self._messagesViews[self._selectedBackendId].update()
681 elif pageIndex == self.CONTACTS_TAB:
682 self._contactsViews[self._selectedBackendId].update()
683 elif pageIndex == self.ACCOUNT_TAB:
684 self._accountViews[self._selectedBackendId].update()
686 if didRecentUpdate or didMessagesUpdate:
687 if self._ledHandler is not None:
688 self._ledHandler.off()
690 self._errorDisplay.push_exception()
692 def _set_tab_refresh(self, *args):
694 pageIndex = self._notebook.get_current_page()
695 child = self._notebook.get_nth_page(pageIndex)
696 self._notebook.get_tab_label(child).set_text("Refresh?")
698 self._errorDisplay.push_exception()
701 def _reset_tab_refresh(self, *args):
703 pageIndex = self._notebook.get_current_page()
704 child = self._notebook.get_nth_page(pageIndex)
705 self._notebook.get_tab_label(child).set_text(self._originalCurrentLabels[pageIndex])
707 self._errorDisplay.push_exception()
710 def _on_tab_refresh(self, *args):
712 self._refresh_active_tab()
713 self._reset_tab_refresh()
715 self._errorDisplay.push_exception()
718 def _on_sms_clicked(self, number, message):
720 assert number, "No number specified"
721 assert message, "Empty message"
723 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
726 self._errorDisplay.push_exception()
730 self._errorDisplay.push_message(
731 "Backend link with grandcentral is not working, please try again"
737 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
740 self._errorDisplay.push_exception()
743 self._dialpads[self._selectedBackendId].clear()
745 self._errorDisplay.push_exception()
747 def _on_dial_clicked(self, number):
749 assert number, "No number to call"
751 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
754 self._errorDisplay.push_exception()
758 self._errorDisplay.push_message(
759 "Backend link with grandcentral is not working, please try again"
765 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != "", "No callback number specified"
766 self._phoneBackends[self._selectedBackendId].dial(number)
769 self._errorDisplay.push_exception()
772 self._dialpads[self._selectedBackendId].clear()
774 self._errorDisplay.push_exception()
776 def _on_menu_refresh(self, *args):
778 self._refresh_active_tab()
780 self._errorDisplay.push_exception()
782 def _on_menu_rotate(self, *args):
784 orientation = gtk_toolbox.get_screen_orientation()
785 if orientation == gtk.ORIENTATION_HORIZONTAL:
786 hildonize.window_to_portrait(self._window)
787 elif orientation == gtk.ORIENTATION_VERTICAL:
788 hildonize.window_to_landscape(self._window)
790 self._errorDisplay.push_exception()
792 def _on_paste(self, *args):
794 contents = self._clipboard.wait_for_text()
795 self._dialpads[self._selectedBackendId].set_number(contents)
797 self._errorDisplay.push_exception()
799 def _on_about_activate(self, *args):
801 dlg = gtk.AboutDialog()
802 dlg.set_name(constants.__pretty_app_name__)
803 dlg.set_version("%s-%d" % (constants.__version__, constants.__build__))
804 dlg.set_copyright("Copyright 2008 - LGPL")
805 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")
806 dlg.set_website("http://gc-dialer.garage.maemo.org/")
807 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
811 self._errorDisplay.push_exception()
817 failureCount, testCount = doctest.testmod()
819 print "Tests Successful"
826 _lock_file = os.path.join(constants._data_path_, ".lock")
827 #with gtk_toolbox.flock(_lock_file, 0):
828 gtk.gdk.threads_init()
830 if hildonize.IS_HILDON:
831 gtk.set_application_name(constants.__pretty_app_name__)
832 handle = Dialcentral()
836 class DummyOptions(object):
842 if __name__ == "__main__":
843 logging.basicConfig(level=logging.DEBUG)
845 if len(sys.argv) > 1:
851 if optparse is not None:
852 parser = optparse.OptionParser()
853 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
854 (commandOptions, commandArgs) = parser.parse_args()
856 commandOptions = DummyOptions()
859 if commandOptions.test: