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 Need to add unit tests
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 Can't text from dialpad (so can't do any arbitrary number texts)
26 @todo Add logging support to make debugging issues for people a lot easier
30 from __future__ import with_statement
53 def getmtime_nothrow(path):
55 return os.path.getmtime(path)
60 def display_error_message(msg):
61 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
63 def close(dialog, response):
65 error_dialog.connect("response", close)
69 class Dialcentral(object):
72 '/usr/lib/dialcentral/dialcentral.glade',
73 os.path.join(os.path.dirname(__file__), "dialcentral.glade"),
74 os.path.join(os.path.dirname(__file__), "../lib/dialcentral.glade"),
86 BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
88 _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
89 _user_settings = "%s/settings.ini" % _data_path
92 self._initDone = False
93 self._connection = None
95 self._clipboard = gtk.clipboard_get()
97 self._deviceIsOnline = True
98 self._credentials = ("", "")
99 self._selectedBackendId = self.NULL_BACKEND
100 self._defaultBackendId = self.GC_BACKEND
101 self._phoneBackends = None
102 self._dialpads = None
103 self._accountViews = None
104 self._messagesViews = None
105 self._recentViews = None
106 self._contactsViews = None
108 for path in self._glade_files:
109 if os.path.isfile(path):
110 self._widgetTree = gtk.glade.XML(path)
113 display_error_message("Cannot find dialcentral.glade")
117 self._window = self._widgetTree.get_widget("mainWindow")
118 self._notebook = self._widgetTree.get_widget("notebook")
119 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
120 self._credentialsDialog = gtk_toolbox.LoginWindow(self._widgetTree)
123 self._isFullScreen = False
124 if hildon is not None:
125 self._app = hildon.Program()
126 oldWindow = self._window
127 self._window = hildon.Window()
128 oldWindow.get_child().reparent(self._window)
129 self._app.add_window(self._window)
130 self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
131 self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
132 self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
133 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
134 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('message_scrolledwindow'), True)
135 hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
137 gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
139 for child in gtkMenu.get_children():
141 self._window.set_menu(menu)
144 self._window.connect("key-press-event", self._on_key_press)
145 self._window.connect("window-state-event", self._on_window_state_change)
147 pass # warnings.warn("No Hildon", UserWarning, 2)
149 if hildon is not None:
150 self._window.set_title("Keypad")
152 self._window.set_title("%s - Keypad" % constants.__pretty_app_name__)
155 "on_dialpad_quit": self._on_close,
157 self._widgetTree.signal_autoconnect(callbackMapping)
159 self._window.connect("destroy", self._on_close)
160 self._window.set_default_size(800, 300)
161 self._window.show_all()
163 backgroundSetup = threading.Thread(target=self._idle_setup)
164 backgroundSetup.setDaemon(True)
165 backgroundSetup.start()
167 def _idle_setup(self):
169 If something can be done after the UI loads, push it here so it's not blocking the UI
171 # Barebones UI handlers
175 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
176 with gtk_toolbox.gtk_lock():
177 self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
178 self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
179 self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
180 self._messagesViews = {self.NULL_BACKEND: null_views.MessagesView(self._widgetTree)}
181 self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
183 self._dialpads[self._selectedBackendId].enable()
184 self._accountViews[self._selectedBackendId].enable()
185 self._recentViews[self._selectedBackendId].enable()
186 self._messagesViews[self._selectedBackendId].enable()
187 self._contactsViews[self._selectedBackendId].enable()
189 # Setup maemo specifics
196 self._osso = osso.Context(constants.__app_name__, constants.__version__, False)
197 device = osso.DeviceState(self._osso)
198 device.set_device_state_callback(self._on_device_state_change, 0)
200 pass # warnings.warn("No OSSO", UserWarning, 2)
202 # Setup maemo specifics
207 self._connection = None
208 if conic is not None:
209 self._connection = conic.Connection()
210 self._connection.connect("connection-event", self._on_connection_change, constants.__app_magic__)
211 self._connection.request_connection(conic.CONNECT_FLAG_NONE)
213 pass # warnings.warn("No Internet Connectivity API ", UserWarning)
215 # Setup costly backends
223 os.makedirs(self._data_path)
227 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
228 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
229 self._defaultBackendId = self._guess_preferred_backend((
230 (self.GC_BACKEND, gcCookiePath),
231 (self.GV_BACKEND, gvCookiePath),
234 self._phoneBackends.update({
235 self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
236 self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
238 with gtk_toolbox.gtk_lock():
239 unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
240 unifiedDialpad.set_number("")
241 self._dialpads.update({
242 self.GC_BACKEND: unifiedDialpad,
243 self.GV_BACKEND: unifiedDialpad,
245 self._accountViews.update({
246 self.GC_BACKEND: gc_views.AccountInfo(
247 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
249 self.GV_BACKEND: gc_views.AccountInfo(
250 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
253 self._recentViews.update({
254 self.GC_BACKEND: gc_views.RecentCallsView(
255 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
257 self.GV_BACKEND: gc_views.RecentCallsView(
258 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
261 self._messagesViews.update({
262 self.GC_BACKEND: null_views.MessagesView(self._widgetTree),
263 self.GV_BACKEND: gc_views.MessagesView(
264 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
267 self._contactsViews.update({
268 self.GC_BACKEND: gc_views.ContactsView(
269 self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
271 self.GV_BACKEND: gc_views.ContactsView(
272 self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
276 evoBackend = evo_backend.EvolutionAddressBook()
277 fsContactsPath = os.path.join(self._data_path, "contacts")
278 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
279 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
280 self._dialpads[backendId].number_selected = self._select_action
281 self._recentViews[backendId].number_selected = self._select_action
282 self._messagesViews[backendId].number_selected = self._select_action
283 self._contactsViews[backendId].number_selected = self._select_action
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)
313 def attempt_login(self, numOfAttempts = 10, force = False):
315 @todo Handle user notification better like attempting to login and failed login
317 @note This must be run outside of the UI lock
320 assert 0 <= numOfAttempts, "That was pointless having 0 or less login attempts"
321 assert self._initDone, "Attempting login before app is fully loaded"
322 if not self._deviceIsOnline:
323 raise RuntimeError("Unable to login, device is not online")
325 serviceId = self.NULL_BACKEND
329 self.refresh_session()
330 serviceId = self._defaultBackendId
332 except StandardError, e:
333 warnings.warn('Session refresh failed with the following message "%s"' % e.message, UserWarning, 2)
336 loggedIn, serviceId = self._login_by_user(numOfAttempts)
338 with gtk_toolbox.gtk_lock():
339 self._change_loggedin_status(serviceId)
340 except StandardError, e:
341 with gtk_toolbox.gtk_lock():
342 self._errorDisplay.push_exception(e)
344 def refresh_session(self):
346 @note Thread agnostic
348 assert self._initDone, "Attempting login before app is fully loaded"
349 if not self._deviceIsOnline:
350 raise RuntimeError("Unable to login, device is not online")
354 loggedIn = self._login_by_cookie()
356 loggedIn = self._login_by_settings()
359 raise RuntimeError("Login Failed")
361 def _login_by_cookie(self):
363 @note Thread agnostic
365 loggedIn = self._phoneBackends[self._defaultBackendId].is_authed()
368 "Logged into %r through cookies" % self._phoneBackends[self._defaultBackendId],
373 def _login_by_settings(self):
375 @note Thread agnostic
377 username, password = self._credentials
378 loggedIn = self._phoneBackends[self._defaultBackendId].login(username, password)
380 self._credentials = username, password
382 "Logged into %r through settings" % self._phoneBackends[self._defaultBackendId],
387 def _login_by_user(self, numOfAttempts):
389 @note This must be run outside of the UI lock
391 loggedIn, (username, password) = False, self._credentials
392 tmpServiceId = self.NULL_BACKEND
393 for attemptCount in xrange(numOfAttempts):
396 availableServices = {
397 self.GV_BACKEND: "Google Voice",
398 self.GC_BACKEND: "Grand Central",
400 with gtk_toolbox.gtk_lock():
401 credentials = self._credentialsDialog.request_credentials_from(
402 availableServices, defaultCredentials = self._credentials
404 tmpServiceId, username, password = credentials
405 loggedIn = self._phoneBackends[tmpServiceId].login(username, password)
408 serviceId = tmpServiceId
409 self._credentials = username, password
411 "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 = int(config.get(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)
469 except ConfigParser.NoSectionError, e:
471 "Settings file %s is missing section %s" % (
478 for backendId, view in itertools.chain(
479 self._dialpads.iteritems(),
480 self._accountViews.iteritems(),
481 self._messagesViews.iteritems(),
482 self._recentViews.iteritems(),
483 self._contactsViews.iteritems(),
485 sectionName = "%s - %s" % (backendId, view.name())
487 view.load_settings(config, sectionName)
488 except ConfigParser.NoSectionError, e:
490 "Settings file %s is missing section %s" % (
497 def save_settings(self, config):
499 @note Thread Agnostic
501 config.add_section(constants.__pretty_app_name__)
502 config.set(constants.__pretty_app_name__, "active", str(self._selectedBackendId))
503 for i, value in enumerate(self._credentials):
504 blob = base64.b64encode(value)
505 config.set(constants.__pretty_app_name__, "bin_blob_%i" % i, blob)
506 for backendId, view in itertools.chain(
507 self._dialpads.iteritems(),
508 self._accountViews.iteritems(),
509 self._messagesViews.iteritems(),
510 self._recentViews.iteritems(),
511 self._contactsViews.iteritems(),
513 sectionName = "%s - %s" % (backendId, view.name())
514 config.add_section(sectionName)
515 view.save_settings(config, sectionName)
517 def _guess_preferred_backend(self, backendAndCookiePaths):
519 (getmtime_nothrow(path), backendId, path)
520 for backendId, path in backendAndCookiePaths
522 modTimeAndPath.sort()
523 return modTimeAndPath[-1][1]
525 def _save_settings(self):
527 @note Thread Agnostic
529 config = ConfigParser.SafeConfigParser()
530 self.save_settings(config)
531 with open(self._user_settings, "wb") as configFile:
532 config.write(configFile)
534 def _on_close(self, *args, **kwds):
536 if self._osso is not None:
540 self._save_settings()
544 def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
546 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
547 For system_inactivity, we have no background tasks to pause
549 @note Hildon specific
552 for backendId in self.BACKENDS:
553 self._phoneBackends[backendId].clear_caches()
554 self._contactsViews[self._selectedBackendId].clear_caches()
557 if save_unsaved_data or shutdown:
558 self._save_settings()
560 def _on_connection_change(self, connection, event, magicIdentifier):
562 @note Hildon specific
566 status = event.get_status()
567 error = event.get_error()
568 iap_id = event.get_iap_id()
569 bearer = event.get_bearer_type()
571 if status == conic.STATUS_CONNECTED:
572 self._deviceIsOnline = True
574 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
575 backgroundLogin.setDaemon(True)
576 backgroundLogin.start()
577 elif status == conic.STATUS_DISCONNECTED:
578 self._deviceIsOnline = False
580 self._defaultBackendId = self._selectedBackendId
581 self._change_loggedin_status(self.NULL_BACKEND)
583 def _on_window_state_change(self, widget, event, *args):
585 @note Hildon specific
587 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
588 self._isFullScreen = True
590 self._isFullScreen = False
592 def _on_key_press(self, widget, event, *args):
594 @note Hildon specific
596 if event.keyval == gtk.keysyms.F6:
597 if self._isFullScreen:
598 self._window.unfullscreen()
600 self._window.fullscreen()
602 def _on_clearcookies_clicked(self, *args):
603 self._phoneBackends[self._selectedBackendId].logout()
604 self._accountViews[self._selectedBackendId].clear()
605 self._recentViews[self._selectedBackendId].clear()
606 self._messagesViews[self._selectedBackendId].clear()
607 self._contactsViews[self._selectedBackendId].clear()
608 self._change_loggedin_status(self.NULL_BACKEND)
610 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2, True])
611 backgroundLogin.setDaemon(True)
612 backgroundLogin.start()
614 def _on_notebook_switch_page(self, notebook, page, page_num):
615 if page_num == self.RECENT_TAB:
616 self._recentViews[self._selectedBackendId].update()
617 elif page_num == self.MESSAGES_TAB:
618 self._messagesViews[self._selectedBackendId].update()
619 elif page_num == self.CONTACTS_TAB:
620 self._contactsViews[self._selectedBackendId].update()
621 elif page_num == self.ACCOUNT_TAB:
622 self._accountViews[self._selectedBackendId].update()
624 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
625 if hildon is not None:
626 self._window.set_title(tabTitle)
628 self._window.set_title("%s - %s" % (constants.__pretty_app_name__, tabTitle))
630 def _on_sms_clicked(self, number, message):
634 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
635 except RuntimeError, e:
637 self._errorDisplay.push_exception(e)
641 self._errorDisplay.push_message(
642 "Backend link with grandcentral is not working, please try again"
648 self._phoneBackends[self._selectedBackendId].send_sms(number, message)
650 except RuntimeError, e:
651 self._errorDisplay.push_exception(e)
652 except ValueError, e:
653 self._errorDisplay.push_exception(e)
655 def _on_dial_clicked(self, number):
658 loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
659 except RuntimeError, e:
661 self._errorDisplay.push_exception(e)
665 self._errorDisplay.push_message(
666 "Backend link with grandcentral is not working, please try again"
672 assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
673 self._phoneBackends[self._selectedBackendId].dial(number)
675 except RuntimeError, e:
676 self._errorDisplay.push_exception(e)
677 except ValueError, e:
678 self._errorDisplay.push_exception(e)
681 self._dialpads[self._selectedBackendId].clear()
683 def _on_refresh(self, *args):
684 page_num = self._notebook.get_current_page()
685 if page_num == self.CONTACTS_TAB:
686 self._contactsViews[self._selectedBackendId].update(force=True)
687 elif page_num == self.RECENT_TAB:
688 self._recentViews[self._selectedBackendId].update(force=True)
689 elif page_num == self.MESSAGES_TAB:
690 self._messagesViews[self._selectedBackendId].update(force=True)
692 def _on_paste(self, *args):
693 contents = self._clipboard.wait_for_text()
694 self._dialpads[self._selectedBackendId].set_number(contents)
696 def _on_about_activate(self, *args):
697 dlg = gtk.AboutDialog()
698 dlg.set_name(constants.__pretty_app_name__)
699 dlg.set_version(constants.__version__)
700 dlg.set_copyright("Copyright 2008 - LGPL")
701 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")
702 dlg.set_website("http://gc-dialer.garage.maemo.org/")
703 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
711 failureCount, testCount = doctest.testmod()
713 print "Tests Successful"
720 gtk.gdk.threads_init()
721 if hildon is not None:
722 gtk.set_application_name(constants.__pretty_app_name__)
723 handle = Dialcentral()
727 class DummyOptions(object):
733 if __name__ == "__main__":
734 if len(sys.argv) > 1:
740 if optparse is not None:
741 parser = optparse.OptionParser()
742 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
743 (commandOptions, commandArgs) = parser.parse_args()
745 commandOptions = DummyOptions()
748 if commandOptions.test: