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