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 Completely broken on Maemo
22 @todo For every X minutes, if logged in, attempt login
23 @todo Force login on connect if not already done
24 @todo Add logging support to make debugging issues for people a lot easier
28 from __future__ import with_statement
51 def getmtime_nothrow(path):
53 return os.path.getmtime(path)
58 def display_error_message(msg):
59 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
61 def close(dialog, response):
63 error_dialog.connect("response", close)
67 class Dialcentral(object):
69 __pretty_app_name__ = "DialCentral"
70 __app_name__ = "dialcentral"
72 __app_magic__ = 0xdeadbeef
75 '/usr/lib/dialcentral/dialcentral.glade',
76 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
77 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
89 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
91 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
92 _user_settings = "%s/settings.ini" % _data_path
95 self._initDone = False
96 self._connection = None
98 self._clipboard = gtk.clipboard_get()
100 self._deviceIsOnline = True
101 self._credentials = ("", "")
102 self._selectedBackendId = self.NULL_BACKEND
103 self._defaultBackendId = self.GC_BACKEND
104 self._phoneBackends = None
105 self._dialpads = None
106 self._accountViews = None
107 self._messagesViews = None
108 self._recentViews = None
109 self._contactsViews = None
111 for path in self._glade_files:
112 if os.path.isfile(path):
113 self._widgetTree = gtk.glade.XML(path)
116 display_error_message("Cannot find dialcentral.glade")
120 self._window = self._widgetTree.get_widget("mainWindow")
121 self._notebook = self._widgetTree.get_widget("notebook")
122 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
123 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
126 self._isFullScreen = False
127 if hildon is not None:
128 self._app = hildon.Program()
129 self._window = hildon.Window()
130 self._widgetTree.get_widget("vbox1").reparent(self._window)
131 self._app.add_window(self._window)
132 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
133 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
134 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
135 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
136 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
138 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
140 for child in gtkMenu.get_children():
142 self._window.set_menu(menu)
145 self._window.connect("key-press-event", self._on_key_press)
146 self._window.connect("window-state-event", self._on_window_state_change)
148 pass # warnings.warn("No Hildon", UserWarning, 2)
150 if hildon is not None:
151 self._window.set_title("Keypad")
153 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
156 "on_dialpad_quit": self._on_close,
158 self._widgetTree.signal_autoconnect(callbackMapping)
161 self._window.connect("destroy", self._on_close)
162 self._window.show_all()
163 self._window.set_default_size(800, 300)
165 backgroundSetup = threading.Thread(target=self._idle_setup)
166 backgroundSetup.setDaemon(True)
167 backgroundSetup.start()
169 def _idle_setup(self):
171 If something can be done after the UI loads, push it here so it's not blocking the UI
173 # Barebones UI handlers
177 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
178 with gtk_toolbox.gtk_lock():
179 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
180 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
181 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
182 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
183 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
185 self._dialpads[self._selectedBackendId].enable()
186 self._accountViews[self._selectedBackendId].enable()
187 self._recentViews[self._selectedBackendId].enable()
188 self._messagesViews[self._selectedBackendId].enable()
189 self._contactsViews[self._selectedBackendId].enable()
191 # Setup maemo specifics
198 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
199 device = osso.DeviceState(self._osso)
200 device.set_device_state_callback(self._on_device_state_change, 0)
202 pass # warnings.warn("No OSSO", UserWarning)
208 self._connection = None
209 if conic is not None:
210 self._connection = conic.Connection()
211 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
212 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
214 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
216 # Setup costly backends
224 os.makedirs(self._data_path)
228 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
229 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
230 self._defaultBackendId = self._guess_preferred_backend((
231 (self.GC_BACKEND, gcCookiePath),
232 (self.GV_BACKEND, gvCookiePath),
235 self._phoneBackends.update({
236 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
237 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
239 with gtk_toolbox.gtk_lock():
240 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
241 unifiedDialpad.set_number("")
242 self._dialpads.update({
243 self.GC_BACKEND: unifiedDialpad,
244 self.GV_BACKEND: unifiedDialpad,
246 self._accountViews.update({
247 self.GC_BACKEND: gc_views.AccountInfo(
248 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
250 self.GV_BACKEND: gc_views.AccountInfo(
251 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
254 self._recentViews.update({
255 self.GC_BACKEND: gc_views.RecentCallsView(
256 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
258 self.GV_BACKEND: gc_views.RecentCallsView(
259 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
262 self._messagesViews.update({
263 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
264 self.GV_BACKEND: gc_views.MessagesView(
265 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
268 self._contactsViews.update({
269 self.GC_BACKEND: gc_views.ContactsView(
270 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
272 self.GV_BACKEND: gc_views.ContactsView(
273 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
277 evoBackend = evo_backend.EvolutionAddressBook()
278 fsContactsPath = os.path.join(self._data_path, "contacts")
279 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
280 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
281 self._dialpads[backendId].dial = self._on_dial_clicked
282 self._recentViews[backendId].number_selected = self._on_number_selected
283 self._contactsViews[backendId].number_selected = self._on_number_selected
286 self._phoneBackends[backendId],
290 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
291 self._contactsViews[backendId].append(mergedBook)
292 self._contactsViews[backendId].extend(addressBooks)
293 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
296 "on_paste": self._on_paste,
297 "on_refresh": self._on_refresh,
298 "on_clearcookies_clicked": self._on_clearcookies_clicked,
299 "on_notebook_switch_page": self._on_notebook_switch_page,
300 "on_about_activate": self._on_about_activate,
302 self._widgetTree.signal_autoconnect(callbackMapping)
304 self._initDone = True
306 config = ConfigParser.SafeConfigParser()
307 config.read(self._user_settings)
308 with gtk_toolbox.gtk_lock():
309 self.load_settings(config)
311 self.attempt_login(2)
315 def attempt_login(self, numOfAttempts = 10):
317 @todo Handle user notification better like attempting to login and failed login
319 @note Not meant to be called directly, but run as a seperate thread.
321 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
323 if not self._deviceIsOnline:
324 warnings.warn("Attempted to login while device was offline")
326 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
328 "Attempted to login before initialization is complete, did an event fire early?"
334 username, password = self._credentials
335 serviceId = self._defaultBackendId
337 # Attempt using the cookies
338 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
341 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
345 # Attempt using the settings file
346 if not loggedIn and username and password:
347 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
350 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
354 # Query the user for credentials
355 for attemptCount in xrange(numOfAttempts):
358 with gtk_toolbox.gtk_lock():
359 availableServices = {
360 self.GV_BACKEND: "Google Voice",
361 self.GC_BACKEND: "Grand Central",
363 credentials = self._credentialsDialog.request_credentials_from(
364 availableServices, defaultCredentials = self._credentials
366 serviceId, username, password = credentials
368 loggedIn = self._phoneBackends[serviceId].login(username, password)
371 "Logged into %r through user request" % self._phoneBackends[serviceId],
374 except RuntimeError, e:
375 warnings.warn(traceback.format_exc())
376 self._errorDisplay.push_exception_with_lock(e)
378 with gtk_toolbox.gtk_lock():
380 self._credentials = username, password
381 self._change_loggedin_status(serviceId)
383 self._errorDisplay.push_message("Login Failed")
384 self._change_loggedin_status(self.NULL_BACKEND)
387 def _on_close(self, *args, **kwds):
389 if self._osso is not None:
393 self._save_settings()
397 def _change_loggedin_status(self, newStatus):
398 oldStatus = self._selectedBackendId
399 if oldStatus == newStatus:
402 self._dialpads[oldStatus].disable()
403 self._accountViews[oldStatus].disable()
404 self._recentViews[oldStatus].disable()
405 self._messagesViews[oldStatus].disable()
406 self._contactsViews[oldStatus].disable()
408 self._dialpads[newStatus].enable()
409 self._accountViews[newStatus].enable()
410 self._recentViews[newStatus].enable()
411 self._messagesViews[newStatus].enable()
412 self._contactsViews[newStatus].enable()
414 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
415 self._phoneBackends[self._selectedBackendId].set_sane_callback()
416 self._accountViews[self._selectedBackendId].update()
418 self._selectedBackendId = newStatus
420 def load_settings(self, config):
424 self._defaultBackendId = int(config.get(self.__pretty_app_name__, "active"))
426 config.get(self.__pretty_app_name__, "bin_blob_%i" % i)
427 for i in xrange(len(self._credentials))
430 base64.b64decode(blob)
433 self._credentials = tuple(creds)
434 for backendId, view in itertools.chain(
435 self._dialpads.iteritems(),
436 self._accountViews.iteritems(),
437 self._messagesViews.iteritems(),
438 self._recentViews.iteritems(),
439 self._contactsViews.iteritems(),
441 sectionName = "%s - %s" % (backendId, view.name())
442 view.load_settings(config, sectionName)
444 def save_settings(self, config):
446 @note Thread Agnostic
448 config.add_section(self.__pretty_app_name__)
449 config.set(self.__pretty_app_name__, "active", str(self._selectedBackendId))
450 for i, value in enumerate(self._credentials):
451 blob = base64.b64encode(value)
452 config.set(self.__pretty_app_name__, "bin_blob_%i" % i, blob)
453 for backendId, view in itertools.chain(
454 self._dialpads.iteritems(),
455 self._accountViews.iteritems(),
456 self._messagesViews.iteritems(),
457 self._recentViews.iteritems(),
458 self._contactsViews.iteritems(),
460 sectionName = "%s - %s" % (backendId, view.name())
461 config.add_section(sectionName)
462 view.save_settings(config, sectionName)
464 def _guess_preferred_backend(self, backendAndCookiePaths):
466 (getmtime_nothrow(path), backendId, path)
467 for backendId, path in backendAndCookiePaths
469 modTimeAndPath.sort()
470 return modTimeAndPath[-1][1]
472 def _save_settings(self):
474 @note Thread Agnostic
476 config = ConfigParser.SafeConfigParser()
477 self.save_settings(config)
478 with open(self._user_settings, "wb") as configFile:
479 config.write(configFile)
481 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
483 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
484 For system_inactivity, we have no background tasks to pause
486 @note Hildon specific
489 for backendId in self.BACKENDS:
490 self._phoneBackends[backendId].clear_caches()
491 self._contactsViews[self._selectedBackendId].clear_caches()
494 if save_unsaved_data or shutdown:
495 self._save_settings()
497 def _on_connection_change(self, connection, event, magicIdentifier):
499 @note Hildon specific
503 status = event.get_status()
504 error = event.get_error()
505 iap_id = event.get_iap_id()
506 bearer = event.get_bearer_type()
508 if status == conic.STATUS_CONNECTED:
509 self._deviceIsOnline = True
510 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
511 backgroundLogin.setDaemon(True)
512 backgroundLogin.start()
513 elif status == conic.STATUS_DISCONNECTED:
514 self._deviceIsOnline = False
515 self._defaultBackendId = self._selectedBackendId
516 self._change_loggedin_status(self.NULL_BACKEND)
518 def _on_window_state_change(self, widget, event, *args):
520 @note Hildon specific
522 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
523 self._isFullScreen = True
525 self._isFullScreen = False
527 def _on_key_press(self, widget, event, *args):
529 @note Hildon specific
531 if event.keyval == gtk.keysyms.F6:
532 if self._isFullScreen:
533 self._window.unfullscreen()
535 self._window.fullscreen()
537 def _on_clearcookies_clicked(self, *args):
538 self._phoneBackends[self._selectedBackendId].logout()
539 self._accountViews[self._selectedBackendId].clear()
540 self._recentViews[self._selectedBackendId].clear()
541 self._messagesViews[self._selectedBackendId].clear()
542 self._contactsViews[self._selectedBackendId].clear()
543 self._change_loggedin_status(self.NULL_BACKEND)
545 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
546 backgroundLogin.setDaemon(True)
547 backgroundLogin.start()
549 def _on_notebook_switch_page(self, notebook, page, page_num):
550 if page_num == self.RECENT_TAB:
551 self._recentViews[self._selectedBackendId].update()
552 elif page_num == self.MESSAGES_TAB:
553 self._messagesViews[self._selectedBackendId].update()
554 elif page_num == self.CONTACTS_TAB:
555 self._contactsViews[self._selectedBackendId].update()
556 elif page_num == self.ACCOUNT_TAB:
557 self._accountViews[self._selectedBackendId].update()
559 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
560 if hildon is not None:
561 self._window.set_title(tabTitle)
563 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
565 def _on_number_selected(self, action, number, message):
566 if action == "select":
567 self._dialpads[self._selectedBackendId].set_number(number)
568 self._notebook.set_current_page(self.KEYPAD_TAB)
569 elif action == "dial":
570 self._on_dial_clicked(number)
571 elif action == "sms":
572 self._on_sms_clicked(number, message)
574 assert False, "Unknown action: %s" % action
576 def _on_sms_clicked(self, number, message):
578 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
583 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
584 except RuntimeError, e:
586 self._errorDisplay.push_exception(e)
590 self._errorDisplay.push_message(
591 "Backend link with grandcentral is not working, please try again"
597 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
599 except RuntimeError, e:
600 self._errorDisplay.push_exception(e)
601 except ValueError, e:
602 self._errorDisplay.push_exception(e)
604 def _on_dial_clicked(self, number):
606 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
610 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
611 except RuntimeError, e:
613 self._errorDisplay.push_exception(e)
617 self._errorDisplay.push_message(
618 "Backend link with grandcentral is not working, please try again"
624 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
625 self._phoneBackends[self._selectedBackendId].dial(number)
627 except RuntimeError, e:
628 self._errorDisplay.push_exception(e)
629 except ValueError, e:
630 self._errorDisplay.push_exception(e)
633 self._dialpads[self._selectedBackendId].clear()
635 def _on_refresh(self, *args):
636 page_num = self._notebook.get_current_page()
637 if page_num == self.CONTACTS_TAB:
638 self._contactsViews[self._selectedBackendId].update(force=True)
639 elif page_num == self.RECENT_TAB:
640 self._recentViews[self._selectedBackendId].update(force=True)
641 elif page_num == self.MESSAGES_TAB:
642 self._messagesViews[self._selectedBackendId].update(force=True)
644 def _on_paste(self, *args):
645 contents = self._clipboard.wait_for_text()
646 self._dialpads[self._selectedBackendId].set_number(contents)
648 def _on_about_activate(self, *args):
649 dlg = gtk.AboutDialog()
650 dlg.set_name(self.__pretty_app_name__)
651 dlg.set_version(self.__version__)
652 dlg.set_copyright("Copyright 2008 - LGPL")
653 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")
654 dlg.set_website("http://gc-dialer.garage.maemo.org/")
655 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
663 failureCount, testCount = doctest.testmod()
665 print "Tests Successful"
672 gtk.gdk.threads_init()
673 if hildon is not None:
674 gtk.set_application_name(Dialcentral.__pretty_app_name__)
675 handle = Dialcentral()
679 class DummyOptions(object):
685 if __name__ == "__main__":
686 if len(sys.argv) > 1:
692 if optparse is not None:
693 parser = optparse.OptionParser()
694 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
695 (commandOptions, commandArgs) = parser.parse_args()
697 commandOptions = DummyOptions()
700 if commandOptions.test: