diff --git a/services/presence/activity.py b/services/presence/activity.py index 0743b2b8..2eb21f6c 100644 --- a/services/presence/activity.py +++ b/services/presence/activity.py @@ -22,7 +22,8 @@ from dbus.gobject_service import ExportedGObject from sugar import util import logging -from telepathy.interfaces import (CHANNEL_INTERFACE) +from telepathy.constants import CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES +from telepathy.interfaces import (CHANNEL_INTERFACE, CHANNEL_INTERFACE_GROUP) _ACTIVITY_PATH = "/org/laptop/Sugar/Presence/Activities/" _ACTIVITY_INTERFACE = "org.laptop.Sugar.Presence.Activity" @@ -80,7 +81,7 @@ class Activity(ExportedGObject): _RESERVED_PROPNAMES = __gproperties__.keys() - def __init__(self, bus, object_id, tp, **kwargs): + def __init__(self, bus, object_id, ps, tp, **kwargs): """Initializes the activity and sets its properties to default values. :Parameters: @@ -88,6 +89,8 @@ class Activity(ExportedGObject): A connection to the D-Bus session bus `object_id` : int PS ID for this activity, used to construct the object-path + `ps` : presenceservice.PresenceService + The presence service `tp` : server plugin The server plugin object (stands for "telepathy plugin") :Keywords: @@ -112,16 +115,20 @@ class Activity(ExportedGObject): if not tp: raise ValueError("telepathy CM must be valid") + self._ps = ps self._object_id = object_id self._object_path = dbus.ObjectPath(_ACTIVITY_PATH + str(self._object_id)) - self._buddies = [] + self._buddies = set() + self._member_handles = set() self._joined = False # the telepathy client self._tp = tp + self._self_handle = None self._text_channel = None + self._text_channel_group_flags = 0 self._valid = False self._id = None @@ -376,8 +383,10 @@ class Activity(ExportedGObject): ret.append(buddy) return ret - def buddy_joined(self, buddy): - """Adds a buddy to this activity and sends a BuddyJoined signal + def buddy_apparently_joined(self, buddy): + """Adds a buddy to this activity and sends a BuddyJoined signal, + unless we can already see who's in the activity by being in it + ourselves. buddy -- Buddy object representing the buddy being added @@ -388,30 +397,54 @@ class Activity(ExportedGObject): This method is called by the PresenceService on the local machine. """ - if buddy not in self._buddies: - self._buddies.append(buddy) + if not self._joined: + self._add_buddies((buddy,)) + + def _add_buddies(self, buddies): + buddies = set(buddies) + + # disregard any who are already there + buddies -= self._buddies + + self._buddies |= buddies + + for buddy in buddies: buddy.add_activity(self) if self.props.valid: self.BuddyJoined(buddy.object_path()) - def buddy_left(self, buddy): - """Removes a buddy from this activity and sends a BuddyLeft signal. + def _remove_buddies(self, buddies): + buddies = set(buddies) + + # disregard any who are not already there + buddies &= self._buddies + + self._buddies -= buddies + + for buddy in buddies: + buddy.remove_activity(self) + if self.props.valid: + self.BuddyJoined(buddy.object_path()) + + if not self._buddies: + self.emit('disappeared') + + def buddy_apparently_left(self, buddy): + """Removes a buddy from this activity and sends a BuddyLeft signal, + unless we can already see who's in the activity by being in it + ourselves. buddy -- Buddy object representing the buddy being removed Removes a buddy from this activity if the buddy is in the buddy list. If this activity is "valid", a BuddyLeft signal is also sent. This method is called by the PresenceService on the local machine. - """ - if buddy in self._buddies: - self._buddies.remove(buddy) - buddy.remove_activity(self) - if self.props.valid: - self.BuddyLeft(buddy.object_path()) + if not self._joined: + self._remove_buddies((buddy,)) - if not self._buddies: - self.emit('disappeared') + def _text_channel_group_flags_changed_cb(self, flags): + self._text_channel_group_flags = flags def _handle_share_join(self, tp, text_channel): """Called when a join to a network activity was successful. @@ -426,7 +459,36 @@ class Activity(ExportedGObject): self._text_channel = text_channel self._text_channel[CHANNEL_INTERFACE].connect_to_signal('Closed', self._text_channel_closed_cb) - self._joined = True + if CHANNEL_INTERFACE_GROUP in self._text_channel: + group = self._text_channel[CHANNEL_INTERFACE_GROUP] + + # FIXME: make these method calls async? + + group.connect_to_signal('GroupFlagsChanged', + self._text_channel_group_flags_changed_cb) + self._text_channel_group_flags = group.GetGroupFlags() + + self._self_handle = group.GetSelfHandle() + + # by the time we hook this, we need to know the group flags + group.connect_to_signal('MembersChanged', + self._text_channel_members_changed_cb) + # bootstrap by getting the current state. This is where we find + # out whether anyone was lying to us in their PEP info + members = set(group.GetMembers()) + added = members - self._member_handles + removed = self._member_handles - members + if added or removed: + self._text_channel_members_changed_cb('', added, removed, + (), (), 0, 0) + + # if we can see any member handles, we're probably able to see + # all members, so can stop caring about PEP announcements for this + # activity + self._joined = (self._self_handle in self._member_handles) + else: + self._joined = True + return True def _shared_cb(self, tp, activity_id, text_channel, exc, userdata): @@ -519,12 +581,59 @@ class Activity(ExportedGObject): if self._joined: self._text_channel[CHANNEL_INTERFACE].Close() + def _text_channel_members_changed_cb(self, message, added, removed, + local_pending, remote_pending, + actor, reason): + # Note: D-Bus calls this with list arguments, but after GetMembers() + # we call it with set and tuple arguments; we cope with any iterable. + + if (self._text_channel_group_flags & + CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES): + map_chan = self._text_channel + else: + # we have global handles here + map_chan = None + + # disregard any who are already there + added = set(added) + added -= self._member_handles + self._member_handles |= added + + # for added people, we need a Buddy object + added_buddies = self._ps.map_handles_to_buddies(self._tp, + map_chan, + added) + self._add_buddies(added_buddies.itervalues()) + + # we treat all pending members as if they weren't there + removed = set(removed) + removed |= set(local_pending) + removed |= set(remote_pending) + # disregard any who aren't already there + removed &= self._member_handles + self._member_handles -= removed + + # for removed people, don't bother creating a Buddy just so we can + # say it left. If we don't already have a Buddy object for someone, + # then obviously they're not in self._buddies! + removed_buddies = self._ps.map_handles_to_buddies(self._tp, + map_chan, + removed, + create=False) + self._remove_buddies(removed_buddies.itervalues()) + + # if we were among those removed, we'll have to start believing + # the spoofable PEP-based activity tracking again. + if self._self_handle not in self._member_handles: + self._joined = False + def _text_channel_closed_cb(self): """Callback method called when the text channel is closed. This callback is set up in the _handle_share_join method. """ self._joined = False + self._self_handle = None self._text_channel = None def send_properties(self): diff --git a/services/presence/presenceservice.py b/services/presence/presenceservice.py index 84814f08..6f28bf5f 100644 --- a/services/presence/presenceservice.py +++ b/services/presence/presenceservice.py @@ -211,7 +211,8 @@ class PresenceService(ExportedGObject): def _new_activity(self, activity_id, tp): try: objid = self._get_next_object_id() - activity = Activity(self._session_bus, objid, tp, id=activity_id) + activity = Activity(self._session_bus, objid, self, tp, + id=activity_id) except Exception: # FIXME: catching bare Exception considered harmful _logger.debug("Invalid activity:", exc_info=1) @@ -259,7 +260,7 @@ class PresenceService(ExportedGObject): activity = self._new_activity(act, tp) if activity is not None: - activity.buddy_joined(buddy) + activity.buddy_apparently_joined(buddy) activities_left = old_activities - new_activities for act in activities_left: @@ -268,7 +269,7 @@ class PresenceService(ExportedGObject): if not activity: continue - activity.buddy_left(buddy) + activity.buddy_apparently_left(buddy) def _activity_invitation(self, tp, act_id): activity = self._activities.get(act_id) @@ -376,6 +377,48 @@ class PresenceService(ExportedGObject): "connection to %s:%s" % (handle, tp_conn_name, tp_conn_path)) + def map_handles_to_buddies(self, tp, tp_chan, handles, create=True): + """ + + :Parameters: + `tp` : Telepathy plugin + The server or link-local plugin + `tp_chan` : telepathy.client.Channel or None + If not None, the channel in which these handles are + channel-specific + `handles` : iterable over int or long + The handles to be mapped to Buddy objects + `create` : bool + If true (default), if a corresponding `Buddy` object is not + found, create one. + :Returns: + A dict mapping handles from `handles` to `Buddy` objects. + If `create` is true, the dict's keys will be exactly the + items of `handles` in some order. If `create` is false, + the dict will contain no entry for handles for which no + `Buddy` is already available. + :Raises LookupError: if `tp` is not a plugin attached to this PS. + """ + handle_to_buddy = self._handles_buddies[tp] + + ret = {} + missing = [] + for handle in handles: + buddy = handle_to_buddy.get(handle) + if buddy is None: + missing.append(handle) + else: + ret[handle] = buddy + + if missing and create: + handle_to_objid = tp.identify_contacts(tp_chan, missing) + for handle, objid in handle_to_objid.iteritems(): + buddy = self.get_buddy(objid) + ret[handle] = buddy + if tp_chan is None: + handle_to_buddy[handle] = buddy + return ret + @dbus.service.method(_PRESENCE_INTERFACE, in_signature='', out_signature="o") def GetOwner(self): @@ -405,9 +448,9 @@ class PresenceService(ExportedGObject): objid = self._get_next_object_id() # FIXME check which tp client we should use to share the activity color = self._owner.props.color - activity = Activity(self._session_bus, objid, self._server_plugin, - id=actid, type=atype, name=name, color=color, - local=True) + activity = Activity(self._session_bus, objid, self, + self._server_plugin, id=actid, type=atype, + name=name, color=color, local=True) activity.connect("validity-changed", self._activity_validity_changed_cb) self._activities[actid] = activity