--- a/browser/components/loop/LoopStorage.jsm
+++ b/browser/components/loop/LoopStorage.jsm
@@ -20,17 +20,19 @@ Cu.import("resource://gre/modules/Servic
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 kDatabasePrefix = "loop-";
+const kDefaultDatabaseName = "default";
+let gDatabaseName = kDatabasePrefix + kDefaultDatabaseName;
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.
@@ -78,26 +80,26 @@ const ensureDatabaseOpen = function(onOp
let invokeCallbacks = err => {
for (let callback of gWaitForOpenCallbacks) {
callback(err, gDatabase);
}
gWaitForOpenCallbacks.clear();
};
- let openRequest = indexedDB.open(kDatabaseName, kDatabaseVersion);
+ let openRequest = indexedDB.open(gDatabaseName, 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);
+ indexedDB.deleteDatabase(gDatabaseName);
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);
};
@@ -105,16 +107,43 @@ const ensureDatabaseOpen = function(onOp
gDatabase = event.target.result;
invokeCallbacks();
// Close the database instance properly on application shutdown.
Services.obs.addObserver(closeDatabase, "quit-application", false);
};
};
/**
+ * Switch to a database with a different name by closing the current connection
+ * and making sure that the next connection attempt will be made using the updated
+ * name.
+ *
+ * @param {String} name New name of the database to switch to.
+ */
+const switchDatabase = function(name) {
+ if (!name) {
+ name = kDefaultDatabaseName;
+ }
+ name = kDatabasePrefix + name;
+ if (name == gDatabaseName) {
+ // This is already the current database, so there's no need to switch.
+ return;
+ }
+
+ gDatabaseName = name;
+ if (gDatabase) {
+ try {
+ gDatabase.close();
+ } finally {
+ gDatabase = null;
+ }
+ }
+};
+
+/**
* 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'
@@ -175,28 +204,46 @@ const getStore = function(store, callbac
*
* 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({
/**
+ * @var {String} databaseName The name of the database that is currently active,
+ * WITHOUT the prefix
+ */
+ get databaseName() {
+ return gDatabaseName.substr(kDatabasePrefix.length);
+ },
+
+ /**
* 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);
},
/**
+ * Switch to a database with a different name.
+ *
+ * @param {String} name New name of the database to switch to. Defaults to
+ * `kDefaultDatabaseName`
+ */
+ switchDatabase: function(name = kDefaultDatabaseName) {
+ switchDatabase(name);
+ },
+
+ /**
* 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
--- a/browser/components/loop/MozLoopService.jsm
+++ b/browser/components/loop/MozLoopService.jsm
@@ -51,16 +51,19 @@ XPCOMUtils.defineLazyModuleGetter(this,
"resource://gre/modules/FxAccountsProfileClient.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "HawkClient",
"resource://services-common/hawkclient.js");
XPCOMUtils.defineLazyModuleGetter(this, "deriveHawkCredentials",
"resource://services-common/hawkrequest.js");
+XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
+ "resource:///modules/loop/LoopStorage.jsm");
+
XPCOMUtils.defineLazyModuleGetter(this, "MozLoopPushHandler",
"resource:///modules/loop/MozLoopPushHandler.jsm");
XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
"@mozilla.org/uuid-generator;1",
"nsIUUIDGenerator");
XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
@@ -362,16 +365,18 @@ let MozLoopServiceInternal = {
*/
set doNotDisturb(aFlag) {
Services.prefs.setBoolPref("loop.do_not_disturb", Boolean(aFlag));
this.notifyStatusChanged();
},
notifyStatusChanged: function(aReason = null) {
log.debug("notifyStatusChanged with reason:", aReason);
+ let profile = MozLoopService.userProfile;
+ LoopStorage.switchDatabase(profile ? profile.uid : null);
Services.obs.notifyObservers(null, "loop-status-changed", aReason);
},
/**
* Record an error and notify interested UI with the relevant user-facing strings attached.
*
* @param {String} errorType a key to identify the type of error. Only one
* error of a type will be saved at a time. This value may be used to
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -148,24 +148,24 @@ loop.contacts = (function(_, mozL10n) {
this.setState({showMenu: false});
}
},
componentWillUnmount: function() {
document.body.removeEventListener("click", this._onBodyClick);
},
- componentShouldUpdate: function(nextProps, nextState) {
+ shouldComponentUpdate: function(nextProps, nextState) {
let currContact = this.props.contact;
let nextContact = nextProps.contact;
return (
currContact.name[0] !== nextContact.name[0] ||
currContact.blocked !== nextContact.blocked ||
- getPreferredEmail(currContact).value !==
- getPreferredEmail(nextContact).value
+ getPreferredEmail(currContact).value !== getPreferredEmail(nextContact).value ||
+ nextState.showMenu !== this.state.showMenu
);
},
handleAction: function(actionName) {
if (this.props.handleContactAction) {
this.props.handleContactAction(this.props.contact, actionName);
}
},
@@ -210,45 +210,78 @@ loop.contacts = (function(_, mozL10n) {
)
);
}
});
const ContactsList = React.createClass({displayName: 'ContactsList',
mixins: [React.addons.LinkedStateMixin],
+ /**
+ * Contacts collection object
+ */
+ contacts: null,
+
+ /**
+ * User profile
+ */
+ _userProfile: null,
+
getInitialState: function() {
return {
- contacts: {},
importBusy: false,
filter: "",
};
},
- componentDidMount: function() {
+ refresh: function(callback = function() {}) {
let contactsAPI = navigator.mozLoop.contacts;
+ this.handleContactRemoveAll();
+
contactsAPI.getAll((err, contacts) => {
if (err) {
- throw err;
+ callback(err);
+ return;
}
// Add contacts already present in the DB. We do this in timed chunks to
// circumvent blocking the main event loop.
let addContactsInChunks = () => {
contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
this.handleContactAddOrUpdate(contact, false);
});
if (contacts.length) {
setTimeout(addContactsInChunks, 0);
+ } else {
+ callback();
}
this.forceUpdate();
};
addContactsInChunks(contacts);
+ });
+ },
+
+ componentWillMount: function() {
+ // Take the time to initialize class variables that are used outside
+ // `this.state`.
+ this.contacts = {};
+ this._userProfile = navigator.mozLoop.userProfile;
+ },
+
+ componentDidMount: function() {
+ window.addEventListener("LoopStatusChanged", this._onStatusChanged);
+
+ this.refresh(err => {
+ if (err) {
+ throw err;
+ }
+
+ let contactsAPI = navigator.mozLoop.contacts;
// Listen for contact changes/ updates.
contactsAPI.on("add", (eventName, contact) => {
this.handleContactAddOrUpdate(contact);
});
contactsAPI.on("remove", (eventName, contact) => {
this.handleContactRemove(contact);
});
@@ -256,37 +289,55 @@ loop.contacts = (function(_, mozL10n) {
this.handleContactRemoveAll();
});
contactsAPI.on("update", (eventName, contact) => {
this.handleContactAddOrUpdate(contact);
});
});
},
+ componentWillUnmount: function() {
+ window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
+ },
+
+ _onStatusChanged: function() {
+ let profile = navigator.mozLoop.userProfile;
+ let currUid = this._userProfile ? this._userProfile.uid : null;
+ let newUid = profile ? profile.uid : null;
+ if (currUid != newUid) {
+ // On profile change (login, logout), reload all contacts.
+ this._userProfile = profile;
+ // The following will do a forceUpdate() for us.
+ this.refresh();
+ }
+ },
+
handleContactAddOrUpdate: function(contact, render = true) {
- let contacts = this.state.contacts;
+ let contacts = this.contacts;
let guid = String(contact._guid);
contacts[guid] = contact;
if (render) {
this.forceUpdate();
}
},
handleContactRemove: function(contact) {
- let contacts = this.state.contacts;
+ let contacts = this.contacts;
let guid = String(contact._guid);
if (!contacts[guid]) {
return;
}
delete contacts[guid];
this.forceUpdate();
},
handleContactRemoveAll: function() {
- this.setState({contacts: {}});
+ // Do not allow any race conditions when removing all contacts.
+ this.contacts = {};
+ this.forceUpdate();
},
handleImportButtonClick: function() {
this.setState({ importBusy: true });
navigator.mozLoop.startImport({
service: "google"
}, (err, stats) => {
this.setState({ importBusy: false });
@@ -359,21 +410,21 @@ loop.contacts = (function(_, mozL10n) {
},
render: function() {
let viewForItem = item => {
return ContactDetail({key: item._guid, contact: item,
handleContactAction: this.handleContactAction})
};
- let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+ let shownContacts = _.groupBy(this.contacts, function(contact) {
return contact.blocked ? "blocked" : "available";
});
- let showFilter = Object.getOwnPropertyNames(this.state.contacts).length >=
+ let showFilter = Object.getOwnPropertyNames(this.contacts).length >=
MIN_CONTACTS_FOR_FILTERING;
if (showFilter) {
let filter = this.state.filter.trim().toLocaleLowerCase();
if (filter) {
let filterFn = contact => {
return contact.name[0].toLocaleLowerCase().contains(filter) ||
getPreferredEmail(contact).value.toLocaleLowerCase().contains(filter);
};
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -148,24 +148,24 @@ loop.contacts = (function(_, mozL10n) {
this.setState({showMenu: false});
}
},
componentWillUnmount: function() {
document.body.removeEventListener("click", this._onBodyClick);
},
- componentShouldUpdate: function(nextProps, nextState) {
+ shouldComponentUpdate: function(nextProps, nextState) {
let currContact = this.props.contact;
let nextContact = nextProps.contact;
return (
currContact.name[0] !== nextContact.name[0] ||
currContact.blocked !== nextContact.blocked ||
- getPreferredEmail(currContact).value !==
- getPreferredEmail(nextContact).value
+ getPreferredEmail(currContact).value !== getPreferredEmail(nextContact).value ||
+ nextState.showMenu !== this.state.showMenu
);
},
handleAction: function(actionName) {
if (this.props.handleContactAction) {
this.props.handleContactAction(this.props.contact, actionName);
}
},
@@ -210,45 +210,78 @@ loop.contacts = (function(_, mozL10n) {
</li>
);
}
});
const ContactsList = React.createClass({
mixins: [React.addons.LinkedStateMixin],
+ /**
+ * Contacts collection object
+ */
+ contacts: null,
+
+ /**
+ * User profile
+ */
+ _userProfile: null,
+
getInitialState: function() {
return {
- contacts: {},
importBusy: false,
filter: "",
};
},
- componentDidMount: function() {
+ refresh: function(callback = function() {}) {
let contactsAPI = navigator.mozLoop.contacts;
+ this.handleContactRemoveAll();
+
contactsAPI.getAll((err, contacts) => {
if (err) {
- throw err;
+ callback(err);
+ return;
}
// Add contacts already present in the DB. We do this in timed chunks to
// circumvent blocking the main event loop.
let addContactsInChunks = () => {
contacts.splice(0, CONTACTS_CHUNK_SIZE).forEach(contact => {
this.handleContactAddOrUpdate(contact, false);
});
if (contacts.length) {
setTimeout(addContactsInChunks, 0);
+ } else {
+ callback();
}
this.forceUpdate();
};
addContactsInChunks(contacts);
+ });
+ },
+
+ componentWillMount: function() {
+ // Take the time to initialize class variables that are used outside
+ // `this.state`.
+ this.contacts = {};
+ this._userProfile = navigator.mozLoop.userProfile;
+ },
+
+ componentDidMount: function() {
+ window.addEventListener("LoopStatusChanged", this._onStatusChanged);
+
+ this.refresh(err => {
+ if (err) {
+ throw err;
+ }
+
+ let contactsAPI = navigator.mozLoop.contacts;
// Listen for contact changes/ updates.
contactsAPI.on("add", (eventName, contact) => {
this.handleContactAddOrUpdate(contact);
});
contactsAPI.on("remove", (eventName, contact) => {
this.handleContactRemove(contact);
});
@@ -256,37 +289,55 @@ loop.contacts = (function(_, mozL10n) {
this.handleContactRemoveAll();
});
contactsAPI.on("update", (eventName, contact) => {
this.handleContactAddOrUpdate(contact);
});
});
},
+ componentWillUnmount: function() {
+ window.removeEventListener("LoopStatusChanged", this._onStatusChanged);
+ },
+
+ _onStatusChanged: function() {
+ let profile = navigator.mozLoop.userProfile;
+ let currUid = this._userProfile ? this._userProfile.uid : null;
+ let newUid = profile ? profile.uid : null;
+ if (currUid != newUid) {
+ // On profile change (login, logout), reload all contacts.
+ this._userProfile = profile;
+ // The following will do a forceUpdate() for us.
+ this.refresh();
+ }
+ },
+
handleContactAddOrUpdate: function(contact, render = true) {
- let contacts = this.state.contacts;
+ let contacts = this.contacts;
let guid = String(contact._guid);
contacts[guid] = contact;
if (render) {
this.forceUpdate();
}
},
handleContactRemove: function(contact) {
- let contacts = this.state.contacts;
+ let contacts = this.contacts;
let guid = String(contact._guid);
if (!contacts[guid]) {
return;
}
delete contacts[guid];
this.forceUpdate();
},
handleContactRemoveAll: function() {
- this.setState({contacts: {}});
+ // Do not allow any race conditions when removing all contacts.
+ this.contacts = {};
+ this.forceUpdate();
},
handleImportButtonClick: function() {
this.setState({ importBusy: true });
navigator.mozLoop.startImport({
service: "google"
}, (err, stats) => {
this.setState({ importBusy: false });
@@ -359,21 +410,21 @@ loop.contacts = (function(_, mozL10n) {
},
render: function() {
let viewForItem = item => {
return <ContactDetail key={item._guid} contact={item}
handleContactAction={this.handleContactAction} />
};
- let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+ let shownContacts = _.groupBy(this.contacts, function(contact) {
return contact.blocked ? "blocked" : "available";
});
- let showFilter = Object.getOwnPropertyNames(this.state.contacts).length >=
+ let showFilter = Object.getOwnPropertyNames(this.contacts).length >=
MIN_CONTACTS_FOR_FILTERING;
if (showFilter) {
let filter = this.state.filter.trim().toLocaleLowerCase();
if (filter) {
let filterFn = contact => {
return contact.name[0].toLocaleLowerCase().contains(filter) ||
getPreferredEmail(contact).value.toLocaleLowerCase().contains(filter);
};
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -620,17 +620,19 @@ loop.panel = (function(_, mozL10n) {
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
var profile = navigator.mozLoop.userProfile;
- if (profile != this.state.userProfile) {
+ var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
+ var newUid = profile ? profile.uid : null;
+ if (currUid != newUid) {
// On profile change (login, logout), switch back to the default tab.
this.selectTab("call");
}
this.setState({userProfile: profile});
this.updateServiceErrors();
},
/**
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -620,17 +620,19 @@ loop.panel = (function(_, mozL10n) {
});
} else {
this.props.notifications.remove(this.props.notifications.get("service-error"));
}
},
_onStatusChanged: function() {
var profile = navigator.mozLoop.userProfile;
- if (profile != this.state.userProfile) {
+ var currUid = this.state.userProfile ? this.state.userProfile.uid : null;
+ var newUid = profile ? profile.uid : null;
+ if (currUid != newUid) {
// On profile change (login, logout), switch back to the default tab.
this.selectTab("call");
}
this.setState({userProfile: profile});
this.updateServiceErrors();
},
/**
--- a/browser/components/loop/test/mochitest/browser_LoopContacts.js
+++ b/browser/components/loop/test/mochitest/browser_LoopContacts.js
@@ -1,12 +1,17 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const {LoopContacts} = Cu.import("resource:///modules/loop/LoopContacts.jsm", {});
+const {LoopStorage} = Cu.import("resource:///modules/loop/LoopStorage.jsm", {});
+
+XPCOMUtils.defineLazyServiceGetter(this, "uuidgen",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator");
const kContacts = [{
id: 1,
name: ["Ally Avocado"],
email: [{
"pref": true,
"type": ["work"],
"value": "ally@mail.com"
@@ -395,8 +400,36 @@ add_task(function* () {
// Test if the event emitter implementation doesn't leak and is working as expected.
add_task(function* () {
yield promiseLoadContacts();
Assert.strictEqual(gExpectedAdds.length, 0, "No contact additions should be expected anymore");
Assert.strictEqual(gExpectedRemovals.length, 0, "No contact removals should be expected anymore");
Assert.strictEqual(gExpectedUpdates.length, 0, "No contact updates should be expected anymore");
});
+
+// Test switching between different databases.
+add_task(function* () {
+ Assert.equal(LoopStorage.databaseName, "default", "First active partition should be the default");
+ yield promiseLoadContacts();
+
+ let uuid = uuidgen.generateUUID().toString().replace(/[{}]+/g, "");
+ LoopStorage.switchDatabase(uuid);
+ Assert.equal(LoopStorage.databaseName, uuid, "The active partition should have changed");
+
+ yield promiseLoadContacts();
+
+ let contacts = yield promiseLoadContacts();
+ for (let i = 0, l = contacts.length; i < l; ++i) {
+ compareContacts(contacts[i], kContacts[i]);
+ }
+
+ LoopStorage.switchDatabase();
+ Assert.equal(LoopStorage.databaseName, "default", "The active partition should have changed");
+
+ LoopContacts.getAll(function(err, contacts) {
+ Assert.equal(err, null, "There shouldn't be an error");
+
+ for (let i = 0, l = contacts.length; i < l; ++i) {
+ compareContacts(contacts[i], kContacts[i]);
+ }
+ });
+});