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