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 @todo Add logging support to make debugging issues for people a lot easier
25 from __future__ import with_statement
45 def getmtime_nothrow(path):
47 return os.path.getmtime(path)
52 class Dialcentral(object):
54 __pretty_app_name__ = "DialCentral"
55 __app_name__ = "dialcentral"
57 __app_magic__ = 0xdeadbeef
60 '/usr/lib/dialcentral/dialcentral.glade',
61 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
62 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
74 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
76 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
79 self._connection = None
81 self._clipboard = gtk.clipboard_get()
83 self._deviceIsOnline = True
84 self._selectedBackendId = self.NULL_BACKEND
85 self._defaultBackendId = self.GC_BACKEND
86 self._phoneBackends = None
88 self._accountViews = None
89 self._messagesViews = None
90 self._recentViews = None
91 self._contactsViews = None
93 for path in self._glade_files:
94 if os.path.isfile(path):
95 self._widgetTree = gtk.glade.XML(path)
98 self.display_error_message("Cannot find dialcentral.glade")
102 self._window = self._widgetTree.get_widget("mainWindow")
103 self._notebook = self._widgetTree.get_widget("notebook")
104 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
105 self._credentials = gtk_toolbox.LoginWindow(self._widgetTree)
108 self._isFullScreen = False
109 if hildon is not None:
110 self._app = hildon.Program()
111 self._window = hildon.Window()
112 self._widgetTree.get_widget("vbox1").reparent(self._window)
113 self._app.add_window(self._window)
114 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
115 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
116 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
117 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
118 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
120 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
122 for child in gtkMenu.get_children():
124 self._window.set_menu(menu)
127 self._window.connect("key-press-event", self._on_key_press)
128 self._window.connect("window-state-event", self._on_window_state_change)
130 pass # warnings.warn("No Hildon", UserWarning, 2)
132 if hildon is not None:
133 self._window.set_title("Keypad")
135 self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
138 "on_dialpad_quit": self._on_close,
140 self._widgetTree.signal_autoconnect(callbackMapping)
143 self._window.connect("destroy", gtk.main_quit)
144 self._window.show_all()
145 self._window.set_default_size(800, 300)
147 backgroundSetup = threading.Thread(target=self._idle_setup)
148 backgroundSetup.setDaemon(True)
149 backgroundSetup.start()
151 def _idle_setup(self):
153 If something can be done after the UI loads, push it here so it's not blocking the UI
155 # Barebones UI handlers
159 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
160 with gtk_toolbox.gtk_lock():
161 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
162 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
163 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
164 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
165 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
167 self._dialpads[self._selectedBackendId].enable()
168 self._accountViews[self._selectedBackendId].enable()
169 self._recentViews[self._selectedBackendId].enable()
170 self._messagesViews[self._selectedBackendId].enable()
171 self._contactsViews[self._selectedBackendId].enable()
173 # Setup maemo specifics
180 self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
181 device = osso.DeviceState(self._osso)
182 device.set_device_state_callback(self._on_device_state_change, 0)
184 pass # warnings.warn("No OSSO", UserWarning)
190 self._connection = None
191 if conic is not None:
192 self._connection = conic.Connection()
193 self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
194 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
196 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
198 # Setup costly backends
206 os.makedirs(self._data_path)
210 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
211 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
212 self._defaultBackendId = self._guess_preferred_backend((
213 (self.GC_BACKEND, gcCookiePath),
214 (self.GV_BACKEND, gvCookiePath),
217 self._phoneBackends.update({
218 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
219 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
221 with gtk_toolbox.gtk_lock():
222 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
223 unifiedDialpad.set_number("")
224 self._dialpads.update({
225 self.GC_BACKEND: unifiedDialpad,
226 self.GV_BACKEND: unifiedDialpad,
228 self._accountViews.update({
229 self.GC_BACKEND: gc_views.AccountInfo(
230 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
232 self.GV_BACKEND: gc_views.AccountInfo(
233 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
236 self._recentViews.update({
237 self.GC_BACKEND: gc_views.RecentCallsView(
238 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
240 self.GV_BACKEND: gc_views.RecentCallsView(
241 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
244 self._messagesViews.update({
245 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
246 self.GV_BACKEND: gc_views.MessagesView(
247 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
250 self._contactsViews.update({
251 self.GC_BACKEND: gc_views.ContactsView(
252 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
254 self.GV_BACKEND: gc_views.ContactsView(
255 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
259 evoBackend = evo_backend.EvolutionAddressBook()
260 fsContactsPath = os.path.join(self._data_path, "contacts")
261 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
262 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
263 self._dialpads[backendId].dial = self._on_dial_clicked
264 self._recentViews[backendId].number_selected = self._on_number_selected
265 self._contactsViews[backendId].number_selected = self._on_number_selected
268 self._phoneBackends[backendId],
272 mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
273 self._contactsViews[backendId].append(mergedBook)
274 self._contactsViews[backendId].extend(addressBooks)
275 self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
278 "on_paste": self._on_paste,
279 "on_clearcookies_clicked": self._on_clearcookies_clicked,
280 "on_notebook_switch_page": self._on_notebook_switch_page,
281 "on_about_activate": self._on_about_activate,
283 self._widgetTree.signal_autoconnect(callbackMapping)
285 self.attempt_login(2)
289 def attempt_login(self, numOfAttempts = 10):
291 @todo Handle user notification better like attempting to login and failed login
293 @note Not meant to be called directly, but run as a seperate thread.
295 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
297 if not self._deviceIsOnline:
298 warnings.warn("Attempted to login while device was offline")
300 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
302 "Attempted to login before initialization is complete, did an event fire early?"
308 if self._phoneBackends[self._defaultBackendId].is_authed():
309 serviceId = self._defaultBackendId
311 for x in xrange(numOfAttempts):
314 with gtk_toolbox.gtk_lock():
315 availableServices = {
316 self.GV_BACKEND: "Google Voice",
317 self.GC_BACKEND: "Grand Central",
319 credentials = self._credentials.request_credentials_from(availableServices)
320 serviceId, username, password = credentials
322 loggedIn = self._phoneBackends[serviceId].login(username, password)
323 except RuntimeError, e:
324 warnings.warn(traceback.format_exc())
325 self._errorDisplay.push_exception_with_lock(e)
327 with gtk_toolbox.gtk_lock():
329 self._errorDisplay.push_message("Login Failed")
330 self._change_loggedin_status(serviceId if loggedIn else self.NULL_BACKEND)
333 def display_error_message(self, msg):
334 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
336 def close(dialog, response, editor):
337 editor.about_dialog = None
339 error_dialog.connect("response", close, self)
342 def _on_close(self, *args, **kwds):
343 if self._osso is not None:
347 def _change_loggedin_status(self, newStatus):
348 oldStatus = self._selectedBackendId
349 if oldStatus == newStatus:
352 self._dialpads[oldStatus].disable()
353 self._accountViews[oldStatus].disable()
354 self._recentViews[oldStatus].disable()
355 self._messagesViews[oldStatus].disable()
356 self._contactsViews[oldStatus].disable()
358 self._dialpads[newStatus].enable()
359 self._accountViews[newStatus].enable()
360 self._recentViews[newStatus].enable()
361 self._messagesViews[newStatus].enable()
362 self._contactsViews[newStatus].enable()
364 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
365 self._phoneBackends[self._selectedBackendId].set_sane_callback()
366 self._accountViews[self._selectedBackendId].update()
368 self._selectedBackendId = newStatus
370 def _guess_preferred_backend(self, backendAndCookiePaths):
372 (getmtime_nothrow(path), backendId, path)
373 for backendId, path in backendAndCookiePaths
375 modTimeAndPath.sort()
376 return modTimeAndPath[-1][1]
378 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
380 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
381 For system_inactivity, we have no background tasks to pause
383 @note Hildon specific
386 for backendId in self.BACKENDS:
387 self._phoneBackends[backendId].clear_caches()
388 self._contactsViews[self._selectedBackendId].clear_caches()
391 def _on_connection_change(self, connection, event, magicIdentifier):
393 @note Hildon specific
397 status = event.get_status()
398 error = event.get_error()
399 iap_id = event.get_iap_id()
400 bearer = event.get_bearer_type()
402 if status == conic.STATUS_CONNECTED:
403 self._deviceIsOnline = True
404 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
405 backgroundLogin.setDaemon(True)
406 backgroundLogin.start()
407 elif status == conic.STATUS_DISCONNECTED:
408 self._deviceIsOnline = False
409 self._defaultBackendId = self._selectedBackendId
410 self._change_loggedin_status(self.NULL_BACKEND)
412 def _on_window_state_change(self, widget, event, *args):
414 @note Hildon specific
416 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
417 self._isFullScreen = True
419 self._isFullScreen = False
421 def _on_key_press(self, widget, event, *args):
423 @note Hildon specific
425 if event.keyval == gtk.keysyms.F6:
426 if self._isFullScreen:
427 self._window.unfullscreen()
429 self._window.fullscreen()
431 def _on_clearcookies_clicked(self, *args):
432 self._phoneBackends[self._selectedBackendId].logout()
433 self._accountViews[self._selectedBackendId].clear()
434 self._recentViews[self._selectedBackendId].clear()
435 self._messagesViews[self._selectedBackendId].clear()
436 self._contactsViews[self._selectedBackendId].clear()
437 self._change_loggedin_status(self.NULL_BACKEND)
439 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
440 backgroundLogin.setDaemon(True)
441 backgroundLogin.start()
443 def _on_notebook_switch_page(self, notebook, page, page_num):
444 if page_num == self.CONTACTS_TAB:
445 self._contactsViews[self._selectedBackendId].update()
446 elif page_num == self.RECENT_TAB:
447 self._recentViews[self._selectedBackendId].update()
448 elif page_num == self.MESSAGES_TAB:
449 self._messagesViews[self._selectedBackendId].update()
451 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
452 if hildon is not None:
453 self._window.set_title(tabTitle)
455 self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
457 def _on_number_selected(self, action, number, message):
458 if action == "select":
459 self._dialpads[self._selectedBackendId].set_number(number)
460 self._notebook.set_current_page(0)
461 elif action == "dial":
462 self._on_dial_clicked(number)
463 elif action == "sms":
464 self._on_sms_clicked(number, message)
466 assert False, "Unknown action: %s" % action
468 def _on_sms_clicked(self, number, message):
470 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
475 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
476 except RuntimeError, e:
478 self._errorDisplay.push_exception(e)
482 self._errorDisplay.push_message(
483 "Backend link with grandcentral is not working, please try again"
489 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
491 except RuntimeError, e:
492 self._errorDisplay.push_exception(e)
493 except ValueError, e:
494 self._errorDisplay.push_exception(e)
496 def _on_dial_clicked(self, number):
498 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
502 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
503 except RuntimeError, e:
505 self._errorDisplay.push_exception(e)
509 self._errorDisplay.push_message(
510 "Backend link with grandcentral is not working, please try again"
516 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
517 self._phoneBackends[self._selectedBackendId].dial(number)
519 except RuntimeError, e:
520 self._errorDisplay.push_exception(e)
521 except ValueError, e:
522 self._errorDisplay.push_exception(e)
525 self._dialpads[self._selectedBackendId].clear()
526 self._recentViews[self._selectedBackendId].clear()
528 def _on_paste(self, *args):
529 contents = self._clipboard.wait_for_text()
530 self._dialpads[self._selectedBackendId].set_number(contents)
532 def _on_about_activate(self, *args):
533 dlg = gtk.AboutDialog()
534 dlg.set_name(self.__pretty_app_name__)
535 dlg.set_version(self.__version__)
536 dlg.set_copyright("Copyright 2008 - LGPL")
537 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")
538 dlg.set_website("http://gc-dialer.garage.maemo.org/")
539 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
547 failureCount, testCount = doctest.testmod()
549 print "Tests Successful"
556 gtk.gdk.threads_init()
557 if hildon is not None:
558 gtk.set_application_name(Dialcentral.__pretty_app_name__)
559 handle = Dialcentral()
563 class DummyOptions(object):
569 if __name__ == "__main__":
570 if len(sys.argv) > 1:
576 if optparse is not None:
577 parser = optparse.OptionParser()
578 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
579 (commandOptions, commandArgs) = parser.parse_args()
581 commandOptions = DummyOptions()
584 if commandOptions.test: