Commit 8e38a1ae authored by Saul Ibarra's avatar Saul Ibarra

Added integration with Google Contacts

parent f8ba1d97
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
"""Definitions of datatypes for use in settings extensions.""" """Definitions of datatypes for use in settings extensions."""
__all__ = ['ApplicationDataPath', 'SoundFile', 'DefaultPath', 'CustomSoundFile', 'HTTPURL'] __all__ = ['ApplicationDataPath', 'SoundFile', 'DefaultPath', 'CustomSoundFile', 'HTTPURL', 'InvalidToken', 'AuthorizationToken']
import os import os
import re import re
...@@ -113,3 +113,33 @@ class HTTPURL(unicode): ...@@ -113,3 +113,33 @@ class HTTPURL(unicode):
return value return value
class InvalidToken(object):
def __repr__(self):
return self.__class__.__name__
class AuthorizationToken(object):
def __init__(self, token=None):
self.token = token
def __getstate__(self):
if self.token is InvalidToken:
return u'invalid'
else:
return u'value:%s' % (self.__dict__['token'])
def __setstate__(self, state):
match = re.match(r'^(?P<type>invalid|value:)(?P<token>.+?)?$', state)
if match is None:
raise ValueError('illegal value: %r' % state)
data = match.groupdict()
if data.pop('type') == 'invalid':
data['token'] = InvalidToken
self.__init__(data['token'])
def __nonzero__(self):
return self.token is not InvalidToken
def __repr__(self):
return '%s(%r)' % (self.__class__.__name__, self.token)
...@@ -12,7 +12,7 @@ from sipsimple.configuration import Setting, SettingsGroup, SettingsObjectExtens ...@@ -12,7 +12,7 @@ from sipsimple.configuration import Setting, SettingsGroup, SettingsObjectExtens
from sipsimple.configuration.settings import AudioSettings, LogsSettings, TLSSettings from sipsimple.configuration.settings import AudioSettings, LogsSettings, TLSSettings
from blink import __version__ from blink import __version__
from blink.configuration.datatypes import ApplicationDataPath, HTTPURL, SoundFile from blink.configuration.datatypes import ApplicationDataPath, AuthorizationToken, HTTPURL, SoundFile
from blink.resources import Resources from blink.resources import Resources
...@@ -20,6 +20,11 @@ class AudioSettingsExtension(AudioSettings): ...@@ -20,6 +20,11 @@ class AudioSettingsExtension(AudioSettings):
recordings_directory = Setting(type=ApplicationDataPath, default=ApplicationDataPath('recordings'), nillable=False) recordings_directory = Setting(type=ApplicationDataPath, default=ApplicationDataPath('recordings'), nillable=False)
class GoogleContactsSettings(SettingsGroup):
authorization_token = Setting(type=AuthorizationToken, default=None, nillable=True)
username = Setting(type=unicode, default=None, nillable=True)
class LogsSettingsExtension(LogsSettings): class LogsSettingsExtension(LogsSettings):
trace_sip = Setting(type=bool, default=False) trace_sip = Setting(type=bool, default=False)
trace_pjsip = Setting(type=bool, default=False) trace_pjsip = Setting(type=bool, default=False)
...@@ -42,6 +47,7 @@ class TLSSettingsExtension(TLSSettings): ...@@ -42,6 +47,7 @@ class TLSSettingsExtension(TLSSettings):
class SIPSimpleSettingsExtension(SettingsObjectExtension): class SIPSimpleSettingsExtension(SettingsObjectExtension):
audio = AudioSettingsExtension audio = AudioSettingsExtension
google_contacts = GoogleContactsSettings
logs = LogsSettingsExtension logs = LogsSettingsExtension
server = ServerSettings server = ServerSettings
sounds = SoundSettings sounds = SoundSettings
......
...@@ -3,11 +3,13 @@ ...@@ -3,11 +3,13 @@
from __future__ import with_statement from __future__ import with_statement
__all__ = ['BonjourGroup', 'BonjourNeighbour', 'Contact', 'ContactGroup', 'ContactModel', 'ContactSearchModel', 'ContactListView', 'ContactSearchListView', 'ContactEditorDialog'] __all__ = ['BonjourGroup', 'BonjourNeighbour', 'Contact', 'ContactGroup', 'ContactModel', 'ContactSearchModel', 'ContactListView', 'ContactSearchListView', 'ContactEditorDialog', 'GoogleContactsDialog']
import cPickle as pickle import cPickle as pickle
import errno import errno
import os import os
import re
import socket
import sys import sys
from PyQt4 import uic from PyQt4 import uic
...@@ -19,17 +21,31 @@ from application.notification import IObserver, NotificationCenter ...@@ -19,17 +21,31 @@ from application.notification import IObserver, NotificationCenter
from application.python.decorator import decorator, preserve_signature from application.python.decorator import decorator, preserve_signature
from application.python.util import Null from application.python.util import Null
from application.system import unlink from application.system import unlink
from collections import deque
from eventlet import api
from eventlet.green import urllib2
from functools import partial from functools import partial
from operator import attrgetter from operator import attrgetter
from twisted.internet import reactor
from twisted.internet.error import ConnectionLost
from zope.interface import implements from zope.interface import implements
from sipsimple.account import AccountManager, BonjourAccount from sipsimple.account import AccountManager, BonjourAccount
from sipsimple.util import makedirs from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.util import makedirs, run_in_green_thread, run_in_twisted_thread
from blink.configuration.datatypes import AuthorizationToken, InvalidToken
from blink.resources import ApplicationData, Resources, IconCache from blink.resources import ApplicationData, Resources, IconCache
from blink.sessions import SessionManager from blink.sessions import SessionManager
from blink.util import run_in_auxiliary_thread, run_in_gui_thread from blink.util import QSingleton, call_in_gui_thread, call_later, run_in_auxiliary_thread, run_in_gui_thread
from blink.widgets.buttons import SwitchViewButton from blink.widgets.buttons import SwitchViewButton
from blink.widgets.labels import Status
from blink.google.gdata.client import BadAuthentication, CaptchaChallenge, RequestError, Unauthorized
from blink.google.gdata.contacts.client import ContactsClient
from blink.google.gdata.contacts.data import ContactsFeed
from blink.google.gdata.contacts.service import ContactsQuery
from blink.google.gdata.gauth import ClientLoginToken
# Functions decorated with updates_contacts_db or ignore_contacts_db_updates must # Functions decorated with updates_contacts_db or ignore_contacts_db_updates must
...@@ -223,6 +239,368 @@ class BonjourNeighbour(Contact): ...@@ -223,6 +239,368 @@ class BonjourNeighbour(Contact):
return "%s (%s)" % (self.name, self.hostname) return "%s (%s)" % (self.name, self.hostname)
class GoogleContactsGroup(ContactGroup):
savable = True
movable = True
editable = True
deletable = False
def __init__(self, name, collapsed=False):
super(GoogleContactsGroup, self).__init__(name, collapsed)
self.id = None
self.update_timestamp = None
def __reduce__(self):
return (self.__class__, (self.name, self.user_collapsed), dict(id=self.id, update_timestamp=self.update_timestamp))
class GoogleContact(Contact):
savable = True
movable = False
editable = False
deletable = False
def __init__(self, id, group, name, uri, company=None, uri_type=None, image=None, image_etag=None):
super(GoogleContact, self).__init__(group, name, uri, image)
self.id = id
self.company = company
self.uri_type = uri_type
self.image_etag = image_etag
def __reduce__(self):
return (self.__class__, (self.id, self.group, self.name, self.uri, self.company, self.uri_type, self.image, self.image_etag), dict(preferred_media=self.preferred_media, sip_aliases=self.sip_aliases))
def __unicode__(self):
return u'%s <%s>' % (self.name_detail, self.uri_detail)
@property
def name_detail(self):
if self.company:
return '%s (%s)' % (self.name, self.company) if self.name else self.company
else:
return self.name or self.uri
@property
def uri_detail(self):
return "%s (%s)" % (self.uri, self.uri_type) if self.uri_type else self.uri
class GoogleContactsManager(object):
implements(IObserver)
def __init__(self, model):
self.client = ContactsClient()
self.contact_model = model
self.entries_map = dict()
self.greenlet = None
self.stop_adding_contacts = False
self._load_timer = None
notification_center = NotificationCenter()
notification_center.add_observer(self, name='CFGSettingsObjectDidChange')
notification_center.add_observer(self, name='SIPApplicationWillStart')
notification_center.add_observer(self, name='SIPApplicationWillEnd')
@property
def group(self):
return self.contact_model.google_contacts_group
def initialize(self):
self.entries_map.clear()
for contact in (item for item in self.contact_model.items if type(item) is GoogleContact):
self.entries_map.setdefault(contact.id, []).append(contact)
@staticmethod
def normalize_uri_label(label):
try:
label = label.lower()
label = label.split('#')[1]
label = re.sub('_', ' ', label)
except AttributeError:
label = ''
except IndexError:
label = re.sub('\/', '', label)
finally:
label = re.sub('generic', '', label)
return label.strip()
@run_in_twisted_thread
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_SIPApplicationWillStart(self, notification):
settings = SIPSimpleSettings()
authorization_token = settings.google_contacts.authorization_token
if authorization_token:
call_in_gui_thread(self.contact_model.addGroup, self.contact_model.google_contacts_group)
self.load_contacts()
elif authorization_token is None:
self.remove_group()
def _NH_CFGSettingsObjectDidChange(self, notification):
if 'google_contacts.authorization_token' in notification.data.modified:
authorization_token = notification.sender.google_contacts.authorization_token
if self._load_timer is not None and self._load_timer.active():
self._load_timer.cancel()
self._load_timer = None
if authorization_token:
call_in_gui_thread(self.contact_model.addGroup, self.contact_model.google_contacts_group)
self.stop_adding_contacts = False
self.load_contacts()
elif authorization_token is None:
if self._load_timer is not None and self._load_timer.active():
self._load_timer.cancel()
self._load_timer = None
if self.greenlet is not None:
api.kill(self.greenlet, api.GreenletExit())
self.greenlet = None
self.stop_adding_contacts = False
self.remove_group()
def _NH_SIPApplicationWillEnd(self, notification):
if self.greenlet is not None:
api.kill(self.greenlet, api.GreenletExit())
@run_in_green_thread
def load_contacts(self):
if self.greenlet is not None:
api.kill(self.greenlet, api.GreenletExit())
self.greenlet = api.getcurrent()
if self._load_timer is not None and self._load_timer.active():
self._load_timer.cancel()
self._load_timer = None
settings = SIPSimpleSettings()
self.client.auth_token = ClientLoginToken(settings.google_contacts.authorization_token.token)
try:
if self.group.id is None:
self.group.id = (entry.id.text for entry in self.client.get_groups().entry if entry.title.text=='System Group: My Contacts').next()
query_params = dict(showdeleted='true')
query = ContactsQuery(feed=self.client.get_feed_uri(kind='contacts'), group=self.group.id, params=query_params)
previous_update = self.contact_model.google_contacts_group.update_timestamp
if previous_update:
query.updated_min = previous_update
feed = self.client.get_feed(query.ToUri(), desired_class=ContactsFeed)
update_timestamp = feed.updated.text if feed else None
while feed:
updated_contacts = []
deleted_contacts = set(entry.id.text for entry in feed.entry if getattr(entry, 'deleted', False))
self.remove_contacts(deleted_contacts)
for entry in (entry for entry in feed.entry if entry.id.text not in deleted_contacts):
name = (getattr(entry, 'title', None) or Null).text or None
company = ((getattr(entry, 'organization', None) or Null).name or Null).text or None
numbers = set((re.sub(r'^00', '+', number.text), number.label or number.rel) for number in getattr(entry, 'phone_number', ()))
numbers.update(set((re.sub('^(sip:|sips:)', '', email.address), email.label or email.rel) for email in getattr(entry, 'email', ()) if re.search('^(sip:|sips:)', email.address)))
numbers.update(set((re.sub('^(sip:|sips:)', '', web.href), web.label or web.rel) for web in getattr(entry, 'website', ()) if re.search('^(sip:|sips:)', web.href)))
numbers.difference_update(set((number, label) for number, label in numbers if label.lower().find('fax') != -1))
if not numbers:
continue
image_data = None
image_url, image_etag = entry.get_entry_photo_data()
if image_url and image_etag and self.entries_map.get(entry.id.text, Null)[0].image_etag != image_etag:
try:
image_data = self.client.Get(image_url).read()
except Exception:
pass
updated_contacts.append((entry.id.text, name, company, numbers, image_data, image_etag))
self.update_contacts(updated_contacts)
feed = self.client.get_next(feed) if feed.find_next_link() is not None else None
except Unauthorized:
settings.google_contacts.authorization_token = AuthorizationToken(InvalidToken)
settings.save()
except (ConnectionLost, RequestError, socket.error):
self._load_timer = reactor.callLater(60, self.load_contacts)
else:
if update_timestamp:
self.update_group_timestamp(update_timestamp)
self._load_timer = reactor.callLater(60, self.load_contacts)
@run_in_gui_thread
@updates_contacts_db
def update_contacts(self, contacts):
if self.stop_adding_contacts:
return
icon_cache = IconCache()
for id, name, company, numbers, image_data, image_etag in contacts:
entries = self.entries_map.setdefault(id, [])
existing_numbers = dict((entry.uri, entry) for entry in entries)
# Save GoogleContact instances that can be reused to hold new contact information
reusable_entries = deque(entry for entry in entries if entry.uri not in (number for number, label in numbers))
image = icon_cache.store_image(image_data)
if image_etag and not image_data:
try:
image = entries[0].image
image_etag = entries[0].image_etag
except IndexError:
image, image_etag = None, None
for number, label in numbers:
if number in existing_numbers:
entry = existing_numbers[number]
self.contact_model.updateContact(entry, dict(name=name, company=company, group=self.group, uri_type=self.normalize_uri_label(label), image=image, image_etag=image_etag))
elif reusable_entries:
entry = reusable_entries.popleft()
self.contact_model.updateContact(entry, dict(name=name, company=company, group=self.group, uri=number, uri_type=self.normalize_uri_label(label), image=image, image_etag=image_etag))
else:
try:
image = entries[0].image
except IndexError:
pass
entry = GoogleContact(id, self.group, name, number, company=company, uri_type=self.normalize_uri_label(label), image=image, image_etag=image_etag)
entries.append(entry)
self.contact_model.addContact(entry)
for entry in reusable_entries:
entries.remove(entry)
self.contact_model.removeContact(entry)
@run_in_gui_thread
@updates_contacts_db
def remove_contacts(self, contact_ids):
deleted_contacts = []
for id in contact_ids:
deleted_contacts.extend(self.entries_map.pop(id, ()))
for contact in deleted_contacts:
self.contact_model.removeContact(contact)
@run_in_gui_thread
@updates_contacts_db
def remove_group(self):
self.contact_model.removeGroup(self.contact_model.google_contacts_group)
self.group.id = None
self.group.update_timestamp = None
self.entries_map.clear()
@run_in_gui_thread
def update_group_timestamp(self, timestamp):
if not self.stop_adding_contacts:
self.group.update_timestamp = timestamp
ui_class, base_class = uic.loadUiType(Resources.get('google_contacts_dialog.ui'))
class GoogleContactsDialog(base_class, ui_class):
__metaclass__ = QSingleton
def __init__(self, parent=None):
super(GoogleContactsDialog, self).__init__(parent)
with Resources.directory:
self.setupUi(self)
self.authorize_button.clicked.connect(self._SH_AuthorizeButtonClicked)
self.captcha_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
self.username_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
self.password_editor.statusChanged.connect(self._SH_ValidityStatusChanged)
self.rejected.connect(self._SH_DialogRejected)
self.captcha_editor.regexp = re.compile('^.+$')
self.username_editor.regexp = re.compile('^.+$')
self.password_editor.regexp = re.compile('^.+$')
self.captcha_token = None
self.enable_captcha(False)
def enable_captcha(self, visible):
self.captcha_label.setVisible(visible)
self.captcha_editor.setVisible(visible)
self.captcha_image_label.setVisible(visible)
inputs = [self.username_editor, self.password_editor]
if visible:
inputs.append(self.captcha_editor)
self.captcha_editor.setText(u'')
call_later(0, self.captcha_editor.setFocus)
self.authorize_button.setEnabled(all(input.text_valid for input in inputs))
def open(self):
settings = SIPSimpleSettings()
self.username_editor.setEnabled(True)
self.username_editor.setText(settings.google_contacts.username or u'')
self.password_editor.setText(u'')
super(GoogleContactsDialog, self).show()
def open_for_incorrect_password(self):
red = '#cc0000'
settings = SIPSimpleSettings()
self.username_editor.setEnabled(False)
self.username_editor.setText(settings.google_contacts.username)
self.status_label.value = Status('Error authenticating with Google. Please enter your password:', color=red)
super(GoogleContactsDialog, self).show()
@run_in_green_thread
def _authorize_google_account(self):
red = '#cc0000'
captcha_response = unicode(self.captcha_editor.text()) if self.captcha_token else None
username = unicode(self.username_editor.text())
password = unicode(self.password_editor.text())
client = ContactsClient()
try:
client.client_login(email=username, password=password, source='Blink', captcha_token=self.captcha_token, captcha_response=captcha_response)
except CaptchaChallenge, e:
call_in_gui_thread(self.username_editor.setEnabled, False)
call_in_gui_thread(setattr, self.status_label, 'value', Status('Error authenticating with Google', color=red))
try:
captcha_data = urllib2.urlopen(e.captcha_url).read()
except (urllib2.HTTPError, urllib2.URLError):
pass
else:
self.captcha_token = e.captcha_token
call_in_gui_thread(self._set_captcha_image, captcha_data)
call_in_gui_thread(self.enable_captcha, True)
except (BadAuthentication, RequestError):
self.captcha_token = None
call_in_gui_thread(self.username_editor.setEnabled, True)
call_in_gui_thread(setattr, self.status_label, 'value', Status('Error authenticating with Google', color=red))
except Exception:
self.captcha_token = None
call_in_gui_thread(self.username_editor.setEnabled, True)
call_in_gui_thread(setattr, self.status_label, 'value', Status('Error connecting with Google', color=red))
else:
self.captcha_token = None
settings = SIPSimpleSettings()
settings.google_contacts.authorization_token = AuthorizationToken(client.auth_token.token_string)
settings.google_contacts.username = username
settings.save()
call_in_gui_thread(self.enable_captcha, False)
call_in_gui_thread(self.accept)
finally:
call_in_gui_thread(self.setEnabled, True)
def _set_captcha_image(self, data):
pixmap = QPixmap()
if pixmap.loadFromData(data):
pixmap = pixmap.scaled(200, 70, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.captcha_image_label.setPixmap(pixmap)
def _SH_AuthorizeButtonClicked(self):
self.status_label.value = Status('Contacting Google server...')
self.setEnabled(False)
self._authorize_google_account()
@run_in_twisted_thread
def _SH_DialogRejected(self):
settings = SIPSimpleSettings()
settings.google_contacts.authorization_token = None
settings.save()
self.captcha_token = None
call_in_gui_thread(self.enable_captcha, False)
def _SH_ValidityStatusChanged(self):
red = '#cc0000'
if not self.username_editor.text_valid:
self.status_label.value = Status('Please specify your Google account username', color=red)
elif not self.password_editor.text_valid:
self.status_label.value = Status('Please specify your Google account password', color=red)
elif self.captcha_editor.isVisible() and not self.captcha_editor.text_valid:
self.status_label.value = Status('Please insert the text in the image below', color=red)
else:
self.status_label.value = None
self.authorize_button.setEnabled(self.username_editor.text_valid and self.password_editor.text_valid and (True if not self.captcha_editor.isVisible() else self.captcha_editor.text_valid))
del ui_class, base_class
ui_class, base_class = uic.loadUiType(Resources.get('contact.ui')) ui_class, base_class = uic.loadUiType(Resources.get('contact.ui'))
class ContactWidget(base_class, ui_class): class ContactWidget(base_class, ui_class):
...@@ -389,7 +767,7 @@ del ui_class, base_class ...@@ -389,7 +767,7 @@ del ui_class, base_class
class ContactDelegate(QStyledItemDelegate): class ContactDelegate(QStyledItemDelegate):
item_size_hints = {Contact: QSize(200, 36), ContactGroup: QSize(200, 18), BonjourNeighbour: QSize(200, 36), BonjourGroup: QSize(200, 18)} item_size_hints = {Contact: QSize(200, 36), ContactGroup: QSize(200, 18), BonjourNeighbour: QSize(200, 36), BonjourGroup: QSize(200, 18), GoogleContact: QSize(200, 36), GoogleContactsGroup: QSize(200, 18)}
def __init__(self, parent=None): def __init__(self, parent=None):
super(ContactDelegate, self).__init__(parent) super(ContactDelegate, self).__init__(parent)
...@@ -488,6 +866,8 @@ class ContactDelegate(QStyledItemDelegate): ...@@ -488,6 +866,8 @@ class ContactDelegate(QStyledItemDelegate):
paintBonjourNeighbour = paintContact paintBonjourNeighbour = paintContact
paintBonjourGroup = paintContactGroup paintBonjourGroup = paintContactGroup
paintGoogleContact = paintContact
paintGoogleContactsGroup = paintContactGroup
def paint(self, painter, option, index): def paint(self, painter, option, index):
item = index.model().data(index, Qt.DisplayRole) item = index.model().data(index, Qt.DisplayRole)
...@@ -519,6 +899,9 @@ class ContactModel(QAbstractListModel): ...@@ -519,6 +899,9 @@ class ContactModel(QAbstractListModel):
self.endResetModel = self.reset self.endResetModel = self.reset
self.bonjour_group = None self.bonjour_group = None
self.google_contacts_group = None
self.google_contacts_manager = GoogleContactsManager(self)
notification_center = NotificationCenter() notification_center = NotificationCenter()
notification_center.add_observer(self, name='BonjourAccountDidAddNeighbour') notification_center.add_observer(self, name='BonjourAccountDidAddNeighbour')
notification_center.add_observer(self, name='BonjourAccountDidRemoveNeighbour') notification_center.add_observer(self, name='BonjourAccountDidRemoveNeighbour')
...@@ -912,8 +1295,13 @@ class ContactModel(QAbstractListModel): ...@@ -912,8 +1295,13 @@ class ContactModel(QAbstractListModel):
self.contact_list.setRowHidden(position, item.group.collapsed) self.contact_list.setRowHidden(position, item.group.collapsed)
if type(item) is BonjourGroup: if type(item) is BonjourGroup:
self.bonjour_group = item self.bonjour_group = item
if type(item) is GoogleContactsGroup:
self.google_contacts_group = item
if self.bonjour_group is None: if self.bonjour_group is None:
self.bonjour_group = BonjourGroup('Bonjour Neighbours') self.bonjour_group = BonjourGroup('Bonjour Neighbours')
if self.google_contacts_group is None:
self.google_contacts_group = GoogleContactsGroup('Google Contacts')
self.google_contacts_manager.initialize()
if file is None: if file is None:
self.save() self.save()
...@@ -926,6 +1314,8 @@ class ContactModel(QAbstractListModel): ...@@ -926,6 +1314,8 @@ class ContactModel(QAbstractListModel):
items.remove(self.bonjour_group) items.remove(self.bonjour_group)
position = items.index(reference) if reference in contact_groups else len(self.items) position = items.index(reference) if reference in contact_groups else len(self.items)
items.insert(position, group) items.insert(position, group)
if self.google_contacts_group not in contact_groups:
items.append(self.google_contacts_group)
self._store_contacts(pickle.dumps(items)) self._store_contacts(pickle.dumps(items))
......
...@@ -410,6 +410,12 @@ class ContactEntry(PersonEntry): ...@@ -410,6 +410,12 @@ class ContactEntry(PersonEntry):
return a_link return a_link
return None return None
def get_entry_photo_data(self):
photo = self.GetPhotoLink()
if photo._other_attributes.get('{http://schemas.google.com/g/2005}etag'):
return (photo.href, photo._other_attributes.get('{http://schemas.google.com/g/2005}etag').strip('"'))
return (None, None)
class ContactsFeed(gdata_data.BatchFeed): class ContactsFeed(gdata_data.BatchFeed):
"""A collection of Contacts.""" """A collection of Contacts."""
......
...@@ -22,7 +22,7 @@ from sipsimple.configuration.settings import SIPSimpleSettings ...@@ -22,7 +22,7 @@ from sipsimple.configuration.settings import SIPSimpleSettings
from blink.aboutpanel import AboutPanel from blink.aboutpanel import AboutPanel
from blink.accounts import AccountModel, ActiveAccountModel, AddAccountDialog, ServerToolsAccountModel, ServerToolsWindow from blink.accounts import AccountModel, ActiveAccountModel, AddAccountDialog, ServerToolsAccountModel, ServerToolsWindow
from blink.contacts import BonjourNeighbour, Contact, ContactGroup, ContactEditorDialog, ContactModel, ContactSearchModel from blink.contacts import BonjourNeighbour, Contact, ContactGroup, ContactEditorDialog, ContactModel, ContactSearchModel, GoogleContactsDialog
from blink.sessions import SessionManager, SessionModel from blink.sessions import SessionManager, SessionModel
from blink.resources import Resources from blink.resources import Resources
from blink.util import call_in_auxiliary_thread, run_in_gui_thread from blink.util import call_in_auxiliary_thread, run_in_gui_thread
...@@ -81,6 +81,7 @@ class MainWindow(base_class, ui_class): ...@@ -81,6 +81,7 @@ class MainWindow(base_class, ui_class):
self.about_panel = AboutPanel(self) self.about_panel = AboutPanel(self)
self.add_account_dialog = AddAccountDialog(self) self.add_account_dialog = AddAccountDialog(self)
self.contact_editor_dialog = ContactEditorDialog(self.contact_model, self) self.contact_editor_dialog = ContactEditorDialog(self.contact_model, self)
self.google_contacts_dialog = GoogleContactsDialog(self)
self.server_tools_window = ServerToolsWindow(self.server_tools_account_model, None) self.server_tools_window = ServerToolsWindow(self.server_tools_account_model, None)
# Signals # Signals
...@@ -195,6 +196,7 @@ class MainWindow(base_class, ui_class): ...@@ -195,6 +196,7 @@ class MainWindow(base_class, ui_class):
self.about_panel.close() self.about_panel.close()
self.add_account_dialog.close() self.add_account_dialog.close()
self.contact_editor_dialog.close() self.contact_editor_dialog.close()
self.google_contacts_dialog.close()
self.server_tools_window.close() self.server_tools_window.close()
def set_user_icon(self, image_file_name): def set_user_icon(self, image_file_name):
...@@ -290,6 +292,15 @@ class MainWindow(base_class, ui_class): ...@@ -290,6 +292,15 @@ class MainWindow(base_class, ui_class):
settings.audio.output_device = action.data().toPyObject() settings.audio.output_device = action.data().toPyObject()
call_in_auxiliary_thread(settings.save) call_in_auxiliary_thread(settings.save)
def _AH_GoogleContactsActionTriggered(self):
settings = SIPSimpleSettings()
if settings.google_contacts.authorization_token:
settings = SIPSimpleSettings()
settings.google_contacts.authorization_token = None
settings.save()
else:
self.google_contacts_dialog.open()
def _AH_RedialActionTriggered(self): def _AH_RedialActionTriggered(self):
session_manager = SessionManager() session_manager = SessionManager()
if session_manager.last_dialed_uri is not None: if session_manager.last_dialed_uri is not None:
...@@ -493,6 +504,16 @@ class MainWindow(base_class, ui_class): ...@@ -493,6 +504,16 @@ class MainWindow(base_class, ui_class):
notification_center.add_observer(self, sender=account_manager) notification_center.add_observer(self, sender=account_manager)
self.silent_action.setChecked(settings.audio.silent) self.silent_action.setChecked(settings.audio.silent)
self.silent_button.setChecked(settings.audio.silent) self.silent_button.setChecked(settings.audio.silent)
if settings.google_contacts.authorization_token:
self.google_contacts_action.setText(u'Disable Google Contacts')
elif settings.google_contacts.authorization_token is not None:
# Token is invalid
self.google_contacts_action.setText(u'Disable Google Contacts')
# Maybe this should be moved to DidStart so that the dialog is shown *after* the MainWindow. -Saul
self.google_contacts_dialog.open_for_incorrect_password()
else:
self.google_contacts_action.setText(u'Enable Google Contacts')
self.google_contacts_action.triggered.connect(self._AH_GoogleContactsActionTriggered)
if all(not account.enabled for account in account_manager.iter_accounts()): if all(not account.enabled for account in account_manager.iter_accounts()):
self.display_name.setEnabled(False) self.display_name.setEnabled(False)
self.activity_note.setEnabled(False) self.activity_note.setEnabled(False)
...@@ -539,6 +560,15 @@ class MainWindow(base_class, ui_class): ...@@ -539,6 +560,15 @@ class MainWindow(base_class, ui_class):
if 'audio.alert_device' in notification.data.modified: if 'audio.alert_device' in notification.data.modified:
action = (action for action in self.alert_devices_group.actions() if action.data().toPyObject() == settings.audio.alert_device).next() action = (action for action in self.alert_devices_group.actions() if action.data().toPyObject() == settings.audio.alert_device).next()
action.setChecked(True) action.setChecked(True)
if 'google_contacts.authorization_token' in notification.data.modified:
authorization_token = notification.sender.google_contacts.authorization_token
if authorization_token:
self.google_contacts_action.setText(u'Disable Google Contacts')
elif authorization_token is not None:
# Token is invalid
self.google_contacts_dialog.open_for_incorrect_password()
else:
self.google_contacts_action.setText(u'Enable Google Contacts')
elif isinstance(notification.sender, (Account, BonjourAccount)): elif isinstance(notification.sender, (Account, BonjourAccount)):
if 'enabled' in notification.data.modified: if 'enabled' in notification.data.modified:
account = notification.sender account = notification.sender
......
...@@ -16,6 +16,7 @@ from application import log ...@@ -16,6 +16,7 @@ from application import log
from application.python.util import Singleton from application.python.util import Singleton
from application.system import unlink from application.system import unlink
from collections import deque from collections import deque
from hashlib import sha512
from sipsimple.util import classproperty, makedirs from sipsimple.util import classproperty, makedirs
...@@ -165,4 +166,39 @@ class IconCache(object): ...@@ -165,4 +166,39 @@ class IconCache(object):
self.available_names.appendleft(os.path.basename(destination_name)) self.available_names.appendleft(os.path.basename(destination_name))
return filename return filename
def store_image(self, data):
if data is None:
return None
data_hash = sha512(data).hexdigest()
try:
return self.filemap[data_hash].destination
except KeyError:
pass
try:
destination_name = os.path.join('images', self.available_names.popleft())
except IndexError:
# No more available file names.
return None
pixmap = QPixmap()
if pixmap.loadFromData(data):
pixmap = pixmap.scaled(32, 32, Qt.KeepAspectRatio, Qt.SmoothTransformation)
makedirs(ApplicationData.get('images'))
if pixmap.save(ApplicationData.get(destination_name)):
file_mapping = FileMapping(data_hash, destination_name)
self.filemap[data_hash] = file_mapping
map_filename = ApplicationData.get(os.path.join('images', '.cached_icons.map'))
map_tempname = map_filename + '.tmp'
try:
file = open(map_tempname, 'wb')
pickle.dump(self.filemap, file)
file.close()
if sys.platform == 'win32':
unlink(map_filename)
os.rename(map_tempname, map_filename)
except Exception, e:
log.error("could not save icon cache file mappings: %s" % e)
return destination_name
else:
self.available_names.appendleft(os.path.basename(destination_name))
return None
...@@ -1032,6 +1032,8 @@ padding: 2px;</string> ...@@ -1032,6 +1032,8 @@ padding: 2px;</string>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="file_transfers_action"/> <addaction name="file_transfers_action"/>
<addaction name="logs_action"/> <addaction name="logs_action"/>
<addaction name="separator"/>
<addaction name="google_contacts_action"/>
</widget> </widget>
<addaction name="blink_menu"/> <addaction name="blink_menu"/>
<addaction name="audio_menu"/> <addaction name="audio_menu"/>
...@@ -1249,6 +1251,11 @@ padding: 2px;</string> ...@@ -1249,6 +1251,11 @@ padding: 2px;</string>
<string>Donate if you like Blink</string> <string>Donate if you like Blink</string>
</property> </property>
</action> </action>
<action name="google_contacts_action">
<property name="text">
<string>Enable Google Contacts</string>
</property>
</action>
<action name="history_on_server_action"> <action name="history_on_server_action">
<property name="text"> <property name="text">
<string>Call history on server...</string> <string>Call history on server...</string>
......
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>290</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="windowTitle">
<string>Google Contacts</string>
</property>
<layout class="QVBoxLayout" name="dialog_layout">
<property name="spacing">
<number>0</number>
</property>
<property name="sizeConstraint">
<enum>QLayout::SetFixedSize</enum>
</property>
<property name="leftMargin">
<number>8</number>
</property>
<property name="rightMargin">
<number>8</number>
</property>
<item>
<widget class="QFrame" name="background_frame">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>245</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QFrame#background_frame {
border: 2px;
border-radius: 4px;
border-style: solid;
border-color: #545454;
background-color: rgba(244, 244, 244, 228); /* 244, 244, 244, 228 or 248, 248, 248, 224 */
}
</string>
</property>
<property name="frameShape">
<enum>QFrame::StyledPanel</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Raised</enum>
</property>
<layout class="QVBoxLayout" name="frame_layout">
<property name="leftMargin">
<number>15</number>
</property>
<property name="topMargin">
<number>10</number>
</property>
<item>
<widget class="QLabel" name="note_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Your credentials will be used once to authorize Blink to access your Google Contacts and will not be stored.</string>
</property>
<property name="textFormat">
<enum>Qt::AutoText</enum>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignJustify|Qt::AlignVCenter</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="buddy">
<cstring></cstring>
</property>
</widget>
</item>
<item>
<spacer name="before_form_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QGridLayout" name="grid_layout">
<item row="0" column="0">
<widget class="QLabel" name="username_label">
<property name="text">
<string>Google account:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="ValidatingLineEdit" name="username_editor">
<property name="minimumSize">
<size>
<width>0</width>
<height>22</height>
</size>
</property>
<property name="inactiveText" stdset="0">
<string>user@domain</string>
</property>
<property name="widgetSpacing" stdset="0">
<number>0</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="password_label">
<property name="text">
<string>Password:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="ValidatingLineEdit" name="password_editor">
<property name="minimumSize">
<size>
<width>0</width>
<height>22</height>
</size>
</property>
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
<property name="inactiveText" stdset="0">
<string/>
</property>
<property name="widgetSpacing" stdset="0">
<number>0</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="captcha_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Captcha:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="ValidatingLineEdit" name="captcha_editor">
<property name="minimumSize">
<size>
<width>0</width>
<height>22</height>
</size>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="captcha_image_label">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>70</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="after_form_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="StatusLabel" name="status_label">
<property name="alignment">
<set>Qt::AlignJustify|Qt::AlignVCenter</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="frame_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontal_layout">
<property name="spacing">
<number>6</number>
</property>
<item>
<spacer name="button_box_spacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>200</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="reject_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>85</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="authorize_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>85</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>Authorize</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ValidatingLineEdit</class>
<extends>QLineEdit</extends>
<header>blink.widgets.lineedit</header>
</customwidget>
<customwidget>
<class>StatusLabel</class>
<extends>QLabel</extends>
<header>blink.widgets.labels</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>reject_button</sender>
<signal>clicked()</signal>
<receiver>Dialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>257</x>
<y>202</y>
</hint>
<hint type="destinationlabel">
<x>199</x>
<y>109</y>
</hint>
</hints>
</connection>
</connections>
</ui>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment