Bug 1038716 - add a contacts API. r=abr,dolske,mak,bholley

This commit is contained in:
Mike de Boer 2014-08-12 12:24:07 +02:00
parent 303c7b3da3
commit 07fefbe809
4 changed files with 1228 additions and 0 deletions

View File

@ -0,0 +1,834 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "console",
"resource://gre/modules/devtools/Console.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
"resource:///modules/loop/LoopStorage.jsm");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
return new EventEmitter();
});
this.EXPORTED_SYMBOLS = ["LoopContacts"];
const kObjectStoreName = "contacts";
/*
* The table used to store contacts information contains two identifiers,
* both of which can be used to look up entries in the table. The table
* key path (primary index, which must be unique) is "_guid", and is
* automatically generated by IndexedDB when an entry is first inserted.
* The other identifier, "id", is the supposedly unique key assigned to this
* entry by whatever service generated it (e.g., Google Contacts). While
* this key should, in theory, be completely unique, we don't use it
* as the key path to avoid generating errors when an external database
* violates this constraint. This second ID is referred to as the "serviceId".
*/
const kKeyPath = "_guid";
const kServiceIdIndex = "id";
/**
* Contacts validation.
*
* To allow for future integration with the Contacts API and/ or potential
* integration with contact synchronization across devices (including Firefox OS
* devices), we are using objects with properties having the same names and
* structure as those used by mozContact.
*
* See https://developer.mozilla.org/en-US/docs/Web/API/mozContact for more
* information.
*/
const kFieldTypeString = "string";
const kFieldTypeNumber = "number";
const kFieldTypeNumberOrString = "number|string";
const kFieldTypeArray = "array";
const kFieldTypeBool = "boolean";
const kContactFields = {
"id": {
// Because "id" is externally generated, it might be numeric
type: kFieldTypeNumberOrString
},
"published": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"updated": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"bday": {
// mozContact, from which we are derived, defines dates as
// "a Date object, which will eventually be converted to a
// long long" -- to be forwards compatible, we allow both
// formats for now.
type: kFieldTypeNumberOrString
},
"blocked": {
type: kFieldTypeBool
},
"adr": {
type: kFieldTypeArray,
contains: {
"countryName": {
type: kFieldTypeString
},
"locality": {
type: kFieldTypeString
},
"postalCode": {
// In some (but not all) locations, postal codes can be strictly numeric
type: kFieldTypeNumberOrString
},
"pref": {
type: kFieldTypeBool
},
"region": {
type: kFieldTypeString
},
"streetAddress": {
type: kFieldTypeString
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
}
}
},
"email": {
type: kFieldTypeArray,
contains: {
"pref": {
type: kFieldTypeBool
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"value": {
type: kFieldTypeString
}
}
},
"tel": {
type: kFieldTypeArray,
contains: {
"pref": {
type: kFieldTypeBool
},
"type": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"value": {
type: kFieldTypeString
}
}
},
"name": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"honorificPrefix": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"givenName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"additionalName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"familyName": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"honorificSuffix": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"category": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"org": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"jobTitle": {
type: kFieldTypeArray,
contains: kFieldTypeString
},
"note": {
type: kFieldTypeArray,
contains: kFieldTypeString
}
};
/**
* Compares the properties contained in an object to the definition as defined in
* `kContactFields`.
* If a property is encountered that is not found in the spec, an Error is thrown.
* If a property is encountered with an invalid value, an Error is thrown.
*
* Please read the spec at https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information.
*
* @param {Object} obj The contact object, or part of it when called recursively
* @param {Object} def The definition of properties to validate against. Defaults
* to `kContactFields`
*/
const validateContact = function(obj, def = kContactFields) {
for (let propName of Object.getOwnPropertyNames(obj)) {
// Ignore internal properties.
if (propName.startsWith("_")) {
continue;
}
let propDef = def[propName];
if (!propDef) {
throw new Error("Field '" + propName + "' is not supported for contacts");
}
let val = obj[propName];
switch (propDef.type) {
case kFieldTypeString:
if (typeof val != kFieldTypeString) {
throw new Error("Field '" + propName + "' must be of type String");
}
break;
case kFieldTypeNumberOrString:
let type = typeof val;
if (type != kFieldTypeNumber && type != kFieldTypeString) {
throw new Error("Field '" + propName + "' must be of type Number or String");
}
break;
case kFieldTypeBool:
if (typeof val != kFieldTypeBool) {
throw new Error("Field '" + propName + "' must be of type Boolean");
}
break;
case kFieldTypeArray:
if (!Array.isArray(val)) {
throw new Error("Field '" + propName + "' must be an Array");
}
let contains = propDef.contains;
// If the type of `contains` is a scalar value, it means that the array
// consists of items of only that type.
let isScalarCheck = (typeof contains == kFieldTypeString);
for (let arrayValue of val) {
if (isScalarCheck) {
if (typeof arrayValue != contains) {
throw new Error("Field '" + propName + "' must be of type " + contains);
}
} else {
validateContact(arrayValue, contains);
}
}
break;
}
}
};
/**
* Provides a method to perform multiple operations in a single transaction on the
* contacts store.
*
* @param {String} operation Name of an operation supported by `IDBObjectStore`
* @param {Array} data List of objects that will be passed to the object
* store operation
* @param {Function} callback Function that will be invoked once the operations
* have finished. The first argument passed will be
* an `Error` object or `null`. The second argument
* will be the `data` Array, if all operations finished
* successfully.
*/
const batch = function(operation, data, callback) {
let processed = [];
if (!LoopContactsInternal.hasOwnProperty(operation) ||
typeof LoopContactsInternal[operation] != 'function') {
callback(new Error ("LoopContactsInternal does not contain a '" +
operation + "' method"));
return;
}
LoopStorage.asyncForEach(data, (item, next) => {
LoopContactsInternal[operation](item, (err, result) => {
if (err) {
next(err);
return;
}
processed.push(result);
next();
});
}, err => {
if (err) {
callback(err, processed);
return;
}
callback(null, processed);
});
}
/**
* Extend a `target` object with the properties defined in `source`.
*
* @param {Object} target The target object to receive properties defined in `source`
* @param {Object} source The source object to copy properties from
*/
const extend = function(target, source) {
for (let key of Object.getOwnPropertyNames(source)) {
target[key] = source[key];
}
return target;
};
LoopStorage.on("upgrade", function(e, db) {
if (db.objectStoreNames.contains(kObjectStoreName)) {
return;
}
// Create the 'contacts' store as it doesn't exist yet.
let store = db.createObjectStore(kObjectStoreName, {
keyPath: kKeyPath,
autoIncrement: true
});
store.createIndex(kServiceIdIndex, kServiceIdIndex, {unique: false});
});
/**
* The Contacts class.
*
* Each method that is a member of this class requires the last argument to be a
* callback Function. MozLoopAPI will cause things to break if this invariant is
* violated. You'll notice this as well in the documentation for each method.
*/
let LoopContactsInternal = Object.freeze({
/**
* Add a contact to the data store.
*
* @param {Object} details An object that will be added to the data store
* as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information of this objects' structure
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if it was stored successfully.
*/
add: function(details, callback) {
if (!(kServiceIdIndex in details)) {
callback(new Error("No '" + kServiceIdIndex + "' field present"));
return;
}
try {
validateContact(details);
} catch (ex) {
callback(ex);
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let contact = extend({}, details);
let now = Date.now();
// The data source should have included "published" and "updated" values
// for any imported records, and we need to keep track of those dated for
// sync purposes (i.e., when we add functionality to push local changes to
// a remote server from which we originally got a contact). We also need
// to track the time at which *we* added and most recently changed the
// contact, so as to determine whether the local or the remote store has
// fresher data.
//
// For clarity: the fields "published" and "updated" indicate when the
// *remote* data source published and updated the contact. The fields
// "_date_add" and "_date_lch" track when the *local* data source
// created and updated the contact.
contact.published = contact.published ? new Date(contact.published).getTime() : now;
contact.updated = contact.updated ? new Date(contact.updated).getTime() : now;
contact._date_add = contact._date_lch = now;
let request;
try {
request = store.add(contact);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
contact[kKeyPath] = event.target.result;
eventEmitter.emit("add", contact);
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
},
/**
* Add a batch of contacts to the data store.
*
* @param {Array} contacts A list of contact objects to be added
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the list of added contacts.
*/
addMany: function(contacts, callback) {
batch("add", contacts, callback);
},
/**
* Remove a contact from the data store.
*
* @param {String} guid String identifier of the contact to remove
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation.
*/
remove: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.delete(guid);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
eventEmitter.emit("remove", contact);
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
});
},
/**
* Remove a batch of contacts from the data store.
*
* @param {Array} guids A list of IDs of the contacts to remove
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the list of IDs, if successfull.
*/
removeMany: function(guids, callback) {
batch("remove", guids, callback);
},
/**
* Remove _all_ contacts from the data store.
* CAUTION: this method will clear the whole data store - you won't have any
* contacts left!
*
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation, if successfull.
*/
removeAll: function(callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.clear();
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
eventEmitter.emit("removeAll", event.target.result);
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
},
/**
* Retrieve a specific contact from the data store.
*
* @param {String} guid String identifier of the contact to retrieve
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successful.
*/
get: function(guid, callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let request;
try {
request = store.get(guid);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
if (!event.target.result) {
callback(new Error("Contact with " + kKeyPath + " '" +
guid + "' could not be found"));;
return;
}
let contact = extend({}, event.target.result);
contact[kKeyPath] = guid;
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve a specific contact from the data store using the kServiceIdIndex
* property.
*
* @param {String} serviceId String identifier of the contact to retrieve
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
getByServiceId: function(serviceId, callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let index = store.index(kServiceIdIndex);
let request;
try {
request = index.get(serviceId);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
if (!event.target.result) {
callback(new Error("Contact with " + kServiceIdIndex + " '" +
serviceId + "' could not be found"));
return;
}
let contact = extend({}, event.target.result);
callback(null, contact);
};
request.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve _all_ contacts from the data store.
* CAUTION: If the amount of contacts is very large (say > 100000), this method
* may slow down your application!
*
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*/
getAll: function(callback) {
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let cursorRequest = store.openCursor();
let contactsList = [];
cursorRequest.onsuccess = event => {
let cursor = event.target.result;
// No more results, return the list.
if (!cursor) {
callback(null, contactsList);
return;
}
let contact = extend({}, cursor.value);
contact[kKeyPath] = cursor.key;
contactsList.push(contact);
cursor.continue();
};
cursorRequest.onerror = event => callback(event.target.error);
});
},
/**
* Retrieve an arbitrary amount of contacts from the data store.
* CAUTION: If the amount of contacts is very large (say > 1000), this method
* may slow down your application!
*
* @param {Array} guids List of contact IDs to retrieve contact objects of
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*/
getMany: function(guids, callback) {
let contacts = [];
LoopStorage.asyncParallel(guids, (guid, next) => {
this.get(guid, (err, contact) => {
if (err) {
next(err);
return;
}
contacts.push(contact);
next();
});
}, err => {
callback(err, !err ? contacts : null);
});
},
/**
* Update a specific contact in the data store.
* The contact object is modified by replacing the fields passed in the `details`
* param and any fields not passed in are left unchanged.
*
* @param {Object} details An object that will be updated in the data store
* as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book
* for more information of this objects' structure
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
update: function(details, callback) {
if (!(kKeyPath in details)) {
callback(new Error("No '" + kKeyPath + "' field present"));
return;
}
try {
validateContact(details);
} catch (ex) {
callback(ex);
return;
}
let guid = details[kKeyPath];
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
LoopStorage.getStore(kObjectStoreName, (err, store) => {
if (err) {
callback(err);
return;
}
let previous = extend({}, contact);
// Update the contact with properties provided by `details`.
extend(contact, details);
details._date_lch = Date.now();
let request;
try {
request = store.put(contact);
} catch (ex) {
callback(ex);
return;
}
request.onsuccess = event => {
eventEmitter.emit("update", contact, previous);
callback(null, event.target.result);
};
request.onerror = event => callback(event.target.error);
}, "readwrite");
});
},
/**
* Block a specific contact in the data store.
*
* @param {String} guid String identifier of the contact to block
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
block: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
contact.blocked = true;
this.update(contact, callback);
});
},
/**
* Un-block a specific contact in the data store.
*
* @param {String} guid String identifier of the contact to unblock
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the contact object, if successfull.
*/
unblock: function(guid, callback) {
this.get(guid, (err, contact) => {
if (err) {
callback(err);
return;
}
contact.blocked = false;
this.update(contact, callback);
});
},
/**
* Import a list of (new) contacts from an external data source.
*
* @param {Object} options Property bag of options for the importer
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be the result of the operation, if successfull.
*/
startImport: function(options, callback) {
//TODO in bug 972000.
callback(new Error("Not implemented yet!"));
},
/**
* Search through the data store for contacts that match a certain (sub-)string.
*
* @param {String} query Needle to search for in our haystack of contacts
* @param {Function} callback Function that will be invoked once the operation
* finished. The first argument passed will be an
* `Error` object or `null`. The second argument will
* be an `Array` of contact objects, if successfull.
*/
search: function(query, callback) {
//TODO in bug 1037114.
callback(new Error("Not implemented yet!"));
}
});
/**
* Public Loop Contacts API.
*
* LoopContacts implements the EventEmitter interface by exposing three methods -
* `on`, `once` and `off` - to subscribe to events.
* At this point the following events may be subscribed to:
* - 'add': A new contact object was successfully added to the data store.
* - 'remove': A contact was successfully removed from the data store.
* - 'removeAll': All contacts were successfully removed from the data store.
* - 'update': A contact object was successfully updated with changed
* properties in the data store.
*/
this.LoopContacts = Object.freeze({
add: function(details, callback) {
return LoopContactsInternal.add(details, callback);
},
addMany: function(contacts, callback) {
return LoopContactsInternal.addMany(contacts, callback);
},
remove: function(guid, callback) {
return LoopContactsInternal.remove(guid, callback);
},
removeMany: function(guids, callback) {
return LoopContactsInternal.removeMany(guids, callback);
},
removeAll: function(callback) {
return LoopContactsInternal.removeAll(callback);
},
get: function(guid, callback) {
return LoopContactsInternal.get(guid, callback);
},
getByServiceId: function(serviceId, callback) {
return LoopContactsInternal.getByServiceId(serviceId, callback);
},
getAll: function(callback) {
return LoopContactsInternal.getAll(callback);
},
getMany: function(guids, callback) {
return LoopContactsInternal.getMany(guids, callback);
},
update: function(details, callback) {
return LoopContactsInternal.update(details, callback);
},
block: function(guid, callback) {
return LoopContactsInternal.block(guid, callback);
},
unblock: function(guid, callback) {
return LoopContactsInternal.unblock(guid, callback);
},
startImport: function(options, callback) {
return LoopContactsInternal.startImport(options, callback);
},
search: function(query, callback) {
return LoopContactsInternal.search(query, callback);
},
on: (...params) => eventEmitter.on(...params),
once: (...params) => eventEmitter.once(...params),
off: (...params) => eventEmitter.off(...params)
});

View File

@ -0,0 +1,319 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
Cu.importGlobalProperties(["indexedDB"]);
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
return new EventEmitter();
});
this.EXPORTED_SYMBOLS = ["LoopStorage"];
const kDatabaseName = "loop";
const kDatabaseVersion = 1;
let gWaitForOpenCallbacks = new Set();
let gDatabase = null;
let gClosed = false;
/**
* Properly shut the database instance down. This is done on application shutdown.
*/
const closeDatabase = function() {
Services.obs.removeObserver(closeDatabase, "quit-application");
if (!gDatabase) {
return;
}
gDatabase.close();
gDatabase = null;
gClosed = true;
};
/**
* Open a connection to the IndexedDB database.
* This function is different than IndexedDBHelper.jsm provides, as it ensures
* only one connection is open during the lifetime of this API. Callbacks are
* queued when a connection attempt is in progress and are invoked once the
* connection is established.
*
* @param {Function} onOpen Callback to be invoked once a database connection is
* established. It takes an Error object as first argument
* and the database connection object as second argument,
* if successful.
*/
const ensureDatabaseOpen = function(onOpen) {
if (gClosed) {
onOpen(new Error("Database already closed"));
return;
}
if (gDatabase) {
onOpen(null, gDatabase);
return;
}
if (!gWaitForOpenCallbacks.has(onOpen)) {
gWaitForOpenCallbacks.add(onOpen);
if (gWaitForOpenCallbacks.size !== 1) {
return;
}
}
let invokeCallbacks = err => {
for (let callback of gWaitForOpenCallbacks) {
callback(err, gDatabase);
}
gWaitForOpenCallbacks.clear();
};
let openRequest = indexedDB.open(kDatabaseName, kDatabaseVersion);
openRequest.onblocked = function(event) {
invokeCallbacks(new Error("Database cannot be upgraded cause in use: " + event.target.error));
};
openRequest.onerror = function(event) {
// Try to delete the old database so that we can start this process over
// next time.
indexedDB.deleteDatabase(kDatabaseName);
invokeCallbacks(new Error("Error while opening database: " + event.target.errorCode));
};
openRequest.onupgradeneeded = function(event) {
let db = event.target.result;
eventEmitter.emit("upgrade", db, event.oldVersion, kDatabaseVersion);
};
openRequest.onsuccess = function(event) {
gDatabase = event.target.result;
invokeCallbacks();
// Close the database instance properly on application shutdown.
Services.obs.addObserver(closeDatabase, "quit-application", false);
};
};
/**
* Start a transaction on the loop database and return it.
*
* @param {String} store Name of the object store to start a transaction on
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* transaction object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
const getTransaction = function(store, callback, mode) {
ensureDatabaseOpen((err, db) => {
if (err) {
callback(err);
return;
}
let trans;
try {
trans = db.transaction(store, mode);
} catch(ex) {
callback(ex);
return;
}
callback(null, trans);
});
};
/**
* Start a transaction on the loop database and return the requested store.
*
* @param {String} store Name of the object store to retrieve
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* store object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
const getStore = function(store, callback, mode) {
getTransaction(store, (err, trans) => {
if (err) {
callback(err);
return;
}
callback(null, trans.objectStore(store));
}, mode);
};
/**
* Public Loop Storage API.
*
* Since IndexedDB transaction can not stand a spin of the event loop _before_
* using a IDBTransaction object, we can't use Promise.jsm promises. Therefore
* LoopStorage provides two async helper functions, `asyncForEach` and `asyncParallel`.
*
* LoopStorage implements the EventEmitter interface by exposing two methods, `on`
* and `off`, to subscribe to events.
* At this point only the `upgrade` event will be emitted. This happens when the
* database is loaded in memory and consumers will be able to change its structure.
*/
this.LoopStorage = Object.freeze({
/**
* Open a connection to the IndexedDB database and return the database object.
*
* @param {Function} callback Callback to be invoked once a database connection
* is established. It takes an Error object as first
* argument and the database connection object as
* second argument, if successful.
*/
getSingleton: function(callback) {
ensureDatabaseOpen(callback);
},
/**
* Start a transaction on the loop database and return it.
* If only two arguments are passed, the default mode will be assumed and the
* second argument is assumed to be a callback.
*
* @param {String} store Name of the object store to start a transaction on
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* transaction object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
getTransaction: function(store, callback, mode = "readonly") {
getTransaction(store, callback, mode);
},
/**
* Start a transaction on the loop database and return the requested store.
* If only two arguments are passed, the default mode will be assumed and the
* second argument is assumed to be a callback.
*
* @param {String} store Name of the object store to retrieve
* @param {Function} callback Callback to be invoked once a database connection
* is established and a transaction can be started.
* It takes an Error object as first argument and the
* store object as second argument.
* @param {String} mode Mode of the transaction. May be 'readonly' or 'readwrite'
*
* @note we can't use a Promise here, as they are resolved after a spin of the
* event loop; the transaction will have finished by then and no operations
* are possible anymore, yielding an error.
*/
getStore: function(store, callback, mode = "readonly") {
getStore(store, callback, mode);
},
/**
* Perform an async function in serial on each of the list items and call a
* callback Function when all list items are done.
* IMPORTANT: only use this iteration method if you are sure that the operations
* performed in `onItem` are guaranteed to be async in the success case.
*
* @param {Array} list Non-empty list of items to iterate
* @param {Function} onItem Callback to invoke for each item in the list. It
* takes the item is first argument and a callback
* function as second, which is to be invoked once
* the consumer is done with its async operation. If
* an error is passed as the first argument to this
* callback function, the iteration will stop and
* `onDone` callback will be invoked with that error.
* @param {callback} onDone Callback to invoke when the list is completed or
* on error. It takes an Error object as first
* argument.
*/
asyncForEach: function(list, onItem, onDone) {
let i = 0;
let len = list.length;
if (!len) {
onDone(new Error("Argument error: empty list"));
return;
}
onItem(list[i], function handler(err) {
if (err) {
onDone(err);
return;
}
i++;
if (i < len) {
onItem(list[i], handler, i);
} else {
onDone();
}
}, i);
},
/**
* Perform an async function in parallel on each of the list items and call a
* callback Function when all list items are done.
* IMPORTANT: only use this iteration method if you are sure that the operations
* performed in `onItem` are guaranteed to be async in the success case.
*
* @param {Array} list Non-empty list of items to iterate
* @param {Function} onItem Callback to invoke for each item in the list. It
* takes the item is first argument and a callback
* function as second, which is to be invoked once
* the consumer is done with its async operation. If
* an error is passed as the first argument to this
* callback function, the iteration will stop and
* `onDone` callback will be invoked with that error.
* @param {callback} onDone Callback to invoke when the list is completed or
* on error. It takes an Error object as first
* argument.
*/
asyncParallel: function(list, onItem, onDone) {
let i = 0;
let done = 0;
let callbackCalled = false;
let len = list.length;
if (!len) {
onDone(new Error("Argument error: empty list"));
return;
}
for (; i < len; ++i) {
onItem(list[i], function handler(err) {
if (callbackCalled) {
return;
}
if (err) {
onDone(err);
callbackCalled = true;
return;
}
if (++done === len) {
onDone();
callbackCalled = true;
}
}, i);
}
},
on: (...params) => eventEmitter.on(...params),
off: (...params) => eventEmitter.off(...params)
});

View File

@ -9,6 +9,7 @@ const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/loop/MozLoopService.jsm");
Cu.import("resource:///modules/loop/LoopContacts.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
"resource://gre/modules/MozSocialAPI.jsm");
@ -22,6 +23,62 @@ XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper",
"nsIClipboardHelper");
this.EXPORTED_SYMBOLS = ["injectLoopAPI"];
/**
* Trying to clone an Error object into a different container will yield an error.
* We can work around this by copying the properties we care about onto a regular
* object.
*
* @param {Error} error Error object to copy
* @param {nsIDOMWindow} targetWindow The content window to attach the API
*/
const cloneErrorObject = function(error, targetWindow) {
let obj = new targetWindow.Error();
for (let prop of Object.getOwnPropertyNames(error)) {
obj[prop] = String(error[prop]);
}
return obj;
};
/**
* Inject any API containing _only_ function properties into the given window.
*
* @param {Object} api Object containing functions that need to
* be exposed to content
* @param {nsIDOMWindow} targetWindow The content window to attach the API
*/
const injectObjectAPI = function(api, targetWindow) {
let injectedAPI = {};
// Wrap all the methods in `api` to help results passed to callbacks get
// through the priv => unpriv barrier with `Cu.cloneInto()`.
Object.keys(api).forEach(func => {
injectedAPI[func] = function(...params) {
let callback = params.pop();
api[func](...params, function(...results) {
results = results.map(result => {
if (result && typeof result == "object") {
// Inspect for an error this way, because the Error object is special.
if (result.constructor.name == "Error") {
return cloneErrorObject(result.message)
}
return Cu.cloneInto(result, targetWindow);
}
return result;
});
callback(...results);
});
};
});
let contentObj = Cu.cloneInto(injectedAPI, targetWindow, {cloneFunctions: true});
// Since we deny preventExtensions on XrayWrappers, because Xray semantics make
// it difficult to act like an object has actually been frozen, we try to seal
// the `contentObj` without Xrays.
try {
Object.seal(Cu.waiveXrays(contentObj));
} catch (ex) {}
return contentObj;
};
/**
* Inject the loop API into the given window. The caller must be sure the
* window is a loop content window (eg, a panel, chatwindow, or similar).
@ -34,6 +91,7 @@ function injectLoopAPI(targetWindow) {
let ringer;
let ringerStopper;
let appVersionInfo;
let contactsAPI;
let api = {
/**
@ -61,6 +119,21 @@ function injectLoopAPI(targetWindow) {
}
},
/**
* Returns the contacts API.
*
* @returns {Object} The contacts API object
*/
contacts: {
enumerable: true,
get: function() {
if (contactsAPI) {
return contactsAPI;
}
return contactsAPI = injectObjectAPI(LoopContacts, targetWindow);
}
},
/**
* Returns translated strings associated with an element. Designed
* for use with l10n.js

View File

@ -13,6 +13,8 @@ BROWSER_CHROME_MANIFESTS += [
]
EXTRA_JS_MODULES.loop += [
'LoopContacts.jsm',
'LoopStorage.jsm',
'MozLoopAPI.jsm',
'MozLoopPushHandler.jsm',
'MozLoopWorker.js',