contacts.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import GLib from 'gi://GLib';
  5. import GObject from 'gi://GObject';
  6. import Plugin from '../plugin.js';
  7. import Contacts from '../components/contacts.js';
  8. import * as Core from '../core.js';
  9. /*
  10. * We prefer libebook's vCard parser if it's available
  11. */
  12. let EBookContacts;
  13. export const setEBookContacts = (ebook) => { // This function is only for tests to call!
  14. EBookContacts = ebook;
  15. };
  16. try {
  17. EBookContacts = (await import('gi://EBookContacts')).default;
  18. } catch {
  19. EBookContacts = null;
  20. }
  21. export const Metadata = {
  22. label: _('Contacts'),
  23. description: _('Access contacts of the paired device'),
  24. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Contacts',
  25. incomingCapabilities: [
  26. 'kdeconnect.contacts.response_uids_timestamps',
  27. 'kdeconnect.contacts.response_vcards',
  28. ],
  29. outgoingCapabilities: [
  30. 'kdeconnect.contacts.request_all_uids_timestamps',
  31. 'kdeconnect.contacts.request_vcards_by_uid',
  32. ],
  33. actions: {},
  34. };
  35. /*
  36. * vCard 2.1 Patterns
  37. */
  38. const VCARD_FOLDING = /\r\n |\r |\n |=\n/g;
  39. const VCARD_SUPPORTED = /^fn|tel|photo|x-kdeconnect/i;
  40. const VCARD_BASIC = /^([^:;]+):(.+)$/;
  41. const VCARD_TYPED = /^([^:;]+);([^:]+):(.+)$/;
  42. const VCARD_TYPED_KEY = /item\d{1,2}\./;
  43. const VCARD_TYPED_META = /([a-z]+)=(.*)/i;
  44. /**
  45. * Contacts Plugin
  46. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/contacts
  47. */
  48. const ContactsPlugin = GObject.registerClass({
  49. GTypeName: 'GSConnectContactsPlugin',
  50. }, class ContactsPlugin extends Plugin {
  51. _init(device) {
  52. super._init(device, 'contacts');
  53. this._store = new Contacts(device.id);
  54. this._store.fetch = this._requestUids.bind(this);
  55. // Notify when the store is ready
  56. this._contactsStoreReadyId = this._store.connect(
  57. 'notify::context',
  58. () => this.device.notify('contacts')
  59. );
  60. // Notify if the contacts source changes
  61. this._contactsSourceChangedId = this.settings.connect(
  62. 'changed::contacts-source',
  63. () => this.device.notify('contacts')
  64. );
  65. // Load the cache
  66. this._store.load();
  67. }
  68. clearCache() {
  69. this._store.clear();
  70. }
  71. connected() {
  72. super.connected();
  73. this._requestUids();
  74. }
  75. handlePacket(packet) {
  76. switch (packet.type) {
  77. case 'kdeconnect.contacts.response_uids_timestamps':
  78. this._handleUids(packet);
  79. break;
  80. case 'kdeconnect.contacts.response_vcards':
  81. this._handleVCards(packet);
  82. break;
  83. }
  84. }
  85. _handleUids(packet) {
  86. try {
  87. const contacts = this._store.contacts;
  88. const remote_uids = packet.body.uids;
  89. let removed = false;
  90. delete packet.body.uids;
  91. // Usually a failed request, so avoid wiping the cache
  92. if (remote_uids.length === 0)
  93. return;
  94. // Delete any contacts that were removed on the device
  95. for (let i = 0, len = contacts.length; i < len; i++) {
  96. const contact = contacts[i];
  97. if (!remote_uids.includes(contact.id)) {
  98. this._store.remove(contact.id, false);
  99. removed = true;
  100. }
  101. }
  102. // Build a list of new or updated contacts
  103. const uids = [];
  104. for (const [uid, timestamp] of Object.entries(packet.body)) {
  105. const contact = this._store.get_contact(uid);
  106. if (!contact || contact.timestamp !== timestamp)
  107. uids.push(uid);
  108. }
  109. // Send a request for any new or updated contacts
  110. if (uids.length)
  111. this._requestVCards(uids);
  112. // If we removed any contacts, save the cache
  113. if (removed)
  114. this._store.save();
  115. } catch (e) {
  116. logError(e);
  117. }
  118. }
  119. /**
  120. * Decode a string encoded as "QUOTED-PRINTABLE" and return a regular string
  121. *
  122. * See: https://github.com/mathiasbynens/quoted-printable/blob/master/src/quoted-printable.js
  123. *
  124. * @param {string} input - The QUOTED-PRINTABLE string
  125. * @returns {string} The decoded string
  126. */
  127. _decodeQuotedPrintable(input) {
  128. return input
  129. // https://tools.ietf.org/html/rfc2045#section-6.7, rule 3
  130. .replace(/[\t\x20]$/gm, '')
  131. // Remove hard line breaks preceded by `=`
  132. .replace(/=(?:\r\n?|\n|$)/g, '')
  133. // https://tools.ietf.org/html/rfc2045#section-6.7, note 1.
  134. .replace(/=([a-fA-F0-9]{2})/g, ($0, $1) => {
  135. const codePoint = parseInt($1, 16);
  136. return String.fromCharCode(codePoint);
  137. });
  138. }
  139. /**
  140. * Decode a string encoded as "UTF-8" and return a regular string
  141. *
  142. * See: https://github.com/kvz/locutus/blob/master/src/php/xml/utf8_decode.js
  143. *
  144. * @param {string} input - The UTF-8 string
  145. * @returns {string} The decoded string
  146. */
  147. _decodeUTF8(input) {
  148. try {
  149. const output = [];
  150. let i = 0;
  151. let c1 = 0;
  152. let seqlen = 0;
  153. while (i < input.length) {
  154. c1 = input.charCodeAt(i) & 0xFF;
  155. seqlen = 0;
  156. if (c1 <= 0xBF) {
  157. c1 &= 0x7F;
  158. seqlen = 1;
  159. } else if (c1 <= 0xDF) {
  160. c1 &= 0x1F;
  161. seqlen = 2;
  162. } else if (c1 <= 0xEF) {
  163. c1 &= 0x0F;
  164. seqlen = 3;
  165. } else {
  166. c1 &= 0x07;
  167. seqlen = 4;
  168. }
  169. for (let ai = 1; ai < seqlen; ++ai)
  170. c1 = ((c1 << 0x06) | (input.charCodeAt(ai + i) & 0x3F));
  171. if (seqlen === 4) {
  172. c1 -= 0x10000;
  173. output.push(String.fromCharCode(0xD800 | ((c1 >> 10) & 0x3FF)));
  174. output.push(String.fromCharCode(0xDC00 | (c1 & 0x3FF)));
  175. } else {
  176. output.push(String.fromCharCode(c1));
  177. }
  178. i += seqlen;
  179. }
  180. return output.join('');
  181. // Fallback to old unfaithful
  182. } catch {
  183. try {
  184. return decodeURIComponent(escape(input));
  185. // Say "chowdah" frenchie!
  186. } catch (e) {
  187. debug(e, `Failed to decode UTF-8 VCard field ${input}`);
  188. return input;
  189. }
  190. }
  191. }
  192. /**
  193. * Parse a vCard (v2.1 only) and return a dictionary of the fields
  194. *
  195. * See: http://jsfiddle.net/ARTsinn/P2t2P/
  196. *
  197. * @param {string} vcard_data - The raw VCard data
  198. * @returns {object} dictionary of vCard data
  199. */
  200. _parseVCard21(vcard_data) {
  201. // vcard skeleton
  202. const vcard = {
  203. fn: _('Unknown Contact'),
  204. tel: [],
  205. };
  206. // Remove line folding and split
  207. const unfolded = vcard_data.replace(VCARD_FOLDING, '');
  208. const lines = unfolded.split(/\r\n|\r|\n/);
  209. for (let i = 0, len = lines.length; i < len; i++) {
  210. const line = lines[i];
  211. let results, key, type, value;
  212. // Empty line or a property we aren't interested in
  213. if (!line || !line.match(VCARD_SUPPORTED))
  214. continue;
  215. // Basic Fields (fn, x-kdeconnect-timestamp, etc)
  216. if ((results = line.match(VCARD_BASIC))) {
  217. [, key, value] = results;
  218. vcard[key.toLowerCase()] = value;
  219. continue;
  220. }
  221. // Typed Fields (tel, adr, etc)
  222. if ((results = line.match(VCARD_TYPED))) {
  223. [, key, type, value] = results;
  224. key = key.replace(VCARD_TYPED_KEY, '').toLowerCase();
  225. value = value.split(';');
  226. type = type.split(';');
  227. // Type(s)
  228. const meta = {};
  229. for (let i = 0, len = type.length; i < len; i++) {
  230. const res = type[i].match(VCARD_TYPED_META);
  231. if (res)
  232. meta[res[1]] = res[2];
  233. else
  234. meta[`type${i === 0 ? '' : i}`] = type[i].toLowerCase();
  235. }
  236. // Value(s)
  237. if (vcard[key] === undefined)
  238. vcard[key] = [];
  239. // Decode QUOTABLE-PRINTABLE
  240. if (meta.ENCODING && meta.ENCODING === 'QUOTED-PRINTABLE') {
  241. delete meta.ENCODING;
  242. value = value.map(v => this._decodeQuotedPrintable(v));
  243. }
  244. // Decode UTF-8
  245. if (meta.CHARSET && meta.CHARSET === 'UTF-8') {
  246. delete meta.CHARSET;
  247. value = value.map(v => this._decodeUTF8(v));
  248. }
  249. // Special case for FN (full name)
  250. if (key === 'fn')
  251. vcard[key] = value[0];
  252. else
  253. vcard[key].push({meta: meta, value: value});
  254. }
  255. }
  256. return vcard;
  257. }
  258. /**
  259. * Parse a vCard (v2.1 only) using native JavaScript and add it to the
  260. * contact store.
  261. *
  262. * @param {string} uid - The contact UID
  263. * @param {string} vcard_data - The raw vCard data
  264. */
  265. async _parseVCardNative(uid, vcard_data) {
  266. try {
  267. const vcard = this._parseVCard21(vcard_data);
  268. const contact = {
  269. id: uid,
  270. name: vcard.fn,
  271. numbers: [],
  272. origin: 'device',
  273. timestamp: parseInt(vcard['x-kdeconnect-timestamp']),
  274. };
  275. // Phone Numbers
  276. contact.numbers = vcard.tel.map(entry => {
  277. let type = 'unknown';
  278. if (entry.meta && entry.meta.type)
  279. type = entry.meta.type;
  280. return {type: type, value: entry.value[0]};
  281. });
  282. // Avatar
  283. if (vcard.photo) {
  284. const data = GLib.base64_decode(vcard.photo[0].value[0]);
  285. contact.avatar = await this._store.storeAvatar(data);
  286. }
  287. this._store.add(contact);
  288. } catch (e) {
  289. debug(e, `Failed to parse VCard contact ${uid}`);
  290. }
  291. }
  292. /**
  293. * Parse a vCard using libebook and add it to the contact store.
  294. *
  295. * @param {string} uid - The contact UID
  296. * @param {string} vcard_data - The raw vCard data
  297. */
  298. async _parseVCard(uid, vcard_data) {
  299. try {
  300. const contact = {
  301. id: uid,
  302. name: _('Unknown Contact'),
  303. numbers: [],
  304. origin: 'device',
  305. timestamp: 0,
  306. };
  307. const evcard = EBookContacts.VCard.new_from_string(vcard_data);
  308. const attrs = evcard.get_attributes();
  309. for (let i = 0, len = attrs.length; i < len; i++) {
  310. const attr = attrs[i];
  311. let data, number;
  312. switch (attr.get_name().toLowerCase()) {
  313. case 'fn':
  314. contact.name = attr.get_value();
  315. break;
  316. case 'tel':
  317. number = {value: attr.get_value(), type: 'unknown'};
  318. if (attr.has_type('CELL'))
  319. number.type = 'cell';
  320. else if (attr.has_type('HOME'))
  321. number.type = 'home';
  322. else if (attr.has_type('WORK'))
  323. number.type = 'work';
  324. contact.numbers.push(number);
  325. break;
  326. case 'x-kdeconnect-timestamp':
  327. contact.timestamp = parseInt(attr.get_value());
  328. break;
  329. case 'photo':
  330. data = GLib.base64_decode(attr.get_value());
  331. contact.avatar = await this._store.storeAvatar(data);
  332. break;
  333. }
  334. }
  335. this._store.add(contact);
  336. } catch (e) {
  337. debug(e, `Failed to parse VCard contact ${uid}`);
  338. }
  339. }
  340. /**
  341. * Handle an incoming list of contact vCards and pass them to the best
  342. * available parser.
  343. *
  344. * @param {Core.Packet} packet - A `kdeconnect.contacts.response_vcards`
  345. */
  346. _handleVCards(packet) {
  347. try {
  348. // We don't use this
  349. delete packet.body.uids;
  350. // Parse each vCard and add the contact
  351. for (const [uid, vcard] of Object.entries(packet.body)) {
  352. if (EBookContacts)
  353. this._parseVCard(uid, vcard);
  354. else
  355. this._parseVCardNative(uid, vcard);
  356. }
  357. } catch (e) {
  358. logError(e, this.device.name);
  359. }
  360. }
  361. /**
  362. * Request a list of contact UIDs with timestamps.
  363. */
  364. _requestUids() {
  365. this.device.sendPacket({
  366. type: 'kdeconnect.contacts.request_all_uids_timestamps',
  367. });
  368. }
  369. /**
  370. * Request the vCards for @uids.
  371. *
  372. * @param {string[]} uids - A list of contact UIDs
  373. */
  374. _requestVCards(uids) {
  375. this.device.sendPacket({
  376. type: 'kdeconnect.contacts.request_vcards_by_uid',
  377. body: {
  378. uids: uids,
  379. },
  380. });
  381. }
  382. destroy() {
  383. this._store.disconnect(this._contactsStoreReadyId);
  384. this.settings.disconnect(this._contactsSourceChangedId);
  385. super.destroy();
  386. }
  387. });
  388. export default ContactsPlugin;