contacts.js 14 KB

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