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