* Changed the file headers to doc strings
[gc-dialer] / src / dc_glade.py
1 #!/usr/bin/python2.5
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Mark Bergman bergman AT merctech DOT com
6
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.
11
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.
16
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
20
21 @todo Add logging support to make debugging issues for people a lot easier
22 """
23
24
25 from __future__ import with_statement
26
27 import sys
28 import gc
29 import os
30 import threading
31 import warnings
32 import traceback
33
34 import gtk
35 import gtk.glade
36
37 try:
38         import hildon
39 except ImportError:
40         hildon = None
41
42 import gtk_toolbox
43
44
45 def getmtime_nothrow(path):
46         try:
47                 return os.path.getmtime(path)
48         except StandardError:
49                 return 0
50
51
52 class Dialcentral(object):
53
54         __pretty_app_name__ = "DialCentral"
55         __app_name__ = "dialcentral"
56         __version__ = "0.9.6"
57         __app_magic__ = 0xdeadbeef
58
59         _glade_files = [
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"),
63         ]
64
65         NULL_BACKEND = 0
66         GC_BACKEND = 1
67         GV_BACKEND = 2
68         BACKENDS = (NULL_BACKEND, GC_BACKEND, GV_BACKEND)
69
70         _data_path = os.path.join(os.path.expanduser("~"), ".dialcentral")
71
72         def __init__(self):
73                 self._connection = None
74                 self._osso = None
75                 self._clipboard = gtk.clipboard_get()
76
77                 self._deviceIsOnline = True
78                 self._selectedBackendId = self.NULL_BACKEND
79                 self._defaultBackendId = self.GC_BACKEND
80                 self._phoneBackends = None
81                 self._dialpads = None
82                 self._accountViews = None
83                 self._recentViews = None
84                 self._contactsViews = None
85
86                 for path in self._glade_files:
87                         if os.path.isfile(path):
88                                 self._widgetTree = gtk.glade.XML(path)
89                                 break
90                 else:
91                         self.display_error_message("Cannot find dialcentral.glade")
92                         gtk.main_quit()
93                         return
94
95                 self._window = self._widgetTree.get_widget("mainWindow")
96                 self._notebook = self._widgetTree.get_widget("notebook")
97                 self._errorDisplay = gtk_toolbox.ErrorDisplay(self._widgetTree)
98                 self._credentials = gtk_toolbox.LoginWindow(self._widgetTree)
99
100                 self._app = None
101                 self._isFullScreen = False
102                 if hildon is not None:
103                         self._app = hildon.Program()
104                         self._window = hildon.Window()
105                         self._widgetTree.get_widget("vbox1").reparent(self._window)
106                         self._app.add_window(self._window)
107                         self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
108                         self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
109                         self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
110                         hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('recent_scrolledwindow'), True)
111                         hildon.hildon_helper_set_thumb_scrollbar(self._widgetTree.get_widget('contacts_scrolledwindow'), True)
112
113                         gtkMenu = self._widgetTree.get_widget("dialpad_menubar")
114                         menu = gtk.Menu()
115                         for child in gtkMenu.get_children():
116                                 child.reparent(menu)
117                         self._window.set_menu(menu)
118                         gtkMenu.destroy()
119
120                         self._window.connect("key-press-event", self._on_key_press)
121                         self._window.connect("window-state-event", self._on_window_state_change)
122                 else:
123                         pass # warnings.warn("No Hildon", UserWarning, 2)
124
125                 if hildon is not None:
126                         self._window.set_title("Keypad")
127                 else:
128                         self._window.set_title("%s - Keypad" % self.__pretty_app_name__)
129
130                 callbackMapping = {
131                         "on_dialpad_quit": self._on_close,
132                 }
133                 self._widgetTree.signal_autoconnect(callbackMapping)
134
135                 if self._window:
136                         self._window.connect("destroy", gtk.main_quit)
137                         self._window.show_all()
138                         self._window.set_default_size(800, 300)
139
140                 backgroundSetup = threading.Thread(target=self._idle_setup)
141                 backgroundSetup.setDaemon(True)
142                 backgroundSetup.start()
143
144         def _idle_setup(self):
145                 """
146                 If something can be done after the UI loads, push it here so it's not blocking the UI
147                 """
148                 # Barebones UI handlers
149                 import null_backend
150                 import null_views
151
152                 self._phoneBackends = {self.NULL_BACKEND: null_backend.NullDialer()}
153                 with gtk_toolbox.gtk_lock():
154                         self._dialpads = {self.NULL_BACKEND: null_views.Dialpad(self._widgetTree)}
155                         self._accountViews = {self.NULL_BACKEND: null_views.AccountInfo(self._widgetTree)}
156                         self._recentViews = {self.NULL_BACKEND: null_views.RecentCallsView(self._widgetTree)}
157                         self._contactsViews = {self.NULL_BACKEND: null_views.ContactsView(self._widgetTree)}
158
159                         self._dialpads[self._selectedBackendId].enable()
160                         self._accountViews[self._selectedBackendId].enable()
161                         self._recentViews[self._selectedBackendId].enable()
162                         self._contactsViews[self._selectedBackendId].enable()
163
164                 # Setup maemo specifics
165                 try:
166                         import osso
167                 except ImportError:
168                         osso = None
169                 self._osso = None
170                 if osso is not None:
171                         self._osso = osso.Context(Dialcentral.__app_name__, Dialcentral.__version__, False)
172                         device = osso.DeviceState(self._osso)
173                         device.set_device_state_callback(self._on_device_state_change, 0)
174                 else:
175                         pass # warnings.warn("No OSSO", UserWarning)
176
177                 try:
178                         import conic
179                 except ImportError:
180                         conic = None
181                 self._connection = None
182                 if conic is not None:
183                         self._connection = conic.Connection()
184                         self._connection.connect("connection-event", self._on_connection_change, Dialcentral.__app_magic__)
185                         self._connection.request_connection(conic.CONNECT_FLAG_NONE)
186                 else:
187                         pass # warnings.warn("No Internet Connectivity API ", UserWarning)
188
189                 # Setup costly backends
190                 import gv_backend
191                 import gc_backend
192                 import file_backend
193                 import evo_backend
194                 import gc_views
195
196                 try:
197                         os.makedirs(self._data_path)
198                 except OSError, e:
199                         if e.errno != 17:
200                                 raise
201                 gcCookiePath = os.path.join(self._data_path, "gc_cookies.txt")
202                 gvCookiePath = os.path.join(self._data_path, "gv_cookies.txt")
203                 self._defaultBackendId = self._guess_preferred_backend((
204                         (self.GC_BACKEND, gcCookiePath),
205                         (self.GV_BACKEND, gvCookiePath),
206                 ))
207
208                 self._phoneBackends.update({
209                         self.GC_BACKEND: gc_backend.GCDialer(gcCookiePath),
210                         self.GV_BACKEND: gv_backend.GVDialer(gvCookiePath),
211                 })
212                 with gtk_toolbox.gtk_lock():
213                         unifiedDialpad = gc_views.Dialpad(self._widgetTree, self._errorDisplay)
214                         unifiedDialpad.set_number("")
215                         self._dialpads.update({
216                                 self.GC_BACKEND: unifiedDialpad,
217                                 self.GV_BACKEND: unifiedDialpad,
218                         })
219                         self._accountViews.update({
220                                 self.GC_BACKEND: gc_views.AccountInfo(
221                                         self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
222                                 ),
223                                 self.GV_BACKEND: gc_views.AccountInfo(
224                                         self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
225                                 ),
226                         })
227                         self._recentViews.update({
228                                 self.GC_BACKEND: gc_views.RecentCallsView(
229                                         self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
230                                 ),
231                                 self.GV_BACKEND: gc_views.RecentCallsView(
232                                         self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
233                                 ),
234                         })
235                         self._contactsViews.update({
236                                 self.GC_BACKEND: gc_views.ContactsView(
237                                         self._widgetTree, self._phoneBackends[self.GC_BACKEND], self._errorDisplay
238                                 ),
239                                 self.GV_BACKEND: gc_views.ContactsView(
240                                         self._widgetTree, self._phoneBackends[self.GV_BACKEND], self._errorDisplay
241                                 ),
242                         })
243
244                 evoBackend = evo_backend.EvolutionAddressBook()
245                 fsContactsPath = os.path.join(self._data_path, "contacts")
246                 fileBackend = file_backend.FilesystemAddressBookFactory(fsContactsPath)
247                 for backendId in (self.GV_BACKEND, self.GC_BACKEND):
248                         self._dialpads[backendId].dial = self._on_dial_clicked
249                         self._recentViews[backendId].number_selected = self._on_number_selected
250                         self._contactsViews[backendId].number_selected = self._on_number_selected
251
252                         addressBooks = [
253                                 self._phoneBackends[backendId],
254                                 evoBackend,
255                                 fileBackend,
256                         ]
257                         mergedBook = gc_views.MergedAddressBook(addressBooks, gc_views.MergedAddressBook.advanced_lastname_sorter)
258                         self._contactsViews[backendId].append(mergedBook)
259                         self._contactsViews[backendId].extend(addressBooks)
260                         self._contactsViews[backendId].open_addressbook(*self._contactsViews[backendId].get_addressbooks().next()[0][0:2])
261
262                 callbackMapping = {
263                         "on_paste": self._on_paste,
264                         "on_clearcookies_clicked": self._on_clearcookies_clicked,
265                         "on_notebook_switch_page": self._on_notebook_switch_page,
266                         "on_about_activate": self._on_about_activate,
267                 }
268                 self._widgetTree.signal_autoconnect(callbackMapping)
269
270                 self.attempt_login(2)
271
272                 return False
273
274         def attempt_login(self, numOfAttempts = 10):
275                 """
276                 @todo Handle user notification better like attempting to login and failed login
277
278                 @note Not meant to be called directly, but run as a seperate thread.
279                 """
280                 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
281
282                 if not self._deviceIsOnline:
283                         warnings.warn("Attempted to login while device was offline")
284                         return False
285                 elif self._phoneBackends is None or len(self._phoneBackends) < len(self.BACKENDS):
286                         warnings.warn(
287                                 "Attempted to login before initialization is complete, did an event fire early?"
288                         )
289                         return False
290
291                 loggedIn = False
292                 try:
293                         if self._phoneBackends[self._defaultBackendId].is_authed():
294                                 serviceId = self._defaultBackendId
295                                 loggedIn = True
296                         for x in xrange(numOfAttempts):
297                                 if loggedIn:
298                                         break
299                                 with gtk_toolbox.gtk_lock():
300                                         availableServices = {
301                                                 self.GV_BACKEND: "Google Voice",
302                                                 self.GC_BACKEND: "Grand Central",
303                                         }
304                                         credentials = self._credentials.request_credentials_from(availableServices)
305                                         serviceId, username, password = credentials
306
307                                 loggedIn = self._phoneBackends[serviceId].login(username, password)
308                 except RuntimeError, e:
309                         warnings.warn(traceback.format_exc())
310                         self._errorDisplay.push_exception_with_lock(e)
311
312                 with gtk_toolbox.gtk_lock():
313                         if not loggedIn:
314                                 self._errorDisplay.push_message("Login Failed")
315                         self._change_loggedin_status(serviceId if loggedIn else self.NULL_BACKEND)
316                 return loggedIn
317
318         def display_error_message(self, msg):
319                 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
320
321                 def close(dialog, response, editor):
322                         editor.about_dialog = None
323                         dialog.destroy()
324                 error_dialog.connect("response", close, self)
325                 error_dialog.run()
326
327         def _on_close(self, *args, **kwds):
328                 if self._osso is not None:
329                         self._osso.close()
330                 gtk.main_quit()
331
332         def _change_loggedin_status(self, newStatus):
333                 oldStatus = self._selectedBackendId
334                 if oldStatus == newStatus:
335                         return
336
337                 self._dialpads[oldStatus].disable()
338                 self._accountViews[oldStatus].disable()
339                 self._recentViews[oldStatus].disable()
340                 self._contactsViews[oldStatus].disable()
341
342                 self._dialpads[newStatus].enable()
343                 self._accountViews[newStatus].enable()
344                 self._recentViews[newStatus].enable()
345                 self._contactsViews[newStatus].enable()
346
347                 if self._phoneBackends[self._selectedBackendId].get_callback_number() is None:
348                         self._phoneBackends[self._selectedBackendId].set_sane_callback()
349                 self._accountViews[self._selectedBackendId].update()
350
351                 self._selectedBackendId = newStatus
352
353         def _guess_preferred_backend(self, backendAndCookiePaths):
354                 modTimeAndPath = [
355                         (getmtime_nothrow(path), backendId, path)
356                         for backendId, path in backendAndCookiePaths
357                 ]
358                 modTimeAndPath.sort()
359                 return modTimeAndPath[-1][1]
360
361         def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
362                 """
363                 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
364                 For system_inactivity, we have no background tasks to pause
365
366                 @note Hildon specific
367                 """
368                 if memory_low:
369                         for backendId in self.BACKENDS:
370                                 self._phoneBackends[backendId].clear_caches()
371                         self._contactsViews[self._selectedBackendId].clear_caches()
372                         gc.collect()
373
374         def _on_connection_change(self, connection, event, magicIdentifier):
375                 """
376                 @note Hildon specific
377                 """
378                 import conic
379
380                 status = event.get_status()
381                 error = event.get_error()
382                 iap_id = event.get_iap_id()
383                 bearer = event.get_bearer_type()
384
385                 if status == conic.STATUS_CONNECTED:
386                         self._deviceIsOnline = True
387                         backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
388                         backgroundLogin.setDaemon(True)
389                         backgroundLogin.start()
390                 elif status == conic.STATUS_DISCONNECTED:
391                         self._deviceIsOnline = False
392                         self._defaultBackendId = self._selectedBackendId
393                         self._change_loggedin_status(self.NULL_BACKEND)
394
395         def _on_window_state_change(self, widget, event, *args):
396                 """
397                 @note Hildon specific
398                 """
399                 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
400                         self._isFullScreen = True
401                 else:
402                         self._isFullScreen = False
403
404         def _on_key_press(self, widget, event, *args):
405                 """
406                 @note Hildon specific
407                 """
408                 if event.keyval == gtk.keysyms.F6:
409                         if self._isFullScreen:
410                                 self._window.unfullscreen()
411                         else:
412                                 self._window.fullscreen()
413
414         def _on_clearcookies_clicked(self, *args):
415                 self._phoneBackends[self._selectedBackendId].logout()
416                 self._accountViews[self._selectedBackendId].clear()
417                 self._recentViews[self._selectedBackendId].clear()
418                 self._contactsViews[self._selectedBackendId].clear()
419                 self._change_loggedin_status(self.NULL_BACKEND)
420
421                 backgroundLogin = threading.Thread(target=self.attempt_login, args=[2])
422                 backgroundLogin.setDaemon(True)
423                 backgroundLogin.start()
424
425         def _on_notebook_switch_page(self, notebook, page, page_num):
426                 if page_num == 1:
427                         self._contactsViews[self._selectedBackendId].update()
428                 elif page_num == 2:
429                         self._recentViews[self._selectedBackendId].update()
430
431                 tabTitle = self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text()
432                 if hildon is not None:
433                         self._window.set_title(tabTitle)
434                 else:
435                         self._window.set_title("%s - %s" % (self.__pretty_app_name__, tabTitle))
436
437         def _on_number_selected(self, number):
438                 self._dialpads[self._selectedBackendId].set_number(number)
439                 self._notebook.set_current_page(0)
440
441         def _on_dial_clicked(self, number):
442                 """
443                 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
444                 """
445                 try:
446                         loggedIn = self._phoneBackends[self._selectedBackendId].is_authed()
447                 except RuntimeError, e:
448                         loggedIn = False
449                         self._errorDisplay.push_exception(e)
450                         return
451
452                 if not loggedIn:
453                         self._errorDisplay.push_message(
454                                 "Backend link with grandcentral is not working, please try again"
455                         )
456                         return
457
458                 dialed = False
459                 try:
460                         assert self._phoneBackends[self._selectedBackendId].get_callback_number() != ""
461                         self._phoneBackends[self._selectedBackendId].dial(number)
462                         dialed = True
463                 except RuntimeError, e:
464                         self._errorDisplay.push_exception(e)
465                 except ValueError, e:
466                         self._errorDisplay.push_exception(e)
467
468                 if dialed:
469                         self._dialpads[self._selectedBackendId].clear()
470                         self._recentViews[self._selectedBackendId].clear()
471
472         def _on_paste(self, *args):
473                 contents = self._clipboard.wait_for_text()
474                 self._dialpads[self._selectedBackendId].set_number(contents)
475
476         def _on_about_activate(self, *args):
477                 dlg = gtk.AboutDialog()
478                 dlg.set_name(self.__pretty_app_name__)
479                 dlg.set_version(self.__version__)
480                 dlg.set_copyright("Copyright 2008 - LGPL")
481                 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")
482                 dlg.set_website("http://gc-dialer.garage.maemo.org/")
483                 dlg.set_authors(["<z2n@merctech.com>", "Eric Warnke <ericew@gmail.com>", "Ed Page <edpage@byu.net>"])
484                 dlg.run()
485                 dlg.destroy()
486
487
488 def run_doctest():
489         import doctest
490
491         failureCount, testCount = doctest.testmod()
492         if not failureCount:
493                 print "Tests Successful"
494                 sys.exit(0)
495         else:
496                 sys.exit(1)
497
498
499 def run_dialpad():
500         gtk.gdk.threads_init()
501         if hildon is not None:
502                 gtk.set_application_name(Dialcentral.__pretty_app_name__)
503         handle = Dialcentral()
504         gtk.main()
505
506
507 class DummyOptions(object):
508
509         def __init__(self):
510                 self.test = False
511
512
513 if __name__ == "__main__":
514         if len(sys.argv) > 1:
515                 try:
516                         import optparse
517                 except ImportError:
518                         optparse = None
519
520                 if optparse is not None:
521                         parser = optparse.OptionParser()
522                         parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
523                         (commandOptions, commandArgs) = parser.parse_args()
524         else:
525                 commandOptions = DummyOptions()
526                 commandArgs = []
527
528         if commandOptions.test:
529                 run_doctest()
530         else:
531                 run_dialpad()