Adding some todos and bugs
[gc-dialer] / src / gc_views.py
1 #!/usr/bin/python2.5
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Mark Bergman bergman AT merctech DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20
21 @todo Feature request: The ability to go to relevant thing in web browser
22 """
23
24 from __future__ import with_statement
25
26 import ConfigParser
27 import warnings
28
29 import gobject
30 import gtk
31
32 import gtk_toolbox
33 import null_backend
34
35
36 def make_ugly(prettynumber):
37         """
38         function to take a phone number and strip out all non-numeric
39         characters
40
41         >>> make_ugly("+012-(345)-678-90")
42         '01234567890'
43         """
44         import re
45         uglynumber = re.sub('\D', '', prettynumber)
46         return uglynumber
47
48
49 def make_pretty(phonenumber):
50         """
51         Function to take a phone number and return the pretty version
52         pretty numbers:
53                 if phonenumber begins with 0:
54                         ...-(...)-...-....
55                 if phonenumber begins with 1: ( for gizmo callback numbers )
56                         1 (...)-...-....
57                 if phonenumber is 13 digits:
58                         (...)-...-....
59                 if phonenumber is 10 digits:
60                         ...-....
61         >>> make_pretty("12")
62         '12'
63         >>> make_pretty("1234567")
64         '123-4567'
65         >>> make_pretty("2345678901")
66         '(234)-567-8901'
67         >>> make_pretty("12345678901")
68         '1 (234)-567-8901'
69         >>> make_pretty("01234567890")
70         '+012-(345)-678-90'
71         """
72         if phonenumber is None or phonenumber is "":
73                 return ""
74
75         phonenumber = make_ugly(phonenumber)
76
77         if len(phonenumber) < 3:
78                 return phonenumber
79
80         if phonenumber[0] == "0":
81                 prettynumber = ""
82                 prettynumber += "+%s" % phonenumber[0:3]
83                 if 3 < len(phonenumber):
84                         prettynumber += "-(%s)" % phonenumber[3:6]
85                         if 6 < len(phonenumber):
86                                 prettynumber += "-%s" % phonenumber[6:9]
87                                 if 9 < len(phonenumber):
88                                         prettynumber += "-%s" % phonenumber[9:]
89                 return prettynumber
90         elif len(phonenumber) <= 7:
91                 prettynumber = "%s-%s" % (phonenumber[0:3], phonenumber[3:])
92         elif len(phonenumber) > 8 and phonenumber[0] == "1":
93                 prettynumber = "1 (%s)-%s-%s" % (phonenumber[1:4], phonenumber[4:7], phonenumber[7:])
94         elif len(phonenumber) > 7:
95                 prettynumber = "(%s)-%s-%s" % (phonenumber[0:3], phonenumber[3:6], phonenumber[6:])
96         return prettynumber
97
98
99 class MergedAddressBook(object):
100         """
101         Merger of all addressbooks
102         """
103
104         def __init__(self, addressbookFactories, sorter = None):
105                 self.__addressbookFactories = addressbookFactories
106                 self.__addressbooks = None
107                 self.__sort_contacts = sorter if sorter is not None else self.null_sorter
108
109         def clear_caches(self):
110                 self.__addressbooks = None
111                 for factory in self.__addressbookFactories:
112                         factory.clear_caches()
113
114         def get_addressbooks(self):
115                 """
116                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
117                 """
118                 yield self, "", ""
119
120         def open_addressbook(self, bookId):
121                 return self
122
123         def contact_source_short_name(self, contactId):
124                 if self.__addressbooks is None:
125                         return ""
126                 bookIndex, originalId = contactId.split("-", 1)
127                 return self.__addressbooks[int(bookIndex)].contact_source_short_name(originalId)
128
129         @staticmethod
130         def factory_name():
131                 return "All Contacts"
132
133         def get_contacts(self):
134                 """
135                 @returns Iterable of (contact id, contact name)
136                 """
137                 if self.__addressbooks is None:
138                         self.__addressbooks = list(
139                                 factory.open_addressbook(id)
140                                 for factory in self.__addressbookFactories
141                                 for (f, id, name) in factory.get_addressbooks()
142                         )
143                 contacts = (
144                         ("-".join([str(bookIndex), contactId]), contactName)
145                                 for (bookIndex, addressbook) in enumerate(self.__addressbooks)
146                                         for (contactId, contactName) in addressbook.get_contacts()
147                 )
148                 sortedContacts = self.__sort_contacts(contacts)
149                 return sortedContacts
150
151         def get_contact_details(self, contactId):
152                 """
153                 @returns Iterable of (Phone Type, Phone Number)
154                 """
155                 if self.__addressbooks is None:
156                         return []
157                 bookIndex, originalId = contactId.split("-", 1)
158                 return self.__addressbooks[int(bookIndex)].get_contact_details(originalId)
159
160         @staticmethod
161         def null_sorter(contacts):
162                 """
163                 Good for speed/low memory
164                 """
165                 return contacts
166
167         @staticmethod
168         def basic_firtname_sorter(contacts):
169                 """
170                 Expects names in "First Last" format
171                 """
172                 contactsWithKey = [
173                         (contactName.rsplit(" ", 1)[0], (contactId, contactName))
174                                 for (contactId, contactName) in contacts
175                 ]
176                 contactsWithKey.sort()
177                 return (contactData for (lastName, contactData) in contactsWithKey)
178
179         @staticmethod
180         def basic_lastname_sorter(contacts):
181                 """
182                 Expects names in "First Last" format
183                 """
184                 contactsWithKey = [
185                         (contactName.rsplit(" ", 1)[-1], (contactId, contactName))
186                                 for (contactId, contactName) in contacts
187                 ]
188                 contactsWithKey.sort()
189                 return (contactData for (lastName, contactData) in contactsWithKey)
190
191         @staticmethod
192         def reversed_firtname_sorter(contacts):
193                 """
194                 Expects names in "Last, First" format
195                 """
196                 contactsWithKey = [
197                         (contactName.split(", ", 1)[-1], (contactId, contactName))
198                                 for (contactId, contactName) in contacts
199                 ]
200                 contactsWithKey.sort()
201                 return (contactData for (lastName, contactData) in contactsWithKey)
202
203         @staticmethod
204         def reversed_lastname_sorter(contacts):
205                 """
206                 Expects names in "Last, First" format
207                 """
208                 contactsWithKey = [
209                         (contactName.split(", ", 1)[0], (contactId, contactName))
210                                 for (contactId, contactName) in contacts
211                 ]
212                 contactsWithKey.sort()
213                 return (contactData for (lastName, contactData) in contactsWithKey)
214
215         @staticmethod
216         def guess_firstname(name):
217                 if ", " in name:
218                         return name.split(", ", 1)[-1]
219                 else:
220                         return name.rsplit(" ", 1)[0]
221
222         @staticmethod
223         def guess_lastname(name):
224                 if ", " in name:
225                         return name.split(", ", 1)[0]
226                 else:
227                         return name.rsplit(" ", 1)[-1]
228
229         @classmethod
230         def advanced_firstname_sorter(cls, contacts):
231                 contactsWithKey = [
232                         (cls.guess_firstname(contactName), (contactId, contactName))
233                                 for (contactId, contactName) in contacts
234                 ]
235                 contactsWithKey.sort()
236                 return (contactData for (lastName, contactData) in contactsWithKey)
237
238         @classmethod
239         def advanced_lastname_sorter(cls, contacts):
240                 contactsWithKey = [
241                         (cls.guess_lastname(contactName), (contactId, contactName))
242                                 for (contactId, contactName) in contacts
243                 ]
244                 contactsWithKey.sort()
245                 return (contactData for (lastName, contactData) in contactsWithKey)
246
247
248 class PhoneTypeSelector(object):
249
250         ACTION_CANCEL = "cancel"
251         ACTION_SELECT = "select"
252         ACTION_DIAL = "dial"
253         ACTION_SEND_SMS = "sms"
254
255         def __init__(self, widgetTree, gcBackend):
256                 self._gcBackend = gcBackend
257                 self._widgetTree = widgetTree
258
259                 self._dialog = self._widgetTree.get_widget("phonetype_dialog")
260                 self._smsDialog = SmsEntryDialog(self._widgetTree)
261
262                 self._smsButton = self._widgetTree.get_widget("sms_button")
263                 self._smsButton.connect("clicked", self._on_phonetype_send_sms)
264
265                 self._dialButton = self._widgetTree.get_widget("dial_button")
266                 self._dialButton.connect("clicked", self._on_phonetype_dial)
267
268                 self._selectButton = self._widgetTree.get_widget("select_button")
269                 self._selectButton.connect("clicked", self._on_phonetype_select)
270
271                 self._cancelButton = self._widgetTree.get_widget("cancel_button")
272                 self._cancelButton.connect("clicked", self._on_phonetype_cancel)
273
274                 self._typemodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
275                 self._typeviewselection = None
276
277                 self._message = self._widgetTree.get_widget("phoneSelectionMessage")
278                 self._typeview = self._widgetTree.get_widget("phonetypes")
279                 self._typeview.connect("row-activated", self._on_phonetype_select)
280
281                 self._action = self.ACTION_CANCEL
282
283         def run(self, contactDetails, message = "", parent = None):
284                 self._action = self.ACTION_CANCEL
285                 self._typemodel.clear()
286                 self._typeview.set_model(self._typemodel)
287
288                 # Add the column to the treeview
289                 textrenderer = gtk.CellRendererText()
290                 numberColumn = gtk.TreeViewColumn("Phone Numbers", textrenderer, text=0)
291                 self._typeview.append_column(numberColumn)
292
293                 textrenderer = gtk.CellRendererText()
294                 typeColumn = gtk.TreeViewColumn("Phone Type", textrenderer, text=1)
295                 self._typeview.append_column(typeColumn)
296
297                 self._typeviewselection = self._typeview.get_selection()
298                 self._typeviewselection.set_mode(gtk.SELECTION_SINGLE)
299
300                 for phoneType, phoneNumber in contactDetails:
301                         display = " - ".join((phoneNumber, phoneType))
302                         display = phoneType
303                         row = (phoneNumber, display)
304                         self._typemodel.append(row)
305
306                 self._typeviewselection.select_iter(self._typemodel.get_iter_first())
307                 if message:
308                         self._message.set_markup(message)
309                         self._message.show()
310                 else:
311                         self._message.set_markup("")
312                         self._message.hide()
313
314                 if parent is not None:
315                         self._dialog.set_transient_for(parent)
316
317                 try:
318                         userResponse = self._dialog.run()
319                 finally:
320                         self._dialog.hide()
321
322                 if userResponse == gtk.RESPONSE_OK:
323                         phoneNumber = self._get_number()
324                         phoneNumber = make_ugly(phoneNumber)
325                 else:
326                         phoneNumber = ""
327                 if not phoneNumber:
328                         self._action = self.ACTION_CANCEL
329
330                 if self._action == self.ACTION_SEND_SMS:
331                         smsMessage = self._smsDialog.run(phoneNumber, message, parent)
332                         if not smsMessage:
333                                 phoneNumber = ""
334                                 self._action = self.ACTION_CANCEL
335                 else:
336                         smsMessage = ""
337
338                 self._typeviewselection.unselect_all()
339                 self._typeview.remove_column(numberColumn)
340                 self._typeview.remove_column(typeColumn)
341                 self._typeview.set_model(None)
342
343                 return self._action, phoneNumber, smsMessage
344
345         def _get_number(self):
346                 model, itr = self._typeviewselection.get_selected()
347                 if not itr:
348                         return ""
349
350                 phoneNumber = self._typemodel.get_value(itr, 0)
351                 return phoneNumber
352
353         def _on_phonetype_dial(self, *args):
354                 self._dialog.response(gtk.RESPONSE_OK)
355                 self._action = self.ACTION_DIAL
356
357         def _on_phonetype_send_sms(self, *args):
358                 self._dialog.response(gtk.RESPONSE_OK)
359                 self._action = self.ACTION_SEND_SMS
360
361         def _on_phonetype_select(self, *args):
362                 self._dialog.response(gtk.RESPONSE_OK)
363                 self._action = self.ACTION_SELECT
364
365         def _on_phonetype_cancel(self, *args):
366                 self._dialog.response(gtk.RESPONSE_CANCEL)
367                 self._action = self.ACTION_CANCEL
368
369
370 class SmsEntryDialog(object):
371
372         """
373         @todo Add multi-SMS messages like GoogleVoice
374         """
375
376         MAX_CHAR = 160
377
378         def __init__(self, widgetTree):
379                 self._widgetTree = widgetTree
380                 self._dialog = self._widgetTree.get_widget("smsDialog")
381
382                 self._smsButton = self._widgetTree.get_widget("sendSmsButton")
383                 self._smsButton.connect("clicked", self._on_send)
384
385                 self._cancelButton = self._widgetTree.get_widget("cancelSmsButton")
386                 self._cancelButton.connect("clicked", self._on_cancel)
387
388                 self._letterCountLabel = self._widgetTree.get_widget("smsLetterCount")
389                 self._message = self._widgetTree.get_widget("smsMessage")
390                 self._smsEntry = self._widgetTree.get_widget("smsEntry")
391                 self._smsEntry.get_buffer().connect("changed", self._on_entry_changed)
392
393         def run(self, number, message = "", parent = None):
394                 if message:
395                         self._message.set_markup(message)
396                         self._message.show()
397                 else:
398                         self._message.set_markup("")
399                         self._message.hide()
400                 self._smsEntry.get_buffer().set_text("")
401                 self._update_letter_count()
402
403                 if parent is not None:
404                         self._dialog.set_transient_for(parent)
405
406                 try:
407                         userResponse = self._dialog.run()
408                 finally:
409                         self._dialog.hide()
410
411                 if userResponse == gtk.RESPONSE_OK:
412                         entryBuffer = self._smsEntry.get_buffer()
413                         enteredMessage = entryBuffer.get_text(entryBuffer.get_start_iter(), entryBuffer.get_end_iter())
414                         enteredMessage = enteredMessage[0:self.MAX_CHAR]
415                 else:
416                         enteredMessage = ""
417
418                 return enteredMessage.strip()
419
420         def _update_letter_count(self, *args):
421                 entryLength = self._smsEntry.get_buffer().get_char_count()
422                 charsLeft = self.MAX_CHAR - entryLength
423                 self._letterCountLabel.set_text(str(charsLeft))
424                 if charsLeft < 0:
425                         self._smsButton.set_sensitive(False)
426                 else:
427                         self._smsButton.set_sensitive(True)
428
429         def _on_entry_changed(self, *args):
430                 self._update_letter_count()
431
432         def _on_send(self, *args):
433                 self._dialog.response(gtk.RESPONSE_OK)
434
435         def _on_cancel(self, *args):
436                 self._dialog.response(gtk.RESPONSE_CANCEL)
437
438
439 class Dialpad(object):
440
441         def __init__(self, widgetTree, errorDisplay):
442                 self._errorDisplay = errorDisplay
443                 self._smsDialog = SmsEntryDialog(widgetTree)
444
445                 self._numberdisplay = widgetTree.get_widget("numberdisplay")
446                 self._dialButton = widgetTree.get_widget("dial")
447                 self._backButton = widgetTree.get_widget("back")
448                 self._phonenumber = ""
449                 self._prettynumber = ""
450
451                 callbackMapping = {
452                         "on_dial_clicked": self._on_dial_clicked,
453                         "on_sms_clicked": self._on_sms_clicked,
454                         "on_digit_clicked": self._on_digit_clicked,
455                         "on_clear_number": self._on_clear_number,
456                 }
457                 widgetTree.signal_autoconnect(callbackMapping)
458
459                 self._originalLabel = self._backButton.get_label()
460                 self._backTapHandler = gtk_toolbox.TapOrHold(self._backButton)
461                 self._backTapHandler.on_tap = self._on_backspace
462                 self._backTapHandler.on_hold = self._on_clearall
463                 self._backTapHandler.on_holding = self._set_clear_button
464                 self._backTapHandler.on_cancel = self._reset_back_button
465
466                 self._window = gtk_toolbox.find_parent_window(self._numberdisplay)
467
468         def enable(self):
469                 self._dialButton.grab_focus()
470                 self._backTapHandler.enable()
471
472         def disable(self):
473                 self._reset_back_button()
474                 self._backTapHandler.disable()
475
476         def number_selected(self, action, number, message):
477                 """
478                 @note Actual dial function is patched in later
479                 """
480                 raise NotImplementedError("Horrible unknown error has occurred")
481
482         def get_number(self):
483                 return self._phonenumber
484
485         def set_number(self, number):
486                 """
487                 Set the number to dial
488                 """
489                 try:
490                         self._phonenumber = make_ugly(number)
491                         self._prettynumber = make_pretty(self._phonenumber)
492                         self._numberdisplay.set_label("<span size='30000' weight='bold'>%s</span>" % (self._prettynumber))
493                 except TypeError, e:
494                         self._errorDisplay.push_exception()
495
496         def clear(self):
497                 self.set_number("")
498
499         @staticmethod
500         def name():
501                 return "Dialpad"
502
503         def load_settings(self, config, section):
504                 pass
505
506         def save_settings(self, config, section):
507                 """
508                 @note Thread Agnostic
509                 """
510                 pass
511
512         def _on_sms_clicked(self, widget):
513                 action = PhoneTypeSelector.ACTION_SEND_SMS
514                 phoneNumber = self.get_number()
515
516                 message = self._smsDialog.run(phoneNumber, "", self._window)
517                 if not message:
518                         phoneNumber = ""
519                         action = PhoneTypeSelector.ACTION_CANCEL
520
521                 if action == PhoneTypeSelector.ACTION_CANCEL:
522                         return
523                 self.number_selected(action, phoneNumber, message)
524
525         def _on_dial_clicked(self, widget):
526                 action = PhoneTypeSelector.ACTION_DIAL
527                 phoneNumber = self.get_number()
528                 message = ""
529                 self.number_selected(action, phoneNumber, message)
530
531         def _on_clear_number(self, *args):
532                 self.clear()
533
534         def _on_digit_clicked(self, widget):
535                 self.set_number(self._phonenumber + widget.get_name()[-1])
536
537         def _on_backspace(self, taps):
538                 self.set_number(self._phonenumber[:-taps])
539                 self._reset_back_button()
540
541         def _on_clearall(self, taps):
542                 self.clear()
543                 self._reset_back_button()
544                 return False
545
546         def _set_clear_button(self):
547                 self._backButton.set_label("gtk-clear")
548
549         def _reset_back_button(self):
550                 self._backButton.set_label(self._originalLabel)
551
552
553 class AccountInfo(object):
554
555         def __init__(self, widgetTree, backend, alarmHandler, errorDisplay):
556                 self._errorDisplay = errorDisplay
557                 self._backend = backend
558                 self._isPopulated = False
559                 self._alarmHandler = alarmHandler
560                 self._notifyOnMissed = False
561                 self._notifyOnVoicemail = False
562                 self._notifyOnSms = False
563
564                 self._callbackList = gtk.ListStore(gobject.TYPE_STRING)
565                 self._accountViewNumberDisplay = widgetTree.get_widget("gcnumber_display")
566                 self._callbackCombo = widgetTree.get_widget("callbackcombo")
567                 self._onCallbackentryChangedId = 0
568
569                 self._notifyCheckbox = widgetTree.get_widget("notifyCheckbox")
570                 self._minutesEntry = widgetTree.get_widget("minutesEntry")
571                 self._missedCheckbox = widgetTree.get_widget("missedCheckbox")
572                 self._voicemailCheckbox = widgetTree.get_widget("voicemailCheckbox")
573                 self._smsCheckbox = widgetTree.get_widget("smsCheckbox")
574                 self._onNotifyToggled = 0
575                 self._onMinutesChanged = 0
576                 self._onMissedToggled = 0
577                 self._onVoicemailToggled = 0
578                 self._onSmsToggled = 0
579                 self._applyAlarmTimeoutId = None
580
581                 self._defaultCallback = ""
582
583         def enable(self):
584                 assert self._backend.is_authed(), "Attempting to enable backend while not logged in"
585
586                 self._accountViewNumberDisplay.set_use_markup(True)
587                 self.set_account_number("")
588
589                 self._callbackList.clear()
590                 self._onCallbackentryChangedId = self._callbackCombo.get_child().connect("changed", self._on_callbackentry_changed)
591
592                 if self._alarmHandler is not None:
593                         self._minutesEntry.set_range(1, 60)
594                         self._minutesEntry.set_increments(1, 5)
595
596                         self._notifyCheckbox.set_active(self._alarmHandler.isEnabled)
597                         self._minutesEntry.set_value(self._alarmHandler.recurrence)
598                         self._missedCheckbox.set_active(self._notifyOnMissed)
599                         self._voicemailCheckbox.set_active(self._notifyOnVoicemail)
600                         self._smsCheckbox.set_active(self._notifyOnSms)
601
602                         self._onNotifyToggled = self._notifyCheckbox.connect("toggled", self._on_notify_toggled)
603                         self._onMinutesChanged = self._minutesEntry.connect("value-changed", self._on_minutes_changed)
604                         self._onMissedToggled = self._missedCheckbox.connect("toggled", self._on_missed_toggled)
605                         self._onVoicemailToggled = self._voicemailCheckbox.connect("toggled", self._on_voicemail_toggled)
606                         self._onSmsToggled = self._smsCheckbox.connect("toggled", self._on_sms_toggled)
607                 else:
608                         self._notifyCheckbox.set_sensitive(False)
609                         self._minutesEntry.set_sensitive(False)
610                         self._missedCheckbox.set_sensitive(False)
611                         self._voicemailCheckbox.set_sensitive(False)
612                         self._smsCheckbox.set_sensitive(False)
613
614                 self.update(force=True)
615
616         def disable(self):
617                 self._callbackCombo.get_child().disconnect(self._onCallbackentryChangedId)
618                 self._onCallbackentryChangedId = 0
619
620                 if self._alarmHandler is not None:
621                         self._notifyCheckbox.disconnect(self._onNotifyToggled)
622                         self._minutesEntry.disconnect(self._onMinutesChanged)
623                         self._missedCheckbox.disconnect(self._onNotifyToggled)
624                         self._voicemailCheckbox.disconnect(self._onNotifyToggled)
625                         self._smsCheckbox.disconnect(self._onNotifyToggled)
626                         self._onNotifyToggled = 0
627                         self._onMinutesChanged = 0
628                         self._onMissedToggled = 0
629                         self._onVoicemailToggled = 0
630                         self._onSmsToggled = 0
631                 else:
632                         self._notifyCheckbox.set_sensitive(True)
633                         self._minutesEntry.set_sensitive(True)
634                         self._missedCheckbox.set_sensitive(True)
635                         self._voicemailCheckbox.set_sensitive(True)
636                         self._smsCheckbox.set_sensitive(True)
637
638                 self.clear()
639                 self._callbackList.clear()
640
641         def get_selected_callback_number(self):
642                 return make_ugly(self._callbackCombo.get_child().get_text())
643
644         def set_account_number(self, number):
645                 """
646                 Displays current account number
647                 """
648                 self._accountViewNumberDisplay.set_label("<span size='23000' weight='bold'>%s</span>" % (number))
649
650         def update(self, force = False):
651                 if not force and self._isPopulated:
652                         return False
653                 self._populate_callback_combo()
654                 self.set_account_number(self._backend.get_account_number())
655                 return True
656
657         def clear(self):
658                 self._callbackCombo.get_child().set_text("")
659                 self.set_account_number("")
660                 self._isPopulated = False
661
662         def save_everything(self):
663                 raise NotImplementedError
664
665         @staticmethod
666         def name():
667                 return "Account Info"
668
669         def load_settings(self, config, section):
670                 self._defaultCallback = config.get(section, "callback")
671                 self._notifyOnMissed = config.getboolean(section, "notifyOnMissed")
672                 self._notifyOnVoicemail = config.getboolean(section, "notifyOnVoicemail")
673                 self._notifyOnSms = config.getboolean(section, "notifyOnSms")
674
675         def save_settings(self, config, section):
676                 """
677                 @note Thread Agnostic
678                 """
679                 callback = self.get_selected_callback_number()
680                 config.set(section, "callback", callback)
681                 config.set(section, "notifyOnMissed", repr(self._notifyOnMissed))
682                 config.set(section, "notifyOnVoicemail", repr(self._notifyOnVoicemail))
683                 config.set(section, "notifyOnSms", repr(self._notifyOnSms))
684
685         def _populate_callback_combo(self):
686                 self._isPopulated = True
687                 self._callbackList.clear()
688                 try:
689                         callbackNumbers = self._backend.get_callback_numbers()
690                 except StandardError, e:
691                         self._errorDisplay.push_exception()
692                         self._isPopulated = False
693                         return
694
695                 for number, description in callbackNumbers.iteritems():
696                         self._callbackList.append((make_pretty(number),))
697
698                 self._callbackCombo.set_model(self._callbackList)
699                 self._callbackCombo.set_text_column(0)
700                 #callbackNumber = self._backend.get_callback_number()
701                 callbackNumber = self._defaultCallback
702                 self._callbackCombo.get_child().set_text(make_pretty(callbackNumber))
703
704         def _set_callback_number(self, number):
705                 try:
706                         if not self._backend.is_valid_syntax(number):
707                                 self._errorDisplay.push_message("%s is not a valid callback number" % number)
708                         elif number == self._backend.get_callback_number():
709                                 warnings.warn(
710                                         "Callback number already is %s" % (
711                                                 self._backend.get_callback_number(),
712                                         ),
713                                         UserWarning,
714                                         2
715                                 )
716                         else:
717                                 self._backend.set_callback_number(number)
718                                 assert make_ugly(number) == make_ugly(self._backend.get_callback_number()), "Callback number should be %s but instead is %s" % (
719                                         make_pretty(number), make_pretty(self._backend.get_callback_number())
720                                 )
721                                 warnings.warn(
722                                         "Callback number set to %s" % (
723                                                 self._backend.get_callback_number(),
724                                         ),
725                                         UserWarning, 2
726                                 )
727                 except StandardError, e:
728                         self._errorDisplay.push_exception()
729
730         def _update_alarm_settings(self):
731                 try:
732                         isEnabled = self._notifyCheckbox.get_active()
733                         recurrence = self._minutesEntry.get_value_as_int()
734                         if isEnabled != self._alarmHandler.isEnabled or recurrence != self._alarmHandler.recurrence:
735                                 self._alarmHandler.apply_settings(isEnabled, recurrence)
736                 finally:
737                         self.save_everything()
738                         self._notifyCheckbox.set_active(self._alarmHandler.isEnabled)
739                         self._minutesEntry.set_value(self._alarmHandler.recurrence)
740
741         def _on_callbackentry_changed(self, *args):
742                 text = self.get_selected_callback_number()
743                 number = make_ugly(text)
744                 self._set_callback_number(number)
745
746         def _on_notify_toggled(self, *args):
747                 if self._applyAlarmTimeoutId is not None:
748                         gobject.source_remove(self._applyAlarmTimeoutId)
749                         self._applyAlarmTimeoutId = None
750                 self._applyAlarmTimeoutId = gobject.timeout_add(500, self._on_apply_timeout)
751
752         def _on_minutes_changed(self, *args):
753                 if self._applyAlarmTimeoutId is not None:
754                         gobject.source_remove(self._applyAlarmTimeoutId)
755                         self._applyAlarmTimeoutId = None
756                 self._applyAlarmTimeoutId = gobject.timeout_add(500, self._on_apply_timeout)
757
758         def _on_apply_timeout(self, *args):
759                 self._applyAlarmTimeoutId = None
760
761                 self._update_alarm_settings()
762                 return False
763
764         def _on_missed_toggled(self, *args):
765                 self._notifyOnMissed = self._missedCheckbox.get_active()
766                 self.save_everything()
767
768         def _on_voicemail_toggled(self, *args):
769                 self._notifyOnVoicemail = self._voicemailCheckbox.get_active()
770                 self.save_everything()
771
772         def _on_sms_toggled(self, *args):
773                 self._notifyOnSms = self._smsCheckbox.get_active()
774                 self.save_everything()
775
776
777 class RecentCallsView(object):
778
779         NUMBER_IDX = 0
780         DATE_IDX = 1
781         ACTION_IDX = 2
782         FROM_IDX = 3
783
784         def __init__(self, widgetTree, backend, errorDisplay):
785                 self._errorDisplay = errorDisplay
786                 self._backend = backend
787
788                 self._isPopulated = False
789                 self._recentmodel = gtk.ListStore(
790                         gobject.TYPE_STRING, # number
791                         gobject.TYPE_STRING, # date
792                         gobject.TYPE_STRING, # action
793                         gobject.TYPE_STRING, # from
794                 )
795                 self._recentview = widgetTree.get_widget("recentview")
796                 self._recentviewselection = None
797                 self._onRecentviewRowActivatedId = 0
798
799                 textrenderer = gtk.CellRendererText()
800                 textrenderer.set_property("yalign", 0)
801                 self._dateColumn = gtk.TreeViewColumn("Date")
802                 self._dateColumn.pack_start(textrenderer, expand=True)
803                 self._dateColumn.add_attribute(textrenderer, "text", self.DATE_IDX)
804
805                 textrenderer = gtk.CellRendererText()
806                 textrenderer.set_property("yalign", 0)
807                 self._actionColumn = gtk.TreeViewColumn("Action")
808                 self._actionColumn.pack_start(textrenderer, expand=True)
809                 self._actionColumn.add_attribute(textrenderer, "text", self.ACTION_IDX)
810
811                 textrenderer = gtk.CellRendererText()
812                 textrenderer.set_property("yalign", 0)
813                 self._fromColumn = gtk.TreeViewColumn("From")
814                 self._fromColumn.pack_start(textrenderer, expand=True)
815                 self._fromColumn.add_attribute(textrenderer, "text", self.FROM_IDX)
816                 self._fromColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
817
818                 self._window = gtk_toolbox.find_parent_window(self._recentview)
819                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
820
821                 self._updateSink = gtk_toolbox.threaded_stage(
822                         gtk_toolbox.comap(
823                                 self._idly_populate_recentview,
824                                 gtk_toolbox.null_sink(),
825                         )
826                 )
827
828         def enable(self):
829                 assert self._backend.is_authed(), "Attempting to enable backend while not logged in"
830                 self._recentview.set_model(self._recentmodel)
831
832                 self._recentview.append_column(self._dateColumn)
833                 self._recentview.append_column(self._actionColumn)
834                 self._recentview.append_column(self._fromColumn)
835                 self._recentviewselection = self._recentview.get_selection()
836                 self._recentviewselection.set_mode(gtk.SELECTION_SINGLE)
837
838                 self._onRecentviewRowActivatedId = self._recentview.connect("row-activated", self._on_recentview_row_activated)
839
840         def disable(self):
841                 self._recentview.disconnect(self._onRecentviewRowActivatedId)
842
843                 self.clear()
844
845                 self._recentview.remove_column(self._dateColumn)
846                 self._recentview.remove_column(self._actionColumn)
847                 self._recentview.remove_column(self._fromColumn)
848                 self._recentview.set_model(None)
849
850         def number_selected(self, action, number, message):
851                 """
852                 @note Actual dial function is patched in later
853                 """
854                 raise NotImplementedError("Horrible unknown error has occurred")
855
856         def update(self, force = False):
857                 if not force and self._isPopulated:
858                         return False
859                 self._updateSink.send(())
860                 return True
861
862         def clear(self):
863                 self._isPopulated = False
864                 self._recentmodel.clear()
865
866         @staticmethod
867         def name():
868                 return "Recent Calls"
869
870         def load_settings(self, config, section):
871                 pass
872
873         def save_settings(self, config, section):
874                 """
875                 @note Thread Agnostic
876                 """
877                 pass
878
879         def _idly_populate_recentview(self):
880                 self._recentmodel.clear()
881                 self._isPopulated = True
882
883                 try:
884                         recentItems = self._backend.get_recent()
885                 except StandardError, e:
886                         self._errorDisplay.push_exception_with_lock()
887                         self._isPopulated = False
888                         recentItems = []
889
890                 for personName, phoneNumber, date, action in recentItems:
891                         if not personName:
892                                 personName = "Unknown"
893                         prettyNumber = phoneNumber[2:] if phoneNumber.startswith("+1") else phoneNumber
894                         prettyNumber = make_pretty(prettyNumber)
895                         description = "%s - %s" % (personName, prettyNumber)
896                         item = (phoneNumber, date, action.capitalize(), description)
897                         with gtk_toolbox.gtk_lock():
898                                 self._recentmodel.append(item)
899
900                 return False
901
902         def _on_recentview_row_activated(self, treeview, path, view_column):
903                 model, itr = self._recentviewselection.get_selected()
904                 if not itr:
905                         return
906
907                 number = self._recentmodel.get_value(itr, self.NUMBER_IDX)
908                 number = make_ugly(number)
909                 contactPhoneNumbers = [("Phone", number)]
910                 description = self._recentmodel.get_value(itr, self.FROM_IDX)
911
912                 action, phoneNumber, message = self._phoneTypeSelector.run(
913                         contactPhoneNumbers,
914                         message = description,
915                         parent = self._window,
916                 )
917                 if action == PhoneTypeSelector.ACTION_CANCEL:
918                         return
919                 assert phoneNumber, "A lack of phone number exists"
920
921                 self.number_selected(action, phoneNumber, message)
922                 self._recentviewselection.unselect_all()
923
924
925 class MessagesView(object):
926
927         NUMBER_IDX = 0
928         DATE_IDX = 1
929         HEADER_IDX = 2
930         MESSAGE_IDX = 3
931
932         def __init__(self, widgetTree, backend, errorDisplay):
933                 self._errorDisplay = errorDisplay
934                 self._backend = backend
935
936                 self._isPopulated = False
937                 self._messagemodel = gtk.ListStore(
938                         gobject.TYPE_STRING, # number
939                         gobject.TYPE_STRING, # date
940                         gobject.TYPE_STRING, # header
941                         gobject.TYPE_STRING, # message
942                 )
943                 self._messageview = widgetTree.get_widget("messages_view")
944                 self._messageviewselection = None
945                 self._onMessageviewRowActivatedId = 0
946
947                 textrenderer = gtk.CellRendererText()
948                 textrenderer.set_property("yalign", 0)
949                 self._dateColumn = gtk.TreeViewColumn("Date")
950                 self._dateColumn.pack_start(textrenderer, expand=True)
951                 self._dateColumn.add_attribute(textrenderer, "markup", self.DATE_IDX)
952
953                 textrenderer = gtk.CellRendererText()
954                 textrenderer.set_property("yalign", 0)
955                 self._headerColumn = gtk.TreeViewColumn("From")
956                 self._headerColumn.pack_start(textrenderer, expand=True)
957                 self._headerColumn.add_attribute(textrenderer, "markup", self.HEADER_IDX)
958
959                 textrenderer = gtk.CellRendererText()
960                 textrenderer.set_property("yalign", 0)
961                 self._messageColumn = gtk.TreeViewColumn("Messages")
962                 self._messageColumn.pack_start(textrenderer, expand=True)
963                 self._messageColumn.add_attribute(textrenderer, "markup", self.MESSAGE_IDX)
964                 self._messageColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
965
966                 self._window = gtk_toolbox.find_parent_window(self._messageview)
967                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
968
969                 self._updateSink = gtk_toolbox.threaded_stage(
970                         gtk_toolbox.comap(
971                                 self._idly_populate_messageview,
972                                 gtk_toolbox.null_sink(),
973                         )
974                 )
975
976         def enable(self):
977                 assert self._backend.is_authed(), "Attempting to enable backend while not logged in"
978                 self._messageview.set_model(self._messagemodel)
979
980                 self._messageview.append_column(self._dateColumn)
981                 self._messageview.append_column(self._headerColumn)
982                 self._messageview.append_column(self._messageColumn)
983                 self._messageviewselection = self._messageview.get_selection()
984                 self._messageviewselection.set_mode(gtk.SELECTION_SINGLE)
985
986                 self._onMessageviewRowActivatedId = self._messageview.connect("row-activated", self._on_messageview_row_activated)
987
988         def disable(self):
989                 self._messageview.disconnect(self._onMessageviewRowActivatedId)
990
991                 self.clear()
992
993                 self._messageview.remove_column(self._dateColumn)
994                 self._messageview.remove_column(self._headerColumn)
995                 self._messageview.remove_column(self._messageColumn)
996                 self._messageview.set_model(None)
997
998         def number_selected(self, action, number, message):
999                 """
1000                 @note Actual dial function is patched in later
1001                 """
1002                 raise NotImplementedError("Horrible unknown error has occurred")
1003
1004         def update(self, force = False):
1005                 if not force and self._isPopulated:
1006                         return False
1007                 self._updateSink.send(())
1008                 return True
1009
1010         def clear(self):
1011                 self._isPopulated = False
1012                 self._messagemodel.clear()
1013
1014         @staticmethod
1015         def name():
1016                 return "Messages"
1017
1018         def load_settings(self, config, section):
1019                 pass
1020
1021         def save_settings(self, config, section):
1022                 """
1023                 @note Thread Agnostic
1024                 """
1025                 pass
1026
1027         def _idly_populate_messageview(self):
1028                 self._messagemodel.clear()
1029                 self._isPopulated = True
1030
1031                 try:
1032                         messageItems = self._backend.get_messages()
1033                 except StandardError, e:
1034                         self._errorDisplay.push_exception_with_lock()
1035                         self._isPopulated = False
1036                         messageItems = []
1037
1038                 for header, number, relativeDate, message in messageItems:
1039                         number = make_ugly(number)
1040                         row = (number, relativeDate, header, message)
1041                         with gtk_toolbox.gtk_lock():
1042                                 self._messagemodel.append(row)
1043
1044                 return False
1045
1046         def _on_messageview_row_activated(self, treeview, path, view_column):
1047                 model, itr = self._messageviewselection.get_selected()
1048                 if not itr:
1049                         return
1050
1051                 contactPhoneNumbers = [("Phone", self._messagemodel.get_value(itr, self.NUMBER_IDX))]
1052                 description = self._messagemodel.get_value(itr, self.MESSAGE_IDX)
1053
1054                 action, phoneNumber, message = self._phoneTypeSelector.run(
1055                         contactPhoneNumbers,
1056                         message = description,
1057                         parent = self._window,
1058                 )
1059                 if action == PhoneTypeSelector.ACTION_CANCEL:
1060                         return
1061                 assert phoneNumber, "A lock of phone number exists"
1062
1063                 self.number_selected(action, phoneNumber, message)
1064                 self._messageviewselection.unselect_all()
1065
1066
1067 class ContactsView(object):
1068
1069         def __init__(self, widgetTree, backend, errorDisplay):
1070                 self._errorDisplay = errorDisplay
1071                 self._backend = backend
1072
1073                 self._addressBook = None
1074                 self._selectedComboIndex = 0
1075                 self._addressBookFactories = [null_backend.NullAddressBook()]
1076
1077                 self._booksList = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
1078                 self._booksSelectionBox = widgetTree.get_widget("addressbook_combo")
1079
1080                 self._isPopulated = False
1081                 self._contactsmodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
1082                 self._contactsviewselection = None
1083                 self._contactsview = widgetTree.get_widget("contactsview")
1084
1085                 self._contactColumn = gtk.TreeViewColumn("Contact")
1086                 displayContactSource = False
1087                 if displayContactSource:
1088                         textrenderer = gtk.CellRendererText()
1089                         self._contactColumn.pack_start(textrenderer, expand=False)
1090                         self._contactColumn.add_attribute(textrenderer, 'text', 0)
1091                 textrenderer = gtk.CellRendererText()
1092                 self._contactColumn.pack_start(textrenderer, expand=True)
1093                 self._contactColumn.add_attribute(textrenderer, 'text', 1)
1094                 textrenderer = gtk.CellRendererText()
1095                 self._contactColumn.pack_start(textrenderer, expand=True)
1096                 self._contactColumn.add_attribute(textrenderer, 'text', 4)
1097                 self._contactColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
1098                 self._contactColumn.set_sort_column_id(1)
1099                 self._contactColumn.set_visible(True)
1100
1101                 self._onContactsviewRowActivatedId = 0
1102                 self._onAddressbookComboChangedId = 0
1103                 self._window = gtk_toolbox.find_parent_window(self._contactsview)
1104                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
1105
1106                 self._updateSink = gtk_toolbox.threaded_stage(
1107                         gtk_toolbox.comap(
1108                                 self._idly_populate_contactsview,
1109                                 gtk_toolbox.null_sink(),
1110                         )
1111                 )
1112
1113         def enable(self):
1114                 assert self._backend.is_authed(), "Attempting to enable backend while not logged in"
1115
1116                 self._contactsview.set_model(self._contactsmodel)
1117                 self._contactsview.append_column(self._contactColumn)
1118                 self._contactsviewselection = self._contactsview.get_selection()
1119                 self._contactsviewselection.set_mode(gtk.SELECTION_SINGLE)
1120
1121                 self._booksList.clear()
1122                 for (factoryId, bookId), (factoryName, bookName) in self.get_addressbooks():
1123                         if factoryName and bookName:
1124                                 entryName = "%s: %s" % (factoryName, bookName)
1125                         elif factoryName:
1126                                 entryName = factoryName
1127                         elif bookName:
1128                                 entryName = bookName
1129                         else:
1130                                 entryName = "Bad name (%d)" % factoryId
1131                         row = (str(factoryId), bookId, entryName)
1132                         self._booksList.append(row)
1133
1134                 self._booksSelectionBox.set_model(self._booksList)
1135                 cell = gtk.CellRendererText()
1136                 self._booksSelectionBox.pack_start(cell, True)
1137                 self._booksSelectionBox.add_attribute(cell, 'text', 2)
1138
1139                 self._onContactsviewRowActivatedId = self._contactsview.connect("row-activated", self._on_contactsview_row_activated)
1140                 self._onAddressbookComboChangedId = self._booksSelectionBox.connect("changed", self._on_addressbook_combo_changed)
1141
1142                 if len(self._booksList) <= self._selectedComboIndex:
1143                         self._selectedComboIndex = 0
1144                 self._booksSelectionBox.set_active(self._selectedComboIndex)
1145
1146         def disable(self):
1147                 self._contactsview.disconnect(self._onContactsviewRowActivatedId)
1148                 self._booksSelectionBox.disconnect(self._onAddressbookComboChangedId)
1149
1150                 self.clear()
1151
1152                 self._booksSelectionBox.clear()
1153                 self._booksSelectionBox.set_model(None)
1154                 self._contactsview.set_model(None)
1155                 self._contactsview.remove_column(self._contactColumn)
1156
1157         def number_selected(self, action, number, message):
1158                 """
1159                 @note Actual dial function is patched in later
1160                 """
1161                 raise NotImplementedError("Horrible unknown error has occurred")
1162
1163         def get_addressbooks(self):
1164                 """
1165                 @returns Iterable of ((Factory Id, Book Id), (Factory Name, Book Name))
1166                 """
1167                 for i, factory in enumerate(self._addressBookFactories):
1168                         for bookFactory, bookId, bookName in factory.get_addressbooks():
1169                                 yield (str(i), bookId), (factory.factory_name(), bookName)
1170
1171         def open_addressbook(self, bookFactoryId, bookId):
1172                 bookFactoryIndex = int(bookFactoryId)
1173                 addressBook = self._addressBookFactories[bookFactoryIndex].open_addressbook(bookId)
1174
1175                 forceUpdate = True if addressBook is not self._addressBook else False
1176
1177                 self._addressBook = addressBook
1178                 self.update(force=forceUpdate)
1179
1180         def update(self, force = False):
1181                 if not force and self._isPopulated:
1182                         return False
1183                 self._updateSink.send(())
1184                 return True
1185
1186         def clear(self):
1187                 self._isPopulated = False
1188                 self._contactsmodel.clear()
1189                 for factory in self._addressBookFactories:
1190                         factory.clear_caches()
1191                 self._addressBook.clear_caches()
1192
1193         def append(self, book):
1194                 self._addressBookFactories.append(book)
1195
1196         def extend(self, books):
1197                 self._addressBookFactories.extend(books)
1198
1199         @staticmethod
1200         def name():
1201                 return "Contacts"
1202
1203         def load_settings(self, config, sectionName):
1204                 try:
1205                         self._selectedComboIndex = config.getint(sectionName, "selectedAddressbook")
1206                 except ConfigParser.NoOptionError:
1207                         self._selectedComboIndex = 0
1208
1209         def save_settings(self, config, sectionName):
1210                 config.set(sectionName, "selectedAddressbook", str(self._selectedComboIndex))
1211
1212         def _idly_populate_contactsview(self):
1213                 addressBook = None
1214                 while addressBook is not self._addressBook:
1215                         addressBook = self._addressBook
1216                         with gtk_toolbox.gtk_lock():
1217                                 self._contactsview.set_model(None)
1218                                 self.clear()
1219
1220                         try:
1221                                 contacts = addressBook.get_contacts()
1222                         except StandardError, e:
1223                                 contacts = []
1224                                 self._isPopulated = False
1225                                 self._errorDisplay.push_exception_with_lock()
1226                         for contactId, contactName in contacts:
1227                                 contactType = (addressBook.contact_source_short_name(contactId), )
1228                                 self._contactsmodel.append(contactType + (contactName, "", contactId) + ("", ))
1229
1230                         with gtk_toolbox.gtk_lock():
1231                                 self._contactsview.set_model(self._contactsmodel)
1232
1233                 self._isPopulated = True
1234                 return False
1235
1236         def _on_addressbook_combo_changed(self, *args, **kwds):
1237                 itr = self._booksSelectionBox.get_active_iter()
1238                 if itr is None:
1239                         return
1240                 self._selectedComboIndex = self._booksSelectionBox.get_active()
1241                 selectedFactoryId = self._booksList.get_value(itr, 0)
1242                 selectedBookId = self._booksList.get_value(itr, 1)
1243                 self.open_addressbook(selectedFactoryId, selectedBookId)
1244
1245         def _on_contactsview_row_activated(self, treeview, path, view_column):
1246                 model, itr = self._contactsviewselection.get_selected()
1247                 if not itr:
1248                         return
1249
1250                 contactId = self._contactsmodel.get_value(itr, 3)
1251                 contactName = self._contactsmodel.get_value(itr, 1)
1252                 try:
1253                         contactDetails = self._addressBook.get_contact_details(contactId)
1254                 except StandardError, e:
1255                         contactDetails = []
1256                         self._errorDisplay.push_exception()
1257                 contactPhoneNumbers = [phoneNumber for phoneNumber in contactDetails]
1258
1259                 if len(contactPhoneNumbers) == 0:
1260                         return
1261
1262                 action, phoneNumber, message = self._phoneTypeSelector.run(
1263                         contactPhoneNumbers,
1264                         message = contactName,
1265                         parent = self._window,
1266                 )
1267                 if action == PhoneTypeSelector.ACTION_CANCEL:
1268                         return
1269                 assert phoneNumber, "A lack of phone number exists"
1270
1271                 self.number_selected(action, phoneNumber, message)
1272                 self._contactsviewselection.unselect_all()