614 lines
18 KiB
JavaScript
Executable File
614 lines
18 KiB
JavaScript
Executable File
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
|
|
import Config from '../../config.js';
|
|
|
|
let HAVE_EDS = true;
|
|
let EBook = null;
|
|
let EBookContacts = null;
|
|
let EDataServer = null;
|
|
|
|
try {
|
|
EBook = (await import('gi://EBook')).default;
|
|
EBookContacts = (await import('gi://EBookContacts')).default;
|
|
EDataServer = (await import('gi://EDataServer')).default;
|
|
} catch (e) {
|
|
HAVE_EDS = false;
|
|
}
|
|
|
|
|
|
/**
|
|
* A store for contacts
|
|
*/
|
|
const Store = GObject.registerClass({
|
|
GTypeName: 'GSConnectContactsStore',
|
|
Properties: {
|
|
'context': GObject.ParamSpec.string(
|
|
'context',
|
|
'Context',
|
|
'Used as the cache directory, relative to Config.CACHEDIR',
|
|
GObject.ParamFlags.CONSTRUCT_ONLY | GObject.ParamFlags.READWRITE,
|
|
null
|
|
),
|
|
},
|
|
Signals: {
|
|
'contact-added': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING],
|
|
},
|
|
'contact-removed': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING],
|
|
},
|
|
'contact-changed': {
|
|
flags: GObject.SignalFlags.RUN_FIRST,
|
|
param_types: [GObject.TYPE_STRING],
|
|
},
|
|
},
|
|
}, class Store extends GObject.Object {
|
|
|
|
_init(context = null) {
|
|
super._init({
|
|
context: context,
|
|
});
|
|
|
|
this._cacheData = {};
|
|
this._edsPrepared = false;
|
|
}
|
|
|
|
/**
|
|
* Parse an EContact and add it to the store.
|
|
*
|
|
* @param {EBookContacts.Contact} econtact - an EContact to parse
|
|
* @param {string} [origin] - an optional origin string
|
|
*/
|
|
async _parseEContact(econtact, origin = 'desktop') {
|
|
try {
|
|
const contact = {
|
|
id: econtact.id,
|
|
name: _('Unknown Contact'),
|
|
numbers: [],
|
|
origin: origin,
|
|
timestamp: 0,
|
|
};
|
|
|
|
// Try to get a contact name
|
|
if (econtact.full_name)
|
|
contact.name = econtact.full_name;
|
|
|
|
// Parse phone numbers
|
|
const nums = econtact.get_attributes(EBookContacts.ContactField.TEL);
|
|
|
|
for (const attr of nums) {
|
|
const number = {
|
|
value: attr.get_value(),
|
|
type: 'unknown',
|
|
};
|
|
|
|
if (attr.has_type('CELL'))
|
|
number.type = 'cell';
|
|
else if (attr.has_type('HOME'))
|
|
number.type = 'home';
|
|
else if (attr.has_type('WORK'))
|
|
number.type = 'work';
|
|
|
|
contact.numbers.push(number);
|
|
}
|
|
|
|
// Try and get a contact photo
|
|
const photo = econtact.photo;
|
|
|
|
if (photo) {
|
|
if (photo.type === EBookContacts.ContactPhotoType.INLINED) {
|
|
const data = photo.get_inlined()[0];
|
|
contact.avatar = await this.storeAvatar(data);
|
|
|
|
} else if (photo.type === EBookContacts.ContactPhotoType.URI) {
|
|
const uri = econtact.photo.get_uri();
|
|
contact.avatar = uri.replace('file://', '');
|
|
}
|
|
}
|
|
|
|
this.add(contact, false);
|
|
} catch (e) {
|
|
logError(e, `Failed to parse VCard contact ${econtact.id}`);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* AddressBook DBus callbacks
|
|
*/
|
|
_onObjectsAdded(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
const adds = params.get_child_value(0).get_strv();
|
|
|
|
// NOTE: sequential pairs of vcard, id
|
|
for (let i = 0, len = adds.length; i < len; i += 2) {
|
|
try {
|
|
const vcard = adds[i];
|
|
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
|
this._parseEContact(econtact);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
_onObjectsRemoved(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
const changes = params.get_child_value(0).get_strv();
|
|
|
|
for (const id of changes) {
|
|
try {
|
|
this.remove(id, false);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
_onObjectsModified(connection, sender, path, iface, signal, params) {
|
|
try {
|
|
const changes = params.get_child_value(0).get_strv();
|
|
|
|
// NOTE: sequential pairs of vcard, id
|
|
for (let i = 0, len = changes.length; i < len; i += 2) {
|
|
try {
|
|
const vcard = changes[i];
|
|
const econtact = EBookContacts.Contact.new_from_vcard(vcard);
|
|
this._parseEContact(econtact);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* SourceRegistryWatcher callbacks
|
|
*/
|
|
async _onAppeared(watcher, source) {
|
|
try {
|
|
// Get an EBookClient and EBookView
|
|
const uid = source.get_uid();
|
|
const client = await EBook.BookClient.connect(source, null);
|
|
const [view] = await client.get_view('exists "tel"', null);
|
|
|
|
// Watch the view for changes to the address book
|
|
const connection = view.get_connection();
|
|
const objectPath = view.get_object_path();
|
|
|
|
view._objectsAddedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsAdded',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsAdded.bind(this)
|
|
);
|
|
|
|
view._objectsRemovedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsRemoved',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsRemoved.bind(this)
|
|
);
|
|
|
|
view._objectsModifiedId = connection.signal_subscribe(
|
|
null,
|
|
'org.gnome.evolution.dataserver.AddressBookView',
|
|
'ObjectsModified',
|
|
objectPath,
|
|
null,
|
|
Gio.DBusSignalFlags.NONE,
|
|
this._onObjectsModified.bind(this)
|
|
);
|
|
|
|
view.start();
|
|
|
|
// Store the EBook in a map
|
|
this._ebooks.set(uid, {
|
|
source: source,
|
|
client: client,
|
|
view: view,
|
|
});
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
_onDisappeared(watcher, source) {
|
|
try {
|
|
const uid = source.get_uid();
|
|
const ebook = this._ebooks.get(uid);
|
|
|
|
if (ebook === undefined)
|
|
return;
|
|
|
|
// Disconnect the EBookView
|
|
if (ebook.view) {
|
|
const connection = ebook.view.get_connection();
|
|
connection.signal_unsubscribe(ebook.view._objectsAddedId);
|
|
connection.signal_unsubscribe(ebook.view._objectsRemovedId);
|
|
connection.signal_unsubscribe(ebook.view._objectsModifiedId);
|
|
|
|
ebook.view.stop();
|
|
}
|
|
|
|
this._ebooks.delete(uid);
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
async _initEvolutionDataServer() {
|
|
try {
|
|
if (this._edsPrepared)
|
|
return;
|
|
|
|
this._edsPrepared = true;
|
|
this._ebooks = new Map();
|
|
|
|
// Get the current EBooks
|
|
const registry = await this._getESourceRegistry();
|
|
|
|
for (const source of registry.list_sources('Address Book'))
|
|
await this._onAppeared(null, source);
|
|
|
|
// Watch for new and removed sources
|
|
this._watcher = new EDataServer.SourceRegistryWatcher({
|
|
registry: registry,
|
|
extension_name: 'Address Book',
|
|
});
|
|
|
|
this._appearedId = this._watcher.connect(
|
|
'appeared',
|
|
this._onAppeared.bind(this)
|
|
);
|
|
this._disappearedId = this._watcher.connect(
|
|
'disappeared',
|
|
this._onDisappeared.bind(this)
|
|
);
|
|
} catch (e) {
|
|
const service = Gio.Application.get_default();
|
|
|
|
if (service !== null)
|
|
service.notify_error(e);
|
|
else
|
|
logError(e);
|
|
}
|
|
}
|
|
|
|
*[Symbol.iterator]() {
|
|
const contacts = Object.values(this._cacheData);
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++)
|
|
yield contacts[i];
|
|
}
|
|
|
|
get contacts() {
|
|
return Object.values(this._cacheData);
|
|
}
|
|
|
|
get context() {
|
|
if (this._context === undefined)
|
|
this._context = null;
|
|
|
|
return this._context;
|
|
}
|
|
|
|
set context(context) {
|
|
this._context = context;
|
|
this._cacheDir = Gio.File.new_for_path(Config.CACHEDIR);
|
|
|
|
if (context !== null)
|
|
this._cacheDir = this._cacheDir.get_child(context);
|
|
|
|
GLib.mkdir_with_parents(this._cacheDir.get_path(), 448);
|
|
this._cacheFile = this._cacheDir.get_child('contacts.json');
|
|
}
|
|
|
|
/**
|
|
* Save a Uint8Array to file and return the path
|
|
*
|
|
* @param {Uint8Array} contents - An image byte array
|
|
* @return {string|undefined} File path or %undefined on failure
|
|
*/
|
|
async storeAvatar(contents) {
|
|
const md5 = GLib.compute_checksum_for_data(GLib.ChecksumType.MD5,
|
|
contents);
|
|
const file = this._cacheDir.get_child(`${md5}`);
|
|
|
|
if (!file.query_exists(null)) {
|
|
try {
|
|
await file.replace_contents_bytes_async(
|
|
new GLib.Bytes(contents),
|
|
null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
|
|
} catch (e) {
|
|
debug(e, 'Storing avatar');
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return file.get_path();
|
|
}
|
|
|
|
/**
|
|
* Query the Store for a contact by name and/or number.
|
|
*
|
|
* @param {Object} query - A query object
|
|
* @param {string} [query.name] - The contact's name
|
|
* @param {string} query.number - The contact's number
|
|
* @return {Object} A contact object
|
|
*/
|
|
query(query) {
|
|
// First look for an existing contact by number
|
|
const contacts = this.contacts;
|
|
const matches = [];
|
|
const qnumber = query.number.toPhoneNumber();
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
const contact = contacts[i];
|
|
|
|
for (const num of contact.numbers) {
|
|
const cnumber = num.value.toPhoneNumber();
|
|
|
|
if (qnumber.endsWith(cnumber) || cnumber.endsWith(qnumber)) {
|
|
// If no query name or exact match, return immediately
|
|
if (!query.name || query.name === contact.name)
|
|
return contact;
|
|
|
|
// Otherwise we might find an exact name match that shares
|
|
// the number with another contact
|
|
matches.push(contact);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the first match (pretty much what Android does)
|
|
if (matches.length > 0)
|
|
return matches[0];
|
|
|
|
// No match; return a mock contact with a unique ID
|
|
let id = GLib.uuid_string_random();
|
|
|
|
while (this._cacheData.hasOwnProperty(id))
|
|
id = GLib.uuid_string_random();
|
|
|
|
return {
|
|
id: id,
|
|
name: query.name || query.number,
|
|
numbers: [{value: query.number, type: 'unknown'}],
|
|
origin: 'gsconnect',
|
|
};
|
|
}
|
|
|
|
get_contact(position) {
|
|
if (this._cacheData[position] !== undefined)
|
|
return this._cacheData[position];
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Add a contact, checking for validity
|
|
*
|
|
* @param {Object} contact - A contact object
|
|
* @param {boolean} write - Write to disk
|
|
*/
|
|
add(contact, write = true) {
|
|
// Ensure the contact has a unique id
|
|
if (!contact.id) {
|
|
let id = GLib.uuid_string_random();
|
|
|
|
while (this._cacheData[id])
|
|
id = GLib.uuid_string_random();
|
|
|
|
contact.id = id;
|
|
}
|
|
|
|
// Ensure the contact has an origin
|
|
if (!contact.origin)
|
|
contact.origin = 'gsconnect';
|
|
|
|
// This is an updated contact
|
|
if (this._cacheData[contact.id]) {
|
|
this._cacheData[contact.id] = contact;
|
|
this.emit('contact-changed', contact.id);
|
|
|
|
// This is a new contact
|
|
} else {
|
|
this._cacheData[contact.id] = contact;
|
|
this.emit('contact-added', contact.id);
|
|
}
|
|
|
|
// Write if requested
|
|
if (write)
|
|
this.save();
|
|
}
|
|
|
|
/**
|
|
* Remove a contact by id
|
|
*
|
|
* @param {string} id - The id of the contact to delete
|
|
* @param {boolean} write - Write to disk
|
|
*/
|
|
remove(id, write = true) {
|
|
// Only remove if the contact actually exists
|
|
if (this._cacheData[id]) {
|
|
delete this._cacheData[id];
|
|
this.emit('contact-removed', id);
|
|
|
|
// Write if requested
|
|
if (write)
|
|
this.save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lookup a contact for each address object in @addresses and return a
|
|
* dictionary of address (eg. phone number) to contact object.
|
|
*
|
|
* { "555-5555": { "name": "...", "numbers": [], ... } }
|
|
*
|
|
* @param {Object[]} addresses - A list of address objects
|
|
* @return {Object} A dictionary of phone numbers and contacts
|
|
*/
|
|
lookupAddresses(addresses) {
|
|
const contacts = {};
|
|
|
|
// Lookup contacts for each address
|
|
for (let i = 0, len = addresses.length; i < len; i++) {
|
|
const address = addresses[i].address;
|
|
|
|
contacts[address] = this.query({
|
|
number: address,
|
|
});
|
|
}
|
|
|
|
return contacts;
|
|
}
|
|
|
|
async clear() {
|
|
try {
|
|
const contacts = this.contacts;
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++)
|
|
await this.remove(contacts[i].id, false);
|
|
|
|
await this.save();
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the contact store from a dictionary of our custom contact objects.
|
|
*
|
|
* @param {Object} json - an Object of contact Objects
|
|
*/
|
|
async update(json = {}) {
|
|
try {
|
|
let contacts = Object.values(json);
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
const new_contact = contacts[i];
|
|
const contact = this._cacheData[new_contact.id];
|
|
|
|
if (!contact || new_contact.timestamp !== contact.timestamp)
|
|
await this.add(new_contact, false);
|
|
}
|
|
|
|
// Prune contacts
|
|
contacts = this.contacts;
|
|
|
|
for (let i = 0, len = contacts.length; i < len; i++) {
|
|
const contact = contacts[i];
|
|
|
|
if (!json[contact.id])
|
|
await this.remove(contact.id, false);
|
|
}
|
|
|
|
await this.save();
|
|
} catch (e) {
|
|
debug(e, 'Updating contacts');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and update the contact store from its source.
|
|
*
|
|
* The default function initializes the EDS server, or logs a debug message
|
|
* if EDS is unavailable. Derived classes should request an update from the
|
|
* remote source.
|
|
*/
|
|
async fetch() {
|
|
try {
|
|
if (this.context === null && HAVE_EDS)
|
|
await this._initEvolutionDataServer();
|
|
else
|
|
throw new Error('Evolution Data Server not available');
|
|
} catch (e) {
|
|
debug(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load the contacts from disk.
|
|
*/
|
|
async load() {
|
|
try {
|
|
const [contents] = await this._cacheFile.load_contents_async(null);
|
|
this._cacheData = JSON.parse(new TextDecoder().decode(contents));
|
|
} catch (e) {
|
|
debug(e);
|
|
} finally {
|
|
this.notify('context');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the contacts to disk.
|
|
*/
|
|
async save() {
|
|
// EDS is handling storage
|
|
if (this.context === null && HAVE_EDS)
|
|
return;
|
|
|
|
if (this.__cache_lock) {
|
|
this.__cache_queue = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.__cache_lock = true;
|
|
|
|
const contents = new GLib.Bytes(JSON.stringify(this._cacheData, null, 2));
|
|
await this._cacheFile.replace_contents_bytes_async(contents, null,
|
|
false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
|
|
} catch (e) {
|
|
debug(e);
|
|
} finally {
|
|
this.__cache_lock = false;
|
|
|
|
if (this.__cache_queue) {
|
|
this.__cache_queue = false;
|
|
this.save();
|
|
}
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this._watcher !== undefined) {
|
|
this._watcher.disconnect(this._appearedId);
|
|
this._watcher.disconnect(this._disappearedId);
|
|
this._watcher = undefined;
|
|
|
|
for (const ebook of this._ebooks.values())
|
|
this._onDisappeared(null, ebook.source);
|
|
|
|
this._edsPrepared = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
export default Store;
|
|
|