contacts.js 18 KB

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