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