contacts.js 14 KB

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