diff --git a/.hgignore b/.hgignore index 619110bc5..1f56821c2 100644 --- a/.hgignore +++ b/.hgignore @@ -1,4 +1,5 @@ syntax: glob +*.orig *.gmo *.in *.la diff --git a/src/chat_control.py b/src/chat_control.py index dc5c4a40f..93aa01576 100644 --- a/src/chat_control.py +++ b/src/chat_control.py @@ -1356,9 +1356,7 @@ class ChatControl(ChatControlBase): def update_toolbar(self): # Formatting - if gajim.capscache.is_supported(self.contact, NS_XHTML_IM) \ - and not gajim.capscache.is_supported(self.contact, 'notexistant') \ - and not self.gpg_is_active: + if self.contact.supports(NS_XHTML_IM) and not self.gpg_is_active: self._formattings_button.set_sensitive(True) else: self._formattings_button.set_sensitive(False) @@ -1371,15 +1369,15 @@ class ChatControl(ChatControlBase): self._add_to_roster_button.hide() # Jingle detection - if gajim.capscache.is_supported(self.contact, NS_JINGLE_ICE_UDP) and \ + if self.contact.supports(NS_JINGLE_ICE_UDP) and \ gajim.HAVE_FARSIGHT and self.contact.resource: - if gajim.capscache.is_supported(self.contact, NS_JINGLE_RTP_AUDIO): + if self.contact.supports(NS_JINGLE_RTP_AUDIO): if self.audio_state == self.JINGLE_STATE_NOT_AVAILABLE: self.set_audio_state('available') else: self.set_audio_state('not_available') - if gajim.capscache.is_supported(self.contact, NS_JINGLE_RTP_VIDEO): + if self.contact.supports(NS_JINGLE_RTP_VIDEO): if self.video_state == self.JINGLE_STATE_NOT_AVAILABLE: self.set_video_state('available') else: @@ -1403,12 +1401,11 @@ class ChatControl(ChatControlBase): self._video_button.set_sensitive(True) # Send file - if gajim.capscache.is_supported(self.contact, NS_FILE) and \ - self.contact.resource: + if self.contact.supports(NS_FILE) and self.contact.resource: self._send_file_button.set_sensitive(True) else: self._send_file_button.set_sensitive(False) - if not gajim.capscache.is_supported(self.contact, NS_FILE): + if not self.contact.supports(NS_FILE): self._send_file_button.set_tooltip_text(_( "This contact does not support file transfer.")) else: @@ -1417,7 +1414,7 @@ class ChatControl(ChatControlBase): "her a file.")) # Convert to GC - if gajim.capscache.is_supported(self.contact, NS_MUC): + if self.contact.supports(NS_MUC): self._convert_to_gc_button.set_sensitive(True) else: self._convert_to_gc_button.set_sensitive(False) @@ -1982,10 +1979,7 @@ class ChatControl(ChatControlBase): self._schedule_activity_timers() def _on_sent(id_, contact, message, encrypted, xhtml): - # XXX: Once we have fallback to disco, remove notexistant check - if gajim.capscache.is_supported(contact, NS_RECEIPTS) \ - and not gajim.capscache.is_supported(contact, - 'notexistant') and gajim.config.get_per('accounts', + if contact.supports(NS_RECEIPTS) and gajim.config.get_per('accounts', self.account, 'request_receipt'): xep0184_id = id_ else: @@ -2504,12 +2498,8 @@ class ChatControl(ChatControlBase): want_e2e = not e2e_is_active and not self.gpg_is_active \ and e2e_pref - # XXX: Once we have fallback to disco, remove notexistant check if want_e2e and not self.no_autonegotiation \ - and gajim.HAVE_PYCRYPTO \ - and gajim.capscache.is_supported(self.contact, - NS_ESESSION) and not gajim.capscache.is_supported( - self.contact, 'notexistant'): + and gajim.HAVE_PYCRYPTO and self.contact.supports(NS_ESESSION): self.begin_e2e_negotiation() else: self.send_chatstate('active', self.contact) diff --git a/src/common/caps.py b/src/common/caps.py index 569bc239d..f676ad265 100644 --- a/src/common/caps.py +++ b/src/common/caps.py @@ -6,7 +6,7 @@ ## Copyright (C) 2007-2008 Yann Leboulanger ## Copyright (C) 2008 Brendan Taylor ## Jonathan Schleifer -## Stephan Erb +## Copyright (C) 2008-2009 Stephan Erb ## ## This file is part of Gajim. ## @@ -23,55 +23,198 @@ ## along with Gajim. If not, see . ## -from itertools import * +''' +Module containing all XEP-115 (Entity Capabilities) related classes + +Basic Idea: +CapsCache caches features to hash relationships. The cache is queried +through ClientCaps objects which are hold by contact instances. +''' + import gajim import helpers +import base64 +import hashlib + +from common.xmpp import NS_XHTML_IM, NS_RECEIPTS, NS_ESESSION, NS_CHATSTATES +# Features where we cannot safely assume that the other side supports them +FEATURE_BLACKLIST = [NS_CHATSTATES, NS_XHTML_IM, NS_RECEIPTS, NS_ESESSION] + + +capscache = None +def initialize(logger): + ''' Initializes the capscache global ''' + global capscache + capscache = CapsCache(logger) + + +def compute_caps_hash(identities, features, dataforms=[], hash_method='sha-1'): + '''Compute caps hash according to XEP-0115, V1.5 + + dataforms are xmpp.DataForms objects as common.dataforms don't allow several + values without a field type list-multi''' + + def sort_identities_func(i1, i2): + cat1 = i1['category'] + cat2 = i2['category'] + if cat1 < cat2: + return -1 + if cat1 > cat2: + return 1 + type1 = i1.get('type', '') + type2 = i2.get('type', '') + if type1 < type2: + return -1 + if type1 > type2: + return 1 + lang1 = i1.get('xml:lang', '') + lang2 = i2.get('xml:lang', '') + if lang1 < lang2: + return -1 + if lang1 > lang2: + return 1 + return 0 + + def sort_dataforms_func(d1, d2): + f1 = d1.getField('FORM_TYPE') + f2 = d2.getField('FORM_TYPE') + if f1 and f2 and (f1.getValue() < f2.getValue()): + return -1 + return 1 + + S = '' + identities.sort(cmp=sort_identities_func) + for i in identities: + c = i['category'] + type_ = i.get('type', '') + lang = i.get('xml:lang', '') + name = i.get('name', '') + S += '%s/%s/%s/%s<' % (c, type_, lang, name) + features.sort() + for f in features: + S += '%s<' % f + dataforms.sort(cmp=sort_dataforms_func) + for dataform in dataforms: + # fields indexed by var + fields = {} + for f in dataform.getChildren(): + fields[f.getVar()] = f + form_type = fields.get('FORM_TYPE') + if form_type: + S += form_type.getValue() + '<' + del fields['FORM_TYPE'] + for var in sorted(fields.keys()): + S += '%s<' % var + values = sorted(fields[var].getValues()) + for value in values: + S += '%s<' % value + + if hash_method == 'sha-1': + hash_ = hashlib.sha1(S) + elif hash_method == 'md5': + hash_ = hashlib.md5(S) + else: + return '' + return base64.b64encode(hash_.digest()) + + +class AbstractClientCaps(object): + ''' + Base class representing a client and its capabilities as advertised by + a caps tag in a presence. + ''' + def __init__(self, caps_hash, node): + self._hash = caps_hash + self._node = node + + def get_discover_strategy(self): + return self._discover + + def _discover(self, connection, jid): + ''' To be implemented by subclassess ''' + raise NotImplementedError() + + def get_cache_lookup_strategy(self): + return self._lookup_in_cache + + def _lookup_in_cache(self, caps_cache): + ''' To be implemented by subclassess ''' + raise NotImplementedError() + + def get_hash_validation_strategy(self): + return self._is_hash_valid + + def _is_hash_valid(self, identities, features, dataforms): + ''' To be implemented by subclassess ''' + raise NotImplementedError() + + +class ClientCaps(AbstractClientCaps): + ''' The current XEP-115 implementation ''' + + def __init__(self, caps_hash, node, hash_method): + AbstractClientCaps.__init__(self, caps_hash, node) + assert hash_method != 'old' + self._hash_method = hash_method + + def _lookup_in_cache(self, caps_cache): + return caps_cache[(self._hash_method, self._hash)] + + def _discover(self, connection, jid): + connection.discoverInfo(jid, '%s#%s' % (self._node, self._hash)) + + def _is_hash_valid(self, identities, features, dataforms): + computed_hash = compute_caps_hash(identities, features, + dataforms=dataforms, hash_method=self._hash_method) + return computed_hash == self._hash + + +class OldClientCaps(AbstractClientCaps): + ''' Old XEP-115 implemtation. Kept around for background competability. ''' + + def __init__(self, caps_hash, node): + AbstractClientCaps.__init__(self, caps_hash, node) + + def _lookup_in_cache(self, caps_cache): + return caps_cache[('old', self._node + '#' + self._hash)] + + def _discover(self, connection, jid): + connection.discoverInfo(jid) + + def _is_hash_valid(self, identities, features, dataforms): + return True + + +class NullClientCaps(AbstractClientCaps): + ''' + This is a NULL-Object to streamline caps handling if a client has not + advertised any caps or has advertised them in an improper way. + + Assumes (almost) everything is supported. + ''' + + def __init__(self): + AbstractClientCaps.__init__(self, None, None) + + def _lookup_in_cache(self, caps_cache): + # lookup something which does not exist to get a new CacheItem created + cache_item = caps_cache[('old', '')] + assert cache_item.queried == 0 + return cache_item + + def _discover(self, connection, jid): + pass + + def _is_hash_valid(self, identities, features, dataforms): + return False + class CapsCache(object): - ''' This object keeps the mapping between caps data and real disco + ''' + This object keeps the mapping between caps data and real disco features they represent, and provides simple way to query that info. - It is application-wide, that is there's one object for all - connections. - Goals: - * handle storing/retrieving info from database - * cache info in memory - * expose simple interface - Properties: - * one object for all connections (move to logger.py?) - * store info efficiently (a set() of urls -- we can assume there won't be - too much of these, ensure that (X,Y,Z1) and (X,Y,Z2) has different - features. - - Connections with other objects: (TODO) - - Interface: - - # object creation - >>> cc=CapsCache(logger_object) - - >>> caps = ('sha-1', '66/0NaeaBKkwk85efJTGmU47vXI=') - >>> muc = 'http://jabber.org/protocol/muc' - >>> chatstates = 'http://jabber.org/protocol/chatstates' - - # setting data - >>> cc[caps].identities = [{'category':'client', 'type':'pc'}] - >>> cc[caps].features = [muc] - - # retrieving data - >>> muc in cc[caps].features - True - >>> chatstates in cc[caps].features - False - >>> cc[caps].identities - [{'category': 'client', 'type': 'pc'}] - >>> x = cc[caps] # more efficient if making several queries for one set of caps - ATypicalBlackBoxObject - >>> muc in x.features - True - ''' def __init__(self, logger=None): - ''' Create a cache for entity capabilities. ''' # our containers: # __cache is a dictionary mapping: pair of hash method and hash maps # to CapsCacheItem object @@ -80,40 +223,39 @@ class CapsCache(object): self.__cache = {} class CacheItem(object): - ''' TODO: logging data into db ''' # __names is a string cache; every string long enough is given # another object, and we will have plenty of identical long # strings. therefore we can cache them - # TODO: maybe put all known xmpp namespace strings here - # (strings given in xmpppy)? __names = {} - def __init__(ciself, hash_method, hash_): + + def __init__(self, hash_method, hash_, logger): # cached into db - ciself.hash_method = hash_method - ciself.hash = hash_ - ciself._features = [] - ciself._identities = [] + self.hash_method = hash_method + self.hash = hash_ + self._features = [] + self._identities = [] + self._logger = logger # not cached into db: # have we sent the query? # 0 == not queried # 1 == queried # 2 == got the answer - ciself.queried = 0 + self.queried = 0 - def _get_features(ciself): - return ciself._features + def _get_features(self): + return self._features - def _set_features(ciself, value): - ciself._features = [] + def _set_features(self, value): + self._features = [] for feature in value: - ciself._features.append(ciself.__names.setdefault(feature, - feature)) + self._features.append(self.__names.setdefault(feature, feature)) + features = property(_get_features, _set_features) - def _get_identities(ciself): + def _get_identities(self): list_ = [] - for i in ciself._identities: + for i in self._identities: # transforms it back in a dict d = dict() d['category'] = i[0] @@ -125,36 +267,27 @@ class CapsCache(object): d['name'] = i[3] list_.append(d) return list_ - def _set_identities(ciself, value): - ciself._identities = [] + + def _set_identities(self, value): + self._identities = [] for identity in value: # dict are not hashable, so transform it into a tuple t = (identity['category'], identity.get('type'), identity.get('xml:lang'), identity.get('name')) - ciself._identities.append(ciself.__names.setdefault(t, t)) + self._identities.append(self.__names.setdefault(t, t)) + identities = property(_get_identities, _set_identities) - def update(ciself, identities, features): - # NOTE: self refers to CapsCache object, not to CacheItem - ciself.identities=identities - ciself.features=features - self.logger.add_caps_entry(ciself.hash_method, ciself.hash, + def set_and_store(self, identities, features): + self.identities = identities + self.features = features + self._logger.add_caps_entry(self.hash_method, self.hash, identities, features) self.__CacheItem = CacheItem - - # prepopulate data which we are sure of; note: we do not log these info - - for account in gajim.connections: - gajimcaps = self[('sha-1', gajim.caps_hash[account])] - gajimcaps.identities = [gajim.gajim_identity] - gajimcaps.features = gajim.gajim_common_features + \ - gajim.gajim_optional_features[account] - - # start logging data from the net self.logger = logger - def load_from_db(self): + def initialize_from_db(self): # get data from logger... if self.logger is not None: for hash_method, hash_, identities, features in \ @@ -170,61 +303,35 @@ class CapsCache(object): hash_method, hash_ = caps - x = self.__CacheItem(hash_method, hash_) + x = self.__CacheItem(hash_method, hash_, self.logger) self.__cache[(hash_method, hash_)] = x return x - def preload(self, con, jid, node, hash_method, hash_): - ''' Preload data about (node, ver, exts) caps using disco - query to jid using proper connection. Don't query if - the data is already in cache. ''' - if hash_method == 'old': - q = self[(hash_method, node + '#' + hash_)] - else: - q = self[(hash_method, hash_)] - - if q.queried==0: + def query_client_of_jid_if_unknown(self, connection, jid, client_caps): + ''' + Start a disco query to determine caps (node, ver, exts). + Won't query if the data is already in cache. + ''' + lookup_cache_item = client_caps.get_cache_lookup_strategy() + q = lookup_cache_item(self) + + if q.queried == 0: # do query for bare node+hash pair # this will create proper object - q.queried=1 - if hash_method == 'old': - con.discoverInfo(jid) - else: - con.discoverInfo(jid, '%s#%s' % (node, hash_)) + q.queried = 1 + discover = client_caps.get_discover_strategy() + discover(connection, jid) - def is_supported(self, contact, feature): - if not contact: - return False - - # Unfortunately, if all resources are offline, the contact - # includes the last resource that was online. Check for its - # show, so we can be sure it's existant. Otherwise, we still - # return caps for a contact that has no resources left. - if contact.show == 'offline': - return False - - # FIXME: We assume everything is supported if we got no caps. - # This is the "Asterix way", after 0.12 release, I will - # likely implement a fallback to disco (could be disabled - # for mobile users who pay for traffic) - if contact.caps_hash_method == 'old': - features = self[(contact.caps_hash_method, contact.caps_node + '#' + \ - contact.caps_hash)].features - else: - features = self[(contact.caps_hash_method, contact.caps_hash)].features - if feature in features or features == []: - return True - - return False - -gajim.capscache = CapsCache(gajim.logger) class ConnectionCaps(object): - ''' This class highly depends on that it is a part of Connection class. ''' + ''' + This class highly depends on that it is a part of Connection class. + ''' def _capsPresenceCB(self, con, presence): - ''' Handle incoming presence stanzas... This is a callback - for xmpp registered in connection_handlers.py''' - + ''' + Handle incoming presence stanzas... This is a callback for xmpp + registered in connection_handlers.py + ''' # we will put these into proper Contact object and ask # for disco... so that disco will learn how to interpret # these caps @@ -245,64 +352,47 @@ class ConnectionCaps(object): # into Contacts return - # get the caps element - caps = presence.getTag('c') - if not caps: - contact.caps_node = None - contact.caps_hash = None - contact.caps_hash_method = None - return + caps_tag = presence.getTag('c') + if not caps_tag: + # presence did not contain caps_tag + client_caps = NullClientCaps() + else: + hash_method, node, caps_hash = caps_tag['hash'], caps_tag['node'], caps_tag['ver'] - hash_method, node, hash_ = caps['hash'], caps['node'], caps['ver'] + if node is None or caps_hash is None: + # improper caps in stanza, ignore client capabilities. + client_caps = NullClientCaps() + elif hash_method is None: + client_caps = OldClientCaps(caps_hash, node) + else: + client_caps = ClientCaps(caps_hash, node, hash_method) + + capscache.query_client_of_jid_if_unknown(self, jid, client_caps) + contact.client_caps = client_caps - if hash_method is None and node and hash_: - # Old XEP-115 implentation - hash_method = 'old' - - if hash_method is None or node is None or hash_ is None: - # improper caps in stanza, ignoring - contact.caps_node = None - contact.caps_hash = None - contact.hash_method = None - return - - # start disco query... - gajim.capscache.preload(self, jid, node, hash_method, hash_) - - # overwriting old data - contact.caps_node = node - contact.caps_hash_method = hash_method - contact.caps_hash = hash_ if pm_ctrl: pm_ctrl.update_contact() - def _capsDiscoCB(self, jid, node, identities, features, dataforms): + def _capsDiscoCB(self, jid, node, identities, features, dataforms): contact = gajim.contacts.get_contact_from_full_jid(self.name, jid) if not contact: room_jid, nick = gajim.get_room_and_nick_from_fjid(jid) contact = gajim.contacts.get_gc_contact(self.name, room_jid, nick) if contact is None: return - if not contact.caps_node: - return # we didn't asked for that? - if contact.caps_hash_method != 'old': - computed_hash = helpers.compute_caps_hash(identities, features, - dataforms=dataforms, hash_method=contact.caps_hash_method) - if computed_hash != contact.caps_hash: - # wrong hash, forget it - contact.caps_node = '' - contact.caps_hash_method = '' - contact.caps_hash = '' - return - # if we don't have this info already... - caps = gajim.capscache[(contact.caps_hash_method, contact.caps_hash)] - else: - # if we don't have this info already... - caps = gajim.capscache[(contact.caps_hash_method, contact.caps_node + \ - '#' + contact.caps_hash)] - if caps.queried == 2: - return - caps.update(identities, features) + lookup = contact.client_caps.get_cache_lookup_strategy() + cache_item = lookup(capscache) + + if cache_item.queried == 2: + return + else: + validate = contact.client_caps.get_hash_validation_strategy() + hash_is_valid = validate(identities, features, dataforms) + + if hash_is_valid: + cache_item.set_and_store(identities, features) + else: + contact.client_caps = NullClientCaps() # vim: se ts=3: diff --git a/src/common/connection.py b/src/common/connection.py index 36595d8d8..78422545f 100644 --- a/src/common/connection.py +++ b/src/common/connection.py @@ -1299,14 +1299,9 @@ class Connection(ConnectionHandlers): # please note that the only valid tag inside a message containing a # tag is the active event if chatstate is not None: - # XXX: Once we have fallback to disco, - # remove notexistant check if ((composing_xep == 'XEP-0085' or not composing_xep) \ and composing_xep != 'asked_once') or \ - (gajim.capscache.is_supported(contact, - common.xmpp.NS_CHATSTATES) and \ - not gajim.capscache.is_supported(contact, - 'notexistant')): + contact.supports(common.xmpp.NS_CHATSTATES): # XEP-0085 msg_iq.setTag(chatstate, namespace=common.xmpp.NS_CHATSTATES) if composing_xep in ('XEP-0022', 'asked_once') or \ @@ -1332,8 +1327,7 @@ class Connection(ConnectionHandlers): # XEP-0184 if msgtxt and gajim.config.get_per('accounts', self.name, - 'request_receipt') and gajim.capscache.is_supported(contact, - common.xmpp.NS_RECEIPTS): + 'request_receipt') and contact.supports(common.xmpp.NS_RECEIPTS): msg_iq.setTag('request', namespace=common.xmpp.NS_RECEIPTS) if session: diff --git a/src/common/connection_handlers.py b/src/common/connection_handlers.py index f48a98d0a..cc84e75bc 100644 --- a/src/common/connection_handlers.py +++ b/src/common/connection_handlers.py @@ -2469,9 +2469,8 @@ class ConnectionHandlers(ConnectionVcard, ConnectionBytestream, ConnectionDisco, if not sess.received_thread_id: contact = gajim.contacts.get_contact(self.name, jid) - session_supported = gajim.capscache.is_supported(contact, - common.xmpp.NS_SSN) or gajim.capscache.is_supported( - contact, common.xmpp.NS_ESESSION) + session_supported = contact.supports(common.xmpp.NS_SSN) or \ + contact.supports(common.xmpp.NS_ESESSION) if session_supported: sess.terminate() del self.sessions[jid][sess.thread_id] diff --git a/src/common/contacts.py b/src/common/contacts.py index 73afb8c70..b50f80d70 100644 --- a/src/common/contacts.py +++ b/src/common/contacts.py @@ -30,31 +30,23 @@ import common.gajim -class Contact: - '''Information concerning each contact''' - def __init__(self, jid='', name='', groups=[], show='', status='', sub='', - ask='', resource='', priority=0, keyID='', caps_node=None, - caps_hash_method=None, caps_hash=None, our_chatstate=None, chatstate=None, - last_status_time=None, msg_id = None, composing_xep = None, mood={}, tune={}, - activity={}): + +from common import caps + + +class CommonContact(object): + + def __init__(self, jid, resource, show, status, name, our_chatstate, + composing_xep, chatstate, client_caps=None): + self.jid = jid - self.name = name - self.contact_name = '' # nick choosen by contact - self.groups = groups + self.resource = resource self.show = show self.status = status - self.sub = sub - self.ask = ask - self.resource = resource - self.priority = priority - self.keyID = keyID - - # Capabilities; filled by caps.py/ConnectionCaps object - # every time it gets these from presence stanzas - self.caps_node = caps_node - self.caps_hash_method = caps_hash_method - self.caps_hash = caps_hash - + self.name = name + + self.client_caps = client_caps or caps.NullClientCaps() + # please read xep-85 http://www.xmpp.org/extensions/xep-0085.html # we keep track of xep85 support with the peer by three extra states: # None, False and 'ask' @@ -63,14 +55,69 @@ class Contact: # 'ask' if we sent the first 'active' chatstate and are waiting for reply # this holds what WE SEND to contact (our current chatstate) self.our_chatstate = our_chatstate - self.msg_id = msg_id # tell which XEP we're using for composing state # None = have to ask, XEP-0022 = use this xep, # XEP-0085 = use this xep, False = no composing support self.composing_xep = composing_xep # this is contact's chatstate self.chatstate = chatstate + + def get_full_jid(self): + raise NotImplementedError + + def get_shown_name(self): + raise NotImplementedError + + def supports(self, requested_feature): + ''' + Returns True if the contact has advertised to support the feature + identified by the given namespace. False otherwise. + ''' + if self.show == 'offline': + # Unfortunately, if all resources are offline, the contact + # includes the last resource that was online. Check for its + # show, so we can be sure it's existant. Otherwise, we still + # return caps for a contact that has no resources left. + return False + else: + return self._client_supports(requested_feature) + + def _client_supports(self, requested_feature): + lookup_item = self.client_caps.get_cache_lookup_strategy() + cache_item = lookup_item(caps.capscache) + + supported_features = cache_item.features + if requested_feature in supported_features: + return True + elif supported_features == [] and cache_item.queried in (0, 1): + # assume feature is supported, if we don't know yet, what the client + # is capable of + return requested_feature not in caps.FEATURE_BLACKLIST + else: + return False + + +class Contact(CommonContact): + '''Information concerning each contact''' + def __init__(self, jid='', name='', groups=[], show='', status='', sub='', + ask='', resource='', priority=0, keyID='', client_caps=None, + our_chatstate=None, chatstate=None, last_status_time=None, msg_id = None, + composing_xep=None, mood={}, tune={}, activity={}): + + CommonContact.__init__(self, jid, resource, show, status, name, + our_chatstate, composing_xep, chatstate, client_caps=client_caps) + + self.contact_name = '' # nick choosen by contact + self.groups = groups + + self.sub = sub + self.ask = ask + + self.priority = priority + self.keyID = keyID + self.msg_id = msg_id self.last_status_time = last_status_time + self.mood = mood.copy() self.tune = tune.copy() self.activity = activity.copy() @@ -135,31 +182,25 @@ class Contact: return False -class GC_Contact: +class GC_Contact(CommonContact): '''Information concerning each groupchat contact''' def __init__(self, room_jid='', name='', show='', status='', role='', - affiliation='', jid = '', resource = '', our_chatstate = None, - composing_xep = None, chatstate = None): + affiliation='', jid='', resource='', our_chatstate=None, + composing_xep=None, chatstate=None): + + CommonContact.__init__(self, jid, resource, show, status, name, + our_chatstate, composing_xep, chatstate) + self.room_jid = room_jid - self.name = name - self.show = show - self.status = status self.role = role self.affiliation = affiliation - self.jid = jid - self.resource = resource - self.caps_node = None - self.caps_hash_method = None - self.caps_hash = None - self.our_chatstate = our_chatstate - self.composing_xep = composing_xep - self.chatstate = chatstate - + def get_full_jid(self): return self.room_jid + '/' + self.name def get_shown_name(self): return self.name + class Contacts: '''Information concerning all contacts and groupchat contacts''' @@ -193,10 +234,9 @@ class Contacts: del self._metacontacts_tags[account] def create_contact(self, jid='', name='', groups=[], show='', status='', - sub='', ask='', resource='', priority=0, keyID='', caps_node=None, - caps_hash_method=None, caps_hash=None, our_chatstate=None, - chatstate=None, last_status_time=None, composing_xep=None, - mood={}, tune={}, activity={}): + sub='', ask='', resource='', priority=0, keyID='', client_caps=None, + our_chatstate=None, chatstate=None, last_status_time=None, + composing_xep=None, mood={}, tune={}, activity={}): # We don't want duplicated group values groups_unique = [] @@ -206,18 +246,16 @@ class Contacts: return Contact(jid=jid, name=name, groups=groups_unique, show=show, status=status, sub=sub, ask=ask, resource=resource, priority=priority, - keyID=keyID, caps_node=caps_node, caps_hash_method=caps_hash_method, - caps_hash=caps_hash, our_chatstate=our_chatstate, chatstate=chatstate, - last_status_time=last_status_time, composing_xep=composing_xep, - mood=mood, tune=tune, activity=activity) + keyID=keyID, client_caps=client_caps, our_chatstate=our_chatstate, + chatstate=chatstate, last_status_time=last_status_time, + composing_xep=composing_xep, mood=mood, tune=tune, activity=activity) def copy_contact(self, contact): return self.create_contact(jid=contact.jid, name=contact.name, groups=contact.groups, show=contact.show, status=contact.status, sub=contact.sub, ask=contact.ask, resource=contact.resource, priority=contact.priority, keyID=contact.keyID, - caps_node=contact.caps_node, caps_hash_method=contact.caps_hash_method, - caps_hash=contact.caps_hash, our_chatstate=contact.our_chatstate, + client_caps=contact.client_caps, our_chatstate=contact.our_chatstate, chatstate=contact.chatstate, last_status_time=contact.last_status_time) def add_contact(self, account, contact): @@ -587,9 +625,7 @@ class Contacts: jid = gc_contact.get_full_jid() return Contact(jid=jid, resource=gc_contact.resource, name=gc_contact.name, groups=[], show=gc_contact.show, - status=gc_contact.status, sub='none', caps_node=gc_contact.caps_node, - caps_hash_method=gc_contact.caps_hash_method, - caps_hash=gc_contact.caps_hash) + status=gc_contact.status, sub='none', client_caps=gc_contact.client_caps) def create_gc_contact(self, room_jid='', name='', show='', status='', role='', affiliation='', jid='', resource=''): diff --git a/src/common/gajim.py b/src/common/gajim.py index 0f340114a..d9181c2de 100644 --- a/src/common/gajim.py +++ b/src/common/gajim.py @@ -32,8 +32,6 @@ import logging import locale import config -from contacts import Contacts -from events import Events import xmpp try: @@ -101,6 +99,9 @@ else: os_info = None # used to cache os information +from contacts import Contacts +from events import Events + gmail_domains = ['gmail.com', 'googlemail.com'] transport_type = {} # list the type of transport @@ -209,6 +210,9 @@ gajim_optional_features = {} # Capabilities hash per account caps_hash = {} +import caps +caps.initialize(logger) + def get_nick_from_jid(jid): pos = jid.find('@') return jid[:pos] diff --git a/src/common/helpers.py b/src/common/helpers.py index 1282e6e42..70bdfc9fe 100644 --- a/src/common/helpers.py +++ b/src/common/helpers.py @@ -37,8 +37,8 @@ import urllib import errno import select import base64 -import sys import hashlib +import caps from encodings.punycode import punycode_encode @@ -566,74 +566,6 @@ def datetime_tuple(timestamp): from time import strptime return strptime(timestamp, '%Y%m%dT%H:%M:%S') -def sort_identities_func(i1, i2): - cat1 = i1['category'] - cat2 = i2['category'] - if cat1 < cat2: - return -1 - if cat1 > cat2: - return 1 - type1 = i1.get('type', '') - type2 = i2.get('type', '') - if type1 < type2: - return -1 - if type1 > type2: - return 1 - lang1 = i1.get('xml:lang', '') - lang2 = i2.get('xml:lang', '') - if lang1 < lang2: - return -1 - if lang1 > lang2: - return 1 - return 0 - -def sort_dataforms_func(d1, d2): - f1 = d1.getField('FORM_TYPE') - f2 = d2.getField('FORM_TYPE') - if f1 and f2 and (f1.getValue() < f2.getValue()): - return -1 - return 1 - -def compute_caps_hash(identities, features, dataforms=[], hash_method='sha-1'): - '''Compute caps hash according to XEP-0115, V1.5 - - dataforms are xmpp.DataForms objects as common.dataforms don't allow several - values without a field type list-multi''' - S = '' - identities.sort(cmp=sort_identities_func) - for i in identities: - c = i['category'] - type_ = i.get('type', '') - lang = i.get('xml:lang', '') - name = i.get('name', '') - S += '%s/%s/%s/%s<' % (c, type_, lang, name) - features.sort() - for f in features: - S += '%s<' % f - dataforms.sort(cmp=sort_dataforms_func) - for dataform in dataforms: - # fields indexed by var - fields = {} - for f in dataform.getChildren(): - fields[f.getVar()] = f - form_type = fields.get('FORM_TYPE') - if form_type: - S += form_type.getValue() + '<' - del fields['FORM_TYPE'] - for var in sorted(fields.keys()): - S += '%s<' % var - values = sorted(fields[var].getValues()) - for value in values: - S += '%s<' % value - - if hash_method == 'sha-1': - hash_ = hashlib.sha1(S) - elif hash_method == 'md5': - hash_ = hashlib.md5(S) - else: - return '' - return base64.b64encode(hash_.digest()) - # import gajim only when needed (after decode_string is defined) see #4764 import gajim @@ -1363,7 +1295,7 @@ def update_optional_features(account = None): gajim.gajim_optional_features[a].append(xmpp.NS_JINGLE_RTP_AUDIO) gajim.gajim_optional_features[a].append(xmpp.NS_JINGLE_RTP_VIDEO) gajim.gajim_optional_features[a].append(xmpp.NS_JINGLE_ICE_UDP) - gajim.caps_hash[a] = compute_caps_hash([gajim.gajim_identity], + gajim.caps_hash[a] = caps.compute_caps_hash([gajim.gajim_identity], gajim.gajim_common_features + gajim.gajim_optional_features[a]) # re-send presence with new hash connected = gajim.connections[a].connected diff --git a/src/common/optparser.py b/src/common/optparser.py index c75319775..0e0b463a6 100644 --- a/src/common/optparser.py +++ b/src/common/optparser.py @@ -31,6 +31,7 @@ import locale import re from common import gajim from common import helpers +from common import caps import exceptions try: @@ -219,7 +220,7 @@ class OptionsParser: gajim.logger.init_vars() gajim.config.set('version', new_version) - gajim.capscache.load_from_db() + caps.capscache.initialize_from_db() def update_config_x_to_09(self): # Var name that changed: diff --git a/src/common/stanza_session.py b/src/common/stanza_session.py index cedf01553..3cb1a13cf 100644 --- a/src/common/stanza_session.py +++ b/src/common/stanza_session.py @@ -223,7 +223,7 @@ class EncryptedStanzaSession(StanzaSession): def _is_buggy_gajim(self): c = self._get_contact() - if gajim.capscache.is_supported(c, xmpp.NS_ROSTERX): + if c and c.supports(xmpp.NS_ROSTERX): return False return True diff --git a/src/gajim.py b/src/gajim.py index 9f17ae7ae..ba6826581 100644 --- a/src/gajim.py +++ b/src/gajim.py @@ -255,6 +255,7 @@ from common import optparser from common import dataforms from common import passwords from common import pep +from common import caps gajimpaths = common.configpaths.gajimpaths @@ -3641,6 +3642,12 @@ class Interface: gajim.caps_hash[a] = '' helpers.update_optional_features() + # prepopulate data which we are sure of; note: we do not log these info + for account in gajim.connections: + gajimcaps = caps.capscache[('sha-1', gajim.caps_hash[account])] + gajimcaps.identities = [gajim.gajim_identity] + gajimcaps.features = gajim.gajim_common_features + \ + gajim.gajim_optional_features[account] self.remote_ctrl = None diff --git a/src/gui_menu_builder.py b/src/gui_menu_builder.py index b6af25ff3..0c975d5b6 100644 --- a/src/gui_menu_builder.py +++ b/src/gui_menu_builder.py @@ -55,7 +55,7 @@ room_account=None, cap=None): else: # start_chat, execute_command, send_file item.connect('activate', action, c, account, c.resource) - if cap and not gajim.capscache.is_supported(c, cap): + if cap and not c.supports(cap): item.set_sensitive(False) return sub_menu @@ -92,7 +92,7 @@ def build_invite_submenu(invite_menuitem, list_): if len(contact_list) > 1: # several resources invite_to_new_room_menuitem.set_submenu(build_resources_submenu( contact_list, account, roster.on_invite_to_new_room, cap=NS_MUC)) - elif len(list_) == 1 and gajim.capscache.is_supported(contact, NS_MUC): + elif len(list_) == 1 and contact.supports(NS_MUC): invite_menuitem.set_sensitive(True) # use resource if it's self contact if contact.jid == gajim.get_jid_from_account(account): @@ -222,14 +222,14 @@ control=None): else: start_chat_menuitem.connect('activate', gajim.interface.on_open_chat_window, contact, account) - if gajim.capscache.is_supported(contact, NS_FILE): + if contact.supports(NS_FILE): send_file_menuitem.set_sensitive(True) send_file_menuitem.connect('activate', roster.on_send_file_menuitem_activate, contact, account) else: send_file_menuitem.set_sensitive(False) - if gajim.capscache.is_supported(contact, NS_COMMANDS): + if contact.supports(NS_COMMANDS): execute_command_menuitem.set_sensitive(True) execute_command_menuitem.connect('activate', roster.on_execute_command, contact, account, contact.resource) @@ -294,10 +294,7 @@ control=None): control._on_toggle_gpg_menuitem_activate) # disable esessions if we or the other client don't support them - # XXX: Once we have fallback to disco, remove notexistant check - if not gajim.HAVE_PYCRYPTO or \ - not gajim.capscache.is_supported(contact, NS_ESESSION) or \ - gajim.capscache.is_supported(contact, 'notexistant') or \ + if not gajim.HAVE_PYCRYPTO or not contact.supports(NS_ESESSION) or \ not gajim.config.get_per('accounts', account, 'enable_esessions'): toggle_e2e_menuitem.set_sensitive(False) else: diff --git a/src/roster_window.py b/src/roster_window.py index 72b6ad969..cd3a8c9ef 100644 --- a/src/roster_window.py +++ b/src/roster_window.py @@ -4061,7 +4061,7 @@ class RosterWindow: return c_dest = gajim.contacts.get_contact_with_highest_priority(account_dest, jid_dest) - if not gajim.capscache.is_supported(c_dest, NS_FILE): + if not c_dest.supports(NS_FILE): return uri = data.strip() uri_splitted = uri.split() # we may have more than one file dropped @@ -5157,17 +5157,6 @@ class RosterWindow: zeroconf_properties_menuitem.connect('activate', self.on_zeroconf_properties, account) - #gc_sub_menu = gtk.Menu() # gc is always a submenu - #join_group_chat_menuitem.set_submenu(gc_sub_menu) - #self.add_bookmarks_list(gc_sub_menu, account) - #new_message_menuitem.connect('activate', - # self.on_new_message_menuitem_activate, account) - - # make some items insensitive if account is offline - #if gajim.connections[account].connected < 2: - # for widget in [join_group_chat_menuitem, new_message_menuitem]: - # widget.set_sensitive(False) - # new_message_menuitem.set_sensitive(False) return account_context_menu diff --git a/test/lib/xmpp_mocks.py b/test/lib/xmpp_mocks.py index 015d3ee31..bf60ae6dc 100644 --- a/test/lib/xmpp_mocks.py +++ b/test/lib/xmpp_mocks.py @@ -7,7 +7,6 @@ import threading, time from mock import Mock from common.xmpp import idlequeue -from common.xmpp.plugin import PlugIn IDLEQUEUE_INTERVAL = 0.2 # polling interval. 200ms is used in Gajim as default IDLEMOCK_TIMEOUT = 30 # how long we wait for an event diff --git a/test/runtests.py b/test/runtests.py index 1feb80e60..fa95009f4 100755 --- a/test/runtests.py +++ b/test/runtests.py @@ -40,6 +40,7 @@ modules = ( 'test_xmpp_dispatcher_nb', 'test_xmpp_transports_nb', 'test_resolver', 'test_caps', + 'test_contacts', ) #modules = () diff --git a/test/test_caps.py b/test/test_caps.py index 37b24007f..787047c1f 100644 --- a/test/test_caps.py +++ b/test/test_caps.py @@ -6,57 +6,148 @@ import unittest import lib lib.setup_env() -from common import gajim -from common import xmpp -from common import helpers - -from common.caps import CapsCache +from common.xmpp import NS_MUC, NS_PING, NS_XHTML_IM +from common import caps +from common.contacts import Contact from mock import Mock -class MockLogger(Mock): - def __init__(self, *args): - Mock.__init__(self, *args) - -class TestCapsCache(unittest.TestCase): + +class CommonCapsTest(unittest.TestCase): + def setUp(self): - self.logger = MockLogger() - self.cc = CapsCache(self.logger) - self.caps_method = 'sha-1' - self.caps_hash = 'zaQfb22o0UCwYDIk8KZOnoZTnrs=' - self.caps = (self.caps_method, self.caps_hash) - self.identity = {'category': 'client', 'type': 'pc'} - - self.muc = 'http://jabber.org/protocol/muc' - self.chatstates = 'http://jabber.org/protocol/chatstates' + self.caps_hash = 'm3P2WeXPMGVH2tZPe7yITnfY0Dw=' + self.client_caps = (self.caps_method, self.caps_hash) + + self.node = "http://gajim.org" + self.identity = {'category': 'client', 'type': 'pc', 'name':'Gajim'} self.identities = [self.identity] - self.features = [self.muc] + self.features = [NS_MUC, NS_XHTML_IM] # NS_MUC not supported! + + # Simulate a filled db + db_caps_cache = [ + (self.caps_method, self.caps_hash, self.identities, self.features), + ('old', self.node + '#' + self.caps_hash, self.identities, self.features)] + self.logger = Mock(returnValues={"iter_caps_data":db_caps_cache}) + + self.cc = caps.CapsCache(self.logger) + caps.capscache = self.cc + + +class TestCapsCache(CommonCapsTest): + + def test_set_retrieve(self): + ''' Test basic set / retrieve cycle ''' - def test_examples(self): - '''tests the examples given in common/caps.py''' + self.cc[self.client_caps].identities = self.identities + self.cc[self.client_caps].features = self.features - self.cc[self.caps].identities = self.identities - self.cc[self.caps].features = self.features + self.assert_(NS_MUC in self.cc[self.client_caps].features) + self.assert_(NS_PING not in self.cc[self.client_caps].features) - self.assert_(self.muc in self.cc[self.caps].features) - self.assert_(self.chatstates not in self.cc[self.caps].features) + identities = self.cc[self.client_caps].identities - id = self.cc[self.caps].identities + self.assertEqual(1, len(identities)) - self.assertEqual(1, len(id)) + identity = identities[0] + self.assertEqual('client', identity['category']) + self.assertEqual('pc', identity['type']) + + def test_set_and_store(self): + ''' Test client_caps update gets logged into db ''' + + item = self.cc[self.client_caps] + item.set_and_store(self.identities, self.features) + + self.logger.mockCheckCall(0, "add_caps_entry", self.caps_method, + self.caps_hash, self.identities, self.features) + + def test_initialize_from_db(self): + ''' Read cashed dummy data from db ''' + self.assertEqual(self.cc[self.client_caps].queried, 0) + self.cc.initialize_from_db() + self.assertEqual(self.cc[self.client_caps].queried, 2) - id = id[0] - self.assertEqual('client', id['category']) - self.assertEqual('pc', id['type']) + def test_preload_triggering_query(self): + ''' Make sure that preload issues a disco ''' + connection = Mock() + client_caps = caps.ClientCaps(self.caps_hash, self.node, self.caps_method) + + self.cc.query_client_of_jid_if_unknown(connection, "test@gajim.org", + client_caps) + + self.assertEqual(1, len(connection.mockGetAllCalls())) + + def test_no_preload_query_if_cashed(self): + ''' Preload must not send a query if the data is already cached ''' + connection = Mock() + client_caps = caps.ClientCaps(self.caps_hash, self.node, self.caps_method) + self.cc.initialize_from_db() + self.cc.query_client_of_jid_if_unknown(connection, "test@gajim.org", + client_caps) + + self.assertEqual(0, len(connection.mockGetAllCalls())) + def test_hash(self): '''tests the hash computation''' - computed_hash = helpers.compute_caps_hash(self.identities, self.features) - + computed_hash = caps.compute_caps_hash(self.identities, self.features) self.assertEqual(self.caps_hash, computed_hash) + +class TestClientCaps(CommonCapsTest): + + def setUp(self): + CommonCapsTest.setUp(self) + self.client_caps = caps.ClientCaps(self.caps_hash, self.node, self.caps_method) + + def test_query_by_get_discover_strategy(self): + ''' Client must be queried if the data is unkown ''' + connection = Mock() + discover = self.client_caps.get_discover_strategy() + discover(connection, "test@gajim.org") + + connection.mockCheckCall(0, "discoverInfo", "test@gajim.org", + "http://gajim.org#m3P2WeXPMGVH2tZPe7yITnfY0Dw=") + + def test_client_supports(self): + contact = Contact(client_caps=self.client_caps) + + self.assertTrue(contact.supports(NS_PING), + msg="Assume supported, if we don't have caps") + + self.assertFalse(contact.supports(NS_XHTML_IM), + msg="Must not assume blacklisted feature is supported on default") + + self.cc.initialize_from_db() + + self.assertFalse(contact.supports(NS_PING), + msg="Must return false on unsupported feature") + + self.assertTrue(contact.supports(NS_XHTML_IM), + msg="Must return True on supported feature") + + self.assertTrue(contact.supports(NS_MUC), + msg="Must return True on supported feature") + + +class TestOldClientCaps(TestClientCaps): + + def setUp(self): + TestClientCaps.setUp(self) + self.client_caps = caps.OldClientCaps(self.caps_hash, self.node) + + def test_query_by_get_discover_strategy(self): + ''' Client must be queried if the data is unknown ''' + connection = Mock() + discover = self.client_caps.get_discover_strategy() + discover(connection, "test@gajim.org") + + connection.mockCheckCall(0, "discoverInfo", "test@gajim.org") + + if __name__ == '__main__': unittest.main() diff --git a/test/test_contacts.py b/test/test_contacts.py new file mode 100644 index 000000000..401f35620 --- /dev/null +++ b/test/test_contacts.py @@ -0,0 +1,51 @@ +''' +Test for Contact, GC_Contact and Contacts +''' +import unittest + +import lib +lib.setup_env() + +from common.contacts import CommonContact, Contact, GC_Contact +from common.xmpp import NS_MUC + +from common import caps + +class TestCommonContact(unittest.TestCase): + + def setUp(self): + self.contact = CommonContact(jid='', resource='', show='', status='', + name='', our_chatstate=None, composing_xep=None, chatstate=None, + client_caps=None) + + def test_default_client_supports(self): + ''' + Test the caps support method of contacts. + See test_caps for more enhanced tests. + ''' + caps.capscache = caps.CapsCache() + self.assertTrue(self.contact.supports(NS_MUC), + msg="Must not backtrace on simple check for supported feature") + + self.contact.client_caps = caps.NullClientCaps() + + self.assertTrue(self.contact.supports(NS_MUC), + msg="Must not backtrace on simple check for supported feature") + + +class TestContact(TestCommonContact): + + def setUp(self): + TestCommonContact.setUp(self) + self.contact = Contact() + + +class TestGC_Contact(TestCommonContact): + + def setUp(self): + TestCommonContact.setUp(self) + self.contact = GC_Contact() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test/test_roster.py b/test/test_roster.py index 0172efa36..10d1ab488 100644 --- a/test/test_roster.py +++ b/test/test_roster.py @@ -9,7 +9,6 @@ from mock import Mock, expectParams from gajim_mocks import * from common import gajim -from common import zeroconf import roster_window gajim.get_jid_from_account = lambda acc: 'myjid@' + acc