Bug 1078309: use a different database for each Fx Account. r=abr,paolo
authorMike de Boer <mdeboer@mozilla.com>
Thu, 09 Oct 2014 13:11:24 +0200
changeset 225597 6dfc2524085b9f645e4e2c0f41d38e748b032f6a
parent 225596 e90f9d476c1f403fe474c1c2df54d5e41eea4d63
child 225598 eaa6723729002aa8a75e52f6e5d782298aa16c73
push id3979
push userraliiev@mozilla.com
push dateMon, 13 Oct 2014 16:35:44 +0000
treeherdermozilla-beta@30f2cc610691 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersabr, paolo
bugs1078309
milestone34.0a2
Bug 1078309: use a different database for each Fx Account. r=abr,paolo
browser/components/loop/LoopStorage.jsm
browser/components/loop/MozLoopService.jsm
browser/components/loop/content/js/contacts.js
browser/components/loop/content/js/contacts.jsx
browser/components/loop/content/js/panel.js
browser/components/loop/content/js/panel.jsx
browser/components/loop/test/mochitest/browser_LoopContacts.js
--- 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
@@ -485,17 +485,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();
     },
 
     startForm: function(name, contact) {
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -485,17 +485,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();
     },
 
     startForm: function(name, contact) {
--- 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]);
+    }
+  });
+});