Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimon McVittie <simon.mcvittie@collabora.co.uk>2007-06-06 15:20:00 (GMT)
committer Simon McVittie <simon.mcvittie@collabora.co.uk>2007-06-06 15:20:00 (GMT)
commit72ffc806b07d6bd0d3ba42d6dc2f55b798fc93af (patch)
tree80f348b2f8682116d958c38019edb70cbe6de5fb
parent947cd5b251f9f67fc6c48614a3be871a1514a7fb (diff)
Copy sugar services/presence/ to presence-service src/
-rw-r--r--src/Makefile.am31
-rw-r--r--src/__init__.py36
-rw-r--r--src/activity.py715
-rw-r--r--src/buddy.py638
-rw-r--r--src/buddyiconcache.py105
-rw-r--r--src/linklocal_plugin.py27
-rw-r--r--src/org.laptop.Sugar.Presence.service.in4
-rw-r--r--src/presenceservice.py517
-rw-r--r--src/pstest.py317
-rw-r--r--src/psutils.py259
-rw-r--r--src/server_plugin.py1171
-rwxr-xr-xsrc/sugar-presence-service63
-rw-r--r--src/test_psutils.py12
13 files changed, 3895 insertions, 0 deletions
diff --git a/src/Makefile.am b/src/Makefile.am
new file mode 100644
index 0000000..44b2be7
--- /dev/null
+++ b/src/Makefile.am
@@ -0,0 +1,31 @@
+servicedir = $(datadir)/dbus-1/services
+service_in_files = org.laptop.Sugar.Presence.service.in
+service_DATA = $(service_in_files:.service.in=.service)
+
+$(service_DATA): $(service_in_files) Makefile
+ @sed -e "s|\@bindir\@|$(bindir)|" $< > $@
+
+sugardir = $(pkgdatadir)/services/presence
+sugar_PYTHON = \
+ __init__.py \
+ activity.py \
+ buddy.py \
+ buddyiconcache.py \
+ linklocal_plugin.py \
+ presenceservice.py \
+ pstest.py \
+ psutils.py \
+ server_plugin.py
+
+dist_bin_SCRIPTS = sugar-presence-service
+
+DISTCLEANFILES = $(service_DATA)
+
+EXTRA_DIST = $(service_in_files)
+
+dist_check_SCRIPTS = test_psutils.py
+
+TESTS_ENVIRONMENT = \
+ PYTHONPATH=$(top_srcdir):$(top_srcdir)/services/presence \
+ $(PYTHON)
+TESTS = $(dist_check_SCRIPTS)
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..bd64375
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,36 @@
+"""Service to track buddies and activities on the network
+
+Model objects:
+
+ activity.Activity -- tracks a (shared/shareable) activity
+ with many properties and observable events
+
+ buddy.Buddy -- tracks a reference to a particular actor
+ on the network
+
+ buddy.GenericOwner -- actor who owns a particular
+ activity on the network
+
+ buddy.ShellOwner -- actor who owns the local machine
+ connects to the owner module (on the server)
+
+Controller objects:
+
+ presenceservice.PresenceService -- controller which connects
+ a networking plugin to a DBUS service. Generates events
+ for networking events, forwards updates/requests to the
+ server plugin.
+
+ server_plugin.ServerPlugin -- implementation of networking
+ plugin using telepathy Python (Jabber) to provide the
+ underlying communications layer. Generates GObject
+ events that the PresenceService observes to forward onto
+ the DBUS clients.
+
+Utility machinery:
+
+ buddyiconcache.BuddyIconCache -- caches buddy icons on disk
+ based on the "jid" XXX Jabber ID? of the buddy.
+
+ psutils -- trivial function to decode int-list to characters
+"""
diff --git a/src/activity.py b/src/activity.py
new file mode 100644
index 0000000..2eb21f6
--- /dev/null
+++ b/src/activity.py
@@ -0,0 +1,715 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007, Collabora Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import gobject
+import dbus
+import dbus.service
+from dbus.gobject_service import ExportedGObject
+from sugar import util
+import logging
+
+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"
+
+_PROP_ID = "id"
+_PROP_NAME = "name"
+_PROP_COLOR = "color"
+_PROP_TYPE = "type"
+_PROP_VALID = "valid"
+_PROP_LOCAL = "local"
+_PROP_JOINED = "joined"
+_PROP_CUSTOM_PROPS = "custom-props"
+
+_logger = logging.getLogger('s-p-s.activity')
+
+class Activity(ExportedGObject):
+ """Represents a shared activity seen on the network, or a local activity
+ that has been, or will be, shared onto the network.
+
+ The activity might be public, restricted to a group, or invite-only.
+ """
+
+ __gtype_name__ = "Activity"
+
+ __gsignals__ = {
+ 'validity-changed':
+ # The activity's validity has changed.
+ # An activity is valid if its name, color, type and ID have been
+ # set.
+ # Arguments:
+ # validity: bool
+ (gobject.SIGNAL_RUN_FIRST, None, [bool]),
+ 'disappeared':
+ # Nobody is in this activity any more.
+ # No arguments.
+ (gobject.SIGNAL_RUN_FIRST, None, []),
+ }
+
+ __gproperties__ = {
+ _PROP_ID : (str, None, None, None,
+ gobject.PARAM_READWRITE |
+ gobject.PARAM_CONSTRUCT_ONLY),
+ _PROP_NAME : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_COLOR : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_TYPE : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_VALID : (bool, None, None, False, gobject.PARAM_READABLE),
+ _PROP_LOCAL : (bool, None, None, False,
+ gobject.PARAM_READWRITE |
+ gobject.PARAM_CONSTRUCT_ONLY),
+ _PROP_JOINED : (bool, None, None, False, gobject.PARAM_READABLE),
+ _PROP_CUSTOM_PROPS : (object, None, None,
+ gobject.PARAM_READWRITE |
+ gobject.PARAM_CONSTRUCT_ONLY)
+ }
+
+ _RESERVED_PROPNAMES = __gproperties__.keys()
+
+ def __init__(self, bus, object_id, ps, tp, **kwargs):
+ """Initializes the activity and sets its properties to default values.
+
+ :Parameters:
+ `bus` : dbus.bus.BusConnection
+ 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:
+ `id` : str
+ The globally unique activity ID (required)
+ `name` : str
+ Human-readable title for the activity
+ `color` : str
+ Activity color in #RRGGBB,#RRGGBB (stroke,fill) format
+ `type` : str
+ D-Bus service name representing the activity type
+ `local : bool
+ If True, this activity was initiated locally and is not
+ (yet) advertised on the network
+ (FIXME: is this description right?)
+ `custom-props` : dict
+ Activity-specific properties
+ """
+
+ if not object_id or not isinstance(object_id, int):
+ raise ValueError("object id must be a valid number")
+ 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 = 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
+ self._actname = None
+ self._color = None
+ self._local = False
+ self._type = None
+ self._custom_props = {}
+
+ # ensure no reserved property names are in custom properties
+ cprops = kwargs.get(_PROP_CUSTOM_PROPS)
+ if cprops is not None:
+ (rprops, cprops) = self._split_properties(cprops)
+ if len(rprops.keys()) > 0:
+ raise ValueError("Cannot use reserved property names '%s'"
+ % ", ".join(rprops.keys()))
+
+ if not kwargs.get(_PROP_ID):
+ raise ValueError("activity id is required")
+ if not util.validate_activity_id(kwargs[_PROP_ID]):
+ raise ValueError("Invalid activity id '%s'" % kwargs[_PROP_ID])
+
+ ExportedGObject.__init__(self, bus, self._object_path,
+ gobject_properties=kwargs)
+ if self.props.local and not self.props.valid:
+ raise RuntimeError("local activities require color, type, and "
+ "name")
+
+ # If not yet valid, query activity properties
+ if not self.props.valid:
+ tp.update_activity_properties(self._id)
+
+ def do_get_property(self, pspec):
+ """Gets the value of a property associated with this activity.
+
+ pspec -- Property specifier
+
+ returns The value of the given property.
+ """
+
+ if pspec.name == _PROP_ID:
+ return self._id
+ elif pspec.name == _PROP_NAME:
+ return self._actname
+ elif pspec.name == _PROP_COLOR:
+ return self._color
+ elif pspec.name == _PROP_TYPE:
+ return self._type
+ elif pspec.name == _PROP_VALID:
+ return self._valid
+ elif pspec.name == _PROP_JOINED:
+ return self._joined
+ elif pspec.name == _PROP_LOCAL:
+ return self._local
+
+ def do_set_property(self, pspec, value):
+ """Sets the value of a property associated with this activity.
+
+ pspec -- Property specifier
+ value -- Desired value
+
+ Note that the "type" property can be set only once; attempting to set
+ it to something different later will raise a RuntimeError.
+
+ """
+ if pspec.name == _PROP_ID:
+ if self._id:
+ raise RuntimeError("activity ID is already set")
+ self._id = value
+ elif pspec.name == _PROP_NAME:
+ self._actname = value
+ elif pspec.name == _PROP_COLOR:
+ self._color = value
+ elif pspec.name == _PROP_TYPE:
+ if self._type:
+ raise RuntimeError("activity type is already set")
+ self._type = value
+ elif pspec.name == _PROP_JOINED:
+ self._joined = value
+ elif pspec.name == _PROP_LOCAL:
+ self._local = value
+ elif pspec.name == _PROP_CUSTOM_PROPS:
+ if not value:
+ value = {}
+ (rprops, cprops) = self._split_properties(value)
+ self._custom_props = {}
+ for (key, dvalue) in cprops.items():
+ self._custom_props[str(key)] = str(dvalue)
+
+ self._update_validity()
+
+ def _update_validity(self):
+ """Sends a "validity-changed" signal if this activity's validity has
+ changed.
+
+ Determines whether this activity's status has changed from valid to
+ invalid, or invalid to valid, and emits a "validity-changed" signal
+ if either is true. "Valid" means that the object's type, ID, name,
+ colour and type properties have all been set to something valid
+ (i.e., not "None").
+
+ """
+ try:
+ old_valid = self._valid
+ if self._color and self._actname and self._id and self._type:
+ self._valid = True
+ else:
+ self._valid = False
+
+ if old_valid != self._valid:
+ self.emit("validity-changed", self._valid)
+ except AttributeError:
+ self._valid = False
+
+ # dbus signals
+ @dbus.service.signal(_ACTIVITY_INTERFACE,
+ signature="o")
+ def BuddyJoined(self, buddy_path):
+ """Generates DBUS signal when a buddy joins this activity.
+
+ buddy_path -- DBUS path to buddy object
+ """
+ pass
+
+ @dbus.service.signal(_ACTIVITY_INTERFACE,
+ signature="o")
+ def BuddyLeft(self, buddy_path):
+ """Generates DBUS signal when a buddy leaves this activity.
+
+ buddy_path -- DBUS path to buddy object
+ """
+ pass
+
+ @dbus.service.signal(_ACTIVITY_INTERFACE,
+ signature="o")
+ def NewChannel(self, channel_path):
+ """Generates DBUS signal when a new channel is created for this
+ activity.
+
+ channel_path -- DBUS path to new channel
+
+ XXX - what is this supposed to do? Who is supposed to call it?
+ What is the channel path? Right now this is never called.
+
+ """
+ pass
+
+ # dbus methods
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="s")
+ def GetId(self):
+ """DBUS method to get this activity's (randomly generated) unique ID
+
+ :Returns: Activity ID as a string
+ """
+ return self.props.id
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="s")
+ def GetColor(self):
+ """DBUS method to get this activity's colour
+
+ :Returns: Activity colour as a string in the format #RRGGBB,#RRGGBB
+ """
+ return self.props.color
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="s")
+ def GetType(self):
+ """DBUS method to get this activity's type
+
+ :Returns: Activity type as a string, in the same form as a D-Bus
+ well-known name
+ """
+ return self.props.type
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="",
+ async_callbacks=('async_cb', 'async_err_cb'))
+ def Join(self, async_cb, async_err_cb):
+ """DBUS method to for the local user to attempt to join the activity
+
+ async_cb -- Callback method to be called if join attempt is successful
+ async_err_cb -- Callback method to be called if join attempt is
+ unsuccessful
+
+ """
+ self.join(async_cb, async_err_cb)
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="ao")
+ def GetJoinedBuddies(self):
+ """DBUS method to return a list of valid buddies who are joined in
+ this activity
+
+ :Returns:
+ A list of buddy object paths corresponding to those buddies
+ in this activity who are 'valid' (i.e. for whom we have complete
+ information)
+ """
+ ret = []
+ for buddy in self._buddies:
+ if buddy.props.valid:
+ ret.append(buddy.object_path())
+ return ret
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="soao")
+ def GetChannels(self):
+ """DBUS method to get the list of channels associated with this
+ activity
+
+ :Returns:
+ a tuple containing:
+ - the D-Bus well-known service name of the connection
+ (FIXME: this is redundant; in Telepathy it can be derived
+ from that of the connection)
+ - the D-Bus object path of the connection
+ - a list of D-Bus object paths representing the channels
+ associated with this activity
+ """
+ return self.get_channels()
+
+ @dbus.service.method(_ACTIVITY_INTERFACE,
+ in_signature="", out_signature="s")
+ def GetName(self):
+ """DBUS method to get this activity's name
+
+ returns Activity name
+ """
+ return self.props.name
+
+ # methods
+ def object_path(self):
+ """Retrieves our dbus.ObjectPath object
+
+ returns DBUS ObjectPath object
+ """
+ return self._object_path
+
+ def get_joined_buddies(self):
+ """Local method to return a list of valid buddies who are joined in
+ this activity
+
+ This method is called by the PresenceService on the local machine.
+
+ returns A list of buddy objects
+ """
+ ret = []
+ for buddy in self._buddies:
+ if buddy.props.valid:
+ ret.append(buddy)
+ return ret
+
+ 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
+
+ Adds a buddy to this activity if the buddy is not already in the
+ buddy list.
+
+ If this activity is "valid", a BuddyJoined signal is also sent.
+ This method is called by the PresenceService on the local machine.
+
+ """
+ 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 _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 not self._joined:
+ self._remove_buddies((buddy,))
+
+ 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.
+
+ Called by the _shared_cb and _joined_cb methods.
+ """
+ if not text_channel:
+ _logger.debug("Error sharing: text channel was None, shouldn't "
+ "happen")
+ raise RuntimeError("Plugin returned invalid text channel")
+
+ self._text_channel = text_channel
+ self._text_channel[CHANNEL_INTERFACE].connect_to_signal('Closed',
+ self._text_channel_closed_cb)
+ 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):
+ """XXX - not documented yet
+ """
+ if activity_id != self.props.id:
+ # Not for us
+ return
+
+ (sigid, owner, async_cb, async_err_cb) = userdata
+ self._tp.disconnect(sigid)
+
+ if exc:
+ _logger.debug("Share of activity %s failed: %s" % (self._id, exc))
+ async_err_cb(exc)
+ else:
+ self._handle_share_join(tp, text_channel)
+ self.send_properties()
+ owner.add_activity(self)
+ async_cb(dbus.ObjectPath(self._object_path))
+ _logger.debug("Share of activity %s succeeded." % self._id)
+
+ def _share(self, (async_cb, async_err_cb), owner):
+ """XXX - not documented yet
+
+ XXX - This method is called externally by the PresenceService
+ despite the fact that this is supposed to be an internal method!
+ """
+ _logger.debug("Starting share of activity %s" % self._id)
+ if self._joined:
+ async_err_cb(RuntimeError("Already shared activity %s"
+ % self.props.id))
+ return
+ sigid = self._tp.connect('activity-shared', self._shared_cb)
+ self._tp.share_activity(self.props.id, (sigid, owner, async_cb,
+ async_err_cb))
+ _logger.debug("done with share attempt %s" % self._id)
+
+ def _joined_cb(self, tp, activity_id, text_channel, exc, userdata):
+ """XXX - not documented yet
+ """
+ if activity_id != self.props.id:
+ # Not for us
+ return
+
+ (sigid, async_cb, async_err_cb) = userdata
+ self._tp.disconnect(sigid)
+
+ if exc:
+ async_err_cb(exc)
+ else:
+ self._handle_share_join(tp, text_channel)
+ async_cb()
+
+ def join(self, async_cb, async_err_cb):
+ """Local method for the local user to attempt to join the activity.
+
+ async_cb -- Callback method to be called if join attempt is successful
+ async_err_cb -- Callback method to be called if join attempt is
+ unsuccessful
+
+ The two callbacks are passed to the server_plugin ("tp") object,
+ which in turn passes them back as parameters in a callback to the
+ _joined_cb method; this callback is set up within this method.
+ """
+ if self._joined:
+ async_err_cb(RuntimeError("Already joined activity %s"
+ % self.props.id))
+ return
+ sigid = self._tp.connect('activity-joined', self._joined_cb)
+ self._tp.join_activity(self.props.id, (sigid, async_cb, async_err_cb))
+
+ def get_channels(self):
+ """Local method to get the list of channels associated with this
+ activity
+
+ returns XXX - expected a list of channels, instead returning a tuple?
+ """
+ conn = self._tp.get_connection()
+ # FIXME add tubes and others channels
+ return (str(conn.service_name), conn.object_path,
+ [self._text_channel.object_path])
+
+ def leave(self):
+ """Local method called when the user wants to leave the activity.
+
+ (XXX - doesn't appear to be called anywhere!)
+
+ """
+ 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):
+ """Tells the Telepathy server what the properties of this activity are.
+
+ """
+ props = {}
+ props['name'] = self._actname
+ props['color'] = self._color
+ props['type'] = self._type
+
+ # Add custom properties
+ for (key, value) in self._custom_props.items():
+ props[key] = value
+
+ self._tp.set_activity_properties(self.props.id, props)
+
+ def set_properties(self, properties):
+ """Sets name, colour and/or type properties for this activity all
+ at once.
+
+ properties - Dictionary object containing properties keyed by
+ property names
+
+ Note that if any of the name, colour and/or type property values is
+ changed from what it originally was, the update_validity method will
+ be called, resulting in a "validity-changed" signal being generated.
+ Called by the PresenceService on the local machine.
+ """
+ changed = False
+ # split reserved properties from activity-custom properties
+ (rprops, cprops) = self._split_properties(properties)
+ if _PROP_NAME in rprops.keys():
+ name = rprops[_PROP_NAME]
+ if name != self._actname:
+ self._actname = name
+ changed = True
+
+ if _PROP_COLOR in rprops.keys():
+ color = rprops[_PROP_COLOR]
+ if color != self._color:
+ self._color = color
+ changed = True
+
+ if _PROP_TYPE in rprops.keys():
+ type = rprops[_PROP_TYPE]
+ if type != self._type:
+ # Type can never be changed after first set
+ if self._type:
+ _logger.debug("Activity type changed by network; this "
+ "is illegal")
+ else:
+ self._type = type
+ changed = True
+
+ # Set custom properties
+ if len(cprops.keys()) > 0:
+ self.props.custom_props = cprops
+
+ if changed:
+ self._update_validity()
+
+ def _split_properties(self, properties):
+ """Extracts reserved properties.
+
+ properties - Dictionary object containing properties keyed by
+ property names
+
+ returns a tuple of 2 dictionaries, reserved properties and custom
+ properties
+ """
+ rprops = {}
+ cprops = {}
+ for (key, value) in properties.items():
+ if key in self._RESERVED_PROPNAMES:
+ rprops[key] = value
+ else:
+ cprops[key] = value
+ return (rprops, cprops)
diff --git a/src/buddy.py b/src/buddy.py
new file mode 100644
index 0000000..82a9b44
--- /dev/null
+++ b/src/buddy.py
@@ -0,0 +1,638 @@
+"""An "actor" on the network, whether remote or local"""
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007, Collabora Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import os
+import gobject
+import dbus
+import dbus.service
+from dbus.gobject_service import ExportedGObject
+import psutils
+
+from sugar import env, profile
+import logging
+
+_BUDDY_PATH = "/org/laptop/Sugar/Presence/Buddies/"
+_BUDDY_INTERFACE = "org.laptop.Sugar.Presence.Buddy"
+_OWNER_INTERFACE = "org.laptop.Sugar.Presence.Buddy.Owner"
+
+_PROP_NICK = "nick"
+_PROP_KEY = "key"
+_PROP_ICON = "icon"
+_PROP_CURACT = "current-activity"
+_PROP_COLOR = "color"
+_PROP_OWNER = "owner"
+_PROP_VALID = "valid"
+_PROP_OBJID = 'objid'
+
+# Will go away soon
+_PROP_IP4_ADDRESS = "ip4-address"
+
+_logger = logging.getLogger('s-p-s.buddy')
+
+
+class Buddy(ExportedGObject):
+ """Person on the network (tracks properties and shared activites)
+
+ The Buddy is a collection of metadata describing a particular
+ actor/person on the network. The Buddy object tracks a set of
+ activities which the actor has shared with the presence service.
+
+ Buddies have a "valid" property which is used to flag Buddies
+ which are no longer reachable. That is, a Buddy may represent
+ a no-longer reachable target on the network.
+
+ The Buddy emits GObject events that the PresenceService uses
+ to track changes in its status.
+
+ Attributes:
+
+ _activities -- dictionary mapping activity ID to
+ activity.Activity objects
+ handles -- dictionary mapping Telepathy client plugin to
+ contact handle (an integer representing the JID or unique ID);
+ channel-specific handles do not appear here
+ """
+
+ __gsignals__ = {
+ 'validity-changed':
+ # The buddy's validity changed.
+ # Validity starts off False, and becomes True when the buddy
+ # has a color, a nick and a key.
+ # * the new validity: bool
+ (gobject.SIGNAL_RUN_FIRST, None, [bool]),
+ 'property-changed':
+ # One of the buddy's properties has changed.
+ # * those properties that have changed:
+ # dict { str => object }
+ (gobject.SIGNAL_RUN_FIRST, None, [object]),
+ 'icon-changed':
+ # The buddy's icon changed.
+ # * the bytes of the icon: str
+ (gobject.SIGNAL_RUN_FIRST, None, [object]),
+ 'disappeared':
+ # The buddy is offline (has no Telepathy handles and is not the
+ # Owner)
+ (gobject.SIGNAL_RUN_FIRST, None, []),
+ }
+
+ __gproperties__ = {
+ _PROP_KEY : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_ICON : (object, None, None, gobject.PARAM_READWRITE),
+ _PROP_NICK : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_COLOR : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_CURACT : (str, None, None, None, gobject.PARAM_READWRITE),
+ _PROP_VALID : (bool, None, None, False, gobject.PARAM_READABLE),
+ _PROP_OWNER : (bool, None, None, False, gobject.PARAM_READABLE),
+ _PROP_OBJID : (str, None, None, None, gobject.PARAM_READABLE),
+ _PROP_IP4_ADDRESS : (str, None, None, None, gobject.PARAM_READWRITE)
+ }
+
+ def __init__(self, bus, object_id, **kwargs):
+ """Initialize the Buddy object
+
+ bus -- connection to the D-Bus session bus
+ object_id -- the buddy's unique identifier, either based on their
+ key-ID or JID
+ kwargs -- used to initialize the object's properties
+
+ constructs a DBUS "object path" from the _BUDDY_PATH
+ and object_id
+ """
+
+ self._object_id = object_id
+ self._object_path = dbus.ObjectPath(_BUDDY_PATH + object_id)
+
+ self._activities = {} # Activity ID -> Activity
+ self._activity_sigids = {}
+ self.handles = {} # tp client -> handle
+
+ self._valid = False
+ self._owner = False
+ self._key = None
+ self._icon = ''
+ self._current_activity = None
+ self._nick = None
+ self._color = None
+ self._ip4_address = None
+
+ _ALLOWED_INIT_PROPS = [_PROP_NICK, _PROP_KEY, _PROP_ICON,
+ _PROP_CURACT, _PROP_COLOR, _PROP_IP4_ADDRESS]
+ for (key, value) in kwargs.items():
+ if key not in _ALLOWED_INIT_PROPS:
+ _logger.debug("Invalid init property '%s'; ignoring..." % key)
+ del kwargs[key]
+
+ # Set icon after superclass init, because it sends DBus and GObject
+ # signals when set
+ icon_data = None
+ if kwargs.has_key(_PROP_ICON):
+ icon_data = kwargs[_PROP_ICON]
+ del kwargs[_PROP_ICON]
+
+ ExportedGObject.__init__(self, bus, self._object_path,
+ gobject_properties=kwargs)
+
+ if icon_data:
+ self.props.icon = icon_data
+
+ def do_get_property(self, pspec):
+ """Retrieve current value for the given property specifier
+
+ pspec -- property specifier with a "name" attribute
+ """
+ if pspec.name == _PROP_OBJID:
+ return self._object_id
+ elif pspec.name == _PROP_KEY:
+ return self._key
+ elif pspec.name == _PROP_ICON:
+ return self._icon
+ elif pspec.name == _PROP_NICK:
+ return self._nick
+ elif pspec.name == _PROP_COLOR:
+ return self._color
+ elif pspec.name == _PROP_CURACT:
+ if not self._current_activity:
+ return None
+ if not self._activities.has_key(self._current_activity):
+ return None
+ return self._current_activity
+ elif pspec.name == _PROP_VALID:
+ return self._valid
+ elif pspec.name == _PROP_OWNER:
+ return self._owner
+ elif pspec.name == _PROP_IP4_ADDRESS:
+ return self._ip4_address
+
+ def do_set_property(self, pspec, value):
+ """Set given property
+
+ pspec -- property specifier with a "name" attribute
+ value -- value to set
+
+ emits 'icon-changed' signal on icon setting
+ calls _update_validity on all calls
+ """
+ if pspec.name == _PROP_ICON:
+ if str(value) != self._icon:
+ self._icon = str(value)
+ self.IconChanged(self._icon)
+ self.emit('icon-changed', self._icon)
+ elif pspec.name == _PROP_NICK:
+ self._nick = value
+ elif pspec.name == _PROP_COLOR:
+ self._color = value
+ elif pspec.name == _PROP_CURACT:
+ self._current_activity = value
+ elif pspec.name == _PROP_KEY:
+ if self._key:
+ raise RuntimeError("Key already set.")
+ self._key = value
+ elif pspec.name == _PROP_IP4_ADDRESS:
+ self._ip4_address = value
+
+ self._update_validity()
+
+ # dbus signals
+ @dbus.service.signal(_BUDDY_INTERFACE,
+ signature="ay")
+ def IconChanged(self, icon_data):
+ """Generates DBUS signal with icon_data"""
+
+ @dbus.service.signal(_BUDDY_INTERFACE,
+ signature="o")
+ def JoinedActivity(self, activity_path):
+ """Generates DBUS signal when buddy joins activity
+
+ activity_path -- DBUS path to the activity object
+ """
+
+ @dbus.service.signal(_BUDDY_INTERFACE,
+ signature="o")
+ def LeftActivity(self, activity_path):
+ """Generates DBUS signal when buddy leaves activity
+
+ activity_path -- DBUS path to the activity object
+ """
+
+ @dbus.service.signal(_BUDDY_INTERFACE,
+ signature="a{sv}")
+ def PropertyChanged(self, updated):
+ """Generates DBUS signal when buddy's property changes
+
+ updated -- updated property-set (dictionary) with the
+ Buddy's property (changed) values. Note: not the
+ full set of properties, just the changes.
+ """
+
+ def add_telepathy_handle(self, tp_client, handle):
+ """Add a Telepathy handle."""
+ conn = tp_client.get_connection()
+ self.TelepathyHandleAdded(conn.service_name, conn.object_path, handle)
+ self.handles[tp_client] = handle
+
+ @dbus.service.signal(_BUDDY_INTERFACE, signature='sou')
+ def TelepathyHandleAdded(self, tp_conn_name, tp_conn_path, handle):
+ """Another Telepathy handle has become associated with the buddy.
+
+ This must only be emitted for non-channel-specific handles.
+
+ tp_conn_name -- The bus name at which the Telepathy connection may be
+ found
+ tp_conn_path -- The object path at which the Telepathy connection may
+ be found
+ handle -- The handle of type CONTACT, which is not channel-specific,
+ newly associated with the buddy
+ """
+
+ def remove_telepathy_handle(self, tp_client, handle):
+ """Remove a Telepathy handle."""
+ conn = tp_client.get_connection()
+ my_handle = self.handles.get(tp_client, 0)
+ if my_handle == handle:
+ del self.handles[tp_client]
+ self.TelepathyHandleRemoved(conn.service_name, conn.object_path,
+ handle)
+ # the Owner can't disappear - that would be silly
+ if not self.handles and not self._owner:
+ self.emit('disappeared')
+ else:
+ _logger.debug('Telepathy handle %u supposedly removed, but '
+ 'my handle on that connection is %u - ignoring',
+ handle, my_handle)
+
+ @dbus.service.signal(_BUDDY_INTERFACE, signature='sou')
+ def TelepathyHandleRemoved(self, tp_conn_name, tp_conn_path, handle):
+ """A Telepathy handle has ceased to be associated with the buddy,
+ probably because that contact went offline.
+
+ The parameters are the same as for TelepathyHandleAdded.
+ """
+
+ # dbus methods
+ @dbus.service.method(_BUDDY_INTERFACE,
+ in_signature="", out_signature="ay")
+ def GetIcon(self):
+ """Retrieve Buddy's icon data
+
+ returns empty string or dbus.ByteArray
+ """
+ if not self.props.icon:
+ return ""
+ return dbus.ByteArray(self.props.icon)
+
+ @dbus.service.method(_BUDDY_INTERFACE,
+ in_signature="", out_signature="ao")
+ def GetJoinedActivities(self):
+ """Retrieve set of Buddy's joined activities (paths)
+
+ returns list of dbus service paths for the Buddy's joined
+ activities
+ """
+ acts = []
+ for act in self.get_joined_activities():
+ if act.props.valid:
+ acts.append(act.object_path())
+ return acts
+
+ @dbus.service.method(_BUDDY_INTERFACE,
+ in_signature="", out_signature="a{sv}")
+ def GetProperties(self):
+ """Retrieve set of Buddy's properties
+
+ returns dictionary of
+ nick : str(nickname)
+ owner : bool( whether this Buddy is an owner??? )
+ XXX what is the owner flag for?
+ key : str(public-key)
+ color: Buddy's icon colour
+ XXX what type?
+ current-activity: Buddy's current activity_id, or
+ "" if no current activity
+ """
+ props = {}
+ props[_PROP_NICK] = self.props.nick
+ props[_PROP_OWNER] = self.props.owner
+ props[_PROP_KEY] = self.props.key
+ props[_PROP_COLOR] = self.props.color
+
+ if self.props.ip4_address:
+ props[_PROP_IP4_ADDRESS] = self.props.ip4_address
+ else:
+ props[_PROP_IP4_ADDRESS] = ""
+
+ if self.props.current_activity:
+ props[_PROP_CURACT] = self.props.current_activity
+ else:
+ props[_PROP_CURACT] = ""
+ return props
+
+ @dbus.service.method(_BUDDY_INTERFACE,
+ in_signature='', out_signature='a(sou)')
+ def GetTelepathyHandles(self):
+ """Return a list of non-channel-specific Telepathy contact handles
+ associated with this Buddy.
+
+ :Returns:
+ An array of triples (connection well-known bus name, connection
+ object path, handle).
+ """
+ ret = []
+ for plugin in self.handles:
+ conn = plugin.get_connection()
+ ret.append((str(conn.service_name), conn.object_path,
+ self.handles[plugin]))
+
+ # methods
+ def object_path(self):
+ """Retrieve our dbus.ObjectPath object"""
+ return dbus.ObjectPath(self._object_path)
+
+ def _activity_validity_changed_cb(self, activity, valid):
+ """Join or leave the activity when its validity changes"""
+ if valid:
+ self.JoinedActivity(activity.object_path())
+ else:
+ self.LeftActivity(activity.object_path())
+
+ def add_activity(self, activity):
+ """Add an activity to the Buddy's set of activities
+
+ activity -- activity.Activity instance
+
+ calls JoinedActivity
+ """
+ actid = activity.props.id
+ if self._activities.has_key(actid):
+ return
+ self._activities[actid] = activity
+ # join/leave activity when it's validity changes
+ sigid = activity.connect("validity-changed",
+ self._activity_validity_changed_cb)
+ self._activity_sigids[actid] = sigid
+ if activity.props.valid:
+ self.JoinedActivity(activity.object_path())
+
+ def remove_activity(self, activity):
+ """Remove the activity from the Buddy's set of activities
+
+ activity -- activity.Activity instance
+
+ calls LeftActivity
+ """
+ actid = activity.props.id
+ if not self._activities.has_key(actid):
+ return
+ activity.disconnect(self._activity_sigids[actid])
+ del self._activity_sigids[actid]
+ del self._activities[actid]
+ if activity.props.valid:
+ self.LeftActivity(activity.object_path())
+
+ def get_joined_activities(self):
+ """Retrieves list of still-valid activity objects"""
+ acts = []
+ for act in self._activities.values():
+ acts.append(act)
+ return acts
+
+ def set_properties(self, properties):
+ """Set the given set of properties on the object
+
+ properties -- set of property values to set
+
+ if no change, no events generated
+ if change, generates property-changed and
+ calls _update_validity
+ """
+ changed = False
+ changed_props = {}
+ if _PROP_NICK in properties:
+ nick = properties[_PROP_NICK]
+ if nick != self._nick:
+ self._nick = nick
+ changed_props[_PROP_NICK] = nick
+ changed = True
+ if _PROP_COLOR in properties:
+ color = properties[_PROP_COLOR]
+ if color != self._color:
+ self._color = color
+ changed_props[_PROP_COLOR] = color
+ changed = True
+ if _PROP_CURACT in properties:
+ curact = properties[_PROP_CURACT]
+ if curact != self._current_activity:
+ self._current_activity = curact
+ changed_props[_PROP_CURACT] = curact
+ changed = True
+ if _PROP_IP4_ADDRESS in properties:
+ ip4addr = properties[_PROP_IP4_ADDRESS]
+ if ip4addr != self._ip4_address:
+ self._ip4_address = ip4addr
+ changed_props[_PROP_IP4_ADDRESS] = ip4addr
+ changed = True
+ if _PROP_KEY in properties:
+ # don't allow key to be set more than once
+ if self._key is None:
+ key = properties[_PROP_KEY]
+ if key is not None:
+ self._key = key
+ changed_props[_PROP_KEY] = key
+ changed = True
+
+ if not changed or not changed_props:
+ return
+
+ # Try emitting PropertyChanged before updating validity
+ # to avoid leaking a PropertyChanged signal before the buddy is
+ # actually valid the first time after creation
+ if self._valid:
+ dbus_changed = {}
+ for key, value in changed_props.items():
+ if value:
+ dbus_changed[key] = value
+ else:
+ dbus_changed[key] = ""
+ self.PropertyChanged(dbus_changed)
+
+ self.emit('property-changed', changed_props)
+
+ self._update_validity()
+
+ def _update_validity(self):
+ """Check whether we are now valid
+
+ validity is True if color, nick and key are non-null
+
+ emits validity-changed if we have changed validity
+ """
+ try:
+ old_valid = self._valid
+ if self._color and self._nick and self._key:
+ self._valid = True
+ else:
+ self._valid = False
+
+ if old_valid != self._valid:
+ self.emit("validity-changed", self._valid)
+ except AttributeError:
+ self._valid = False
+
+
+class GenericOwner(Buddy):
+ """Common functionality for Local User-like objects
+
+ The TestOwner wants to produce something *like* a
+ ShellOwner, but with randomised changes and the like.
+ This class provides the common features for a real
+ local owner and a testing one.
+ """
+ __gtype_name__ = "GenericOwner"
+
+ def __init__(self, ps, bus, object_id, **kwargs):
+ """Initialize the GenericOwner instance
+
+ ps -- presenceservice.PresenceService object
+ bus -- a connection to the D-Bus session bus
+ object_id -- the activity's unique identifier
+ kwargs -- used to initialize the object's properties
+
+ calls Buddy.__init__
+ """
+ self._ps = ps
+ self._server = kwargs.pop("server", "olpc.collabora.co.uk")
+ self._key_hash = kwargs.pop("key_hash", None)
+ self._registered = kwargs.pop("registered", False)
+
+ self._ip4_addr_monitor = psutils.IP4AddressMonitor.get_instance()
+ self._ip4_addr_monitor.connect("address-changed",
+ self._ip4_address_changed_cb)
+ if self._ip4_addr_monitor.props.address:
+ kwargs["ip4-address"] = self._ip4_addr_monitor.props.address
+
+ Buddy.__init__(self, bus, object_id, **kwargs)
+ self._owner = True
+
+ self._bus = dbus.SessionBus()
+
+ def _ip4_address_changed_cb(self, monitor, address):
+ """Handle IPv4 address change, set property to generate event"""
+ props = {_PROP_IP4_ADDRESS: address}
+ self.set_properties(props)
+
+ def get_registered(self):
+ """Retrieve whether owner has registered with presence server"""
+ return self._registered
+
+ def get_server(self):
+ """Retrieve XMPP server hostname (used by the server plugin)"""
+ return self._server
+
+ def get_key_hash(self):
+ """Retrieve the user's private-key hash (used by the server plugin
+ as a password)
+ """
+ return self._key_hash
+
+ def set_registered(self, registered):
+ """Customisation point: handle the registration of the owner"""
+ raise RuntimeError("Subclasses must implement")
+
+
+class ShellOwner(GenericOwner):
+ """Representation of the local-machine owner using Sugar's Shell
+
+ The ShellOwner uses the Sugar Shell's dbus services to
+ register for updates about the user's profile description.
+ """
+ __gtype_name__ = "ShellOwner"
+
+ _SHELL_SERVICE = "org.laptop.Shell"
+ _SHELL_OWNER_INTERFACE = "org.laptop.Shell.Owner"
+ _SHELL_PATH = "/org/laptop/Shell"
+
+ def __init__(self, ps, bus):
+ """Initialize the ShellOwner instance
+
+ ps -- presenceservice.PresenceService object
+ bus -- a connection to the D-Bus session bus
+
+ Retrieves initial property values from the profile
+ module. Loads the buddy icon from file as well.
+ XXX note: no error handling on that
+
+ calls GenericOwner.__init__
+ """
+ server = profile.get_server()
+ key_hash = profile.get_private_key_hash()
+ registered = profile.get_server_registered()
+ key = profile.get_pubkey()
+ nick = profile.get_nick_name()
+ color = profile.get_color().to_string()
+
+ icon_file = os.path.join(env.get_profile_path(), "buddy-icon.jpg")
+ f = open(icon_file, "r")
+ icon = f.read()
+ f.close()
+
+ GenericOwner.__init__(self, ps, bus,
+ 'keyid/' + psutils.pubkey_to_keyid(key),
+ key=key, nick=nick, color=color, icon=icon, server=server,
+ key_hash=key_hash, registered=registered)
+
+ # Ask to get notifications on Owner object property changes in the
+ # shell. If it's not currently running, no problem - we'll get the
+ # signals when it does run
+ for (signal, cb) in (('IconChanged', self._icon_changed_cb),
+ ('ColorChanged', self._color_changed_cb),
+ ('NickChanged', self._nick_changed_cb)):
+ self._bus.add_signal_receiver(cb, signal_name=signal,
+ dbus_interface=self._SHELL_OWNER_INTERFACE,
+ bus_name=self._SHELL_SERVICE,
+ path=self._SHELL_PATH)
+
+ def set_registered(self, value):
+ """Handle notification that we have been registered"""
+ if value:
+ profile.set_server_registered()
+
+ def _icon_changed_cb(self, icon):
+ """Handle icon change, set property to generate event"""
+ self.props.icon = icon
+
+ def _color_changed_cb(self, color):
+ """Handle color change, set property to generate event"""
+ props = {_PROP_COLOR: color}
+ self.set_properties(props)
+
+ def _nick_changed_cb(self, nick):
+ """Handle nickname change, set property to generate event"""
+ props = {_PROP_NICK: nick}
+ self.set_properties(props)
+
+ def _cur_activity_changed_cb(self, activity_id):
+ """Handle current-activity change, set property to generate event
+
+ Filters out local activities (those not in self.activites)
+ because the network users can't join those activities, so
+ the activity_id shared will be None in those cases...
+ """
+ if not self._activities.has_key(activity_id):
+ # This activity is local-only
+ activity_id = None
+ props = {_PROP_CURACT: activity_id}
+ self.set_properties(props)
diff --git a/src/buddyiconcache.py b/src/buddyiconcache.py
new file mode 100644
index 0000000..9d355bb
--- /dev/null
+++ b/src/buddyiconcache.py
@@ -0,0 +1,105 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007, Collabora Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+from sugar import env
+
+import os.path
+import cPickle
+
+class BuddyIconCache(object):
+ """Caches icons on disk and finds them based on the jid of their owners."""
+ def __init__(self):
+ ppath = env.get_profile_path()
+ self._cachepath = os.path.join(ppath, "cache", "buddy-icons", "cache")
+
+ # Ensure cache directory exists
+ if not os.path.exists(os.path.dirname(self._cachepath)):
+ os.makedirs(os.path.dirname(self._cachepath))
+
+ if not os.path.exists(self._cachepath):
+ self._cache = {}
+ # md5 and server token of the last avatar uploaded
+ self._md5 = ''
+ self._token = ''
+ else:
+ self._load_cache()
+
+ def _load_cache(self):
+ try:
+ self._cache, self._md5, self._token = cPickle.load(open(self._cachepath, "r"))
+ except:
+ self._cache, self._md5, self._token = {}, '', ''
+
+ def _save_cache(self):
+ out = open(self._cachepath, "w")
+ cPickle.dump((self._cache, self._md5, self._token), out, protocol=2)
+
+ def get_icon(self, jid, token):
+ hit = self._cache.get(jid)
+
+ if hit:
+ t, icon = hit[0], hit[1]
+ if t == token:
+ return icon
+
+ return None
+
+ def store_icon(self, jid, token, data):
+ self._cache[jid] = (token, data)
+ self._save_cache()
+
+ def check_avatar(self, md5, token):
+ return self._md5 == md5 and self._token == token
+
+ def set_avatar(self, md5, token):
+ self._md5 = md5
+ self._token = token
+ self._save_cache()
+
+if __name__ == "__main__":
+ my_cache = BuddyIconCache()
+
+ # look for the icon in the cache
+ icon = my_cache.get_icon("test@olpc.collabora.co.uk", "aaaa")
+ print icon
+
+ my_cache.store_icon("test@olpc.collabora.co.uk", "aaaa", "icon1")
+
+ # now we're sure that the icon is in the cache
+ icon = my_cache.get_icon("test@olpc.collabora.co.uk", "aaaa")
+ print icon
+
+ # new icon
+ my_cache.store_icon("test@olpc.collabora.co.uk", "bbbb", "icon2")
+
+ # the icon in the cache is not valid now
+ icon = my_cache.get_icon("test@olpc.collabora.co.uk", "aaaa")
+ print icon
+
+
+ my_avatar_md5 = "111"
+ my_avatar_token = "222"
+
+ if not my_cache.check_avatar(my_avatar_md5, my_avatar_token):
+ # upload of the new avatar
+ print "upload of the new avatar"
+ my_cache.set_avatar(my_avatar_md5, my_avatar_token)
+ else:
+ print "No need to upload the new avatar"
+
+ if my_cache.check_avatar(my_avatar_md5, my_avatar_token):
+ print "No need to upload the new avatar"
diff --git a/src/linklocal_plugin.py b/src/linklocal_plugin.py
new file mode 100644
index 0000000..b8f6445
--- /dev/null
+++ b/src/linklocal_plugin.py
@@ -0,0 +1,27 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007, Collabora Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import gobject
+
+class LinkLocalPlugin(gobject.GObject):
+ def __init__(self, registry, owner):
+ gobject.GObject.__init__(self)
+ self._registry = registry
+ self._owner = owner
+
+ def cleanup(self):
+ pass
diff --git a/src/org.laptop.Sugar.Presence.service.in b/src/org.laptop.Sugar.Presence.service.in
new file mode 100644
index 0000000..70ecda6
--- /dev/null
+++ b/src/org.laptop.Sugar.Presence.service.in
@@ -0,0 +1,4 @@
+[D-BUS Service]
+Name = org.laptop.Sugar.Presence
+Exec = @bindir@/sugar-presence-service
+
diff --git a/src/presenceservice.py b/src/presenceservice.py
new file mode 100644
index 0000000..5bcfd45
--- /dev/null
+++ b/src/presenceservice.py
@@ -0,0 +1,517 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007 Collabora Ltd. <http://www.collabora.co.uk/>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import logging
+from weakref import WeakValueDictionary
+
+import dbus
+import dbus.service
+import gobject
+from dbus.gobject_service import ExportedGObject
+from dbus.mainloop.glib import DBusGMainLoop
+from telepathy.client import ManagerRegistry, Connection
+from telepathy.interfaces import (CONN_MGR_INTERFACE, CONN_INTERFACE)
+from telepathy.constants import (CONNECTION_STATUS_CONNECTING,
+ CONNECTION_STATUS_CONNECTED,
+ CONNECTION_STATUS_DISCONNECTED)
+
+from sugar import util
+
+from server_plugin import ServerPlugin
+from linklocal_plugin import LinkLocalPlugin
+from buddy import Buddy, ShellOwner
+from activity import Activity
+from psutils import pubkey_to_keyid
+
+_PRESENCE_SERVICE = "org.laptop.Sugar.Presence"
+_PRESENCE_INTERFACE = "org.laptop.Sugar.Presence"
+_PRESENCE_PATH = "/org/laptop/Sugar/Presence"
+
+
+_logger = logging.getLogger('s-p-s.presenceservice')
+
+
+class NotFoundError(dbus.DBusException):
+ def __init__(self, msg):
+ dbus.DBusException.__init__(self, msg)
+ self._dbus_error_name = _PRESENCE_INTERFACE + '.NotFound'
+
+class PresenceService(ExportedGObject):
+ __gtype_name__ = "PresenceService"
+
+ __gsignals__ = {
+ 'connection-status': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
+ ([gobject.TYPE_BOOLEAN]))
+ }
+
+ def _create_owner(self):
+ # Overridden by TestPresenceService
+ return ShellOwner(self, self._session_bus)
+
+ def __init__(self):
+ self._next_object_id = 0
+ self._connected = False
+
+ # all Buddy objects
+ # identifier -> Buddy, GC'd when no more refs exist
+ self._buddies = WeakValueDictionary()
+
+ # the online buddies for whom we know the full public key
+ # base64 public key -> Buddy
+ self._buddies_by_pubkey = {}
+
+ # The online buddies (those who're available via some CM)
+ # TP plugin -> (handle -> Buddy)
+ self._handles_buddies = {}
+
+ # activity id -> Activity
+ self._activities = {}
+
+ self._session_bus = dbus.SessionBus()
+ self._session_bus.add_signal_receiver(self._connection_disconnected_cb,
+ signal_name="Disconnected",
+ dbus_interface="org.freedesktop.DBus")
+
+ # Create the Owner object
+ self._owner = self._create_owner()
+ key = self._owner.props.key
+ keyid = pubkey_to_keyid(key)
+ self._buddies['keyid/' + keyid] = self._owner
+ self._buddies_by_pubkey[key] = self._owner
+
+ self._registry = ManagerRegistry()
+ self._registry.LoadManagers()
+
+ # Set up the server connection
+ self._server_plugin = ServerPlugin(self._registry, self._owner)
+ self._handles_buddies[self._server_plugin] = {}
+
+ self._server_plugin.connect('status', self._server_status_cb)
+ self._server_plugin.connect('contact-online', self._contact_online)
+ self._server_plugin.connect('contact-offline', self._contact_offline)
+ self._server_plugin.connect('avatar-updated', self._avatar_updated)
+ self._server_plugin.connect('buddy-properties-changed',
+ self._buddy_properties_changed)
+ self._server_plugin.connect('buddy-activities-changed',
+ self._buddy_activities_changed)
+ self._server_plugin.connect('activity-invitation',
+ self._activity_invitation)
+ self._server_plugin.connect('private-invitation',
+ self._private_invitation)
+ self._server_plugin.connect('activity-properties-changed',
+ self._activity_properties_changed)
+ self._server_plugin.start()
+
+ # Set up the link local connection
+ self._ll_plugin = LinkLocalPlugin(self._registry, self._owner)
+ self._handles_buddies[self._ll_plugin] = {}
+
+ ExportedGObject.__init__(self, self._session_bus, _PRESENCE_PATH)
+
+ # for activation to work in a race-free way, we should really
+ # export the bus name only after we export our initial object;
+ # so this comes after the parent __init__
+ self._bus_name = dbus.service.BusName(_PRESENCE_SERVICE,
+ bus=self._session_bus)
+
+ def _connection_disconnected_cb(self, foo=None):
+ """Log event when D-Bus kicks us off the bus for some reason"""
+ _logger.debug("Disconnected from session bus!!!")
+
+ def _server_status_cb(self, plugin, status, reason):
+
+ # FIXME: figure out connection status when we have a salut plugin too
+ old_status = self._connected
+ if status == CONNECTION_STATUS_CONNECTED:
+ self._connected = True
+ self._handles_buddies[plugin][plugin.self_handle] = self._owner
+ self._owner.add_telepathy_handle(plugin, plugin.self_handle)
+ else:
+ self._connected = False
+ if plugin.self_handle is not None:
+ self._handles_buddies.setdefault(plugin, {}).pop(
+ plugin.self_handle, None)
+ self._owner.remove_telepathy_handle(plugin, plugin.self_handle)
+
+ if self._connected != old_status:
+ self.emit('connection-status', self._connected)
+
+ def get_buddy(self, objid):
+ buddy = self._buddies.get(objid)
+ if buddy is None:
+ _logger.debug('Creating new buddy at .../%s', objid)
+ # we don't know yet this buddy
+ buddy = Buddy(self._session_bus, objid)
+ buddy.connect("validity-changed", self._buddy_validity_changed_cb)
+ buddy.connect("disappeared", self._buddy_disappeared_cb)
+ self._buddies[objid] = buddy
+ return buddy
+
+ def _contact_online(self, tp, objid, handle, props):
+ _logger.debug('Handle %u, .../%s is now online', handle, objid)
+ buddy = self.get_buddy(objid)
+
+ self._handles_buddies[tp][handle] = buddy
+ # store the handle of the buddy for this CM
+ buddy.add_telepathy_handle(tp, handle)
+ buddy.set_properties(props)
+
+ def _buddy_validity_changed_cb(self, buddy, valid):
+ if valid:
+ self.BuddyAppeared(buddy.object_path())
+ self._buddies_by_pubkey[buddy.props.key] = buddy
+ _logger.debug("New Buddy: %s (%s)", buddy.props.nick,
+ buddy.props.color)
+ else:
+ self.BuddyDisappeared(buddy.object_path())
+ self._buddies_by_pubkey.pop(buddy.props.key, None)
+ _logger.debug("Buddy left: %s (%s)", buddy.props.nick,
+ buddy.props.color)
+
+ def _buddy_disappeared_cb(self, buddy):
+ if buddy.props.valid:
+ self._buddy_validity_changed_cb(buddy, False)
+
+ def _contact_offline(self, tp, handle):
+ if not self._handles_buddies[tp].has_key(handle):
+ return
+
+ buddy = self._handles_buddies[tp].pop(handle)
+ # the handle of the buddy for this CM is not valid anymore
+ # (this might trigger _buddy_disappeared_cb if they are not visible
+ # via any CM)
+ buddy.remove_telepathy_handle(tp, handle)
+
+ def _get_next_object_id(self):
+ """Increment and return the object ID counter."""
+ self._next_object_id = self._next_object_id + 1
+ return self._next_object_id
+
+ def _avatar_updated(self, tp, handle, avatar):
+ buddy = self._handles_buddies[tp].get(handle)
+ if buddy and not buddy.props.owner:
+ _logger.debug("Buddy %s icon updated" % buddy.props.nick)
+ buddy.props.icon = avatar
+
+ def _buddy_properties_changed(self, tp, handle, properties):
+ buddy = self._handles_buddies[tp].get(handle)
+ if buddy:
+ buddy.set_properties(properties)
+ _logger.debug("Buddy %s properties updated: %s", buddy.props.nick,
+ properties.keys())
+
+ def _new_activity(self, activity_id, tp):
+ try:
+ objid = self._get_next_object_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)
+ return None
+
+ activity.connect("validity-changed",
+ self._activity_validity_changed_cb)
+ activity.connect("disappeared", self._activity_disappeared_cb)
+ self._activities[activity_id] = activity
+ return activity
+
+ def _activity_disappeared_cb(self, activity):
+ _logger.debug("activity %s disappeared" % activity.props.id)
+
+ self.ActivityDisappeared(activity.object_path())
+ del self._activities[activity.props.id]
+
+ def _buddy_activities_changed(self, tp, contact_handle, activities):
+ acts = []
+ for act in activities:
+ acts.append(str(act))
+ _logger.debug("Handle %s activities changed: %s", contact_handle, acts)
+ buddies = self._handles_buddies[tp]
+ buddy = buddies.get(contact_handle)
+
+ if not buddy:
+ # We don't know this buddy
+ # FIXME: What should we do here?
+ # FIXME: Do we need to check if the buddy is valid or something?
+ _logger.debug("contact_activities_changed: buddy unknown")
+ return
+
+ old_activities = set()
+ for activity in buddy.get_joined_activities():
+ old_activities.add(activity.props.id)
+
+ new_activities = set(activities)
+
+ activities_joined = new_activities - old_activities
+ for act in activities_joined:
+ _logger.debug("Handle %s joined activity %s", contact_handle, act)
+ activity = self._activities.get(act)
+ if activity is None:
+ # new activity, can fail
+ activity = self._new_activity(act, tp)
+
+ if activity is not None:
+ activity.buddy_apparently_joined(buddy)
+
+ activities_left = old_activities - new_activities
+ for act in activities_left:
+ _logger.debug("Handle %s left activity %s", contact_handle, act)
+ activity = self._activities.get(act)
+ if not activity:
+ continue
+
+ activity.buddy_apparently_left(buddy)
+
+ def _activity_invitation(self, tp, act_id):
+ activity = self._activities.get(act_id)
+ if activity:
+ self.ActivityInvitation(activity.object_path())
+
+ def _private_invitation(self, tp, chan_path):
+ conn = tp.get_connection()
+ self.PrivateInvitation(str(conn.service_name), conn.object_path,
+ chan_path)
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="o")
+ def ActivityAppeared(self, activity):
+ pass
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="o")
+ def ActivityDisappeared(self, activity):
+ pass
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="o")
+ def BuddyAppeared(self, buddy):
+ pass
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="o")
+ def BuddyDisappeared(self, buddy):
+ pass
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="o")
+ def ActivityInvitation(self, activity):
+ pass
+
+ @dbus.service.signal(_PRESENCE_INTERFACE, signature="soo")
+ def PrivateInvitation(self, bus_name, connection, channel):
+ pass
+
+ @dbus.service.method(_PRESENCE_INTERFACE, in_signature='',
+ out_signature="ao")
+ def GetActivities(self):
+ ret = []
+ for act in self._activities.values():
+ if act.props.valid:
+ ret.append(act.object_path())
+ return ret
+
+ @dbus.service.method(_PRESENCE_INTERFACE, in_signature="s",
+ out_signature="o")
+ def GetActivityById(self, actid):
+ act = self._activities.get(actid, None)
+ if not act or not act.props.valid:
+ raise NotFoundError("The activity was not found.")
+ return act.object_path()
+
+ @dbus.service.method(_PRESENCE_INTERFACE, in_signature='',
+ out_signature="ao")
+ def GetBuddies(self):
+ # in the presence of an out_signature, dbus-python will convert
+ # this set into an Array automatically (because it's iterable),
+ # so it's easy to use for uniquification (we want to avoid returning
+ # buddies who're visible on both Salut and Gabble twice)
+
+ # always include myself even if I have no handles
+ ret = set((self._owner,))
+
+ for handles_buddies in self._handles_buddies.itervalues():
+ for buddy in handles_buddies.itervalues():
+ if buddy.props.valid:
+ ret.add(buddy.object_path())
+ return ret
+
+ @dbus.service.method(_PRESENCE_INTERFACE,
+ in_signature="ay", out_signature="o",
+ byte_arrays=True)
+ def GetBuddyByPublicKey(self, key):
+ buddy = self._buddies_by_pubkey.get(key)
+ if buddy is not None:
+ if buddy.props.valid:
+ return buddy.object_path()
+ keyid = pubkey_to_keyid(key)
+ buddy = self._buddies.get('keyid/' + keyid)
+ if buddy is not None:
+ if buddy.props.valid:
+ return buddy.object_path()
+ raise NotFoundError("The buddy was not found.")
+
+ @dbus.service.method(_PRESENCE_INTERFACE, in_signature='sou',
+ out_signature='o')
+ def GetBuddyByTelepathyHandle(self, tp_conn_name, tp_conn_path, handle):
+ """Get the buddy corresponding to a Telepathy handle.
+
+ :Parameters:
+ `tp_conn_name` : str
+ The well-known bus name of a Telepathy connection
+ `tp_conn_path` : dbus.ObjectPath
+ The object path of the Telepathy connection
+ `handle` : int or long
+ The handle of a Telepathy contact on that connection,
+ of type HANDLE_TYPE_CONTACT. This may not be a
+ channel-specific handle.
+ :Returns: the object path of a Buddy
+ :Raises NotFoundError: if the buddy is not found.
+ """
+ for tp, handles in self._handles_buddies.iteritems():
+ conn = tp.get_connection()
+ if conn is None:
+ continue
+ if (conn.service_name == tp_conn_name
+ and conn.object_path == tp_conn_path):
+ buddy = handles.get(handle)
+ if buddy is not None and buddy.props.valid:
+ return buddy.object_path()
+ # either the handle is invalid, or we don't have a Buddy
+ # object for that buddy because we don't have all their
+ # details yet
+ raise NotFoundError("The buddy %u was not found on the "
+ "connection to %s:%s"
+ % (handle, tp_conn_name, tp_conn_path))
+ raise NotFoundError("The buddy %u was not found: we have no "
+ "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):
+ if not self._owner:
+ raise NotFoundError("The owner was not found.")
+ else:
+ return self._owner.object_path()
+
+ @dbus.service.method(_PRESENCE_INTERFACE, in_signature="sssa{sv}",
+ out_signature="o", async_callbacks=('async_cb', 'async_err_cb'))
+ def ShareActivity(self, actid, atype, name, properties, async_cb,
+ async_err_cb):
+ self._share_activity(actid, atype, name, properties,
+ (async_cb, async_err_cb))
+
+ @dbus.service.method(_PRESENCE_INTERFACE,
+ in_signature='', out_signature="so")
+ def GetPreferredConnection(self):
+ conn = self._server_plugin.get_connection()
+ return str(conn.service_name), conn.object_path
+
+ def cleanup(self):
+ for tp in self._handles_buddies:
+ tp.cleanup()
+
+ def _share_activity(self, actid, atype, name, properties, callbacks):
+ 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,
+ 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
+ activity._share(callbacks, self._owner)
+
+ # local activities are valid at creation by definition, but we can't
+ # connect to the activity's validity-changed signal until its already
+ # issued the signal, which happens in the activity's constructor
+ # for local activities.
+ self._activity_validity_changed_cb(activity, activity.props.valid)
+
+ def _activity_validity_changed_cb(self, activity, valid):
+ if valid:
+ self.ActivityAppeared(activity.object_path())
+ _logger.debug("New Activity: %s (%s)", activity.props.name,
+ activity.props.id)
+ else:
+ self.ActivityDisappeared(activity.object_path())
+ _logger.debug("Activity disappeared: %s (%s)", activity.props.name,
+ activity.props.id)
+
+ def _activity_properties_changed(self, tp, act_id, props):
+ activity = self._activities.get(act_id)
+ if activity:
+ activity.set_properties(props)
+
+
+def main(test_num=0, randomize=False):
+ loop = gobject.MainLoop()
+ dbus_mainloop_wrapper = DBusGMainLoop(set_as_default=True)
+
+ if test_num > 0:
+ from pstest import TestPresenceService
+ ps = TestPresenceService(test_num, randomize)
+ else:
+ ps = PresenceService()
+
+ try:
+ loop.run()
+ except KeyboardInterrupt:
+ ps.cleanup()
+ _logger.debug('Ctrl+C pressed, exiting...')
+
+if __name__ == "__main__":
+ main()
diff --git a/src/pstest.py b/src/pstest.py
new file mode 100644
index 0000000..7715fd3
--- /dev/null
+++ b/src/pstest.py
@@ -0,0 +1,317 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007 Collabora Ltd. <http://www.collabora.co.uk/>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import logging
+import os
+import random
+from ConfigParser import ConfigParser, NoOptionError
+
+import gobject
+
+from sugar import env, util
+
+from buddy import GenericOwner, _PROP_NICK, _PROP_CURACT, _PROP_COLOR
+from presenceservice import PresenceService
+from psutils import pubkey_to_keyid
+
+
+_logger = logging.getLogger('s-p-s.pstest')
+
+
+class TestOwner(GenericOwner):
+ """Class representing the owner of the machine. This test owner
+ changes random attributes periodically."""
+
+ __gtype_name__ = "TestOwner"
+
+ def __init__(self, ps, bus, test_num, randomize):
+ self._cp = ConfigParser()
+ self._section = "Info"
+ self._test_activities = []
+ self._test_cur_act = ""
+ self._change_timeout = 0
+
+ self._cfg_file = os.path.join(env.get_profile_path(), 'test-buddy-%d' % test_num)
+
+ (pubkey, privkey, registered) = self._load_config()
+ if not pubkey or not len(pubkey) or not privkey or not len(privkey):
+ (pubkey, privkey) = _get_new_keypair(test_num)
+
+ if not pubkey or not privkey:
+ raise RuntimeError("Couldn't get or create test buddy keypair")
+
+ self._save_config(pubkey, privkey, registered)
+ privkey_hash = util.printable_hash(util._sha_data(privkey))
+
+ nick = _get_random_name()
+ from sugar.graphics import xocolor
+ color = xocolor.XoColor().to_string()
+ icon = _get_random_image()
+
+ _logger.debug("pubkey is %s" % pubkey)
+ GenericOwner.__init__(self, ps, bus,
+ 'keyid/' + pubkey_to_keyid(pubkey),
+ key=pubkey, nick=nick, color=color, icon=icon,
+ registered=registered, key_hash=privkey_hash)
+
+ # Only do the random stuff if randomize is true
+ if randomize:
+ self._ps.connect('connection-status', self._ps_connection_status_cb)
+
+ def _share_reply_cb(self, actid, object_path):
+ activity = self._ps.internal_get_activity(actid)
+ if not activity or not object_path:
+ _logger.debug("Couldn't find activity %s even though it was shared." % actid)
+ return
+ _logger.debug("Shared activity %s (%s)." % (actid, activity.props.name))
+ self._test_activities.append(activity)
+
+ def _share_error_cb(self, actid, err):
+ _logger.debug("Error sharing activity %s: %s" % (actid, str(err)))
+
+ def _ps_connection_status_cb(self, ps, connected):
+ if not connected:
+ return
+
+ if not len(self._test_activities):
+ # Share some activities
+ actid = util.unique_id("Activity 1")
+ callbacks = (lambda *args: self._share_reply_cb(actid, *args),
+ lambda *args: self._share_error_cb(actid, *args))
+ atype = "org.laptop.WebActivity"
+ properties = {"foo": "bar"}
+ self._ps._share_activity(actid, atype, "Wembley Stadium", properties, callbacks)
+
+ actid2 = util.unique_id("Activity 2")
+ callbacks = (lambda *args: self._share_reply_cb(actid2, *args),
+ lambda *args: self._share_error_cb(actid2, *args))
+ atype = "org.laptop.WebActivity"
+ properties = {"baz": "bar"}
+ self._ps._share_activity(actid2, atype, "Maine Road", properties, callbacks)
+
+ # Change a random property ever 10 seconds
+ if self._change_timeout == 0:
+ self._change_timeout = gobject.timeout_add(10000, self._update_something)
+
+ def set_registered(self, value):
+ if value:
+ self._registered = True
+
+ def _load_config(self):
+ if not os.path.exists(self._cfg_file):
+ return (None, None, False)
+ if not self._cp.read([self._cfg_file]):
+ return (None, None, False)
+ if not self._cp.has_section(self._section):
+ return (None, None, False)
+
+ try:
+ pubkey = self._cp.get(self._section, "pubkey")
+ privkey = self._cp.get(self._section, "privkey")
+ registered = self._cp.get(self._section, "registered")
+ return (pubkey, privkey, registered)
+ except NoOptionError:
+ pass
+
+ return (None, None, False)
+
+ def _save_config(self, pubkey, privkey, registered):
+ # Save config again
+ if not self._cp.has_section(self._section):
+ self._cp.add_section(self._section)
+ self._cp.set(self._section, "pubkey", pubkey)
+ self._cp.set(self._section, "privkey", privkey)
+ self._cp.set(self._section, "registered", registered)
+ f = open(self._cfg_file, 'w')
+ self._cp.write(f)
+ f.close()
+
+ def _update_something(self):
+ it = random.randint(0, 10000) % 4
+ if it == 0:
+ self.props.icon = _get_random_image()
+ elif it == 1:
+ from sugar.graphics import xocolor
+ props = {_PROP_COLOR: xocolor.XoColor().to_string()}
+ self.set_properties(props)
+ elif it == 2:
+ props = {_PROP_NICK: _get_random_name()}
+ self.set_properties(props)
+ elif it == 3:
+ actid = ""
+ idx = random.randint(0, len(self._test_activities))
+ # if idx == len(self._test_activites), it means no current
+ # activity
+ if idx < len(self._test_activities):
+ activity = self._test_activities[idx]
+ actid = activity.props.id
+ props = {_PROP_CURACT: actid}
+ self.set_properties(props)
+ return True
+
+
+class TestPresenceService(PresenceService):
+
+ def __init__(self, test_num=0, randomize=False):
+ self.__test_num = test_num
+ self.__randomize = randomize
+ PresenceService.__init__(self)
+
+ def _create_owner(self):
+ return TestOwner(self, self._session_bus,
+ self.__test_num, self.__randomize)
+
+ def internal_get_activity(self, actid):
+ return self._activities.get(actid, None)
+
+
+def _extract_public_key(keyfile):
+ try:
+ f = open(keyfile, "r")
+ lines = f.readlines()
+ f.close()
+ except IOError, e:
+ _logger.error("Error reading public key: %s" % e)
+ return None
+
+ # Extract the public key
+ magic = "ssh-dss "
+ key = ""
+ for l in lines:
+ l = l.strip()
+ if not l.startswith(magic):
+ continue
+ key = l[len(magic):]
+ break
+ if not len(key):
+ _logger.error("Error parsing public key.")
+ return None
+ return key
+
+def _extract_private_key(keyfile):
+ """Get a private key from a private key file"""
+ # Extract the private key
+ try:
+ f = open(keyfile, "r")
+ lines = f.readlines()
+ f.close()
+ except IOError, e:
+ _logger.error("Error reading private key: %s" % e)
+ return None
+
+ key = ""
+ for l in lines:
+ l = l.strip()
+ if l.startswith("-----BEGIN DSA PRIVATE KEY-----"):
+ continue
+ if l.startswith("-----END DSA PRIVATE KEY-----"):
+ continue
+ key += l
+ if not len(key):
+ _logger.error("Error parsing private key.")
+ return None
+ return key
+
+def _get_new_keypair(num):
+ """Retrieve a public/private key pair for testing"""
+ # Generate keypair
+ privkeyfile = os.path.join("/tmp", "test%d.key" % num)
+ pubkeyfile = os.path.join("/tmp", 'test%d.key.pub' % num)
+
+ # force-remove key files if they exist to ssh-keygen doesn't
+ # start asking questions
+ try:
+ os.remove(pubkeyfile)
+ os.remove(privkeyfile)
+ except OSError:
+ pass
+
+ cmd = "ssh-keygen -q -t dsa -f %s -C '' -N ''" % privkeyfile
+ import commands
+ print "Generating new keypair..."
+ (s, o) = commands.getstatusoutput(cmd)
+ print "Done."
+ pubkey = privkey = None
+ if s != 0:
+ _logger.error("Could not generate key pair: %d (%s)" % (s, o))
+ else:
+ pubkey = _extract_public_key(pubkeyfile)
+ privkey = _extract_private_key(privkeyfile)
+
+ try:
+ os.remove(pubkeyfile)
+ os.remove(privkeyfile)
+ except OSError:
+ pass
+ return (pubkey, privkey)
+
+def _get_random_name():
+ """Produce random names for testing"""
+ names = ["Liam", "Noel", "Guigsy", "Whitey", "Bonehead"]
+ return names[random.randint(0, len(names) - 1)]
+
+def _get_random_image():
+ """Produce a random image for display"""
+ import cairo, math, gtk
+
+ def rand():
+ return random.random()
+
+ SIZE = 200
+
+ s = cairo.ImageSurface(cairo.FORMAT_ARGB32, SIZE, SIZE)
+ cr = cairo.Context(s)
+
+ # background gradient
+ cr.save()
+ g = cairo.LinearGradient(0, 0, 1, 1)
+ g.add_color_stop_rgba(1, rand(), rand(), rand(), rand())
+ g.add_color_stop_rgba(0, rand(), rand(), rand(), rand())
+ cr.set_source(g)
+ cr.rectangle(0, 0, SIZE, SIZE);
+ cr.fill()
+ cr.restore()
+
+ # random path
+ cr.set_line_width(10 * rand() + 5)
+ cr.move_to(SIZE * rand(), SIZE * rand())
+ cr.line_to(SIZE * rand(), SIZE * rand())
+ cr.rel_line_to(SIZE * rand() * -1, 0)
+ cr.close_path()
+ cr.stroke()
+
+ # a circle
+ cr.set_source_rgba(rand(), rand(), rand(), rand())
+ cr.arc(SIZE * rand(), SIZE * rand(), 100 * rand() + 30, 0, 2 * math.pi)
+ cr.fill()
+
+ # another circle
+ cr.set_source_rgba(rand(), rand(), rand(), rand())
+ cr.arc(SIZE * rand(), SIZE * rand(), 100 * rand() + 30, 0, 2 * math.pi)
+ cr.fill()
+
+ def img_convert_func(buf, data):
+ data[0] += buf
+ return True
+
+ data = [""]
+ pixbuf = gtk.gdk.pixbuf_new_from_data(s.get_data(), gtk.gdk.COLORSPACE_RGB,
+ True, 8, s.get_width(), s.get_height(), s.get_stride())
+ pixbuf.save_to_callback(img_convert_func, "jpeg", {"quality": "90"}, data)
+ del pixbuf
+
+ return str(data[0])
diff --git a/src/psutils.py b/src/psutils.py
new file mode 100644
index 0000000..25b24b9
--- /dev/null
+++ b/src/psutils.py
@@ -0,0 +1,259 @@
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007 Collabora Ltd. <http://www.collabora.co.uk/>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import logging
+from string import ascii_letters, digits
+try:
+ from hashlib import sha1
+except ImportError:
+ # Python < 2.5
+ from sha import new as sha1
+
+import dbus
+import gobject
+
+
+_logger = logging.getLogger('s-p-s.psutils')
+
+_ASCII_ALNUM = ascii_letters + digits
+
+
+def pubkey_to_keyid(key):
+ """Return the key ID for the given public key. This is currently its SHA-1
+ in hex.
+
+ :Parameters:
+ `key` : str
+ The public key as a Base64 string
+ :Returns:
+ The key ID as a string of hex digits
+ """
+ return sha1(key).hexdigest()
+
+
+def escape_identifier(identifier):
+ """Escape the given string to be a valid D-Bus object path or service
+ name component, using a reversible encoding to ensure uniqueness.
+
+ The reversible encoding is as follows:
+
+ * The empty string becomes '_'
+ * Otherwise, each non-alphanumeric character is replaced by '_' plus
+ two lower-case hex digits; the same replacement is carried out on
+ the first character, if it's a digit
+ """
+ # '' -> '_'
+ if not identifier:
+ return '_'
+
+ # A bit of a fast path for strings which are already OK.
+ # We deliberately omit '_' because, for reversibility, that must also
+ # be escaped.
+ if (identifier.strip(_ASCII_ALNUM) == '' and
+ identifier[0] in ascii_letters):
+ return identifier
+
+ # The first character may not be a digit
+ if identifier[0] not in ascii_letters:
+ ret = ['_%02x' % ord(identifier[0])]
+ else:
+ ret = [identifier[0]]
+
+ # Subsequent characters may be digits or ASCII letters
+ for c in identifier[1:]:
+ if c in _ASCII_ALNUM:
+ ret.append(c)
+ else:
+ ret.append('_%02x' % ord(c))
+
+ return ''.join(ret)
+
+
+NM_SERVICE = 'org.freedesktop.NetworkManager'
+NM_IFACE = 'org.freedesktop.NetworkManager'
+NM_IFACE_DEVICES = 'org.freedesktop.NetworkManager.Devices'
+NM_PATH = '/org/freedesktop/NetworkManager'
+
+_ip4am = None
+
+class IP4AddressMonitor(gobject.GObject):
+ """This class, and direct buddy IPv4 address access, will go away quite soon"""
+
+ __gsignals__ = {
+ 'address-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE,
+ ([gobject.TYPE_PYOBJECT]))
+ }
+
+ __gproperties__ = {
+ 'address' : (str, None, None, None, gobject.PARAM_READABLE)
+ }
+
+ def get_instance():
+ """Retrieve (or create) the IP4Address monitor singleton instance"""
+ global _ip4am
+ if not _ip4am:
+ _ip4am = IP4AddressMonitor()
+ return _ip4am
+ get_instance = staticmethod(get_instance)
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self._nm_present = False
+ self._nm_has_been_present = False
+ self._matches = []
+ self._addr = None
+ self._nm_obj = None
+
+ sys_bus = dbus.SystemBus()
+ self._watch = sys_bus.watch_name_owner(NM_SERVICE, self._nm_owner_cb)
+ if not sys_bus.name_has_owner(NM_SERVICE):
+ addr = self._get_address_fallback()
+ self._update_address(addr)
+
+ def do_get_property(self, pspec):
+ if pspec.name == "address":
+ return self._addr
+
+ def _update_address(self, new_addr):
+ if new_addr == "0.0.0.0":
+ new_addr = None
+ if new_addr == self._addr:
+ return
+
+ self._addr = new_addr
+ _logger.debug("IP4 address now '%s'" % new_addr)
+ self.emit('address-changed', new_addr)
+
+ def _connect_to_nm(self):
+ """Connect to NM device state signals to tell when the IPv4 address changes"""
+ try:
+ sys_bus = dbus.SystemBus()
+ proxy = sys_bus.get_object(NM_SERVICE, NM_PATH)
+ self._nm_obj = dbus.Interface(proxy, NM_IFACE)
+ except dbus.DBusException, err:
+ _logger.debug("Error finding NetworkManager: %s" % err)
+ self._nm_present = False
+ return
+
+ sys_bus = dbus.SystemBus()
+ match = sys_bus.add_signal_receiver(self._nm_device_active_cb,
+ signal_name="DeviceNowActive",
+ dbus_interface=NM_IFACE)
+ self._matches.append(match)
+
+ match = sys_bus.add_signal_receiver(self._nm_device_no_longer_active_cb,
+ signal_name="DeviceNoLongerActive",
+ dbus_interface=NM_IFACE,
+ bus_name=NM_SERVICE)
+ self._matches.append(match)
+
+ match = sys_bus.add_signal_receiver(self._nm_state_change_cb,
+ signal_name="StateChange",
+ dbus_interface=NM_IFACE,
+ bus_name=NM_SERVICE)
+ self._matches.append(match)
+
+ state = self._nm_obj.state()
+ if state == 3: # NM_STATE_CONNECTED
+ self._query_devices()
+
+ def _device_properties_cb(self, *props):
+ active = props[4]
+ if not active:
+ return
+ act_stage = props[5]
+ # HACK: OLPC NM has an extra stage, so activated == 8 on OLPC
+ # but 7 everywhere else
+ if act_stage != 8 and act_stage != 7:
+ # not activated
+ return
+ self._update_address(props[6])
+
+ def _device_properties_error_cb(self, err):
+ _logger.debug("Error querying device properties: %s" % err)
+
+ def _query_device_properties(self, device):
+ sys_bus = dbus.SystemBus()
+ proxy = sys_bus.get_object(NM_SERVICE, device)
+ dev = dbus.Interface(proxy, NM_IFACE_DEVICES)
+ dev.getProperties(reply_handler=self._device_properties_cb,
+ error_handler=self._device_properties_error_cb)
+
+ def _get_devices_cb(self, ops):
+ """Query each device's properties"""
+ for op in ops:
+ self._query_device_properties(op)
+
+ def _get_devices_error_cb(self, err):
+ _logger.debug("Error getting NetworkManager devices: %s" % err)
+
+ def _query_devices(self):
+ """Query NM for a list of network devices"""
+ self._nm_obj.getDevices(reply_handler=self._get_devices_cb,
+ error_handler=self._get_devices_error_cb)
+
+ def _nm_device_active_cb(self, device, ssid=None):
+ self._query_device_properties(device)
+
+ def _nm_device_no_longer_active_cb(self, device):
+ self._update_address(None)
+
+ def _nm_state_change_cb(self, new_state):
+ if new_state == 4: # NM_STATE_DISCONNECTED
+ self._update_address(None)
+
+ def _nm_owner_cb(self, unique_name):
+ """Clear state when NM goes away"""
+ if unique_name == '':
+ # NM went away, or isn't there at all
+ self._nm_present = False
+ for match in self._matches:
+ match.remove()
+ self._matches = []
+ if self._nm_has_been_present:
+ self._update_address(None)
+ else:
+ addr = self._get_address_fallback()
+ self._update_address(addr)
+ elif not self._nm_present:
+ # NM started up
+ self._nm_present = True
+ self._nm_has_been_present = True
+ self._connect_to_nm()
+
+ def _get_iface_address(self, iface):
+ import socket
+ import fcntl
+ import struct
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ fd = s.fileno()
+ SIOCGIFADDR = 0x8915
+ addr = fcntl.ioctl(fd, SIOCGIFADDR, struct.pack('256s', iface[:15]))[20:24]
+ s.close()
+ return socket.inet_ntoa(addr)
+
+ def _get_address_fallback(self):
+ import commands
+ (s, o) = commands.getstatusoutput("/sbin/route -n")
+ if s != 0:
+ return
+ for line in o.split('\n'):
+ fields = line.split(" ")
+ if fields[0] == "0.0.0.0":
+ iface = fields[len(fields) - 1]
+ return self._get_iface_address(iface)
+ return None
diff --git a/src/server_plugin.py b/src/server_plugin.py
new file mode 100644
index 0000000..548b41f
--- /dev/null
+++ b/src/server_plugin.py
@@ -0,0 +1,1171 @@
+"""Telepathy-python presence server interface/implementation plugin"""
+# Copyright (C) 2007, Red Hat, Inc.
+# Copyright (C) 2007, Collabora Ltd.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+# Standard library
+import logging
+import os
+import sys
+from string import hexdigits
+try:
+ # Python >= 2.5
+ from hashlib import md5
+except ImportError:
+ from md5 import new as md5
+
+# Other libraries
+import dbus
+import gobject
+import gtk
+from telepathy.client import (ConnectionManager, ManagerRegistry, Connection,
+ Channel)
+from telepathy.interfaces import (CONN_MGR_INTERFACE, CONN_INTERFACE,
+ CHANNEL_TYPE_CONTACT_LIST, CHANNEL_INTERFACE_GROUP,
+ CONN_INTERFACE_ALIASING, CONN_INTERFACE_AVATARS, CONN_INTERFACE_PRESENCE,
+ CHANNEL_TYPE_TEXT, CHANNEL_TYPE_STREAMED_MEDIA, PROPERTIES_INTERFACE)
+from telepathy.constants import (HANDLE_TYPE_CONTACT,
+ HANDLE_TYPE_LIST, HANDLE_TYPE_CONTACT, HANDLE_TYPE_ROOM,
+ CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED,
+ CONNECTION_STATUS_CONNECTING,
+ CONNECTION_STATUS_REASON_AUTHENTICATION_FAILED,
+ CONNECTION_STATUS_REASON_NONE_SPECIFIED,
+ CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES,
+ PROPERTY_FLAG_WRITE)
+from sugar import util
+
+# Presence Service local modules
+from buddyiconcache import BuddyIconCache
+import psutils
+
+
+CONN_INTERFACE_BUDDY_INFO = 'org.laptop.Telepathy.BuddyInfo'
+CONN_INTERFACE_ACTIVITY_PROPERTIES = 'org.laptop.Telepathy.ActivityProperties'
+
+_PROTOCOL = "jabber"
+_OBJ_PATH_PREFIX = "/org/freedesktop/Telepathy/Connection/gabble/jabber/"
+
+_logger = logging.getLogger('s-p-s.server_plugin')
+
+_RECONNECT_TIMEOUT = 5000
+
+def _buddy_icon_save_cb(buf, data):
+ data[0] += buf
+ return True
+
+def _get_buddy_icon_at_size(icon, maxw, maxh, maxsize):
+ loader = gtk.gdk.PixbufLoader()
+ loader.write(icon)
+ loader.close()
+ unscaled_pixbuf = loader.get_pixbuf()
+ del loader
+
+ pixbuf = unscaled_pixbuf.scale_simple(maxw, maxh, gtk.gdk.INTERP_BILINEAR)
+ del unscaled_pixbuf
+
+ data = [""]
+ quality = 90
+ img_size = maxsize + 1
+ while img_size > maxsize:
+ del data
+ data = [""]
+ pixbuf.save_to_callback(_buddy_icon_save_cb, "jpeg",
+ {"quality":"%d" % quality}, data)
+ quality -= 10
+ img_size = len(data[0])
+ del pixbuf
+
+ if img_size > maxsize:
+ del data
+ raise RuntimeError("could not size image less than %d bytes" % maxsize)
+
+ return str(data[0])
+
+
+class ServerPlugin(gobject.GObject):
+ """Telepathy-python-based presence server interface
+
+ The ServerPlugin instance translates network events from
+ Telepathy Python into GObject events. It provides direct
+ python calls to perform the required network operations
+ to implement the PresenceService.
+ """
+ __gsignals__ = {
+ 'contact-online':
+ # Contact has come online and we've discovered all their buddy
+ # properties.
+ # args:
+ # contact identification (based on key ID or JID): str
+ # contact handle: int or long
+ # dict {name: str => property: object}
+ (gobject.SIGNAL_RUN_FIRST, None, [str, object, object]),
+ 'contact-offline':
+ # Contact has gone offline.
+ # args: contact handle
+ (gobject.SIGNAL_RUN_FIRST, None, [object]),
+ 'status':
+ # Connection status changed.
+ # args: status, reason as for Telepathy StatusChanged
+ (gobject.SIGNAL_RUN_FIRST, None, [int, int]),
+ 'avatar-updated':
+ # Contact's avatar has changed
+ # args: contact handle: int; icon data: str
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object]),
+ 'buddy-properties-changed':
+ # OLPC buddy properties changed; as for PropertiesChanged
+ # args:
+ # contact handle: int
+ # properties: dict {name: str => property: object}
+ # FIXME: are these all the properties or just those that changed?
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object]),
+ 'buddy-activities-changed':
+ # OLPC activities changed
+ # args:
+ # contact handle: int
+ # activity IDs: list of str
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object]),
+ 'activity-invitation':
+ # We were invited to join an activity
+ # args: activity ID: str
+ (gobject.SIGNAL_RUN_FIRST, None, [object]),
+ 'private-invitation':
+ # We were invited to join a chat or a media call
+ # args: channel object path
+ (gobject.SIGNAL_RUN_FIRST, None, [object]),
+ 'activity-properties-changed':
+ # An activity's properties changed; as for
+ # ActivityPropertiesChanged
+ # args: activity ID: str; properties: dict { str => object }
+ # FIXME: are these all the properties or just those that changed?
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object]),
+ 'activity-shared':
+ # share_activity() succeeded
+ # args:
+ # activity ID: str
+ # channel: telepathy.client.Channel, or None on failure
+ # error: None, or Exception on failure
+ # userdata as passed to share_activity
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object, object, object]),
+ 'activity-joined':
+ # join_activity() succeeded
+ # args: as for activity-shared
+ (gobject.SIGNAL_RUN_FIRST, None, [object, object, object, object]),
+ }
+
+ def __init__(self, registry, owner):
+ """Initialize the ServerPlugin instance
+
+ registry -- telepathy.client.ManagerRegistry from the
+ PresenceService, used to find the "gabble" connection
+ manager in this case...
+ owner -- presence.buddy.GenericOwner instance (normally a
+ presence.buddy.ShellOwner instance)
+ """
+ gobject.GObject.__init__(self)
+
+ self._conn = None
+ self._icon_cache = BuddyIconCache()
+
+ self._registry = registry
+ self._online_contacts = {} # handle -> jid
+
+ # activity id -> handle
+ self._activities = {}
+ # (activity_id, handle of the activity channel)
+ self._joined_activities = []
+
+ self._owner = owner
+ self._owner.connect("property-changed",
+ self._owner_property_changed_cb)
+ self._owner.connect("icon-changed", self._owner_icon_changed_cb)
+ self.self_handle = None
+
+ self._account = self._get_account_info()
+ self._conn_status = CONNECTION_STATUS_DISCONNECTED
+ self._reconnect_id = 0
+
+ # Monitor IPv4 address as an indicator of the network connection
+ self._ip4am = psutils.IP4AddressMonitor.get_instance()
+ self._ip4am.connect('address-changed', self._ip4_address_changed_cb)
+
+ self._publish_channel = None
+ self._subscribe_channel = None
+ self._subscribe_members = set()
+ self._subscribe_local_pending = set()
+ self._subscribe_remote_pending = set()
+
+ def _ip4_address_changed_cb(self, ip4am, address):
+ _logger.debug("::: IP4 address now %s", address)
+ if address:
+ _logger.debug("::: valid IP4 address, conn_status %s",
+ self._conn_status)
+ if self._conn_status == CONNECTION_STATUS_DISCONNECTED:
+ _logger.debug("::: will connect")
+ self.start()
+ else:
+ _logger.debug("::: invalid IP4 address, will disconnect")
+ self.cleanup()
+
+ def _owner_property_changed_cb(self, owner, properties):
+ """Local user's configuration properties have changed
+
+ owner -- the Buddy object for the local user
+ properties -- set of updated properties
+
+ calls:
+
+ _set_self_current_activity current-activity
+ _set_self_alias nick
+ _set_self_olpc_properties color
+
+ depending on which properties are present in the
+ set of properties.
+ """
+ _logger.debug("Owner properties changed: %s", properties)
+
+ if properties.has_key("current-activity"):
+ self._set_self_current_activity()
+
+ if properties.has_key("nick"):
+ self._set_self_alias()
+ # Hack; send twice to make sure the server gets it
+ gobject.timeout_add(1000, self._set_self_alias)
+
+ if properties.has_key("color") or properties.has_key("ip4-address"):
+ if self._conn_status == CONNECTION_STATUS_CONNECTED:
+ self._set_self_olpc_properties()
+
+ def _owner_icon_changed_cb(self, owner, icon):
+ """Owner has changed their icon, forward to network"""
+ _logger.debug("Owner icon changed to size %d", len(str(icon)))
+ self._set_self_avatar(icon)
+
+ def _get_account_info(self):
+ """Retrieve metadata dictionary describing this account
+
+ returns dictionary with:
+
+ server : server url from owner
+ account : printable-ssh-key-hash@server
+ password : ssh-key-hash
+ register : whether to register (i.e. whether not yet
+ registered)
+ """
+ account_info = {}
+
+ account_info['server'] = self._owner.get_server()
+
+ khash = psutils.pubkey_to_keyid(self._owner.props.key)
+ account_info['account'] = "%s@%s" % (khash, account_info['server'])
+
+ account_info['password'] = self._owner.get_key_hash()
+ account_info['register'] = not self._owner.get_registered()
+
+ print "ACCT: %s" % account_info
+ return account_info
+
+ def _find_existing_connection(self):
+ """Try to find an existing Telepathy connection to this server
+
+ filters the set of connections from
+ telepathy.client.Connection.get_connections
+ to find a connection using our protocol with the
+ "self handle" of that connection being a handle
+ which matches our account (see _get_account_info)
+
+ returns connection or None
+ """
+ our_name = self._account['account']
+
+ # Search existing connections, if any, that we might be able to use
+ connections = Connection.get_connections()
+ conn = None
+ for item in connections:
+ if not item.object_path.startswith(_OBJ_PATH_PREFIX):
+ continue
+ if item[CONN_INTERFACE].GetProtocol() != _PROTOCOL:
+ continue
+ if item[CONN_INTERFACE].GetStatus() == CONNECTION_STATUS_CONNECTED:
+ test_handle = item[CONN_INTERFACE].RequestHandles(
+ HANDLE_TYPE_CONTACT, [our_name])[0]
+ if item[CONN_INTERFACE].GetSelfHandle() != test_handle:
+ continue
+ return item
+ return None
+
+ def get_connection(self):
+ """Retrieve our telepathy.client.Connection object"""
+ return self._conn
+
+ def _init_connection(self):
+ """Set up our connection
+
+ if there is no existing connection
+ (_find_existing_connection returns None)
+ produce a new connection with our protocol for our
+ account.
+
+ if there is an existing connection, reuse it by
+ registering for various of events on it.
+ """
+ conn = self._find_existing_connection()
+ if not conn:
+ acct = self._account.copy()
+
+ # Create a new connection
+ gabble_mgr = self._registry.GetManager('gabble')
+ name, path = gabble_mgr[CONN_MGR_INTERFACE].RequestConnection(
+ _PROTOCOL, acct)
+ conn = Connection(name, path)
+ del acct
+
+ conn[CONN_INTERFACE].connect_to_signal('StatusChanged',
+ self._status_changed_cb)
+ conn[CONN_INTERFACE].connect_to_signal('NewChannel',
+ self._new_channel_cb)
+
+ # hack
+ conn._valid_interfaces.add(CONN_INTERFACE_PRESENCE)
+ conn._valid_interfaces.add(CONN_INTERFACE_BUDDY_INFO)
+ conn._valid_interfaces.add(CONN_INTERFACE_ACTIVITY_PROPERTIES)
+ conn._valid_interfaces.add(CONN_INTERFACE_AVATARS)
+ conn._valid_interfaces.add(CONN_INTERFACE_ALIASING)
+
+ conn[CONN_INTERFACE_PRESENCE].connect_to_signal('PresenceUpdate',
+ self._presence_update_cb)
+
+ self._conn = conn
+ status = self._conn[CONN_INTERFACE].GetStatus()
+
+ if status == CONNECTION_STATUS_DISCONNECTED:
+ def connect_reply():
+ _logger.debug('Connect() succeeded')
+ def connect_error(e):
+ _logger.debug('Connect() failed: %s', e)
+ if not self._reconnect_id:
+ self._reconnect_id = gobject.timeout_add(_RECONNECT_TIMEOUT,
+ self._reconnect_cb)
+
+ self._conn[CONN_INTERFACE].Connect(reply_handler=connect_reply,
+ error_handler=connect_error)
+
+ self._handle_connection_status_change(status,
+ CONNECTION_STATUS_REASON_NONE_SPECIFIED)
+
+ def _connected_cb(self):
+ """Callback on successful connection to a server
+ """
+
+ if self._account['register']:
+ # we successfully register this account
+ self._owner.set_registered(True)
+
+ # request both handles at the same time to reduce round-trips
+ pub_handle, sub_handle = self._conn[CONN_INTERFACE].RequestHandles(
+ HANDLE_TYPE_LIST, ['publish', 'subscribe'])
+
+ # the group of contacts who may receive your presence
+ publish = self._conn.request_channel(CHANNEL_TYPE_CONTACT_LIST,
+ HANDLE_TYPE_LIST, pub_handle, True)
+ self._publish_channel = publish
+ publish[CHANNEL_INTERFACE_GROUP].connect_to_signal('MembersChanged',
+ self._publish_members_changed_cb)
+ publish_handles, local_pending, remote_pending = \
+ publish[CHANNEL_INTERFACE_GROUP].GetAllMembers()
+
+ # the group of contacts for whom you wish to receive presence
+ subscribe = self._conn.request_channel(CHANNEL_TYPE_CONTACT_LIST,
+ HANDLE_TYPE_LIST, sub_handle, True)
+ self._subscribe_channel = subscribe
+ subscribe[CHANNEL_INTERFACE_GROUP].connect_to_signal('MembersChanged',
+ self._subscribe_members_changed_cb)
+ subscribe_handles, subscribe_lp, subscribe_rp = \
+ subscribe[CHANNEL_INTERFACE_GROUP].GetAllMembers()
+ self._subscribe_members = set(subscribe_handles)
+ self._subscribe_local_pending = set(subscribe_lp)
+ self._subscribe_remote_pending = set(subscribe_rp)
+
+ if local_pending:
+ # accept pending subscriptions
+ publish[CHANNEL_INTERFACE_GROUP].AddMembers(local_pending, '')
+
+ self.self_handle = self._conn[CONN_INTERFACE].GetSelfHandle()
+ self._online_contacts[self.self_handle] = self._account['account']
+
+ # request subscriptions from people subscribed to us if we're not
+ # subscribed to them
+ not_subscribed = list(set(publish_handles) - set(subscribe_handles))
+ subscribe[CHANNEL_INTERFACE_GROUP].AddMembers(not_subscribed, '')
+
+ if CONN_INTERFACE_BUDDY_INFO not in self._conn.get_valid_interfaces():
+ _logger.debug('OLPC information not available')
+ return False
+
+ self._conn[CONN_INTERFACE_BUDDY_INFO].connect_to_signal(
+ 'PropertiesChanged', self._buddy_properties_changed_cb)
+ self._conn[CONN_INTERFACE_BUDDY_INFO].connect_to_signal(
+ 'ActivitiesChanged', self._buddy_activities_changed_cb)
+ self._conn[CONN_INTERFACE_BUDDY_INFO].connect_to_signal(
+ 'CurrentActivityChanged',
+ self._buddy_current_activity_changed_cb)
+
+ self._conn[CONN_INTERFACE_AVATARS].connect_to_signal('AvatarUpdated',
+ self._avatar_updated_cb)
+ self._conn[CONN_INTERFACE_ALIASING].connect_to_signal('AliasesChanged',
+ self._alias_changed_cb)
+ self._conn[CONN_INTERFACE_ACTIVITY_PROPERTIES].connect_to_signal(
+ 'ActivityPropertiesChanged',
+ self._activity_properties_changed_cb)
+
+ # Set initial buddy properties, avatar, and activities
+ self._set_self_olpc_properties()
+ self._set_self_alias()
+ # Hack; send twice to make sure the server gets it
+ gobject.timeout_add(1000, self._set_self_alias)
+ self._set_self_activities()
+ self._set_self_current_activity()
+ self._set_self_avatar()
+
+ # Request presence for everyone we're subscribed to
+ self._conn[CONN_INTERFACE_PRESENCE].RequestPresence(subscribe_handles)
+ return True
+
+ def _set_self_avatar_cb(self, token):
+ self._icon_cache.set_avatar(hash, token)
+
+ def _set_self_avatar(self, icon_data=None):
+ if not icon_data:
+ icon_data = self._owner.props.icon
+
+ m = md5()
+ m.update(icon_data)
+ hash = m.hexdigest()
+
+ self_handle = self._conn[CONN_INTERFACE].GetSelfHandle()
+ token = self._conn[CONN_INTERFACE_AVATARS].GetAvatarTokens(
+ [self_handle])[0]
+
+ if self._icon_cache.check_avatar(hash, token):
+ # avatar is up to date
+ return
+
+ types, minw, minh, maxw, maxh, maxsize = \
+ self._conn[CONN_INTERFACE_AVATARS].GetAvatarRequirements()
+ if not "image/jpeg" in types:
+ _logger.debug("server does not accept JPEG format avatars.")
+ return
+
+ img_data = _get_buddy_icon_at_size(icon_data, min(maxw, 96),
+ min(maxh, 96), maxsize)
+ self._conn[CONN_INTERFACE_AVATARS].SetAvatar(img_data, "image/jpeg",
+ reply_handler=self._set_self_avatar_cb,
+ error_handler=lambda e: self._log_error_cb("setting avatar", e))
+
+ def _join_activity_channel_props_set_cb(self, activity_id, signal, handle,
+ channel, userdata):
+ self._joined_activities.append((activity_id, handle))
+ self._set_self_activities()
+ self.emit(signal, activity_id, channel, None, userdata)
+
+ def _join_activity_channel_props_listed_cb(self, activity_id, signal,
+ handle, channel, userdata,
+ props, prop_specs):
+
+ props_to_set = []
+ for ident, name, sig, flags in prop_specs:
+ value = props.pop(name, None)
+ if value is not None:
+ if flags & PROPERTY_FLAG_WRITE:
+ props_to_set.append((ident, value))
+ # FIXME: else error, but only if we're creating the room?
+ # FIXME: if props is nonempty, then we want to set props that aren't
+ # supported here - raise an error?
+
+ if props_to_set:
+ channel[PROPERTIES_INTERFACE].SetProperties(props_to_set,
+ reply_handler=lambda: self._join_activity_channel_props_set_cb(
+ activity_id, signal, handle, channel, userdata),
+ error_handler=lambda e: self._join_error_cb(
+ activity_id, signal, userdata,
+ 'SetProperties(%r)' % props_to_set, e))
+ else:
+ self._join_activity_channel_props_set_cb(activity_id, signal,
+ handle, channel, userdata)
+
+ def _join_activity_create_channel_cb(self, activity_id, signal, handle,
+ userdata, chan_path):
+ channel = Channel(self._conn.service_name, chan_path)
+ props = {
+ 'anonymous': False, # otherwise buddy resolution breaks
+ 'invite-only': False, # XXX: should be True in future
+ #'name': ... # XXX: set from activity name?
+ 'persistent': False, # vanish when there are no members
+ 'private': False, # XXX: should be True unless public
+ }
+ channel[PROPERTIES_INTERFACE].ListProperties(
+ reply_handler=lambda prop_specs: self._join_activity_channel_props_listed_cb(
+ activity_id, signal, handle, channel, userdata, props, prop_specs),
+ error_handler=lambda e: self._join_error_cb(
+ activity_id, signal, userdata, 'ListProperties', e))
+
+ def _join_activity_get_channel_cb(self, activity_id, signal, userdata,
+ handles):
+ if not self._activities.has_key(activity_id):
+ self._activities[activity_id] = handles[0]
+
+ if (activity_id, handles[0]) in self._joined_activities:
+ e = RuntimeError("Already joined activity %s" % activity_id)
+ _logger.debug('%s', e)
+ self.emit(signal, activity_id, None, e, userdata)
+ return
+
+ self._conn[CONN_INTERFACE].RequestChannel(CHANNEL_TYPE_TEXT,
+ HANDLE_TYPE_ROOM, handles[0], True,
+ reply_handler=lambda *args: self._join_activity_create_channel_cb(
+ activity_id, signal, handles[0], userdata, *args),
+ error_handler=lambda e: self._join_error_cb(activity_id, signal,
+ userdata, 'RequestChannel(TEXT, ROOM, %r, True)' % handles[0],
+ e))
+
+ def _join_error_cb(self, activity_id, signal, userdata, where, err):
+ e = Exception("Error joining/sharing activity %s: (%s): %s"
+ % (activity_id, where, err))
+ _logger.debug('%s', e)
+ self.emit(signal, activity_id, None, e, userdata)
+
+ def _internal_join_activity(self, activity_id, signal, userdata):
+ handle = self._activities.get(activity_id)
+ if not handle:
+ # FIXME: figure out why the server can't figure this out itself
+ room_jid = activity_id + "@conference." + self._account["server"]
+ self._conn[CONN_INTERFACE].RequestHandles(HANDLE_TYPE_ROOM,
+ [room_jid],
+ reply_handler=lambda *args: self._join_activity_get_channel_cb(
+ activity_id, signal, userdata, *args),
+ error_handler=lambda e: self._join_error_cb(activity_id,
+ signal, userdata, 'RequestHandles([%u])' % room_jid,
+ e))
+ else:
+ self._join_activity_get_channel_cb(activity_id, signal, userdata,
+ [handle])
+
+ def share_activity(self, activity_id, userdata):
+ """Share activity with the network
+
+ activity_id -- unique ID for the activity
+ userdata -- opaque token to be passed in the resulting event
+ (id, callback, errback) normally
+
+ Asks the Telepathy server to create a "conference" channel
+ for the activity or return a handle to an already created
+ conference channel for the activity.
+ """
+ self._internal_join_activity(activity_id, "activity-shared", userdata)
+
+ def join_activity(self, activity_id, userdata):
+ """Join an activity on the network (or locally)
+
+ activity_id -- unique ID for the activity
+ userdata -- opaque token to be passed in the resulting event
+ (id, callback, errback) normally
+
+ Asks the Telepathy server to create a "conference" channel
+ for the activity or return a handle to an already created
+ conference channel for the activity.
+ """
+ self._internal_join_activity(activity_id, "activity-joined", userdata)
+
+ def _ignore_success_cb(self):
+ """Ignore an event (null-operation)"""
+
+ def _log_error_cb(self, msg, err):
+ """Log a message (error) at debug level with prefix msg"""
+ _logger.debug("Error %s: %s", msg, err)
+
+ def _set_self_olpc_properties(self):
+ """Set color and key on our Telepathy server identity"""
+ props = {}
+ props['color'] = self._owner.props.color
+ props['key'] = dbus.ByteArray(self._owner.props.key)
+ addr = self._owner.props.ip4_address
+ if not addr:
+ props['ip4-address'] = ""
+ else:
+ props['ip4-address'] = addr
+ self._conn[CONN_INTERFACE_BUDDY_INFO].SetProperties(props,
+ reply_handler=self._ignore_success_cb,
+ error_handler=lambda e: self._log_error_cb("setting properties", e))
+
+ def _set_self_alias(self):
+ """Forwarded to SetActivities on AliasInfo channel"""
+ alias = self._owner.props.nick
+ self_handle = self._conn[CONN_INTERFACE].GetSelfHandle()
+ self._conn[CONN_INTERFACE_ALIASING].SetAliases({self_handle : alias},
+ reply_handler=self._ignore_success_cb,
+ error_handler=lambda e: self._log_error_cb("setting alias", e))
+ return False
+
+ def _set_self_activities(self):
+ """Forward set of joined activities to network
+
+ uses SetActivities on BuddyInfo channel
+ """
+ self._conn[CONN_INTERFACE_BUDDY_INFO].SetActivities(
+ self._joined_activities,
+ reply_handler=self._ignore_success_cb,
+ error_handler=lambda e: self._log_error_cb("setting activities", e))
+
+ def _set_self_current_activity(self):
+ """Forward our current activity (or "") to network
+
+ uses SetCurrentActivity on BuddyInfo channel
+ """
+ cur_activity = self._owner.props.current_activity
+ cur_activity_handle = 0
+ if not cur_activity:
+ cur_activity = ""
+ else:
+ cur_activity_handle = self._get_handle_for_activity(cur_activity)
+ if not cur_activity_handle:
+ # dont advertise a current activity that's not shared
+ cur_activity = ""
+
+ _logger.debug("Setting current activity to '%s' (handle %s)",
+ cur_activity, cur_activity_handle)
+ self._conn[CONN_INTERFACE_BUDDY_INFO].SetCurrentActivity(cur_activity,
+ cur_activity_handle,
+ reply_handler=self._ignore_success_cb,
+ error_handler=lambda e: self._log_error_cb("setting current activity", e))
+
+ def _get_handle_for_activity(self, activity_id):
+ """Retrieve current handle for given activity or None"""
+ for (act, handle) in self._joined_activities:
+ if activity_id == act:
+ return handle
+ return None
+
+ def _reconnect_cb(self):
+ """Attempt to reconnect to the server"""
+ self.start()
+ return False
+
+ def _handle_connection_status_change(self, status, reason):
+ if status == self._conn_status:
+ return
+
+ if status == CONNECTION_STATUS_CONNECTING:
+ self._conn_status = status
+ _logger.debug("status: connecting...")
+ elif status == CONNECTION_STATUS_CONNECTED:
+ if self._connected_cb():
+ _logger.debug("status: connected")
+ self._conn_status = status
+ else:
+ self.cleanup()
+ _logger.debug("status: was connected, but an error occurred")
+ elif status == CONNECTION_STATUS_DISCONNECTED:
+ self.cleanup()
+ _logger.debug("status: disconnected (reason %r)", reason)
+ if reason == CONNECTION_STATUS_REASON_AUTHENTICATION_FAILED:
+ # FIXME: handle connection failure; retry later?
+ pass
+ else:
+ # If disconnected, but still have a network connection, retry
+ # If disconnected and no network connection, do nothing here
+ # and let the IP4AddressMonitor address-changed signal handle
+ # reconnection
+ if self._ip4am.props.address and not self._reconnect_id:
+ self._reconnect_id = gobject.timeout_add(_RECONNECT_TIMEOUT,
+ self._reconnect_cb)
+
+ self.emit('status', self._conn_status, int(reason))
+ return False
+
+ def _status_changed_cb(self, status, reason):
+ """Handle notification of connection-status change
+
+ status -- CONNECTION_STATUS_*
+ reason -- integer code describing the reason...
+ """
+ _logger.debug("::: connection status changed to %s", status)
+ self._handle_connection_status_change(status, reason)
+
+ def start(self):
+ """Start up the Telepathy networking connections
+
+ if we are already connected, query for the initial contact
+ information.
+
+ if we are already connecting, do nothing
+
+ otherwise initiate a connection and transfer control to
+ _connect_reply_cb or _connect_error_cb
+ """
+ _logger.debug("Starting up...")
+
+ if self._reconnect_id > 0:
+ gobject.source_remove(self._reconnect_id)
+ self._reconnect_id = 0
+
+ # Only init connection if we have a valid IP address
+ if self._ip4am.props.address:
+ _logger.debug("::: Have IP4 address %s, will connect",
+ self._ip4am.props.address)
+ self._init_connection()
+ else:
+ _logger.debug("::: No IP4 address, postponing connection")
+
+ def cleanup(self):
+ """If we still have a connection, disconnect it"""
+ if self._conn:
+ try:
+ self._conn[CONN_INTERFACE].Disconnect()
+ except:
+ pass
+ self._conn = None
+ self._conn_status = CONNECTION_STATUS_DISCONNECTED
+
+ for handle in self._online_contacts.keys():
+ self._contact_offline(handle)
+ self._online_contacts = {}
+ self._joined_activites = []
+ self._activites = {}
+
+ if self._reconnect_id > 0:
+ gobject.source_remove(self._reconnect_id)
+ self._reconnect_id = 0
+
+ def _contact_offline(self, handle):
+ """Handle contact going offline (send message, update set)"""
+ if not self._online_contacts.has_key(handle):
+ return
+ if self._online_contacts[handle]:
+ self.emit("contact-offline", handle)
+ del self._online_contacts[handle]
+
+ def _contact_online_activities_cb(self, handle, activities):
+ """Handle contact's activity list update"""
+ self._buddy_activities_changed_cb(handle, activities)
+
+ def _contact_online_activities_error_cb(self, handle, err):
+ """Handle contact's activity list being unavailable"""
+ _logger.debug("Handle %s - Error getting activities: %s",
+ handle, err)
+ # Don't drop the buddy if we can't get their activities, for now
+ #self._contact_offline(handle)
+
+ def _contact_online_aliases_cb(self, handle, props, aliases):
+ """Handle contact's alias being received (do further queries)"""
+ if not self._conn or not aliases or not len(aliases):
+ _logger.debug("Handle %s - No aliases", handle)
+ self._contact_offline(handle)
+ return
+
+ props['nick'] = aliases[0]
+
+ jid = self._conn[CONN_INTERFACE].InspectHandles(HANDLE_TYPE_CONTACT,
+ [handle])[0]
+ self._online_contacts[handle] = jid
+ objid = self.identify_contacts(None, [handle])[handle]
+
+ self.emit("contact-online", objid, handle, props)
+
+ self._conn[CONN_INTERFACE_BUDDY_INFO].GetActivities(handle,
+ reply_handler=lambda *args: self._contact_online_activities_cb(
+ handle, *args),
+ error_handler=lambda e: self._contact_online_activities_error_cb(
+ handle, e))
+
+ def _contact_online_aliases_error_cb(self, handle, props, retry, err):
+ """Handle failure to retrieve given user's alias/information"""
+ if retry:
+ _logger.debug("Handle %s - Error getting nickname (will retry):"
+ "%s", handle, err)
+ self._conn[CONN_INTERFACE_ALIASING].RequestAliases([handle],
+ reply_handler=lambda *args: self._contact_online_aliases_cb(
+ handle, props, *args),
+ error_handler=lambda e: self._contact_online_aliases_error_cb(
+ handle, props, False, e))
+ else:
+ _logger.debug("Handle %s - Error getting nickname: %s",
+ handle, err)
+ self._contact_offline(handle)
+
+ def _contact_online_properties_cb(self, handle, props):
+ """Handle failure to retrieve given user's alias/information"""
+ if not props.has_key('key'):
+ _logger.debug("Handle %s - invalid key.", handle)
+ self._contact_offline(handle)
+ return
+ if not props.has_key('color'):
+ _logger.debug("Handle %s - invalid color.", handle)
+ self._contact_offline(handle)
+ return
+
+ self._conn[CONN_INTERFACE_ALIASING].RequestAliases([handle],
+ reply_handler=lambda *args: self._contact_online_aliases_cb(
+ handle, props, *args),
+ error_handler=lambda e: self._contact_online_aliases_error_cb(
+ handle, props, True, e))
+
+ def _contact_online_request_properties(self, handle, tries):
+ self._conn[CONN_INTERFACE_BUDDY_INFO].GetProperties(handle,
+ byte_arrays=True,
+ reply_handler=lambda *args: self._contact_online_properties_cb(
+ handle, *args),
+ error_handler=lambda e: self._contact_online_properties_error_cb(
+ handle, tries, e))
+ return False
+
+ def _contact_online_properties_error_cb(self, handle, tries, err):
+ """Handle error retrieving property-set for a user (handle)"""
+ if tries <= 3:
+ _logger.debug("Handle %s - Error getting properties (will retry):"
+ " %s", handle, err)
+ tries += 1
+ gobject.timeout_add(1000, self._contact_online_request_properties,
+ handle, tries)
+ else:
+ _logger.debug("Handle %s - Error getting properties: %s",
+ handle, err)
+ self._contact_offline(handle)
+
+ def _contact_online(self, handle):
+ """Handle a contact coming online"""
+ if (handle not in self._subscribe_members and
+ handle not in self._subscribe_local_pending and
+ handle not in self._subscribe_remote_pending):
+ # it's probably a channel-specific handle - can't create a Buddy
+ # object for those yet
+ return
+
+ self._online_contacts[handle] = None
+ if handle == self._conn[CONN_INTERFACE].GetSelfHandle():
+ jid = self._conn[CONN_INTERFACE].InspectHandles(
+ HANDLE_TYPE_CONTACT, [handle])[0]
+ self._online_contacts[handle] = jid
+ # ignore network events for Owner property changes since those
+ # are handled locally
+ return
+
+ self._contact_online_request_properties(handle, 1)
+
+ def _subscribe_members_changed_cb(self, message, added, removed,
+ local_pending, remote_pending,
+ actor, reason):
+
+ added = set(added)
+ removed = set(removed)
+ local_pending = set(local_pending)
+ remote_pending = set(remote_pending)
+
+ affected = added|removed
+ affected |= local_pending
+ affected |= remote_pending
+
+ self._subscribe_members -= affected
+ self._subscribe_members |= added
+ self._subscribe_local_pending -= affected
+ self._subscribe_local_pending |= local_pending
+ self._subscribe_remote_pending -= affected
+ self._subscribe_remote_pending |= remote_pending
+
+ def _publish_members_changed_cb(self, added, removed, local_pending,
+ remote_pending, actor, reason):
+
+ if local_pending:
+ # accept all requested subscriptions
+ self._publish_channel[CHANNEL_INTERFACE_GROUP].AddMembers(
+ local_pending, '')
+
+ # subscribe to people who've subscribed to us, if necessary
+ added = list(set(added) - self._subscribe_members
+ - self._subscribe_remote_pending)
+ if added:
+ self._subscribe_channel[CHANNEL_INTERFACE_GROUP].AddMembers(
+ added, '')
+
+ def _presence_update_cb(self, presence):
+ """Send update for online/offline status of presence"""
+ for handle in presence:
+ timestamp, statuses = presence[handle]
+ online = handle in self._online_contacts
+ for status, params in statuses.items():
+ if not online and status == "offline":
+ # weren't online in the first place...
+ continue
+ jid = self._conn[CONN_INTERFACE].InspectHandles(
+ HANDLE_TYPE_CONTACT, [handle])[0]
+ olstr = "ONLINE"
+ if not online: olstr = "OFFLINE"
+ _logger.debug("Handle %s (%s) was %s, status now '%s'.",
+ handle, jid, olstr, status)
+ if not online and status in ["available", "away", "brb",
+ "busy", "dnd", "xa"]:
+ self._contact_online(handle)
+ elif status in ["offline", "invisible"]:
+ self._contact_offline(handle)
+
+ def _request_avatar_cb(self, handle, new_avatar_token, avatar, mime_type):
+ jid = self._online_contacts[handle]
+ if not jid:
+ logging.debug("Handle %s not valid yet..." % handle)
+ return
+ icon = ''.join(map(chr, avatar))
+ self._icon_cache.store_icon(jid, new_avatar_token, icon)
+ self.emit("avatar-updated", handle, icon)
+
+ def _avatar_updated_cb(self, handle, new_avatar_token):
+ """Handle update of given user (handle)'s avatar"""
+ if handle == self._conn[CONN_INTERFACE].GetSelfHandle():
+ # ignore network events for Owner property changes since those
+ # are handled locally
+ return
+
+ if not self._online_contacts.has_key(handle):
+ _logger.debug("Handle %s unknown.", handle)
+ return
+
+ jid = self._online_contacts[handle]
+ if not jid:
+ _logger.debug("Handle %s not valid yet...", handle)
+ return
+
+ icon = self._icon_cache.get_icon(jid, new_avatar_token)
+ if not icon:
+ # cache miss
+ self._conn[CONN_INTERFACE_AVATARS].RequestAvatar(handle,
+ reply_handler=lambda *args: self._request_avatar_cb(handle,
+ new_avatar_token, *args),
+ error_handler=lambda e: self._log_error_cb(
+ "getting avatar", e))
+ else:
+ self.emit("avatar-updated", handle, icon)
+
+ def _alias_changed_cb(self, aliases):
+ """Handle update of aliases for all users"""
+ for handle, alias in aliases:
+ prop = {'nick': alias}
+ #print "Buddy %s alias changed to %s" % (handle, alias)
+ if (self._online_contacts.has_key(handle) and
+ self._online_contacts[handle]):
+ self._buddy_properties_changed_cb(handle, prop)
+
+ def _buddy_properties_changed_cb(self, handle, properties):
+ """Handle update of given user (handle)'s properties"""
+ if handle == self._conn[CONN_INTERFACE].GetSelfHandle():
+ # ignore network events for Owner property changes since those
+ # are handled locally
+ return
+ if (self._online_contacts.has_key(handle) and
+ self._online_contacts[handle]):
+ self.emit("buddy-properties-changed", handle, properties)
+
+ def _buddy_activities_changed_cb(self, handle, activities):
+ """Handle update of given user (handle)'s activities"""
+ if handle == self._conn[CONN_INTERFACE].GetSelfHandle():
+ # ignore network events for Owner activity changes since those
+ # are handled locally
+ return
+ if (not self._online_contacts.has_key(handle) or
+ not self._online_contacts[handle]):
+ return
+
+ for act_id, act_handle in activities:
+ self._activities[act_id] = act_handle
+ activities_id = map(lambda x: x[0], activities)
+ self.emit("buddy-activities-changed", handle, activities_id)
+
+ def _buddy_current_activity_changed_cb(self, handle, activity, channel):
+ """Handle update of given user (handle)'s current activity"""
+
+ if handle == self._conn[CONN_INTERFACE].GetSelfHandle():
+ # ignore network events for Owner current activity changes since
+ # those are handled locally
+ return
+ if (not self._online_contacts.has_key(handle) or
+ not self._online_contacts[handle]):
+ return
+
+ if not len(activity) or not util.validate_activity_id(activity):
+ activity = None
+ prop = {'current-activity': activity}
+ _logger.debug("Handle %s: current activity now %s", handle, activity)
+ self._buddy_properties_changed_cb(handle, prop)
+
+ def _new_channel_cb(self, object_path, channel_type, handle_type, handle,
+ suppress_handler):
+ """Handle creation of a new channel
+ """
+ if (handle_type == HANDLE_TYPE_ROOM and
+ channel_type == CHANNEL_TYPE_TEXT):
+ def ready(channel):
+
+ for act_id, act_handle in self._activities.iteritems():
+ if handle == act_handle:
+ break
+ else:
+ return
+
+ def got_all_members(current, local_pending, remote_pending):
+ if local_pending:
+ for act_id, act_handle in self._activities.iteritems():
+ if handle == act_handle:
+ self.emit('activity-invitation', act_id)
+ def got_all_members_err(e):
+ logger.debug('Unable to get channel members for %s:',
+ object_path, exc_info=1)
+
+ group = channel[CHANNEL_INTERFACE_GROUP]
+ group.GetAllMembers(reply_handler=got_all_members,
+ error_handler=got_all_members_err)
+
+ # we throw away the channel as soon as ready() finishes
+ Channel(self._conn.service_name, object_path,
+ ready_handler=ready)
+
+ elif (handle_type == HANDLE_TYPE_CONTACT and
+ channel_type in (CHANNEL_TYPE_TEXT,
+ CHANNEL_TYPE_STREAMED_MEDIA)):
+ self.emit("private-invitation", object_path)
+
+ def update_activity_properties(self, act_id):
+ """Request update from network on the activity properties of act_id"""
+ handle = self._activities.get(act_id)
+ if not handle:
+ raise RuntimeError("Unknown activity %s: couldn't find handle.")
+
+ self._conn[CONN_INTERFACE_ACTIVITY_PROPERTIES].GetProperties(handle,
+ reply_handler=lambda *args: self._activity_properties_changed_cb(
+ handle, *args),
+ error_handler=lambda e: self._log_error_cb(
+ "getting activity properties", e))
+
+ def set_activity_properties(self, act_id, props):
+ """Send update to network on the activity properties of act_id (props).
+ """
+ handle = self._activities.get(act_id)
+ if not handle:
+ raise RuntimeError("Unknown activity %s: couldn't find handle.")
+
+ self._conn[CONN_INTERFACE_ACTIVITY_PROPERTIES].SetProperties(handle,
+ props, reply_handler=self._ignore_success_cb,
+ error_handler=lambda e: self._log_error_cb(
+ "setting activity properties", e))
+
+ def _activity_properties_changed_cb(self, room, properties):
+ """Handle update of properties for a "room" (activity handle)"""
+ for act_id, act_handle in self._activities.items():
+ if room == act_handle:
+ self.emit("activity-properties-changed", act_id, properties)
+ return
+
+ def _server_is_trusted(self, hostname):
+ """Return True if the server with the given hostname is trusted to
+ verify public-key ownership correctly, and only allows users to
+ register JIDs whose username part is either a public key fingerprint,
+ or of the wrong form to be a public key fingerprint (to allow for
+ ejabberd's admin@example.com address).
+
+ If we trust the server, we can skip verifying the key ourselves,
+ which leads to simplifications. In the current implementation we
+ never verify that people actually own the key they claim to, so
+ we will always give contacts on untrusted servers a JID- rather than
+ key-based identity.
+
+ For the moment we assume that the test server, olpc.collabora.co.uk,
+ does this verification.
+ """
+ return (hostname == 'olpc.collabora.co.uk')
+
+ def identify_contacts(self, tp_chan, handles):
+ """Work out the "best" unique identifier we can for the given handles,
+ in the context of the given channel (which may be None), using only
+ 'fast' connection manager API (that does not involve network
+ round-trips).
+
+ For the XMPP server case, we proceed as follows:
+
+ * Find the owners of the given handles, if the channel has
+ channel-specific handles
+ * If the owner (globally-valid JID) is on a trusted server, return
+ 'keyid/' plus the 'key fingerprint' (the user part of their JID,
+ currently implemented as the SHA-1 of the Base64 blob in
+ owner.key.pub)
+ * If the owner (globally-valid JID) cannot be found or is on an
+ untrusted server, return 'xmpp/' plus an escaped form of the JID
+
+ The idea is that we identify buddies by key-ID (i.e. by key, assuming
+ no collisions) if we can find it without making network round-trips,
+ but if that's not possible we just use their JIDs.
+
+ :Parameters:
+ `tp_chan` : telepathy.client.Channel or None
+ The channel in which the handles were found, or None if they
+ are known to be channel-specific handles
+ `handles` : iterable over (int or long)
+ The contacts' handles in that channel
+ :Returns:
+ A dict mapping the provided handles to the best available
+ unique identifier, which is a string that could be used as a
+ suffix to an object path
+ """
+ # we need to be able to index into handles, so force them to
+ # be a sequence
+ if not isinstance(handles, (tuple, list)):
+ handles = tuple(handles)
+
+ owners = handles
+
+ if tp_chan is not None and CHANNEL_INTERFACE_GROUP in tp_chan:
+ group = tp_chan[CHANNEL_INTERFACE_GROUP]
+ if (group.GetGroupFlags() &
+ CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES):
+ owners = group.GetHandleOwners(handles)
+ for i, owner in enumerate(owners):
+ if owner == 0:
+ owners[i] = handles[i]
+ else:
+ group = None
+
+ jids = self._conn[CONN_INTERFACE].InspectHandles(HANDLE_TYPE_CONTACT,
+ owners)
+
+ ret = {}
+ for handle, jid in zip(handles, jids):
+ # special-case the Owner - we always know who we are
+ if (handle == self.self_handle or
+ (group is not None and handle == group.GetSelfHandle())):
+ ret[handle] = self._owner.props.objid
+ continue
+
+ if '/' in jid:
+ # the contact is unidentifiable (in an anonymous MUC) - create
+ # a temporary identity for them, based on their room-JID
+ ret[handle] = 'xmpp/' + psutils.escape_identifier(jid)
+ else:
+ user, host = jid.split('@', 1)
+ if (self._server_is_trusted(host) and len(user) == 40 and
+ user.strip(hexdigits) == ''):
+ # they're on a trusted server and their username looks
+ # like a key-ID
+ ret[handle] = 'keyid/' + user.lower()
+ else:
+ # untrusted server, or not the right format to be a
+ # key-ID - identify the contact by their JID
+ ret[handle] = 'xmpp/' + psutils.escape_identifier(jid)
+
+ return ret
diff --git a/src/sugar-presence-service b/src/sugar-presence-service
new file mode 100755
index 0000000..6ab871c
--- /dev/null
+++ b/src/sugar-presence-service
@@ -0,0 +1,63 @@
+#!/usr/bin/env python
+# vi: ts=4 ai noet
+#
+# Copyright (C) 2006, Red Hat, Inc.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+import logging
+import sys
+import os
+
+from sugar import logger
+from sugar import env
+
+
+_logger = logging.getLogger('s-p-s')
+
+
+def usage():
+ _logger.debug("Usage: sugar-presence-service [<test buddy number (1 - 10)>] [randomize]")
+
+sys.path.append(env.get_service_path('presence'))
+
+test_num = 0
+randomize = False
+if len(sys.argv) in [2, 3]:
+ try:
+ test_num = int(sys.argv[1])
+ except ValueError:
+ _logger.debug("Bad test user number.")
+ if test_num < 1 or test_num > 10:
+ _logger.debug("Bad test user number.")
+
+ if len(sys.argv) == 3 and sys.argv[2] == "randomize":
+ randomize = True
+elif len(sys.argv) == 1:
+ pass
+else:
+ usage()
+ os._exit(1)
+
+if test_num > 0:
+ logger.start('test-%d-presenceservice' % test_num)
+else:
+ logger.start('presenceservice')
+
+import presenceservice
+
+_logger.info('Starting presence service...')
+
+presenceservice.main(test_num, randomize)
diff --git a/src/test_psutils.py b/src/test_psutils.py
new file mode 100644
index 0000000..7436d98
--- /dev/null
+++ b/src/test_psutils.py
@@ -0,0 +1,12 @@
+print "Running test_psutils..."
+
+from psutils import escape_identifier, pubkey_to_keyid
+
+assert pubkey_to_keyid('abc') == 'a9993e364706816aba3e25717850c26c9cd0d89d'
+
+assert escape_identifier('') == '_'
+assert escape_identifier('_') == '_5f'
+assert escape_identifier('1') == '_31'
+assert escape_identifier('a1') == 'a1'
+assert escape_identifier('1a') == '_31a'
+assert escape_identifier("0123abc_xyz\x01\xff") == '_30123abc_5fxyz_01_ff'