diff options
Diffstat (limited to 'backends/bluez/bluez-persona-store.vala')
-rw-r--r-- | backends/bluez/bluez-persona-store.vala | 843 |
1 files changed, 843 insertions, 0 deletions
diff --git a/backends/bluez/bluez-persona-store.vala b/backends/bluez/bluez-persona-store.vala new file mode 100644 index 00000000..b0b0b226 --- /dev/null +++ b/backends/bluez/bluez-persona-store.vala @@ -0,0 +1,843 @@ +/* + * Copyright (C) 2010-2013 Collabora Ltd. + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 2.1 of the License, or + * (at your option) any later version. + * + * This library 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 Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see <http://www.gnu.org/licenses/>. + * + * Authors: + * Arun Raghavan <arun.raghavan@collabora.co.uk> + * Jeremy Whiting <jeremy.whiting@collabora.com> + * Simon McVittie <simon.mcvittie@collabora.co.uk> + * Gustavo Padovan <gustavo.padovan@collabora.co.uk> + * Matthieu Bouron <matthieu.bouron@collabora.com> + * Philip Withnall <philip.withnall@collabora.co.uk> + * + * Based on kf-persona-store.vala by: + * Travis Reitter <travis.reitter@collabora.co.uk> + * Philip Withnall <philip.withnall@collabora.co.uk> + */ + +using GLib; +using Gee; +using Folks; +using Folks.Backends.BlueZ; +using org.bluez; + +/** + * A persona store which is associated with a single BlueZ PBAP server (i.e. + * one {@link PersonaStore} per device). It will create a {@link Persona} for + * each contact on the device. + * + * @since UNRELEASED + */ +public class Folks.Backends.BlueZ.PersonaStore : Folks.PersonaStore +{ + private HashMap<string, Persona> _personas; + private Map<string, Persona> _personas_ro; + private bool _is_prepared = false; + private bool _prepare_pending = false; + private bool _is_quiescent = false; + + private static string[] _always_writeable_properties = {}; + + private org.bluez.obex.Client _obex_client; + private HashTable<string, Variant> _phonebook_filter; + private string _object_path; + private Device _device; + private string _display_name; + + /* Non-null iff an _update_contacts() call is in progress. */ + private Cancellable? _update_contacts_cancellable = null; + + /** + * {@inheritDoc} + * + * @since UNRELEASED + */ + public override string type_id { get { return BACKEND_NAME; } } + + /** + * Whether this PersonaStore can add {@link Folks.Persona}s. + * + * See {@link Folks.PersonaStore.can_add_personas}. + * + * @since UNRELEASED + */ + public override MaybeBool can_add_personas + { + get { return MaybeBool.FALSE; } + } + + /** + * Whether this PersonaStore can set the alias of {@link Folks.Persona}s. + * + * See {@link Folks.PersonaStore.can_alias_personas}. + * + * @since UNRELEASED + */ + public override MaybeBool can_alias_personas + { + get { return MaybeBool.FALSE; } + } + + /** + * Whether this PersonaStore can set the groups of {@link Folks.Persona}s. + * + * See {@link Folks.PersonaStore.can_group_personas}. + * + * @since UNRELEASED + */ + public override MaybeBool can_group_personas + { + get { return MaybeBool.FALSE; } + } + + /** + * Whether this PersonaStore can remove {@link Folks.Persona}s. + * + * See {@link Folks.PersonaStore.can_remove_personas}. + * + * @since UNRELEASED + */ + public override MaybeBool can_remove_personas + { + get { return MaybeBool.FALSE; } + } + + /** + * Whether this PersonaStore has been prepared. + * + * See {@link Folks.PersonaStore.is_prepared}. + * + * @since UNRELEASED + */ + public override bool is_prepared + { + get { return this._is_prepared; } + } + + /** + * Whether this PersonaStore has reached a quiescent state. + * + * See {@link Folks.PersonaStore.is_quiescent}. + * + * @since UNRELEASED + */ + public override bool is_quiescent + { + get { return this._is_quiescent; } + } + + /** + * {@inheritDoc} + * + * @since unreleased + */ + public override string[] always_writeable_properties + { + get { return BlueZ.PersonaStore._always_writeable_properties; } + } + + /** + * {@inheritDoc} + * + * @since UNRELEASED + */ + public override Map<string, Persona> personas + { + get { return this._personas_ro; } + } + + /** + * {@inheritDoc} + * + * @since UNRELEASED + */ + public new string display_name + { + get { return this._display_name; } + construct { this._display_name = value; } + } + + /** + * Path of the D-Bus object backing this {@link PersonaStore}. + * + * This is the path of the BlueZ device object on D-Bus which provides the + * contacts in this store. + * + * @since UNRELEASED + */ + public string object_path + { + get { return this._object_path; } + construct { this._object_path = value; } + } + + /** + * Create a new PersonaStore. + * + * Create a new persona store to expose the {@link Persona}s provided by the + * device with the given Bluetooth address. + * + * @param device the D-Bus object for the Bluetooth device. + * @param object_path the D-Bus path of the object for the Bluetooth device + * @param obex_client the D-Bus obex client object. + * + * @since UNRELEASED + */ + public PersonaStore (Device device, string object_path, + org.bluez.obex.Client obex_client) + { + Object (id: device.address, + object_path: object_path, + display_name: device.alias); + + this._device = device; + this._obex_client = obex_client; + + this.set_is_trusted (this._device.trusted); + } + + construct + { + this._personas = new HashMap<string, Persona> (); + this._personas_ro = this._personas.read_only_view; + this._phonebook_filter = new HashTable<string, Variant> (null , null); + this._phonebook_filter.insert ("Format", "Vcard30"); + this._phonebook_filter.insert ("Fields", + new Variant.strv ({ + "N", "FN", "NICKNAME", "TEL", "URL", "EMAIL", "PHOTO" + })); + } + + /** + * Load contacts from a file and update the persona store. + * + * Load contacts from a file identified by its {@link File} and update + * the persona store accordingly. Contacts are stored in the file as a + * sequence of vCards, separated by blank lines. + * + * If this throws an error, it guarantees to leave the store’s internal state + * unchanged. + * + * @param file the file where the contacts are stored + * @param obex_pbap the current OBEX PBAP D-Bus proxy + * @throws IOError if there was an error communicating with D-Bus + * @throws DBusError if an error was returned over the bus + * @throws Error if the given file couldn’t be read + * + * @since UNRELEASED + */ + private async void _update_contacts_from_file (File file, + org.bluez.obex.PhonebookAccess obex_pbap) + throws DBusError, IOError + { + var added_personas = new HashSet<Persona> (); + + /* Get the vCard listing data where every entry + * consists of a pair of strings containing the vCard + * handle and the contact name. For example: + * "0.vcf" : "Me" + * "1.vcf" : "John" + * + * First entry corresponds to the user themselves. + */ + var entries = obex_pbap.list (this._phonebook_filter); + + try + { + var dis = new DataInputStream (file.read ()); + uint i = 0; + string? line = null; + StringBuilder vcard = new StringBuilder (); + + /* For each vCard in the file create a new Persona */ + while ((line = yield dis.read_line_async ()) != null) + { + /* Ignore blank lines between vCards. */ + if (vcard.len == 0 && line.strip () == "") + continue; + + vcard.append (line); + vcard.append_c ('\n'); + if (line.strip () == "END:VCARD") + { + var entry = entries[i]; + + /* The first vCard is always the user themselves. */ + var is_user = (i == 0); + + var persona = new Persona (entry.vcard, entry.name, + vcard.str, this, is_user); + added_personas.add (persona); + + i++; + vcard.erase (); + } + } + } + catch (GLib.Error e1) + { + /* I/O error reading the file. */ + throw new IOError.FAILED ( + /* Translators: the parameter is an error message. */ + _("Error reading the transferred address book file: %s"), + e1.message); + } + + /* Now that all the I/O is done and no more errors can be thrown, update + * the store’s internal state. */ + foreach (var p in added_personas) + this._personas.set (p.iid, p); + + if (added_personas.is_empty == false) + this._emit_personas_changed (added_personas, null); + } + + /** + * Set the persona store's alias. + * + * This will be called in response to a property change sent to the Backend. + * + * @param alias the device’s new alias + * + * @since UNRELEASED + */ + internal void set_alias (string alias) + { + debug ("Device ‘%s’ (%s) changed alias to ‘%s’.", this._display_name, + this._device.address, alias); + + this._display_name = alias; + this.notify_property ("display-name"); + } + + /** + * Set the persona store's trust level. + * + * This will be called in response to a property change sent to the Backend. + * + * Default to partial trust. BlueZ persona UIDs are built from a SHA1 + * of the contact’s vCard, which we believe can’t be maliciously edited + * to corrupt linking. + * + * The trust for each device is manually set by the user in the BlueZ + * interface on the computer. + * + * @param trusted ``true`` if the user trusts the device, ``false`` otherwise + * + * @since UNRELEASED + */ + internal void set_is_trusted (bool trusted) + { + debug ("Device ‘%s’ (%s) marked as %s.", this._device.alias, + this._device.address, trusted ? "trusted" : "untrusted"); + + this.trust_level = + trusted ? PersonaStoreTrust.FULL : PersonaStoreTrust.PARTIAL; + } + + /** + * Set the persona store's connection state. + * + * This will be called in response to a property change sent to the Backend. + * + * If this throws an error, it guarantees to leave the store’s internal state + * unchanged. + * + * @param connected ``true`` if the device is now connected, ``false`` + * otherwise + * + * @throws IOError if the operation was cancelled + * (see {@link _update_contacts}) + * @throws PersonaStoreError if the contacts couldn’t be updated + * (see {@link _update_contacts}) + * + * @since UNRELEASED + */ + internal async void set_connection_state (bool connected) + throws IOError, PersonaStoreError + { + if (connected == true) + { + debug ("Device ‘%s’ (%s) is connected.", this._device.alias, + this._device.address); + + yield this._update_contacts (); + } + else + { + debug ("Device ‘%s’ (%s) is disconnected.", this._device.alias, + this._device.address); + + /* Cancel any ongoing transfers. */ + if (this._update_contacts_cancellable != null) + this._update_contacts_cancellable.cancel (); + } + } + + /** + * Create a new obex session for this Persona store. + * + * Create a new obex session for this Persona store if no previous session + * already exists. + * + * @param obex_pbap return location for an OBEX PBAP proxy object + * @returns the path of the OBEX session D-Bus object + * @throws IOError if it can't connect to D-Bus + * @throws DBusError if it can't create a new OBEX session + * + * @since UNRELEASED + */ + private async dynamic ObjectPath _new_obex_session ( + out org.bluez.obex.PhonebookAccess obex_pbap) + throws DBusError, IOError + { + debug ("Creating a new OBEX session."); + + var args = new HashTable<string, Variant> (null, null); + args["Target"] = "PBAP"; + + var session_path = yield this._obex_client.create_session (this.id, args); + + debug (" Got OBEX session path: %s", session_path); + + obex_pbap = + yield Bus.get_proxy (BusType.SESSION, "org.bluez.obex", session_path); + + debug (" Got OBEX PBAP proxy: %p", obex_pbap); + + return session_path; + } + + /** + * Remove the specified OBEX session from this persona store. + * + * Remove the specified OBEX session for this persona store and discard its + * transfer. + * + * @param session_path the path of the OBEX session D-Bus object to remove + * + * @since UNRELEASED + */ + private async void _remove_obex_session (dynamic ObjectPath session_path) + { + try + { + yield this._obex_client.remove_session (session_path); + } + catch (IOError ie) + { + warning ("Couldn’t remove OBEX session ‘%s’: %s", + session_path, ie.message); + } + catch (DBusError de) + { + warning ("Couldn’t remove OBEX session ‘%s’: %s", + session_path, de.message); + } + } + + /** + * Watch an OBEX transfer identified by its D-Bus path. + * + * This only returns once the transfer is complete (or has failed) and the + * transfer object has been destroyed. + * + * If this throws an error, it guarantees to leave the store’s internal state + * unchanged. + * + * @param path the D-Bus transfer object path to watch. + * @param obex_pbap an OBEX PBAP proxy object to access the address book from + * @param cancellable an optional {@link Cancellable} object to cancel the + * transfer + * + * @throws IOError if the operation was cancelled, or if another failure + * occurred (unavoidable; valac generates invalid C if we try to handle + * IOError internally here) + * @throws PersonaStoreError if the transfer failed + * + * @since UNRELEASED + */ + private async void _perform_obex_transfer (string path, + org.bluez.obex.PhonebookAccess obex_pbap, + Cancellable? cancellable = null) + throws IOError, PersonaStoreError + { + org.bluez.obex.Transfer? transfer = null; + + try + { + /* Bail early if the transfer's already been cancelled. */ + if (cancellable != null) + cancellable.set_error_if_cancelled (); + + /* Get an OBEX proxy for the transfer object. */ + transfer = + yield Bus.get_proxy (BusType.SESSION, "org.bluez.obex", path); + var transfer_proxy = (DBusProxy) transfer; + + var has_yielded = false; + string? transfer_status = null; + ulong signal_id; + ulong cancellable_id = 0; + + /* Set up the cancellable. */ + if (cancellable != null) + { + cancellable_id = cancellable.connect (() => + { + transfer_status = "error"; + if (has_yielded == true) + this._perform_obex_transfer.callback (); + }); + } + + /* There is no need to add a timeout here, as BlueZ already has one + * implemented for if transactions take too long. */ + signal_id = transfer_proxy.g_properties_changed.connect ( + (changed, invalidated) => + { + var property = + changed.lookup_value ("Status", VariantType.STRING); + if (property == null) + return; + + var status = property.get_string (); + transfer_status = status; + + if (status == "complete" || status == "error") + { + /* Finished. Return to the yield. */ + if (has_yielded == true) + this._perform_obex_transfer.callback (); + } + else if (status == "queued" || status == "active") + { + /* Do nothing. */ + } + else + { + warning ("Unknown OBEX transfer status ‘%s’.", status); + } + }); + + /* Yield until the above signal handler is called with a ‘success’ or + * ‘error’ status. */ + if (transfer_status == null) + { + has_yielded = true; + yield; + } + + transfer_proxy.disconnect (signal_id); + + if (cancellable_id != 0) + cancellable.disconnect (cancellable_id); + + /* Process the results: either success or error. */ + if (transfer_status == "complete") + { + string filename = transfer.filename; + var file = File.new_for_path (filename); + + debug ("vCard’s filename for device ‘%s’ (%s): %s", + this._display_name, this.id, filename); + + yield this._update_contacts_from_file (file, obex_pbap); + } + else if (transfer_status == "error") + { + /* On cancellation, throw an IOError instead of a + * PersonaStoreError. */ + if (cancellable != null) + cancellable.set_error_if_cancelled (); + + throw new PersonaStoreError.STORE_OFFLINE ( + /* Translators: the first parameter is the name of the failed + * transfer, and the second is a Bluetooth device alias. */ + _("Error during transfer of the address book ‘%s’ from " + + "Bluetooth device ‘%s’."), + transfer.name, this._display_name); + } + else + { + assert_not_reached (); + } + } + catch (DBusError e2) + { + throw new PersonaStoreError.STORE_OFFLINE ( + /* Translators: the first parameter is the name of the + * failed transfer, the second is a Bluetooth device + * alias, and the third is an error message. */ + _("Error during transfer of the address book ‘%s’ from " + + "Bluetooth device ‘%s’: %s"), + transfer.name, this._display_name, e2.message); + } + finally + { + /* Reset the OBEX transfer and clear out the temporary file. Do this + * without yielding because BlueZ should choose a different filename + * next time (using mkstemp() or similar). */ + if (transfer != null && transfer.filename != null) + { + var file = File.new_for_path (transfer.filename); + file.delete_async.begin (GLib.Priority.DEFAULT, null, + (o, r) => + { + try + { + file.delete_async.end (r); + } + catch (GLib.Error e1) + { + /* Ignore. */ + } + }); + } + } + } + + /** + * Update contacts from this persona store. + * + * Update contacts from this persona store by initiating a new OBEX + * transfer, unless one is already in progress. If a transfer is already in + * progress, leave it running and return immediately. + * + * If this throws an error, it guarantees to leave the store’s internal state + * unchanged. + * + * @throws IOError if the operation was cancelled + * @throws PersonaStoreError if the contacts couldn’t be downloaded from the + * device + * + * @since UNRELEASED + */ + private async void _update_contacts () throws IOError, PersonaStoreError + { + dynamic ObjectPath? session_path = null; + org.bluez.obex.PhonebookAccess? obex_pbap = null; + + if (this._update_contacts_cancellable != null) + { + /* There’s an ongoing _update_contacts() call. Since downloading the + * address book takes a long time (tens of seconds), we don’t want + * to cancel the ongoing operation. Just return immediately. */ + debug ("Not updating contacts due to ongoing update operation."); + return; + } + + Internal.profiling_start ("updating BlueZ.PersonaStore (ID: %s) contacts", + this.id); + + debug ("Updating contacts."); + + try + { + string path; + HashTable<string, Variant> props; + + this._update_contacts_cancellable = new Cancellable (); + + /* Set up an OBEX session. */ + try + { + session_path = yield this._new_obex_session (out obex_pbap); + } + catch (GLib.Error e1) + { + if (e1 is IOError.DBUS_ERROR && + e1.message.has_suffix ("OBEX Connect failed with 0x43")) + { + /* This error is sent when the user denies the computer access + * to the phone’s address book over Bluetooth, after accepting + * the pairing request. */ + throw new PersonaStoreError.PERMISSION_DENIED ( + _("Permission to access the address book on Bluetooth " + + "device ‘%s’ was denied by the user."), + this._device.alias); + } + + throw new PersonaStoreError.STORE_OFFLINE ( + /* Translators: the first parameter is a Bluetooth device + * alias, and the second is an error message. */ + _("An OBEX address book transfer from device ‘%s’ could " + + "not be started: %s"), + this._device.alias, e1.message); + } + + try + { + /* Select the phonebook object we want to download ie: + * PB: phonebook for the saved contacts */ + obex_pbap.select ("int", "PB"); + + /* Initiate a phone book transfer from the PSE server using a + * plain string vCard format, transferring to a temporary file. */ + obex_pbap.pull_all ("", this._phonebook_filter, out path, + out props); + } + catch (GLib.Error e2) + { + throw new PersonaStoreError.STORE_OFFLINE ( + /* Translators: the first parameter is a Bluetooth device + * alias, and the second is an error message. */ + _("The OBEX address book transfer from device ‘%s’ " + + "failed: %s"), + this._device.alias, e2.message); + } + + try + { + yield this._perform_obex_transfer (path, obex_pbap, + this._update_contacts_cancellable); + } + catch (IOError e3) + { + if (e3 is IOError.CANCELLED) + throw e3; + + throw new PersonaStoreError.STORE_OFFLINE ( + /* Translators: the first parameter is a Bluetooth device + * alias, and the second is an error message. */ + _("Error during transfer of the address book from " + + "Bluetooth device ‘%s’: %s"), + this._display_name, e3.message); + } + } + finally + { + /* Tear down again. */ + if (session_path != null) + yield this._remove_obex_session (session_path); + obex_pbap = null; + + this._update_contacts_cancellable = null; + + Internal.profiling_end ("updating BlueZ.PersonaStore (ID: %s) " + + "contacts", this.id); + } + } + + /** + * {@inheritDoc} + * + * @since UNRELEASED + */ + public override async void prepare () throws PersonaStoreError + { + Internal.profiling_start ("preparing BlueZ.PersonaStore (ID: %s)", + this.id); + + if (this._is_prepared || this._prepare_pending) + { + return; + } + + try + { + this._prepare_pending = true; + + /* Start downloading the contacts, regardless of the phone’s + * connection state. If the phone is disconnected, the download should + * force it to be connected. */ + try + { + yield this._update_contacts (); + } + catch (IOError e1) + { + /* If this happens, the update operation was cancelled, which + * means the phone spontaneously disconnected during the transfer. + * Act as if the store has gone offline and mark preparation as + * complete. */ + throw new PersonaStoreError.STORE_OFFLINE ( + _("Bluetooth device ‘%s’ disappeared during address book " + + "transfer."), this._device.alias); + } + finally + { + /* Done or failed. We always mark the persona store as prepared + * and quiescent because of the limited data available to us from + * BlueZ: we only have the Paired and Connected properties. + * So a phone can be paired with the laptop, but its Bluetooth + * can be turned off; or a phone can be paired with the laptop and + * its Bluetooth turned on but no connection is active. In the + * former case, we don't want to connect to the device (because + * that will just fail). In the latter case, we do, because we + * want to download the address book. However, BlueZ exposes no + * information allowing differentiation of the two cases, so we + * must always create a persona store for a paired device, and + * must always try and connect. In order to prevent paired but + * disconnected phones from causing quiescence to never be reached + * (which may be a common occurrence), we always mark the stores + * as prepared and quiescent. + * + * FIXME: Note that this will fit in well with caching, if that is + * ever implemented in the BlueZ backend. Paired but disconnected + * phones (with their Bluetooth off) can still have persona stores + * on the laptop, and those persona stores can be populated by + * cached personas until the phone is reconnected. */ + this._is_prepared = true; + this.notify_property ("is-prepared"); + + this._is_quiescent = true; + this.notify_property ("is-quiescent"); + } + } + finally + { + this._prepare_pending = false; + } + + Internal.profiling_end ("preparing BlueZ.PersonaStore (ID: %s)", this.id); + } + + /** + * Remove a {@link Persona} from the PersonaStore. + * + * See {@link Folks.PersonaStore.remove_persona}. + * + * @param persona the {@link Persona} to remove + * @throws Folks.PersonaStoreError.READ_ONLY every time since the + * BlueZ backend is read-only. + * + * @since UNRELEASED + */ + public override async void remove_persona (Folks.Persona persona) + throws Folks.PersonaStoreError + { + throw new PersonaStoreError.READ_ONLY ( + "Personas cannot be removed from this store."); + } + + /** + * Add a new {@link Persona} to the PersonaStore. + * + * See {@link Folks.PersonaStore.add_persona_from_details}. + * + * @param details a map of keys to values giving the persona’s initial details + * @throws Folks.PersonaStoreError.READ_ONLY every time since the + * BlueZ backend is read-only. + * + * @since UNRELEASED + */ + public override async Folks.Persona? add_persona_from_details ( + HashTable<string, Value?> details) throws Folks.PersonaStoreError + { + throw new PersonaStoreError.READ_ONLY ( + "Personas cannot be added to this store."); + } +} |