// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import Plugin from '../plugin.js'; import Contacts from '../components/contacts.js'; /* * We prefer libebook's vCard parser if it's available */ let EBookContacts; export const setEBookContacts = (ebook) => { // This function is only for tests to call! EBookContacts = ebook; }; try { EBookContacts = (await import('gi://EBookContacts')).default; } catch (e) { EBookContacts = null; } export const Metadata = { label: _('Contacts'), description: _('Access contacts of the paired device'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts', incomingCapabilities: [ 'kdeconnect.contacts.response_uids_timestamps', 'kdeconnect.contacts.response_vcards', ], outgoingCapabilities: [ 'kdeconnect.contacts.request_all_uids_timestamps', 'kdeconnect.contacts.request_vcards_by_uid', ], actions: {}, }; /* * vCard 2.1 Patterns */ const VCARD_FOLDING = /\r\n |\r |\n |=\n/g; const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i; const VCARD_BASIC = /^([^:;]+):(.+)$/; const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/; const VCARD_TYPED_KEY = /item\d{1,2}\./; const VCARD_TYPED_META = /([a-z]+)=(.*)/i; /** * Contacts Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts */ const ContactsPlugin = GObject.registerClass({ GTypeName: 'GSConnectContactsPlugin', }, class ContactsPlugin extends Plugin { _init(device) { super._init(device, 'contacts'); this._store = new Contacts(device.id); this._store.fetch = this._requestUids.bind(this); // Notify when the store is ready this._contactsStoreReadyId = this._store.connect( 'notify::context', () => this.device.notify('contacts') ); // Notify if the contacts source changes this._contactsSourceChangedId = this.settings.connect( 'changed::contacts-source', () => this.device.notify('contacts') ); // Load the cache this._store.load(); } clearCache() { this._store.clear(); } connected() { super.connected(); this._requestUids(); } handlePacket(packet) { switch (packet.type) { case 'kdeconnect.contacts.response_uids_timestamps': this._handleUids(packet); break; case 'kdeconnect.contacts.response_vcards': this._handleVCards(packet); break; } } _handleUids(packet) { try { const contacts = this._store.contacts; const remote_uids = packet.body.uids; let removed = false; delete packet.body.uids; // Usually a failed request, so avoid wiping the cache if (remote_uids.length === 0) return; // Delete any contacts that were removed on the device for (let i = 0, len = contacts.length; i < len; i++) { const contact = contacts[i]; if (!remote_uids.includes(contact.id)) { this._store.remove(contact.id, false); removed = true; } } // Build a list of new or updated contacts const uids = []; for (const [uid, timestamp] of Object.entries(packet.body)) { const contact = this._store.get_contact(uid); if (!contact || contact.timestamp !== timestamp) uids.push(uid); } // Send a request for any new or updated contacts if (uids.length) this._requestVCards(uids); // If we removed any contacts, save the cache if (removed) this._store.save(); } catch (e) { logError(e); } } /** * Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string * * See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js * * @param {string} input - The QUOTED-PRINTABLE string * @return {string} The decoded string */ _decodeQuotedPrintable(input) { return input // https://tools.ietf.org/html/rfc2045#section-6.7, rule 3 .replace(/[\t\x20]$/gm, '') // Remove hard line breaks preceded by `=` .replace(/=(?:\r\n?|\n|$)/g, '') // https://tools.ietf.org/html/rfc2045#section-6.7, note 1. .replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => { const codePoint = parseInt($1, 16); return String.fromCharCode(codePoint); }); } /** * Decode a string encoded as "UTF-8" and return a regular string * * See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js * * @param {string} input - The UTF-8 string * @return {string} The decoded string */ _decodeUTF8(input) { try { const output = []; let i = 0; let c1 = 0; let seqlen = 0; while (i < input.length) { c1 = input.charCodeAt(i) & 0xFF; seqlen = 0; if (c1 <= 0xBF) { c1 &= 0x7F; seqlen = 1; } else if (c1 <= 0xDF) { c1 &= 0x1F; seqlen = 2; } else if (c1 <= 0xEF) { c1 &= 0x0F; seqlen = 3; } else { c1 &= 0x07; seqlen = 4; } for (let ai = 1; ai < seqlen; ++ai) c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F)); if (seqlen === 4) { c1 -= 0x10000; output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF))); output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF))); } else { output.push(String.fromCharCode(c1)); } i += seqlen; } return output.join(''); // Fallback to old unfaithful } catch (e) { try { return decodeURIComponent(escape(input)); // Say "chowdah" frenchie! } catch (e) { debug(e, `Failed to decode UTF-8 VCard field ${input}`); return input; } } } /** * Parse a vCard (v2.1 only) and return a dictionary of the fields * * See: http://jsfiddle.net/ARTsinn/P2t2P/ * * @param {string} vcard_data - The raw VCard data * @return {Object} dictionary of vCard data */ _parseVCard21(vcard_data) { // vcard skeleton const vcard = { fn: _('Unknown Contact'), tel: [], }; // Remove line folding and split const unfolded = vcard_data.replace(VCARD_FOLDING, ''); const lines = unfolded.split(/\r\n|\r|\n/); for (let i = 0, len = lines.length; i < len; i++) { const line = lines[i]; let results, key, type, value; // Empty line or a property we aren't interested in if (!line || !line.match(VCARD_SUPPORTED)) continue; // Basic Fields (fn, x-kdeconnect-timestamp, etc) if ((results = line.match(VCARD_BASIC))) { [, key, value] = results; vcard[key.toLowerCase()] = value; continue; } // Typed Fields (tel, adr, etc) if ((results = line.match(VCARD_TYPED))) { [, key, type, value] = results; key = key.replace(VCARD_TYPED_KEY, '').toLowerCase(); value = value.split(';'); type = type.split(';'); // Type(s) const meta = {}; for (let i = 0, len = type.length; i < len; i++) { const res = type[i].match(VCARD_TYPED_META); if (res) meta[res[1]] = res[2]; else meta[`type${i === 0 ? '' : i}`] = type[i].toLowerCase(); } // Value(s) if (vcard[key] === undefined) vcard[key] = []; // Decode QUOTABLE-PRINTABLE if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') { delete meta.ENCODING; value = value.map(v => this._decodeQuotedPrintable(v)); } // Decode UTF-8 if (meta.CHARSET && meta.CHARSET === 'UTF-8') { delete meta.CHARSET; value = value.map(v => this._decodeUTF8(v)); } // Special case for FN (full name) if (key === 'fn') vcard[key] = value[0]; else vcard[key].push({meta: meta, value: value}); } } return vcard; } /** * Parse a vCard (v2.1 only) using native JavaScript and add it to the * contact store. * * @param {string} uid - The contact UID * @param {string} vcard_data - The raw vCard data */ async _parseVCardNative(uid, vcard_data) { try { const vcard = this._parseVCard21(vcard_data); const contact = { id: uid, name: vcard.fn, numbers: [], origin: 'device', timestamp: parseInt(vcard['x-kdeconnect-timestamp']), }; // Phone Numbers contact.numbers = vcard.tel.map(entry => { let type = 'unknown'; if (entry.meta && entry.meta.type) type = entry.meta.type; return {type: type, value: entry.value[0]}; }); // Avatar if (vcard.photo) { const data = GLib.base64_decode(vcard.photo[0].value[0]); contact.avatar = await this._store.storeAvatar(data); } this._store.add(contact); } catch (e) { debug(e, `Failed to parse VCard contact ${uid}`); } } /** * Parse a vCard using libebook and add it to the contact store. * * @param {string} uid - The contact UID * @param {string} vcard_data - The raw vCard data */ async _parseVCard(uid, vcard_data) { try { const contact = { id: uid, name: _('Unknown Contact'), numbers: [], origin: 'device', timestamp: 0, }; const evcard = EBookContacts.VCard.new_from_string(vcard_data); const attrs = evcard.get_attributes(); for (let i = 0, len = attrs.length; i < len; i++) { const attr = attrs[i]; let data, number; switch (attr.get_name().toLowerCase()) { case 'fn': contact.name = attr.get_value(); break; case 'tel': 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); break; case 'x-kdeconnect-timestamp': contact.timestamp = parseInt(attr.get_value()); break; case 'photo': data = GLib.base64_decode(attr.get_value()); contact.avatar = await this._store.storeAvatar(data); break; } } this._store.add(contact); } catch (e) { debug(e, `Failed to parse VCard contact ${uid}`); } } /** * Handle an incoming list of contact vCards and pass them to the best * available parser. * * @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards` */ _handleVCards(packet) { try { // We don't use this delete packet.body.uids; // Parse each vCard and add the contact for (const [uid, vcard] of Object.entries(packet.body)) { if (EBookContacts) this._parseVCard(uid, vcard); else this._parseVCardNative(uid, vcard); } } catch (e) { logError(e, this.device.name); } } /** * Request a list of contact UIDs with timestamps. */ _requestUids() { this.device.sendPacket({ type: 'kdeconnect.contacts.request_all_uids_timestamps', }); } /** * Request the vCards for @uids. * * @param {string[]} uids - A list of contact UIDs */ _requestVCards(uids) { this.device.sendPacket({ type: 'kdeconnect.contacts.request_vcards_by_uid', body: { uids: uids, }, }); } destroy() { this._store.disconnect(this._contactsStoreReadyId); this.settings.disconnect(this._contactsSourceChangedId); super.destroy(); } }); export default ContactsPlugin;