Updating the combo box on new login. WARNING: this gives a GtkWarning for some reason
[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                 if not self._gcBackend.is_authed():
303                         self.attempt_login(2)
304                 gobject.idle_add(self._init_grandcentral)
305                 gobject.idle_add(self._init_recent_view)
306                 gobject.idle_add(self._init_contacts_view)
307
308         def _init_grandcentral(self):
309                 """ Deferred initalization of the grandcentral info """
310
311                 if self._gcBackend.is_authed():
312                         if self._gcBackend.get_callback_number() is None:
313                                 self._gcBackend.set_sane_callback()
314                         self.set_account_number()
315
316                 return False
317
318         def _init_recent_view(self):
319                 """ Deferred initalization of the recent view treeview """
320
321                 recentview = self._widgetTree.get_widget("recentview")
322                 recentview.set_model(self._recentmodel)
323                 textrenderer = gtk.CellRendererText()
324
325                 # Add the column to the treeview
326                 column = gtk.TreeViewColumn("Calls", textrenderer, text=1)
327                 column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
328
329                 recentview.append_column(column)
330
331                 self._recentviewselection = recentview.get_selection()
332                 self._recentviewselection.set_mode(gtk.SELECTION_SINGLE)
333
334                 return False
335
336         def _init_contacts_view(self):
337                 """ deferred initalization of the contacts view treeview """
338
339                 contactsview = self._widgetTree.get_widget("contactsview")
340                 contactsview.set_model(self._contactsmodel)
341
342                 # Add the column to the treeview
343                 column = gtk.TreeViewColumn("Contact")
344
345                 iconrenderer = gtk.CellRendererPixbuf()
346                 column.pack_start(iconrenderer, expand=False)
347                 column.add_attribute(iconrenderer, 'pixbuf', 0)
348
349                 textrenderer = gtk.CellRendererText()
350                 column.pack_start(textrenderer, expand=True)
351                 column.add_attribute(textrenderer, 'text', 1)
352
353                 textrenderer = gtk.CellRendererText()
354                 column.pack_start(textrenderer, expand=True)
355                 column.add_attribute(textrenderer, 'text', 4)
356
357                 column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
358                 column.set_sort_column_id(1)
359                 column.set_visible(True)
360                 contactsview.append_column(column)
361
362                 #textrenderer = gtk.CellRendererText()
363                 #column = gtk.TreeViewColumn("Location", textrenderer, text=2)
364                 #column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
365                 #column.set_sort_column_id(2)
366                 #column.set_visible(True)
367                 #contactsview.append_column(column)
368
369                 #textrenderer = gtk.CellRendererText()
370                 #column = gtk.TreeViewColumn("Phone", textrenderer, text=3)
371                 #column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
372                 #column.set_sort_column_id(3)
373                 #column.set_visible(True)
374                 #contactsview.append_column(column)
375
376                 self._contactsviewselection = contactsview.get_selection()
377                 self._contactsviewselection.set_mode(gtk.SELECTION_SINGLE)
378
379                 return False
380
381         def _setup_callback_combo(self):
382                 combobox = self._widgetTree.get_widget("callbackcombo")
383                 self.callbacklist = gtk.ListStore(gobject.TYPE_STRING)
384                 combobox.set_model(self.callbacklist)
385                 combobox.set_text_column(0)
386                 for number, description in self._gcBackend.get_callback_numbers().iteritems():
387                         self.callbacklist.append([make_pretty(number)])
388
389                 combobox.get_child().set_text(make_pretty(self._gcBackend.get_callback_number()))
390                 self._callbackNeedsSetup = False
391
392         def populate_recentview(self):
393                 self._recentmodel.clear()
394
395                 for personsName, phoneNumber, date, action in self._gcBackend.get_recent():
396                         item = (phoneNumber, "%s on %s from/to %s - %s" % (action.capitalize(), date, personsName, phoneNumber))
397                         self._recentmodel.append(item)
398
399                 self._recenttime = time.time()
400                 return False
401
402         def populate_contactsview(self):
403                 self._contactsmodel.clear()
404
405                 # completely disable updating the treeview while we populate the data
406                 contactsview = self._widgetTree.get_widget("contactsview")
407                 contactsview.freeze_child_notify()
408                 contactsview.set_model(None)
409
410                 # get gc icon
411                 gc_icon = gtk.gdk.pixbuf_new_from_file_at_size('gc_contact.png', 16, 16)
412                 for contactId, contactName in self._gcBackend.get_contacts():
413                         self._contactsmodel.append((gc_icon,) + (contactName, "", contactId) + ("",))
414
415                 # restart the treeview data rendering
416                 contactsview.set_model(self._contactsmodel)
417                 contactsview.thaw_child_notify()
418
419                 self._contactstime = time.time()
420                 return False
421
422         def attempt_login(self, numOfAttempts = 1):
423                 """
424                 @note Assumes that you are already logged in
425                 """
426                 assert 0 < numOfAttempts, "That was pointless having 0 or less login attempts"
427                 dialog = self._widgetTree.get_widget("login_dialog")
428
429                 for i in range(numOfAttempts):
430                         dialog.run()
431
432                         username = self._widgetTree.get_widget("usernameentry").get_text()
433                         password = self._widgetTree.get_widget("passwordentry").get_text()
434                         self._widgetTree.get_widget("passwordentry").set_text("")
435
436                         loggedIn = self._gcBackend.login(username, password)
437                         dialog.hide()
438                         if loggedIn:
439                                 return True
440
441                 return False
442
443         def display_error_message(self, msg):
444                 error_dialog = gtk.MessageDialog(None, 0, gtk.MESSAGE_ERROR, gtk.BUTTONS_CLOSE, msg)
445
446                 def close(dialog, response, editor):
447                         editor.about_dialog = None
448                         dialog.destroy()
449                 error_dialog.connect("response", close, self)
450                 error_dialog.run()
451
452         def set_number(self, number):
453                 self._phonenumber = make_ugly(number)
454                 self._prettynumber = make_pretty(self._phonenumber)
455                 self._numberdisplay.set_label("<span size='30000' weight='bold'>%s</span>" % ( self._prettynumber ) )
456
457         def set_account_number(self):
458                 accountnumber = self._gcBackend.get_account_number()
459                 self._widgetTree.get_widget("gcnumberlabel").set_label("<span size='23000' weight='bold'>%s</span>" % (accountnumber))
460
461         def _on_device_state_change(self, shutdown, save_unsaved_data, memory_low, system_inactivity, message, userData):
462                 """
463                 For shutdown or save_unsaved_data, our only state is cookies and I think the cookie manager handles that for us.
464                 For system_inactivity, we have no background tasks to pause
465
466                 @note Hildon specific
467                 """
468                 if memory_low:
469                         self._gcBackend.clear_caches()
470                         re.purge()
471                         gc.collect()
472
473         def _on_connection_change(self, connection, event, magicIdentifier):
474                 """
475                 @note Hildon specific
476                 """
477                 status = event.get_status()
478                 error = event.get_error()
479                 iap_id = event.get_iap_id()
480                 bearer = event.get_bearer_type()
481
482                 if status == conic.STATUS_CONNECTED:
483                         self._window.set_sensitive(True)
484                         self._deviceIsOnline = True
485                 elif status == conic.STATUS_DISCONNECTED:
486                         self._window.set_sensitive(False)
487                         self._deviceIsOnline = False
488
489         def _on_window_state_change(self, widget, event, *args):
490                 """
491                 @note Hildon specific
492                 """
493                 if event.new_window_state & gtk.gdk.WINDOW_STATE_FULLSCREEN:
494                         self._isFullScreen = True
495                 else:
496                         self._isFullScreen = False
497
498         def _on_key_press(self, widget, event, *args):
499                 """
500                 @note Hildon specific
501                 """
502                 if event.keyval == gtk.keysyms.F6:
503                         if self._isFullScreen:
504                                 self._window.unfullscreen()
505                         else:
506                                 self._window.fullscreen()
507
508         def _on_loginbutton_clicked(self, *args):
509                 self._widgetTree.get_widget("login_dialog").response(gtk.RESPONSE_OK)
510
511         def _on_loginclose_clicked(self, *args):
512                 gtk.main_quit()
513                 sys.exit(0)
514
515         def _on_clearcookies_clicked(self, *args):
516                 self._gcBackend.reset()
517                 self._callbackNeedsSetup = True
518                 self._recenttime = 0.0
519                 self._contactstime = 0.0
520                 self._recentmodel.clear()
521                 self._widgetTree.get_widget("callbackcombo").get_child().set_text("")
522
523                 # re-run the inital grandcentral setup
524                 self.attempt_login(2)
525                 gobject.idle_add(self._init_grandcentral)
526                 gobject.idle_add(self._setup_callback_combo)
527
528         def _on_callbackentry_changed(self, *args):
529                 """
530                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
531                 """
532                 text = make_ugly(self._widgetTree.get_widget("callbackcombo").get_child().get_text())
533                 if not self._gcBackend.is_valid_syntax(text):
534                         warnings.warn("%s is not a valid callback number" % text, UserWarning, 2)
535                 elif text != self._gcBackend.get_callback_number():
536                         self._gcBackend.set_callback_number(text)
537                 else:
538                         warnings.warn("Callback number already is %s" % self._gcBackend.get_callback_number(), UserWarning, 2)
539
540         def _on_recentview_row_activated(self, treeview, path, view_column):
541                 model, itr = self._recentviewselection.get_selected()
542                 if not itr:
543                         return
544
545                 self.set_number(self._recentmodel.get_value(itr, 0))
546                 self._notebook.set_current_page(0)
547                 self._recentviewselection.unselect_all()
548
549         def _on_contactsview_row_activated(self, treeview, path, view_column):
550                 model, itr = self._contactsviewselection.get_selected()
551                 if not itr:
552                         return
553
554                 contactId = self._contactsmodel.get_value(itr, 3)
555                 contactDetails = self._gcBackend.get_contact_details(contactId)
556                 contactDetails = [phoneNumber for phoneNumber in contactDetails]
557
558                 if len(contactDetails) == 0:
559                         phoneNumber = ""
560                 elif len(contactDetails) == 1:
561                         phoneNumber = contactDetails[0][1]
562                 else:
563                         phoneNumber = self._phoneTypeSelector.run(contactDetails)
564
565                 if 0 < len(phoneNumber):
566                         self.set_number(phoneNumber)
567                         self._notebook.set_current_page(0)
568
569                 self._contactsviewselection.unselect_all()
570
571         def _on_notebook_switch_page(self, notebook, page, page_num):
572                 if page_num == 1 and 300 < (time.time() - self._contactstime):
573                         gobject.idle_add(self.populate_contactsview)
574                 elif page_num == 2 and 300 < (time.time() - self._recenttime):
575                         gobject.idle_add(self.populate_recentview)
576                 elif page_num == 3 and self._callbackNeedsSetup:
577                         gobject.idle_add(self._setup_callback_combo)
578
579                 if hildon:
580                         self._window.set_title(self._notebook.get_tab_label(self._notebook.get_nth_page(page_num)).get_text())
581
582         def _on_dial_clicked(self, widget):
583                 """
584                 @todo Potential blocking on web access, maybe we should defer parts of this or put up a dialog?
585                 """
586                 if not self._gcBackend.is_authed():
587                         loggedIn = self.attempt_login(2)
588
589                 if not loggedIn or not self._gcBackend.is_authed() or self._gcBackend.get_callback_number() == "":
590                         self.display_error_message("Backend link with grandcentral is not working, please try again")
591                         warnings.warn("Backend Status: Logged in? %s, Authenticated? %s, Callback=%s" % (loggedIn, self._gcBackend.is_authed(), self._gcBackend.get_callback_number()), UserWarning, 2)
592                         return
593
594                 try:
595                         callSuccess = self._gcBackend.dial(self._phonenumber)
596                 except ValueError, e:
597                         self._gcBackend._msg = e.message
598                         callSuccess = False
599
600                 if not callSuccess:
601                         self.display_error_message(self._gcBackend._msg)
602                 else:
603                         self.set_number("")
604
605                 self._recentmodel.clear()
606                 self._recenttime = 0.0
607
608         def _on_paste(self, *args):
609                 contents = self._clipboard.wait_for_text()
610                 phoneNumber = re.sub('\D', '', contents)
611                 self.set_number(phoneNumber)
612
613         def _on_clear_number(self, *args):
614                 self.set_number("")
615
616         def _on_digit_clicked(self, widget):
617                 self.set_number(self._phonenumber + widget.get_name()[5])
618
619         def _on_backspace(self, widget):
620                 self.set_number(self._phonenumber[:-1])
621
622
623 def run_doctest():
624         failureCount, testCount = doctest.testmod()
625         if not failureCount:
626                 print "Tests Successful"
627                 sys.exit(0)
628         else:
629                 sys.exit(1)
630
631
632 def run_dialpad():
633         gtk.gdk.threads_init()
634         title = 'Dialpad'
635         handle = Dialpad()
636         gtk.main()
637
638
639 class DummyOptions(object):
640
641         def __init__(self):
642                 self.test = False
643
644
645 if __name__ == "__main__":
646         if hildon is not None:
647                 gtk.set_application_name("Dialer")
648
649         if optparse is not None:
650                 parser = optparse.OptionParser()
651                 parser.add_option("-t", "--test", action="store_true", dest="test", help="Run tests")
652                 (commandOptions, commandArgs) = parser.parse_args()
653         else:
654                 commandOptions = DummyOptions()
655                 commandArgs = []
656
657         if commandOptions.test:
658                 run_doctest()
659         else:
660                 run_dialpad()