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
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):
72 __pretty_app_name__ = "DialCentral"
73 __app_name__ = "dialcentral"
75 __app_magic__ = 0xdeadbeef
78 '/usr/lib/dialcentral/dialcentral.glade',
79 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
80 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
92 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
94 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
95 _user_settings = "%s/settings.ini" % _data_path
98 self._initDone = False
99 self._connection = None
101 self._clipboard = gtk.clipboard_get()
103 self._deviceIsOnline = True
104 self._credentials = ("", "")
105 self._selectedBackendId = self.NULL_BACKEND
106 self._defaultBackendId = self.GC_BACKEND
107 self._phoneBackends = None
108 self._dialpads = None
109 self._accountViews = None
110 self._messagesViews = None
111 self._recentViews = None
112 self._contactsViews = None
114 for path in self._glade_files:
115 if os.path.isfile(path):
116 self._widgetTree = gtk.glade.XML(path)
119 display_error_message("Cannot find dialcentral.glade")
123 self._window = self._widgetTree.get_widget("mainWindow")
124 self._notebook = self._widgetTree.get_widget("notebook")
125 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
126 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
129 self._isFullScreen = False
130 if hildon is not None:
131 self._app = hildon.Program()
132 oldWindow = self._window
133 self._window = hildon.Window()
134 oldWindow.get_child().reparent(self._window)
135 self._app.add_window(self._window)
136 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
137 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
138 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
139 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
140 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
141 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
143 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
145 for child in gtkMenu.get_children():
147 self._window.set_menu(menu)
150 self._window.connect("key-press-event", self._on_key_press)
151 self._window.connect("window-state-event", self._on_window_state_change)
153 pass # warnings.warn("No Hildon", UserWarning, 2)
155 if hildon is not None:
156 self._window.set_title("Keypad")
158 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
161 "on_dialpad_quit": self._on_close,
163 self._widgetTree.signal_autoconnect(callbackMapping)
166 self._window.connect("destroy", self._on_close)
167 self._window.show_all()
168 self._window.set_default_size(800, 300)
170 backgroundSetup = threading.Thread(target=self._idle_setup)
171 backgroundSetup.setDaemon(True)
172 backgroundSetup.start()
174 def _idle_setup(self):
176 If something can be done after the UI loads, push it here so it's not blocking the UI
178 # Barebones UI handlers
182 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
183 with gtk_toolbox.gtk_lock():
184 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
185 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
186 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
187 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
188 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
190 self._dialpads[self._selectedBackendId].enable()
191 self._accountViews[self._selectedBackendId].enable()
192 self._recentViews[self._selectedBackendId].enable()
193 self._messagesViews[self._selectedBackendId].enable()
194 self._contactsViews[self._selectedBackendId].enable()
196 # Setup maemo specifics
203 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
204 device = osso.DeviceState(self._osso)
205 device.set_device_state_callback(self._on_device_state_change, 0)
207 pass # warnings.warn("No OSSO", UserWarning)
209 # Setup maemo specifics
214 self._connection = None
215 if conic is not None:
216 self._connection = conic.Connection()
217 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
218 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
220 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
222 # Setup costly backends
230 os.makedirs(self._data_path)
234 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
235 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
236 self._defaultBackendId = self._guess_preferred_backend((
237 (self.GC_BACKEND, gcCookiePath),
238 (self.GV_BACKEND, gvCookiePath),
241 self._phoneBackends.update({
242 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
243 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
245 with gtk_toolbox.gtk_lock():
246 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
247 unifiedDialpad.set_number("")
248 self._dialpads.update({
249 self.GC_BACKEND: unifiedDialpad,
250 self.GV_BACKEND: unifiedDialpad,
252 self._accountViews.update({
253 self.GC_BACKEND: gc_views.AccountInfo(
254 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
256 self.GV_BACKEND: gc_views.AccountInfo(
257 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
260 self._recentViews.update({
261 self.GC_BACKEND: gc_views.RecentCallsView(
262 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
264 self.GV_BACKEND: gc_views.RecentCallsView(
265 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
268 self._messagesViews.update({
269 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
270 self.GV_BACKEND: gc_views.MessagesView(
271 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
274 self._contactsViews.update({
275 self.GC_BACKEND: gc_views.ContactsView(
276 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
278 self.GV_BACKEND: gc_views.ContactsView(
279 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
283 evoBackend = evo_backend.EvolutionAddressBook()
284 fsContactsPath = os.path.join(self._data_path, "contacts")
285 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
286 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
287 self._dialpads[backendId].dial = self._on_dial_clicked
288 self._recentViews[backendId].number_selected = self._on_number_selected
289 self._messagesViews[backendId].number_selected = self._on_number_selected
290 self._contactsViews[backendId].number_selected = self._on_number_selected
293 self._phoneBackends[backendId],
297 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
298 self._contactsViews[backendId].append(mergedBook)
299 self._contactsViews[backendId].extend(addressBooks)
300 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
303 "on_paste": self._on_paste,
304 "on_refresh": self._on_refresh,
305 "on_clearcookies_clicked": self._on_clearcookies_clicked,
306 "on_notebook_switch_page": self._on_notebook_switch_page,
307 "on_about_activate": self._on_about_activate,
309 self._widgetTree.signal_autoconnect(callbackMapping)
311 self._initDone = True
313 config = ConfigParser.SafeConfigParser()
314 config.read(self._user_settings)
315 with gtk_toolbox.gtk_lock():
316 self.load_settings(config)
318 self.attempt_login(2)
322 def attempt_login(self, numOfAttempts = 10):
324 @todo Handle user notification better like attempting to login and failed login
326 @note Not meant to be called directly, but run as a seperate thread.
328 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
330 if not self._deviceIsOnline:
331 warnings.warn("Attempted to login while device was offline")
333 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
335 "Attempted to login before initialization is complete, did an event fire early?"
341 username, password = self._credentials
342 serviceId = self._defaultBackendId
344 # Attempt using the cookies
345 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
348 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
352 # Attempt using the settings file
353 if not loggedIn and username and password:
354 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
357 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
361 # Query the user for credentials
362 for attemptCount in xrange(numOfAttempts):
365 with gtk_toolbox.gtk_lock():
366 availableServices = {
367 self.GV_BACKEND: "Google Voice",
368 self.GC_BACKEND: "Grand Central",
370 credentials = self._credentialsDialog.request_credentials_from(
371 availableServices, defaultCredentials = self._credentials
373 serviceId, username, password = credentials
375 loggedIn = self._phoneBackends[serviceId].login(username, password)
378 "Logged into %r through user request" % self._phoneBackends[serviceId],
381 except RuntimeError, e:
382 warnings.warn(traceback.format_exc())
383 self._errorDisplay.push_exception_with_lock(e)
385 with gtk_toolbox.gtk_lock():
387 self._credentials = username, password
388 self._change_loggedin_status(serviceId)
390 self._errorDisplay.push_message("Login Failed")
391 self._change_loggedin_status(self.NULL_BACKEND)
394 def _on_close(self, *args, **kwds):
396 if self._osso is not None:
400 self._save_settings()
404 def _change_loggedin_status(self, newStatus):
405 oldStatus = self._selectedBackendId
406 if oldStatus == newStatus:
409 self._dialpads[oldStatus].disable()
410 self._accountViews[oldStatus].disable()
411 self._recentViews[oldStatus].disable()
412 self._messagesViews[oldStatus].disable()
413 self._contactsViews[oldStatus].disable()
415 self._dialpads[newStatus].enable()
416 self._accountViews[newStatus].enable()
417 self._recentViews[newStatus].enable()
418 self._messagesViews[newStatus].enable()
419 self._contactsViews[newStatus].enable()
421 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
422 self._phoneBackends[self._selectedBackendId].set_sane_callback()
423 self._accountViews[self._selectedBackendId].update()
425 self._selectedBackendId = newStatus
427 def load_settings(self, config):
431 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
434 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
435 for i in xrange(len(self._credentials))
438 base64.b64decode(blob)
441 self._credentials = tuple(creds)
442 except ConfigParser.NoSectionError, e:
444 "Settings file %s is missing section %s" % (
451 for backendId, view in itertools.chain(
452 self._dialpads.iteritems(),
453 self._accountViews.iteritems(),
454 self._messagesViews.iteritems(),
455 self._recentViews.iteritems(),
456 self._contactsViews.iteritems(),
458 sectionName = "%s - %s" % (backendId, view.name())
460 view.load_settings(config, sectionName)
461 except ConfigParser.NoSectionError, e:
463 "Settings file %s is missing section %s" % (
470 def save_settings(self, config):
472 @note Thread Agnostic
474 config.add_section(self.__pretty_app_name__)
475 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
476 for i, value in enumerate(self._credentials):
477 blob = base64.b64encode(value)
478 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
479 for backendId, view in itertools.chain(
480 self._dialpads.iteritems(),
481 self._accountViews.iteritems(),
482 self._messagesViews.iteritems(),
483 self._recentViews.iteritems(),
484 self._contactsViews.iteritems(),
486 sectionName = "%s - %s" % (backendId, view.name())
487 config.add_section(sectionName)
488 view.save_settings(config, sectionName)
490 def _guess_preferred_backend(self, backendAndCookiePaths):
492 (getmtime_nothrow(path), backendId, path)
493 for backendId, path in backendAndCookiePaths
495 modTimeAndPath.sort()
496 return modTimeAndPath[-1][1]
498 def _save_settings(self):
500 @note Thread Agnostic
502 config = ConfigParser.SafeConfigParser()
503 self.save_settings(config)
504 with open(self._user_settings, "wb") as configFile:
505 config.write(configFile)
507 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
509 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
510 For system_inactivity, we have no background tasks to pause
512 @note Hildon specific
515 for backendId in self.BACKENDS:
516 self._phoneBackends[backendId].clear_caches()
517 self._contactsViews[self._selectedBackendId].clear_caches()
520 if save_unsaved_data or shutdown:
521 self._save_settings()
523 def _on_connection_change(self, connection, event, magicIdentifier):
525 @note Hildon specific
529 status = event.get_status()
530 error = event.get_error()
531 iap_id = event.get_iap_id()
532 bearer = event.get_bearer_type()
534 if status == conic.STATUS_CONNECTED:
535 self._deviceIsOnline = True
537 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
538 backgroundLogin.setDaemon(True)
539 backgroundLogin.start()
540 elif status == conic.STATUS_DISCONNECTED:
541 self._deviceIsOnline = False
543 self._defaultBackendId = self._selectedBackendId
544 self._change_loggedin_status(self.NULL_BACKEND)
546 def _on_window_state_change(self, widget, event, *args):
548 @note Hildon specific
550 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
551 self._isFullScreen = True
553 self._isFullScreen = False
555 def _on_key_press(self, widget, event, *args):
557 @note Hildon specific
559 if event.keyval == gtk.keysyms.F6:
560 if self._isFullScreen:
561 self._window.unfullscreen()
563 self._window.fullscreen()
565 def _on_clearcookies_clicked(self, *args):
566 self._phoneBackends[self._selectedBackendId].logout()
567 self._accountViews[self._selectedBackendId].clear()
568 self._recentViews[self._selectedBackendId].clear()
569 self._messagesViews[self._selectedBackendId].clear()
570 self._contactsViews[self._selectedBackendId].clear()
571 self._change_loggedin_status(self.NULL_BACKEND)
573 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
574 backgroundLogin.setDaemon(True)
575 backgroundLogin.start()
577 def _on_notebook_switch_page(self, notebook, page, page_num):
578 if page_num == self.RECENT_TAB:
579 self._recentViews[self._selectedBackendId].update()
580 elif page_num == self.MESSAGES_TAB:
581 self._messagesViews[self._selectedBackendId].update()
582 elif page_num == self.CONTACTS_TAB:
583 self._contactsViews[self._selectedBackendId].update()
584 elif page_num == self.ACCOUNT_TAB:
585 self._accountViews[self._selectedBackendId].update()
587 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
588 if hildon is not None:
589 self._window.set_title(tabTitle)
591 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
593 def _on_number_selected(self, action, number, message):
594 if action == "select":
595 self._dialpads[self._selectedBackendId].set_number(number)
596 self._notebook.set_current_page(self.KEYPAD_TAB)
597 elif action == "dial":
598 self._on_dial_clicked(number)
599 elif action == "sms":
600 self._on_sms_clicked(number, message)
602 assert False, "Unknown action: %s" % action
604 def _on_sms_clicked(self, number, message):
606 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
611 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
612 except RuntimeError, e:
614 self._errorDisplay.push_exception(e)
618 self._errorDisplay.push_message(
619 "Backend link with grandcentral is not working, please try again"
625 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
627 except RuntimeError, e:
628 self._errorDisplay.push_exception(e)
629 except ValueError, e:
630 self._errorDisplay.push_exception(e)
632 def _on_dial_clicked(self, number):
634 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
638 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
639 except RuntimeError, e:
641 self._errorDisplay.push_exception(e)
645 self._errorDisplay.push_message(
646 "Backend link with grandcentral is not working, please try again"
652 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
653 self._phoneBackends[self._selectedBackendId].dial(number)
655 except RuntimeError, e:
656 self._errorDisplay.push_exception(e)
657 except ValueError, e:
658 self._errorDisplay.push_exception(e)
661 self._dialpads[self._selectedBackendId].clear()
663 def _on_refresh(self, *args):
664 page_num = self._notebook.get_current_page()
665 if page_num == self.CONTACTS_TAB:
666 self._contactsViews[self._selectedBackendId].update(force=True)
667 elif page_num == self.RECENT_TAB:
668 self._recentViews[self._selectedBackendId].update(force=True)
669 elif page_num == self.MESSAGES_TAB:
670 self._messagesViews[self._selectedBackendId].update(force=True)
672 def _on_paste(self, *args):
673 contents = self._clipboard.wait_for_text()
674 self._dialpads[self._selectedBackendId].set_number(contents)
676 def _on_about_activate(self, *args):
677 dlg = gtk.AboutDialog()
678 dlg.set_name(self.__pretty_app_name__)
679 dlg.set_version(self.__version__)
680 dlg.set_copyright("Copyright 2008 - LGPL")
681 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")
682 dlg.set_website("http://gc-dialer.garage.maemo.org/")
683 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
691 failureCount, testCount = doctest.testmod()
693 print "Tests Successful"
700 gtk.gdk.threads_init()
701 if hildon is not None:
702 gtk.set_application_name(Dialcentral.__pretty_app_name__)
703 handle = Dialcentral()
707 class DummyOptions(object):
713 if __name__ == "__main__":
714 if len(sys.argv) > 1:
720 if optparse is not None:
721 parser = optparse.OptionParser()
722 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
723 (commandOptions, commandArgs) = parser.parse_args()
725 commandOptions = DummyOptions()
728 if commandOptions.test: