48691ba89f3e5d9032610165b93fb3aaa3e279ca
[gc-dialer] / src / gc_dialer.py
1 #!/usr/bin/python2.5
2
3
4 """
5 Grandcentral Dialer
6 Python front-end to a wget script to use grandcentral.com to place outbound VOIP calls.
7 (C) 2008 Mark Bergman
8 bergman@merctech.com
9 """
10
11
12 import sys
13 import gc
14 import os
15 import threading
16 import time
17 import re
18 import warnings
19
20 import gobject
21 import gtk
22 import gtk.glade
23
24 try:
25         import hildon
26 except ImportError:
27         hildon = None
28
29 try:
30         import osso
31         try:
32                 import abook
33                 import evolution.ebook as evobook
34         except ImportError:
35                 abook = None
36                 evobook = None
37 except ImportError:
38         osso = None
39
40 try:
41         import conic
42 except ImportError:
43         conic = None
44
45 try:
46         import doctest
47         import optparse
48 except ImportError:
49         doctest = None
50         optparse = None
51
52 from gcbackend import GCDialer
53
54 import socket
55
56
57 socket.setdefaulttimeout(5)
58
59
60 def make_ugly(prettynumber):
61         """
62         function to take a phone number and strip out all non-numeric
63         characters
64
65         >>> make_ugly("+012-(345)-678-90")
66         '01234567890'
67         """
68         uglynumber = re.sub('\D', '', prettynumber)
69         return uglynumber
70
71
72 def make_pretty(phonenumber):
73         """
74         Function to take a phone number and return the pretty version
75         pretty numbers:
76                 if phonenumber begins with 0:
77                         ...-(...)-...-....
78                 if phonenumber begins with 1: ( for gizmo callback numbers )
79                         1 (...)-...-....
80                 if phonenumber is 13 digits:
81                         (...)-...-....
82                 if phonenumber is 10 digits:
83                         ...-....
84         >>> make_pretty("12")
85         '12'
86         >>> make_pretty("1234567")
87         '123-4567'
88         >>> make_pretty("2345678901")
89         '(234)-567-8901'
90         >>> make_pretty("12345678901")
91         '1 (234)-567-8901'
92         >>> make_pretty("01234567890")
93         '+012-(345)-678-90'
94         """
95         if phonenumber is None:
96                 return ""
97
98         if len(phonenumber) < 3:
99                 return phonenumber
100
101         if phonenumber[0] == "0":
102                 prettynumber = ""
103                 prettynumber += "+%s" % phonenumber[0:3]
104                 if 3 < len(phonenumber):
105                         prettynumber += "-(%s)" % phonenumber[3:6]
106                         if 6 < len(phonenumber):
107                                 prettynumber += "-%s" % phonenumber[6:9]
108                                 if 9 < len(phonenumber):
109                                         prettynumber += "-%s" % phonenumber[9:]
110                 return prettynumber
111         elif len(phonenumber) <= 7:
112                 prettynumber = "%s-%s" % (phonenumber[0:3], phonenumber[3:])
113         elif len(phonenumber) > 8 and phonenumber[0] == "1":
114                 prettynumber = "1 (%s)-%s-%s" % (phonenumber[1:4], phonenumber[4:7], phonenumber[7:])
115         elif len(phonenumber) > 7:
116                 prettynumber = "(%s)-%s-%s" % (phonenumber[0:3], phonenumber[3:6], phonenumber[6:])
117         return prettynumber
118
119
120 class PhoneTypeSelector(object):
121
122         def __init__(self, widgetTree, gcBackend):
123                 self._gcBackend = gcBackend
124                 self._widgetTree = widgetTree
125                 self._dialog = self._widgetTree.get_widget("phonetype_dialog")
126
127                 self._selectButton = self._widgetTree.get_widget("select_button")
128                 self._selectButton.connect("clicked", self._on_phonetype_select)
129
130                 self._cancelButton = self._widgetTree.get_widget("cancel_button")
131                 self._cancelButton.connect("clicked", self._on_phonetype_cancel)
132
133                 self._typemodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
134                 self._typeviewselection = None
135
136                 typeview = self._widgetTree.get_widget("phonetypes")
137                 typeview.connect("row-activated", self._on_phonetype_select)
138                 typeview.set_model(self._typemodel)
139                 textrenderer = gtk.CellRendererText()
140
141                 # Add the column to the treeview
142                 column = gtk.TreeViewColumn("Phone Numbers", textrenderer, text=1)
143                 column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
144
145                 typeview.append_column(column)
146
147                 self._typeviewselection = typeview.get_selection()
148                 self._typeviewselection.set_mode(gtk.SELECTION_SINGLE)
149
150         def run(self, contactDetails):
151                 self._typemodel.clear()
152
153                 for phoneType, phoneNumber in contactDetails:
154                         self._typemodel.append((phoneNumber, "%s - %s" % (make_pretty(phoneNumber), phoneType)))
155
156                 userResponse = self._dialog.run()
157
158                 if userResponse == gtk.RESPONSE_OK:
159                         model, itr = self._typeviewselection.get_selected()
160                         if itr:
161                                 phoneNumber = self._typemodel.get_value(itr, 0)
162                 else:
163                         phoneNumber = ""
164
165                 self._typeviewselection.unselect_all()
166                 self._dialog.hide()
167                 return phoneNumber
168         
169         def _on_phonetype_select(self, *args):
170                 self._dialog.response(gtk.RESPONSE_OK)
171
172         def _on_phonetype_cancel(self, *args):
173                 self._dialog.response(gtk.RESPONSE_CANCEL)
174
175
176 class Dialpad(object):
177
178         __app_name__ = "gc_dialer"
179         __version__ = "0.7.0"
180         __app_magic__ = 0xdeadbeef
181
182         _glade_files = [
183                 './gc_dialer.glade',
184                 '../lib/gc_dialer.glade',
185                 '/usr/local/lib/gc_dialer.glade',
186         ]
187
188         def __init__(self):
189                 self._phonenumber = ""
190                 self._prettynumber = ""
191                 self._areacode = "518"
192
193                 self._clipboard = gtk.clipboard_get()
194
195                 self._deviceIsOnline = True
196                 self._callbackNeedsSetup = True
197
198                 self._recenttime = 0.0
199                 self._recentmodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
200                 self._recentviewselection = None
201
202                 self._contactstime = 0.0
203                 self._contactsmodel = gtk.ListStore(gtk.gdk.Pixbuf, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
204                 self._contactsviewselection = None
205
206                 for path in Dialpad._glade_files:
207                         if os.path.isfile(path):
208                                 self._widgetTree = gtk.glade.XML(path)
209                                 break
210                 else:
211                         self.display_error_message("Cannot find gc_dialer.glade")
212                         gtk.main_quit()
213                         return
214
215                 aboutHeader = self._widgetTree.get_widget("about_title")
216                 aboutHeader.set_label("%s\nVersion %s" % (aboutHeader.get_label(), Dialpad.__version__))
217
218                 #Get the buffer associated with the number display
219                 self._numberdisplay = self._widgetTree.get_widget("numberdisplay")
220                 self.set_number("")
221                 self._notebook = self._widgetTree.get_widget("notebook")
222
223                 self._window = self._widgetTree.get_widget("Dialpad")
224
225                 global hildon
226                 self._app = None
227                 self._isFullScreen = False
228                 if hildon is not None and isinstance(self._window, gtk.Window):
229                         warnings.warn("Hildon installed but glade file not updated to work with hildon", UserWarning, 2)
230                         hildon = None
231                 elif hildon is not None:
232                         self._app = hildon.Program()
233                         self._window.set_title("Keypad")
234                         self._app.add_window(self._window)
235                         self._widgetTree.get_widget("callbackcombo").get_child().set_property('hildon-input-mode', (1 << 4))
236                         self._widgetTree.get_widget("usernameentry").set_property('hildon-input-mode', 7)
237                         self._widgetTree.get_widget("passwordentry").set_property('hildon-input-mode', 7|(1 << 29))
238
239                         gtkMenu = self._widgetTree.get_widget("menubar1")
240                         menu = gtk.Menu()
241                         for child in gtkMenu.get_children():
242                                 child.reparent(menu)
243                         self._window.set_menu(menu)
244                         gtkMenu.destroy()
245
246                         self._window.connect("key-press-event", self._on_key_press)
247                         self._window.connect("window-state-event", self._on_window_state_change)
248                 else:
249                         warnings.warn("No Hildon", UserWarning, 2)
250
251                 self._osso = None
252                 self._ebook = None
253                 if osso is not None:
254                         self._osso = osso.Context(Dialpad.__app_name__, Dialpad.__version__, False)
255                         device = osso.DeviceState(self._osso)
256                         device.set_device_state_callback(self._on_device_state_change, 0)
257                         if abook is not None and evobook is not None:
258                                 abook.init_with_name(Dialpad.__app_name__, self._osso)
259                                 self._ebook = evobook.open_addressbook("default")
260                         else:
261                                 warnings.warn("No abook and No evolution address book support", UserWarning, 2)
262                 else:
263                         warnings.warn("No OSSO", UserWarning, 2)
264
265                 self._connection = None
266                 if conic is not None:
267                         self._connection = conic.Connection()
268                         self._connection.connect("connection-event", self._on_connection_change, Dialpad.__app_magic__)
269                         self._connection.request_connection(conic.CONNECT_FLAG_NONE)
270                 else:
271                         warnings.warn("No Internet Connectivity API ", UserWarning, 2)
272
273                 callbackMapping = {
274                         # Process signals from buttons
275                         "on_loginbutton_clicked": self._on_loginbutton_clicked,
276                         "on_loginclose_clicked": self._on_loginclose_clicked,
277
278                         "on_dialpad_quit": (lambda data: gtk.main_quit()),
279                         "on_paste": self._on_paste,
280                         "on_clear_number": self._on_clear_number,
281
282                         "on_clearcookies_clicked": self._on_clearcookies_clicked,
283                         "on_notebook_switch_page": self._on_notebook_switch_page,
284                         "on_recentview_row_activated": self._on_recentview_row_activated,
285                         "on_contactsview_row_activated" : self._on_contactsview_row_activated,
286
287                         "on_digit_clicked": self._on_digit_clicked,
288                         "on_back_clicked": self._on_backspace,
289                         "on_dial_clicked": self._on_dial_clicked,
290                 }
291                 self._widgetTree.signal_autoconnect(callbackMapping)
292                 self._widgetTree.get_widget("callbackcombo").get_child().connect("changed", self._on_callbackentry_changed)
293
294                 if self._window:
295                         self._window.connect("destroy", gtk.main_quit)
296                         self._window.show_all()
297
298                 self._gcBackend = GCDialer()
299
300                 self._phoneTypeSelector = PhoneTypeSelector(self._widgetTree, self._gcBackend)
301
302                 self.attempt_login(2)
303                 gobject.idle_add(self._init_grandcentral)
304                 gobject.idle_add(self._init_recent_view)
305                 gobject.idle_add(self._init_contacts_view)
306
307         def _init_grandcentral(self):
308                 """ Deferred initalization of the grandcentral info """
309
310                 if self._gcBackend.is_authed():
311                         if self._gcBackend.get_callback_number() is None:
312                                 self._gcBackend.set_sane_callback()
313                         self.set_account_number()
314
315                 return False
316
317         def _init_recent_view(self):
318                 """ Deferred initalization of the recent view treeview """
319
320                 recentview = self._widgetTree.get_widget("recentview")
321                 recentview.set_model(self._recentmodel)
322                 textrenderer = gtk.CellRendererText()
323
324                 # Add the column to the treeview
325                 column = gtk.TreeViewColumn("Calls", textrenderer, text=1)
326                 column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
327
328                 recentview.append_column(column)
329
330                 self._recentviewselection = recentview.get_selection()
331                 self._recentviewselection.set_mode(gtk.SELECTION_SINGLE)
332
333                 return False
334
335         def _init_contacts_view(self):
336                 """ deferred initalization of the contacts view treeview """
337
338                 contactsview = self._widgetTree.get_widget("contactsview")
339                 contactsview.set_model(self._contactsmodel)
340
341                 # Add the column to the treeview
342                 column = gtk.TreeViewColumn("Contact")
343
344                 iconrenderer = gtk.CellRendererPixbuf()
345                 column.pack_start(iconrenderer, expand=False)
346                 column.add_attribute(iconrenderer, 'pixbuf', 0)
347
348                 textrenderer = gtk.CellRendererText()
349                 column.pack_start(textrenderer, expand=True)
350                 column.add_attribute(textrenderer, 'text', 1)
351
352                 textrenderer = gtk.CellRendererText()
353                 column.pack_start(textrenderer, expand=True)
354                 column.add_attribute(textrenderer, 'text', 4)
355
356                 column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
357                 column.set_sort_column_id(1)
358                 column.set_visible(True)
359                 contactsview.append_column(column)
360
361                 #textrenderer = gtk.CellRendererText()
362                 #column = gtk.TreeViewColumn("Location", textrenderer, text=2)
363                 #column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
364                 #column.set_sort_column_id(2)
365                 #column.set_visible(True)
366                 #contactsview.append_column(column)
367
368                 #textrenderer = gtk.CellRendererText()
369                 #column = gtk.TreeViewColumn("Phone", textrenderer, text=3)
370                 #column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
371                 #column.set_sort_column_id(3)
372                 #column.set_visible(True)
373                 #contactsview.append_column(column)
374
375                 self._contactsviewselection = contactsview.get_selection()
376                 self._contactsviewselection.set_mode(gtk.SELECTION_SINGLE)
377
378                 return False
379
380         def _setup_callback_combo(self):
381                 combobox = self._widgetTree.get_widget("callbackcombo")
382                 self.callbacklist = gtk.ListStore(gobject.TYPE_STRING)
383                 combobox.set_model(self.callbacklist)
384                 combobox.set_text_column(0)
385                 for number, description in self._gcBackend.get_callback_numbers().iteritems():
386                         self.callbacklist.append([make_pretty(number)])
387
388                 combobox.get_child().set_text(make_pretty(self._gcBackend.get_callback_number()))
389                 self._callbackNeedsSetup = False
390
391         def populate_recentview(self):
392                 self._recentmodel.clear()
393
394                 for personsName, phoneNumber, date, action in self._gcBackend.get_recent():
395                         item = (phoneNumber, "%s on %s from/to %s - %s" % (action.capitalize(), date, personsName, phoneNumber))
396                         self._recentmodel.append(item)
397
398                 self._recenttime = time.time()
399                 return False
400
401         def populate_contactsview(self):
402                 self._contactsmodel.clear()
403
404                 # completely disable updating the treeview while we populate the data
405                 contactsview = self._widgetTree.get_widget("contactsview")
406                 contactsview.freeze_child_notify()
407                 contactsview.set_model(None)
408
409                 # get gc icon
410                 gc_icon = gtk.gdk.pixbuf_new_from_file_at_size('gc_contact.png', 16, 16)
411                 for contactId, contactName in self._gcBackend.get_contacts():
412                         self._contactsmodel.append((gc_icon,) + (contactName, "", contactId) + ("",))
413
414                 # restart the treeview data rendering
415                 contactsview.set_model(self._contactsmodel)
416                 contactsview.thaw_child_notify()
417
418                 self._contactstime = time.time()
419                 return False
420
421         def attempt_login(self, numOfAttempts = 1):
422                 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
423                 dialog = self._widgetTree.get_widget("login_dialog")
424
425                 if self._gcBackend.is_authed():
426                         return True
427
428                 for i in range(numOfAttempts):
429                         dialog.run()
430
431                         username = self._widgetTree.get_widget("usernameentry").get_text()
432                         password = self._widgetTree.get_widget("passwordentry").get_text()
433                         self._widgetTree.get_widget("passwordentry").set_text("")
434
435                         loggedIn = self._gcBackend.login(username, password)
436                         dialog.hide()
437                         if loggedIn:
438                                 return True
439
440                 return False
441
442         def display_error_message(self, msg):
443                 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
444
445                 def close(dialog, response, editor):
446                         editor.about_dialog = None
447                         dialog.destroy()
448                 error_dialog.connect("response", close, self)
449                 error_dialog.run()
450
451         def set_number(self, number):
452                 self._phonenumber = make_ugly(number)
453                 self._prettynumber = make_pretty(self._phonenumber)
454                 self._numberdisplay.set_label("<span size='30000' weight='bold'>%s</span>" % ( self._prettynumber ) )
455
456         def set_account_number(self):
457                 accountnumber = self._gcBackend.get_account_number()
458                 self._widgetTree.get_widget("gcnumberlabel").set_label("<span size='23000' weight='bold'>%s</span>" % (accountnumber))
459
460         def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
461                 """
462                 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
463                 For system_inactivity, we have no background tasks to pause
464
465                 @note Hildon specific
466                 """
467                 if memory_low:
468                         self._gcBackend.clear_caches()
469                         re.purge()
470                         gc.collect()
471
472         def _on_connection_change(self, connection, event, magicIdentifier):
473                 """
474                 @note Hildon specific
475                 """
476                 status = event.get_status()
477                 error = event.get_error()
478                 iap_id = event.get_iap_id()
479                 bearer = event.get_bearer_type()
480
481                 if status == conic.STATUS_CONNECTED:
482                         self._window.set_sensitive(True)
483                         self._deviceIsOnline = True
484                 elif status == conic.STATUS_DISCONNECTED:
485                         self._window.set_sensitive(False)
486                         self._deviceIsOnline = False
487
488         def _on_window_state_change(self, widget, event, *args):
489                 """
490                 @note Hildon specific
491                 """
492                 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
493                         self._isFullScreen = True
494                 else:
495                         self._isFullScreen = False
496
497         def _on_key_press(self, widget, event, *args):
498                 """
499                 @note Hildon specific
500                 """
501                 if event.keyval == gtk.keysyms.F6:
502                         if self._isFullScreen:
503                                 self._window.unfullscreen()
504                         else:
505                                 self._window.fullscreen()
506
507         def _on_loginbutton_clicked(self, *args):
508                 self._widgetTree.get_widget("login_dialog").response(gtk.RESPONSE_OK)
509
510         def _on_loginclose_clicked(self, *args):
511                 gtk.main_quit()
512                 sys.exit(0)
513
514         def _on_clearcookies_clicked(self, *args):
515                 self._gcBackend.reset()
516                 self._callbackNeedsSetup = True
517                 self._recenttime = 0.0
518                 self._contactstime = 0.0
519                 self._recentmodel.clear()
520                 self._widgetTree.get_widget("callbackcombo").get_child().set_text("")
521
522                 # re-run the inital grandcentral setup
523                 self.attempt_login(2)
524                 gobject.idle_add(self._init_grandcentral)
525
526         def _on_callbackentry_changed(self, *args):
527                 """
528                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
529                 """
530                 text = make_ugly(self._widgetTree.get_widget("callbackcombo").get_child().get_text())
531                 if not self._gcBackend.is_valid_syntax(text):
532                         warnings.warn("%s is not a valid callback number" % text, UserWarning, 2)
533                 elif text != self._gcBackend.get_callback_number():
534                         self._gcBackend.set_callback_number(text)
535                 else:
536                         warnings.warn("Callback number already is %s" % self._gcBackend.get_callback_number(), UserWarning, 2)
537
538         def _on_recentview_row_activated(self, treeview, path, view_column):
539                 model, itr = self._recentviewselection.get_selected()
540                 if not itr:
541                         return
542
543                 self.set_number(self._recentmodel.get_value(itr, 0))
544                 self._notebook.set_current_page(0)
545                 self._recentviewselection.unselect_all()
546
547         def _on_contactsview_row_activated(self, treeview, path, view_column):
548                 model, itr = self._contactsviewselection.get_selected()
549                 if not itr:
550                         return
551
552                 contactId = self._contactsmodel.get_value(itr, 3)
553                 contactDetails = self._gcBackend.get_contact_details(contactId)
554                 contactDetails = [phoneNumber for phoneNumber in contactDetails]
555
556                 if len(contactDetails) == 0:
557                         phoneNumber = ""
558                 elif len(contactDetails) == 1:
559                         phoneNumber = contactDetails[0][1]
560                 else:
561                         phoneNumber = self._phoneTypeSelector.run(contactDetails)
562
563                 if 0 < len(phoneNumber):
564                         self.set_number(phoneNumber)
565                         self._notebook.set_current_page(0)
566
567                 self._contactsviewselection.unselect_all()
568
569         def _on_notebook_switch_page(self, notebook, page, page_num):
570                 if page_num == 1 and 300 < (time.time() - self._contactstime):
571                         gobject.idle_add(self.populate_contactsview)
572                 elif page_num == 2 and 300 < (time.time() - self._recenttime):
573                         gobject.idle_add(self.populate_recentview)
574                 elif page_num == 3 and self._callbackNeedsSetup:
575                         gobject.idle_add(self._setup_callback_combo)
576
577                 if hildon:
578                         self._window.set_title(self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text())
579
580         def _on_dial_clicked(self, widget):
581                 """
582                 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
583                 """
584                 loggedIn = self.attempt_login(2)
585                 if not loggedIn or not self._gcBackend.is_authed() or self._gcBackend.get_callback_number() == "":
586                         self.display_error_message("Backend link with grandcentral is not working, please try again")
587                         warnings.warn("Backend Status: Logged in? %s, Authenticated? %s, Callback=%s" % (loggedIn, self._gcBackend.is_authed(), self._gcBackend.get_callback_number()), UserWarning, 2)
588                         return
589
590                 try:
591                         callSuccess = self._gcBackend.dial(self._phonenumber)
592                 except ValueError, e:
593                         self._gcBackend._msg = e.message
594                         callSuccess = False
595
596                 if not callSuccess:
597                         self.display_error_message(self._gcBackend._msg)
598                 else:
599                         self.set_number("")
600
601                 self._recentmodel.clear()
602                 self._recenttime = 0.0
603
604         def _on_paste(self, *args):
605                 contents = self._clipboard.wait_for_text()
606                 phoneNumber = re.sub('\D', '', contents)
607                 self.set_number(phoneNumber)
608
609         def _on_clear_number(self, *args):
610                 self.set_number("")
611
612         def _on_digit_clicked(self, widget):
613                 self.set_number(self._phonenumber + widget.get_name()[5])
614
615         def _on_backspace(self, widget):
616                 self.set_number(self._phonenumber[:-1])
617
618
619 def run_doctest():
620         failureCount, testCount = doctest.testmod()
621         if not failureCount:
622                 print "Tests Successful"
623                 sys.exit(0)
624         else:
625                 sys.exit(1)
626
627
628 def run_dialpad():
629         gtk.gdk.threads_init()
630         title = 'Dialpad'
631         handle = Dialpad()
632         gtk.main()
633
634
635 class DummyOptions(object):
636
637         def __init__(self):
638                 self.test = False
639
640
641 if __name__ == "__main__":
642         if hildon is not None:
643                 gtk.set_application_name("Dialer")
644
645         if optparse is not None:
646                 parser = optparse.OptionParser()
647                 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
648                 (commandOptions, commandArgs) = parser.parse_args()
649         else:
650                 commandOptions = DummyOptions()
651                 commandArgs = []
652
653         if commandOptions.test:
654                 run_doctest()
655         else:
656                 run_dialpad()