// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Config from '../../config.js'; let HAVE_EDS = true; let EBook = null; let EBookContacts = null; let EDataServer = null; try { EBook = (await import('gi://EBook')).default; EBookContacts = (await import('gi://EBookContacts')).default; EDataServer = (await import('gi://EDataServer')).default; } catch (e) { HAVE_EDS = false; } /** * A store for contacts */ const Store = GObject.registerClass({ GTypeName: 'GSConnectContactsStore', Properties: { 'context': GObject.ParamSpec.string( 'context', 'Context', 'Used as the cache directory, relative to Config.CACHEDIR', GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE, null ), }, Signals: { 'contact-added': { flags: GObject.SignalFlags.RUN_FIRST, param_types: [GObject.TYPE_STRING], }, 'contact-removed': { flags: GObject.SignalFlags.RUN_FIRST, param_types: [GObject.TYPE_STRING], }, 'contact-changed': { flags: GObject.SignalFlags.RUN_FIRST, param_types: [GObject.TYPE_STRING], }, }, }, class Store extends GObject.Object { _init(context = null) { super._init({ context: context, }); this._cacheData = {}; this._edsPrepared = false; } /** * Parse an EContact and add it to the store. * * @param {EBookContacts.Contact} econtact - an EContact to parse * @param {string} [origin] - an optional origin string */ async _parseEContact(econtact, origin = 'desktop') { try { const contact = { id: econtact.id, name: _('Unknown Contact'), numbers: [], origin: origin, timestamp: 0, }; // Try to get a contact name if (econtact.full_name) contact.name = econtact.full_name; // Parse phone numbers const nums = econtact.get_attributes(EBookContacts.ContactField.TEL); for (const attr of nums) { const number = { value: attr.get_value(), type: 'unknown', }; if (attr.has_type('CELL')) number.type = 'cell'; else if (attr.has_type('HOME')) number.type = 'home'; else if (attr.has_type('WORK')) number.type = 'work'; contact.numbers.push(number); } // Try and get a contact photo const photo = econtact.photo; if (photo) { if (photo.type === EBookContacts.ContactPhotoType.INLINED) { const data = photo.get_inlined()[0]; contact.avatar = await this.storeAvatar(data); } else if (photo.type === EBookContacts.ContactPhotoType.URI) { const uri = econtact.photo.get_uri(); contact.avatar = uri.replace('file://', ''); } } this.add(contact, false); } catch (e) { logError(e, `Failed to parse VCard contact ${econtact.id}`); } } /* * AddressBook DBus callbacks */ _onObjectsAdded(connection, sender, path, iface, signal, params) { try { const adds = params.get_child_value(0).get_strv(); // NOTE: sequential pairs of vcard, id for (let i = 0, len = adds.length; i < len; i += 2) { try { const vcard = adds[i]; const econtact = EBookContacts.Contact.new_from_vcard(vcard); this._parseEContact(econtact); } catch (e) { debug(e); } } } catch (e) { debug(e); } } _onObjectsRemoved(connection, sender, path, iface, signal, params) { try { const changes = params.get_child_value(0).get_strv(); for (const id of changes) { try { this.remove(id, false); } catch (e) { debug(e); } } } catch (e) { debug(e); } } _onObjectsModified(connection, sender, path, iface, signal, params) { try { const changes = params.get_child_value(0).get_strv(); // NOTE: sequential pairs of vcard, id for (let i = 0, len = changes.length; i < len; i += 2) { try { const vcard = changes[i]; const econtact = EBookContacts.Contact.new_from_vcard(vcard); this._parseEContact(econtact); } catch (e) { debug(e); } } } catch (e) { debug(e); } } /* * SourceRegistryWatcher callbacks */ async _onAppeared(watcher, source) { try { // Get an EBookClient and EBookView const uid = source.get_uid(); const client = await EBook.BookClient.connect(source, null); const [view] = await client.get_view('exists "tel"', null); // Watch the view for changes to the address book const connection = view.get_connection(); const objectPath = view.get_object_path(); view._objectsAddedId = connection.signal_subscribe( null, 'org.gnome.evolution.dataserver.AddressBookView', 'ObjectsAdded', objectPath, null, Gio.DBusSignalFlags.NONE, this._onObjectsAdded.bind(this) ); view._objectsRemovedId = connection.signal_subscribe( null, 'org.gnome.evolution.dataserver.AddressBookView', 'ObjectsRemoved', objectPath, null, Gio.DBusSignalFlags.NONE, this._onObjectsRemoved.bind(this) ); view._objectsModifiedId = connection.signal_subscribe( null, 'org.gnome.evolution.dataserver.AddressBookView', 'ObjectsModified', objectPath, null, Gio.DBusSignalFlags.NONE, this._onObjectsModified.bind(this) ); view.start(); // Store the EBook in a map this._ebooks.set(uid, { source: source, client: client, view: view, }); } catch (e) { debug(e); } } _onDisappeared(watcher, source) { try { const uid = source.get_uid(); const ebook = this._ebooks.get(uid); if (ebook === undefined) return; // Disconnect the EBookView if (ebook.view) { const connection = ebook.view.get_connection(); connection.signal_unsubscribe(ebook.view._objectsAddedId); connection.signal_unsubscribe(ebook.view._objectsRemovedId); connection.signal_unsubscribe(ebook.view._objectsModifiedId); ebook.view.stop(); } this._ebooks.delete(uid); } catch (e) { debug(e); } } async _initEvolutionDataServer() { try { if (this._edsPrepared) return; this._edsPrepared = true; this._ebooks = new Map(); // Get the current EBooks const registry = await this._getESourceRegistry(); for (const source of registry.list_sources('Address Book')) await this._onAppeared(null, source); // Watch for new and removed sources this._watcher = new EDataServer.SourceRegistryWatcher({ registry: registry, extension_name: 'Address Book', }); this._appearedId = this._watcher.connect( 'appeared', this._onAppeared.bind(this) ); this._disappearedId = this._watcher.connect( 'disappeared', this._onDisappeared.bind(this) ); } catch (e) { const service = Gio.Application.get_default(); if (service !== null) service.notify_error(e); else logError(e); } } *[Symbol.iterator]() { const contacts = Object.values(this._cacheData); for (let i = 0, len = contacts.length; i < len; i++) yield contacts[i]; } get contacts() { return Object.values(this._cacheData); } get context() { if (this._context === undefined) this._context = null; return this._context; } set context(context) { this._context = context; this._cacheDir = Gio.File.new_for_path(Config.CACHEDIR); if (context !== null) this._cacheDir = this._cacheDir.get_child(context); GLib.mkdir_with_parents(this._cacheDir.get_path(), 448); this._cacheFile = this._cacheDir.get_child('contacts.json'); } /** * Save a Uint8Array to file and return the path * * @param {Uint8Array} contents - An image byte array * @return {string|undefined} File path or %undefined on failure */ async storeAvatar(contents) { const md5 = GLib.compute_checksum_for_data(GLib.ChecksumType.MD5, contents); const file = this._cacheDir.get_child(`${md5}`); if (!file.query_exists(null)) { try { await file.replace_contents_bytes_async( new GLib.Bytes(contents), null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); } catch (e) { debug(e, 'Storing avatar'); return undefined; } } return file.get_path(); } /** * Query the Store for a contact by name and/or number. * * @param {Object} query - A query object * @param {string} [query.name] - The contact's name * @param {string} query.number - The contact's number * @return {Object} A contact object */ query(query) { // First look for an existing contact by number const contacts = this.contacts; const matches = []; const qnumber = query.number.toPhoneNumber(); for (let i = 0, len = contacts.length; i < len; i++) { const contact = contacts[i]; for (const num of contact.numbers) { const cnumber = num.value.toPhoneNumber(); if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) { // If no query name or exact match, return immediately if (!query.name || query.name === contact.name) return contact; // Otherwise we might find an exact name match that shares // the number with another contact matches.push(contact); } } } // Return the first match (pretty much what Android does) if (matches.length > 0) return matches[0]; // No match; return a mock contact with a unique ID let id = GLib.uuid_string_random(); while (this._cacheData.hasOwnProperty(id)) id = GLib.uuid_string_random(); return { id: id, name: query.name || query.number, numbers: [{value: query.number, type: 'unknown'}], origin: 'gsconnect', }; } get_contact(position) { if (this._cacheData[position] !== undefined) return this._cacheData[position]; return null; } /** * Add a contact, checking for validity * * @param {Object} contact - A contact object * @param {boolean} write - Write to disk */ add(contact, write = true) { // Ensure the contact has a unique id if (!contact.id) { let id = GLib.uuid_string_random(); while (this._cacheData[id]) id = GLib.uuid_string_random(); contact.id = id; } // Ensure the contact has an origin if (!contact.origin) contact.origin = 'gsconnect'; // This is an updated contact if (this._cacheData[contact.id]) { this._cacheData[contact.id] = contact; this.emit('contact-changed', contact.id); // This is a new contact } else { this._cacheData[contact.id] = contact; this.emit('contact-added', contact.id); } // Write if requested if (write) this.save(); } /** * Remove a contact by id * * @param {string} id - The id of the contact to delete * @param {boolean} write - Write to disk */ remove(id, write = true) { // Only remove if the contact actually exists if (this._cacheData[id]) { delete this._cacheData[id]; this.emit('contact-removed', id); // Write if requested if (write) this.save(); } } /** * Lookup a contact for each address object in @addresses and return a * dictionary of address (eg. phone number) to contact object. * * { "555-5555": { "name": "...", "numbers": [], ... } } * * @param {Object[]} addresses - A list of address objects * @return {Object} A dictionary of phone numbers and contacts */ lookupAddresses(addresses) { const contacts = {}; // Lookup contacts for each address for (let i = 0, len = addresses.length; i < len; i++) { const address = addresses[i].address; contacts[address] = this.query({ number: address, }); } return contacts; } async clear() { try { const contacts = this.contacts; for (let i = 0, len = contacts.length; i < len; i++) await this.remove(contacts[i].id, false); await this.save(); } catch (e) { debug(e); } } /** * Update the contact store from a dictionary of our custom contact objects. * * @param {Object} json - an Object of contact Objects */ async update(json = {}) { try { let contacts = Object.values(json); for (let i = 0, len = contacts.length; i < len; i++) { const new_contact = contacts[i]; const contact = this._cacheData[new_contact.id]; if (!contact || new_contact.timestamp !== contact.timestamp) await this.add(new_contact, false); } // Prune contacts contacts = this.contacts; for (let i = 0, len = contacts.length; i < len; i++) { const contact = contacts[i]; if (!json[contact.id]) await this.remove(contact.id, false); } await this.save(); } catch (e) { debug(e, 'Updating contacts'); } } /** * Fetch and update the contact store from its source. * * The default function initializes the EDS server, or logs a debug message * if EDS is unavailable. Derived classes should request an update from the * remote source. */ async fetch() { try { if (this.context === null && HAVE_EDS) await this._initEvolutionDataServer(); else throw new Error('Evolution Data Server not available'); } catch (e) { debug(e); } } /** * Load the contacts from disk. */ async load() { try { const [contents] = await this._cacheFile.load_contents_async(null); this._cacheData = JSON.parse(new TextDecoder().decode(contents)); } catch (e) { debug(e); } finally { this.notify('context'); } } /** * Save the contacts to disk. */ async save() { // EDS is handling storage if (this.context === null && HAVE_EDS) return; if (this.__cache_lock) { this.__cache_queue = true; return; } try { this.__cache_lock = true; const contents = new GLib.Bytes(JSON.stringify(this._cacheData, null, 2)); await this._cacheFile.replace_contents_bytes_async(contents, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); } catch (e) { debug(e); } finally { this.__cache_lock = false; if (this.__cache_queue) { this.__cache_queue = false; this.save(); } } } destroy() { if (this._watcher !== undefined) { this._watcher.disconnect(this._appearedId); this._watcher.disconnect(this._disappearedId); this._watcher = undefined; for (const ebook of this._ebooks.values()) this._onDisappeared(null, ebook.source); this._edsPrepared = false; } } }); export default Store;