contacts.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Config = imports.config;
  9. var HAVE_EDS = true;
  10. var EBook = null;
  11. var EBookContacts = null;
  12. var EDataServer = null;
  13. try {
  14. EBook = imports.gi.EBook;
  15. EBookContacts = imports.gi.EBookContacts;
  16. EDataServer = imports.gi.EDataServer;
  17. } catch (e) {
  18. HAVE_EDS = false;
  19. }
  20. /**
  21. * A store for contacts
  22. */
  23. var Store = GObject.registerClass({
  24. GTypeName: 'GSConnectContactsStore',
  25. Properties: {
  26. 'context': GObject.ParamSpec.string(
  27. 'context',
  28. 'Context',
  29. 'Used as the cache directory, relative to Config.CACHEDIR',
  30. GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE,
  31. null
  32. ),
  33. },
  34. Signals: {
  35. 'contact-added': {
  36. flags: GObject.SignalFlags.RUN_FIRST,
  37. param_types: [GObject.TYPE_STRING],
  38. },
  39. 'contact-removed': {
  40. flags: GObject.SignalFlags.RUN_FIRST,
  41. param_types: [GObject.TYPE_STRING],
  42. },
  43. 'contact-changed': {
  44. flags: GObject.SignalFlags.RUN_FIRST,
  45. param_types: [GObject.TYPE_STRING],
  46. },
  47. },
  48. }, class Store extends GObject.Object {
  49. _init(context = null) {
  50. super._init({
  51. context: context,
  52. });
  53. this._cacheData = {};
  54. this._edsPrepared = false;
  55. }
  56. /**
  57. * Parse an EContact and add it to the store.
  58. *
  59. * @param {EBookContacts.Contact} econtact - an EContact to parse
  60. * @param {string} [origin] - an optional origin string
  61. */
  62. async _parseEContact(econtact, origin = 'desktop') {
  63. try {
  64. const contact = {
  65. id: econtact.id,
  66. name: _('Unknown Contact'),
  67. numbers: [],
  68. origin: origin,
  69. timestamp: 0,
  70. };
  71. // Try to get a contact name
  72. if (econtact.full_name)
  73. contact.name = econtact.full_name;
  74. // Parse phone numbers
  75. const nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
  76. for (const attr of nums) {
  77. const number = {
  78. value: attr.get_value(),
  79. type: 'unknown',
  80. };
  81. if (attr.has_type('CELL'))
  82. number.type = 'cell';
  83. else if (attr.has_type('HOME'))
  84. number.type = 'home';
  85. else if (attr.has_type('WORK'))
  86. number.type = 'work';
  87. contact.numbers.push(number);
  88. }
  89. // Try and get a contact photo
  90. const photo = econtact.photo;
  91. if (photo) {
  92. if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
  93. const data = photo.get_inlined()[0];
  94. contact.avatar = await this.storeAvatar(data);
  95. } else if (photo.type === EBookContacts.ContactPhotoType.URI) {
  96. const uri = econtact.photo.get_uri();
  97. contact.avatar = uri.replace('file://', '');
  98. }
  99. }
  100. this.add(contact, false);
  101. } catch (e) {
  102. logError(e, `Failed to parse VCard contact ${econtact.id}`);
  103. }
  104. }
  105. /*
  106. * AddressBook DBus callbacks
  107. */
  108. _onObjectsAdded(connection, sender, path, iface, signal, params) {
  109. try {
  110. const adds = params.get_child_value(0).get_strv();
  111. // NOTE: sequential pairs of vcard, id
  112. for (let i = 0, len = adds.length; i < len; i += 2) {
  113. try {
  114. const vcard = adds[i];
  115. const econtact = EBookContacts.Contact.new_from_vcard(vcard);
  116. this._parseEContact(econtact);
  117. } catch (e) {
  118. debug(e);
  119. }
  120. }
  121. } catch (e) {
  122. debug(e);
  123. }
  124. }
  125. _onObjectsRemoved(connection, sender, path, iface, signal, params) {
  126. try {
  127. const changes = params.get_child_value(0).get_strv();
  128. for (const id of changes) {
  129. try {
  130. this.remove(id, false);
  131. } catch (e) {
  132. debug(e);
  133. }
  134. }
  135. } catch (e) {
  136. debug(e);
  137. }
  138. }
  139. _onObjectsModified(connection, sender, path, iface, signal, params) {
  140. try {
  141. const changes = params.get_child_value(0).get_strv();
  142. // NOTE: sequential pairs of vcard, id
  143. for (let i = 0, len = changes.length; i < len; i += 2) {
  144. try {
  145. const vcard = changes[i];
  146. const econtact = EBookContacts.Contact.new_from_vcard(vcard);
  147. this._parseEContact(econtact);
  148. } catch (e) {
  149. debug(e);
  150. }
  151. }
  152. } catch (e) {
  153. debug(e);
  154. }
  155. }
  156. /*
  157. * SourceRegistryWatcher callbacks
  158. */
  159. async _onAppeared(watcher, source) {
  160. try {
  161. // Get an EBookClient and EBookView
  162. const uid = source.get_uid();
  163. const client = await EBook.BookClient.connect(source, null);
  164. const [view] = await client.get_view('exists "tel"', null);
  165. // Watch the view for changes to the address book
  166. const connection = view.get_connection();
  167. const objectPath = view.get_object_path();
  168. view._objectsAddedId = connection.signal_subscribe(
  169. null,
  170. 'org.gnome.evolution.dataserver.AddressBookView',
  171. 'ObjectsAdded',
  172. objectPath,
  173. null,
  174. Gio.DBusSignalFlags.NONE,
  175. this._onObjectsAdded.bind(this)
  176. );
  177. view._objectsRemovedId = connection.signal_subscribe(
  178. null,
  179. 'org.gnome.evolution.dataserver.AddressBookView',
  180. 'ObjectsRemoved',
  181. objectPath,
  182. null,
  183. Gio.DBusSignalFlags.NONE,
  184. this._onObjectsRemoved.bind(this)
  185. );
  186. view._objectsModifiedId = connection.signal_subscribe(
  187. null,
  188. 'org.gnome.evolution.dataserver.AddressBookView',
  189. 'ObjectsModified',
  190. objectPath,
  191. null,
  192. Gio.DBusSignalFlags.NONE,
  193. this._onObjectsModified.bind(this)
  194. );
  195. view.start();
  196. // Store the EBook in a map
  197. this._ebooks.set(uid, {
  198. source: source,
  199. client: client,
  200. view: view,
  201. });
  202. } catch (e) {
  203. debug(e);
  204. }
  205. }
  206. _onDisappeared(watcher, source) {
  207. try {
  208. const uid = source.get_uid();
  209. const ebook = this._ebooks.get(uid);
  210. if (ebook === undefined)
  211. return;
  212. // Disconnect the EBookView
  213. if (ebook.view) {
  214. const connection = ebook.view.get_connection();
  215. connection.signal_unsubscribe(ebook.view._objectsAddedId);
  216. connection.signal_unsubscribe(ebook.view._objectsRemovedId);
  217. connection.signal_unsubscribe(ebook.view._objectsModifiedId);
  218. ebook.view.stop();
  219. }
  220. this._ebooks.delete(uid);
  221. } catch (e) {
  222. debug(e);
  223. }
  224. }
  225. async _initEvolutionDataServer() {
  226. try {
  227. if (this._edsPrepared)
  228. return;
  229. this._edsPrepared = true;
  230. this._ebooks = new Map();
  231. // Get the current EBooks
  232. const registry = await this._getESourceRegistry();
  233. for (const source of registry.list_sources('Address Book'))
  234. await this._onAppeared(null, source);
  235. // Watch for new and removed sources
  236. this._watcher = new EDataServer.SourceRegistryWatcher({
  237. registry: registry,
  238. extension_name: 'Address Book',
  239. });
  240. this._appearedId = this._watcher.connect(
  241. 'appeared',
  242. this._onAppeared.bind(this)
  243. );
  244. this._disappearedId = this._watcher.connect(
  245. 'disappeared',
  246. this._onDisappeared.bind(this)
  247. );
  248. } catch (e) {
  249. const service = Gio.Application.get_default();
  250. if (service !== null)
  251. service.notify_error(e);
  252. else
  253. logError(e);
  254. }
  255. }
  256. *[Symbol.iterator]() {
  257. const contacts = Object.values(this._cacheData);
  258. for (let i = 0, len = contacts.length; i < len; i++)
  259. yield contacts[i];
  260. }
  261. get contacts() {
  262. return Object.values(this._cacheData);
  263. }
  264. get context() {
  265. if (this._context === undefined)
  266. this._context = null;
  267. return this._context;
  268. }
  269. set context(context) {
  270. this._context = context;
  271. this._cacheDir = Gio.File.new_for_path(Config.CACHEDIR);
  272. if (context !== null)
  273. this._cacheDir = this._cacheDir.get_child(context);
  274. GLib.mkdir_with_parents(this._cacheDir.get_path(), 448);
  275. this._cacheFile = this._cacheDir.get_child('contacts.json');
  276. }
  277. /**
  278. * Save a Uint8Array to file and return the path
  279. *
  280. * @param {Uint8Array} contents - An image byte array
  281. * @return {string|undefined} File path or %undefined on failure
  282. */
  283. async storeAvatar(contents) {
  284. const md5 = GLib.compute_checksum_for_data(GLib.ChecksumType.MD5,
  285. contents);
  286. const file = this._cacheDir.get_child(`${md5}`);
  287. if (!file.query_exists(null)) {
  288. try {
  289. await file.replace_contents_bytes_async(
  290. new GLib.Bytes(contents),
  291. null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
  292. } catch (e) {
  293. debug(e, 'Storing avatar');
  294. return undefined;
  295. }
  296. }
  297. return file.get_path();
  298. }
  299. /**
  300. * Query the Store for a contact by name and/or number.
  301. *
  302. * @param {Object} query - A query object
  303. * @param {string} [query.name] - The contact's name
  304. * @param {string} query.number - The contact's number
  305. * @return {Object} A contact object
  306. */
  307. query(query) {
  308. // First look for an existing contact by number
  309. const contacts = this.contacts;
  310. const matches = [];
  311. const qnumber = query.number.toPhoneNumber();
  312. for (let i = 0, len = contacts.length; i < len; i++) {
  313. const contact = contacts[i];
  314. for (const num of contact.numbers) {
  315. const cnumber = num.value.toPhoneNumber();
  316. if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) {
  317. // If no query name or exact match, return immediately
  318. if (!query.name || query.name === contact.name)
  319. return contact;
  320. // Otherwise we might find an exact name match that shares
  321. // the number with another contact
  322. matches.push(contact);
  323. }
  324. }
  325. }
  326. // Return the first match (pretty much what Android does)
  327. if (matches.length > 0)
  328. return matches[0];
  329. // No match; return a mock contact with a unique ID
  330. let id = GLib.uuid_string_random();
  331. while (this._cacheData.hasOwnProperty(id))
  332. id = GLib.uuid_string_random();
  333. return {
  334. id: id,
  335. name: query.name || query.number,
  336. numbers: [{value: query.number, type: 'unknown'}],
  337. origin: 'gsconnect',
  338. };
  339. }
  340. get_contact(position) {
  341. if (this._cacheData[position] !== undefined)
  342. return this._cacheData[position];
  343. return null;
  344. }
  345. /**
  346. * Add a contact, checking for validity
  347. *
  348. * @param {Object} contact - A contact object
  349. * @param {boolean} write - Write to disk
  350. */
  351. add(contact, write = true) {
  352. // Ensure the contact has a unique id
  353. if (!contact.id) {
  354. let id = GLib.uuid_string_random();
  355. while (this._cacheData[id])
  356. id = GLib.uuid_string_random();
  357. contact.id = id;
  358. }
  359. // Ensure the contact has an origin
  360. if (!contact.origin)
  361. contact.origin = 'gsconnect';
  362. // This is an updated contact
  363. if (this._cacheData[contact.id]) {
  364. this._cacheData[contact.id] = contact;
  365. this.emit('contact-changed', contact.id);
  366. // This is a new contact
  367. } else {
  368. this._cacheData[contact.id] = contact;
  369. this.emit('contact-added', contact.id);
  370. }
  371. // Write if requested
  372. if (write)
  373. this.save();
  374. }
  375. /**
  376. * Remove a contact by id
  377. *
  378. * @param {string} id - The id of the contact to delete
  379. * @param {boolean} write - Write to disk
  380. */
  381. remove(id, write = true) {
  382. // Only remove if the contact actually exists
  383. if (this._cacheData[id]) {
  384. delete this._cacheData[id];
  385. this.emit('contact-removed', id);
  386. // Write if requested
  387. if (write)
  388. this.save();
  389. }
  390. }
  391. /**
  392. * Lookup a contact for each address object in @addresses and return a
  393. * dictionary of address (eg. phone number) to contact object.
  394. *
  395. * { "555-5555": { "name": "...", "numbers": [], ... } }
  396. *
  397. * @param {Object[]} addresses - A list of address objects
  398. * @return {Object} A dictionary of phone numbers and contacts
  399. */
  400. lookupAddresses(addresses) {
  401. const contacts = {};
  402. // Lookup contacts for each address
  403. for (let i = 0, len = addresses.length; i < len; i++) {
  404. const address = addresses[i].address;
  405. contacts[address] = this.query({
  406. number: address,
  407. });
  408. }
  409. return contacts;
  410. }
  411. async clear() {
  412. try {
  413. const contacts = this.contacts;
  414. for (let i = 0, len = contacts.length; i < len; i++)
  415. await this.remove(contacts[i].id, false);
  416. await this.save();
  417. } catch (e) {
  418. debug(e);
  419. }
  420. }
  421. /**
  422. * Update the contact store from a dictionary of our custom contact objects.
  423. *
  424. * @param {Object} json - an Object of contact Objects
  425. */
  426. async update(json = {}) {
  427. try {
  428. let contacts = Object.values(json);
  429. for (let i = 0, len = contacts.length; i < len; i++) {
  430. const new_contact = contacts[i];
  431. const contact = this._cacheData[new_contact.id];
  432. if (!contact || new_contact.timestamp !== contact.timestamp)
  433. await this.add(new_contact, false);
  434. }
  435. // Prune contacts
  436. contacts = this.contacts;
  437. for (let i = 0, len = contacts.length; i < len; i++) {
  438. const contact = contacts[i];
  439. if (!json[contact.id])
  440. await this.remove(contact.id, false);
  441. }
  442. await this.save();
  443. } catch (e) {
  444. debug(e, 'Updating contacts');
  445. }
  446. }
  447. /**
  448. * Fetch and update the contact store from its source.
  449. *
  450. * The default function initializes the EDS server, or logs a debug message
  451. * if EDS is unavailable. Derived classes should request an update from the
  452. * remote source.
  453. */
  454. async fetch() {
  455. try {
  456. if (this.context === null && HAVE_EDS)
  457. await this._initEvolutionDataServer();
  458. else
  459. throw new Error('Evolution Data Server not available');
  460. } catch (e) {
  461. debug(e);
  462. }
  463. }
  464. /**
  465. * Load the contacts from disk.
  466. */
  467. async load() {
  468. try {
  469. const [contents] = await this._cacheFile.load_contents_async(null);
  470. this._cacheData = JSON.parse(new TextDecoder().decode(contents));
  471. } catch (e) {
  472. debug(e);
  473. } finally {
  474. this.notify('context');
  475. }
  476. }
  477. /**
  478. * Save the contacts to disk.
  479. */
  480. async save() {
  481. // EDS is handling storage
  482. if (this.context === null && HAVE_EDS)
  483. return;
  484. if (this.__cache_lock) {
  485. this.__cache_queue = true;
  486. return;
  487. }
  488. try {
  489. this.__cache_lock = true;
  490. const contents = new GLib.Bytes(JSON.stringify(this._cacheData, null, 2));
  491. await this._cacheFile.replace_contents_bytes_async(contents, null,
  492. false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
  493. } catch (e) {
  494. debug(e);
  495. } finally {
  496. this.__cache_lock = false;
  497. if (this.__cache_queue) {
  498. this.__cache_queue = false;
  499. this.save();
  500. }
  501. }
  502. }
  503. destroy() {
  504. if (this._watcher !== undefined) {
  505. this._watcher.disconnect(this._appearedId);
  506. this._watcher.disconnect(this._disappearedId);
  507. this._watcher = undefined;
  508. for (const ebook of this._ebooks.values())
  509. this._onDisappeared(null, ebook.source);
  510. this._edsPrepared = false;
  511. }
  512. }
  513. });
  514. /**
  515. * The service class for this component
  516. */
  517. var Component = Store;