Merge fx-team to m-c a=merge
authorWes Kocher <wkocher@mozilla.com>
Fri, 19 Sep 2014 16:25:24 -0700
changeset 206267 a85324dfc960d9394bb751515584f37933823e49
parent 206250 8be4d99d08909adb1ab1935b3edd7b512587ef68 (current diff)
parent 206266 43bc9236ea468d1b9386970037f4d9ec96878f24 (diff)
child 206285 27253887d2ccaaf1e00b49033a0d8b0efe3b27b6
push id27519
push userkwierso@gmail.com
push dateFri, 19 Sep 2014 23:56:39 +0000
treeherdermozilla-central@a85324dfc960 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone35.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c a=merge
--- a/addon-sdk/source/lib/sdk/simple-storage.js
+++ b/addon-sdk/source/lib/sdk/simple-storage.js
@@ -3,26 +3,23 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 module.metadata = {
   "stability": "stable"
 };
 
-const { Cc, Ci, Cu } = require("chrome");
+const { Cc, Ci } = require("chrome");
 const file = require("./io/file");
 const prefs = require("./preferences/service");
 const jpSelf = require("./self");
 const timer = require("./timers");
 const unload = require("./system/unload");
 const { emit, on, off } = require("./event/core");
-const { defer } = require('./core/promise');
-
-const { Task } = Cu.import("resource://gre/modules/Task.jsm", {});
 
 const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod";
 const WRITE_PERIOD_DEFAULT = 300000; // 5 minutes
 
 const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota";
 const QUOTA_DEFAULT = 5242880; // 5 MiB
 
 const JETPACK_DIR_BASENAME = "jetpack";
@@ -33,78 +30,29 @@ Object.defineProperties(exports, {
     get: function() { return manager.root; },
     set: function(value) { manager.root = value; }
   },
   quotaUsage: {
     get: function() { return manager.quotaUsage; }
   }
 });
 
-function getHash(data) {
-  let { promise, resolve } = defer();
-
-  let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
-  crypto.init(crypto.MD5);
-
-  let listener = {
-    onStartRequest: function() { },
-
-    onDataAvailable: function(request, context, inputStream, offset, count) {
-      crypto.updateFromStream(inputStream, count);
-    },
-
-    onStopRequest: function(request, context, status) {
-      resolve(crypto.finish(false));
-    }
-  };
-
-  let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
-                  createInstance(Ci.nsIScriptableUnicodeConverter);
-  converter.charset = "UTF-8";
-  let stream = converter.convertToInputStream(data);
-  let pump = Cc["@mozilla.org/network/input-stream-pump;1"].
-             createInstance(Ci.nsIInputStreamPump);
-  pump.init(stream, -1, -1, 0, 0, true);
-  pump.asyncRead(listener, null);
-
-  return promise;
-}
-
-function writeData(filename, data) {
-  let { promise, resolve, reject } = defer();
-
-  let stream = file.open(filename, "w");
-  try {
-    stream.writeAsync(data, err => {
-      if (err)
-        reject(err);
-      else
-        resolve();
-    });
-  }
-  catch (err) {
-    // writeAsync closes the stream after it's done, so only close on error.
-    stream.close();
-    reject(err);
-  }
-
-  return promise;
-}
-
 // A generic JSON store backed by a file on disk.  This should be isolated
 // enough to move to its own module if need be...
 function JsonStore(options) {
   this.filename = options.filename;
   this.quota = options.quota;
   this.writePeriod = options.writePeriod;
   this.onOverQuota = options.onOverQuota;
   this.onWrite = options.onWrite;
-  this.hash = null;
+
   unload.ensure(this);
-  this.startTimer();
+
+  this.writeTimer = timer.setInterval(this.write.bind(this),
+                                      this.writePeriod);
 }
 
 JsonStore.prototype = {
   // The store's root.
   get root() {
     return this.isRootInited ? this._root : {};
   },
 
@@ -128,28 +76,21 @@ JsonStore.prototype = {
   // Percentage of quota used, as a number [0, Inf).  > 1 implies over quota.
   // Undefined if there is no quota.
   get quotaUsage() {
     return this.quota > 0 ?
            JSON.stringify(this.root).length / this.quota :
            undefined;
   },
 
-  startTimer: function JsonStore_startTimer() {
-    timer.setTimeout(() => {
-      this.write().then(this.startTimer.bind(this));
-    }, this.writePeriod);
-  },
-
   // Removes the backing file and all empty subdirectories.
   purge: function JsonStore_purge() {
     try {
       // This'll throw if the file doesn't exist.
       file.remove(this.filename);
-      this.hash = null;
       let parentPath = this.filename;
       do {
         parentPath = file.dirname(parentPath);
         // This'll throw if the dir isn't empty.
         file.rmdir(parentPath);
       } while (file.basename(parentPath) !== JETPACK_DIR_BASENAME);
     }
     catch (err) {}
@@ -159,35 +100,41 @@ JsonStore.prototype = {
   read: function JsonStore_read() {
     try {
       let str = file.read(this.filename);
 
       // Ideally we'd log the parse error with console.error(), but logged
       // errors cause tests to fail.  Supporting "known" errors in the test
       // harness appears to be non-trivial.  Maybe later.
       this.root = JSON.parse(str);
-      let self = this;
-      getHash(str).then(hash => this.hash = hash);
     }
     catch (err) {
       this.root = {};
-      this.hash = null;
     }
   },
 
+  // If the store is under quota, writes the root to the backing file.
+  // Otherwise quota observers are notified and nothing is written.
+  write: function JsonStore_write() {
+    if (this.quotaUsage > 1)
+      this.onOverQuota(this);
+    else
+      this._write();
+  },
+
   // Cleans up on unload.  If unloading because of uninstall, the store is
   // purged; otherwise it's written.
   unload: function JsonStore_unload(reason) {
-    timer.clearTimeout(this.writeTimer);
+    timer.clearInterval(this.writeTimer);
     this.writeTimer = null;
 
     if (reason === "uninstall")
       this.purge();
     else
-      this.write();
+      this._write();
   },
 
   // True if the root is an empty object.
   get _isEmpty() {
     if (this.root && typeof(this.root) === "object") {
       let empty = true;
       for (let key in this.root) {
         empty = false;
@@ -196,50 +143,42 @@ JsonStore.prototype = {
       return empty;
     }
     return false;
   },
 
   // Writes the root to the backing file, notifying write observers when
   // complete.  If the store is over quota or if it's empty and the store has
   // never been written, nothing is written and write observers aren't notified.
-  write: Task.async(function JsonStore_write() {
+  _write: function JsonStore__write() {
     // Don't write if the root is uninitialized or if the store is empty and the
     // backing file doesn't yet exist.
     if (!this.isRootInited || (this._isEmpty && !file.exists(this.filename)))
       return;
 
-    let data = JSON.stringify(this.root);
-
     // If the store is over quota, don't write.  The current under-quota state
     // should persist.
-    if ((this.quota > 0) && (data.length > this.quota)) {
-      this.onOverQuota(this);
-      return;
-    }
-
-    // Hash the data to compare it to any previously written data
-    let hash = yield getHash(data);
-
-    if (hash == this.hash)
+    if (this.quotaUsage > 1)
       return;
 
     // Finally, write.
+    let stream = file.open(this.filename, "w");
     try {
-      yield writeData(this.filename, data);
-
-      this.hash = hash;
-      if (this.onWrite)
-        this.onWrite(this);
+      stream.writeAsync(JSON.stringify(this.root), function writeAsync(err) {
+        if (err)
+          console.error("Error writing simple storage file: " + this.filename);
+        else if (this.onWrite)
+          this.onWrite(this);
+      }.bind(this));
     }
     catch (err) {
-      console.error("Error writing simple storage file: " + this.filename);
-      console.error(err);
+      // writeAsync closes the stream after it's done, so only close on error.
+      stream.close();
     }
-  })
+  }
 };
 
 
 // This manages a JsonStore singleton and tailors its use to simple storage.
 // The root of the JsonStore is lazy-loaded:  The backing file is only read the
 // first time the root's gotten.
 let manager = ({
   jsonStore: null,
--- a/addon-sdk/source/test/test-simple-storage.js
+++ b/addon-sdk/source/test/test-simple-storage.js
@@ -1,17 +1,16 @@
 /* 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/. */
 
 const file = require("sdk/io/file");
 const prefs = require("sdk/preferences/service");
 
 const QUOTA_PREF = "extensions.addon-sdk.simple-storage.quota";
-const WRITE_PERIOD_PREF = "extensions.addon-sdk.simple-storage.writePeriod";
 
 let {Cc,Ci} = require("chrome");
 
 const { Loader } = require("sdk/test/loader");
 const { id } = require("sdk/self");
 
 let storeFile = Cc["@mozilla.org/file/directory_service;1"].
                 getService(Ci.nsIProperties).
@@ -30,23 +29,22 @@ exports.testSetGet = function (assert, d
   let loader = Loader(module);
   let ss = loader.require("sdk/simple-storage");
   manager(loader).jsonStore.onWrite = function (storage) {
     assert.ok(file.exists(storeFilename), "Store file should exist");
 
     // Load the module again and make sure the value stuck.
     loader = Loader(module);
     ss = loader.require("sdk/simple-storage");
-    assert.equal(ss.storage.foo, val, "Value should persist");
     manager(loader).jsonStore.onWrite = function (storage) {
-      assert.fail("Nothing should be written since `storage` was not changed.");
+      file.remove(storeFilename);
+      done();
     };
+    assert.equal(ss.storage.foo, val, "Value should persist");
     loader.unload();
-    file.remove(storeFilename);
-    done();
   };
   let val = "foo";
   ss.storage.foo = val;
   assert.equal(ss.storage.foo, val, "Value read should be value set");
   loader.unload();
 };
 
 exports.testSetGetRootArray = function (assert, done) {
@@ -157,21 +155,20 @@ exports.testQuotaExceededHandle = functi
       let numProps = 0;
       for (let prop in ss.storage)
         numProps++;
       assert.ok(numProps, 2,
                   "Store should contain 2 values: " + ss.storage.toSource());
       assert.equal(ss.storage.x, 4, "x value should be correct");
       assert.equal(ss.storage.y, 5, "y value should be correct");
       manager(loader).jsonStore.onWrite = function (storage) {
-        assert.fail("Nothing should be written since `storage` was not changed.");
+        prefs.reset(QUOTA_PREF);
+        done();
       };
       loader.unload();
-      prefs.reset(QUOTA_PREF);
-      done();
     };
     loader.unload();
   });
   // This will be JSON.stringify()ed to: {"a":1,"b":2,"c":3} (19 bytes)
   ss.storage = { a: 1, b: 2, c: 3 };
   manager(loader).jsonStore.write();
 };
 
@@ -195,19 +192,16 @@ exports.testQuotaExceededNoHandle = func
       };
       loader.unload();
 
       loader = Loader(module);
       ss = loader.require("sdk/simple-storage");
       assert.equal(ss.storage, val,
                        "Over-quota value should not have been written, " +
                        "old value should have persisted: " + ss.storage);
-      manager(loader).jsonStore.onWrite = function (storage) {
-        assert.fail("Nothing should be written since `storage` was not changed.");
-      };
       loader.unload();
       prefs.reset(QUOTA_PREF);
       done();
     });
     manager(loader).jsonStore.write();
   };
 
   let val = "foo";
@@ -252,194 +246,16 @@ exports.testUninstall = function (assert
     loader.unload("uninstall");
     assert.ok(!file.exists(storeFilename), "Store file should be removed");
     done();
   };
   ss.storage.foo = "foo";
   loader.unload();
 };
 
-exports.testChangeInnerArray = function(assert, done) {
-  prefs.set(WRITE_PERIOD_PREF, 10);
-
-  let expected = {
-    x: [5, 7],
-    y: [7, 28],
-    z: [6, 2]
-  };
-
-  // Load the module, set a value.
-  let loader = Loader(module);
-  let ss = loader.require("sdk/simple-storage");
-  manager(loader).jsonStore.onWrite = function (storage) {
-    assert.ok(file.exists(storeFilename), "Store file should exist");
-
-    // Load the module again and check the result
-    loader = Loader(module);
-    ss = loader.require("sdk/simple-storage");
-    assert.equal(JSON.stringify(ss.storage),
-                     JSON.stringify(expected), "Should see the expected object");
-
-    // Add a property
-    ss.storage.x.push(["bar"]);
-    expected.x.push(["bar"]);
-    manager(loader).jsonStore.onWrite = function (storage) {
-      assert.equal(JSON.stringify(ss.storage),
-                       JSON.stringify(expected), "Should see the expected object");
-
-      // Modify a property
-      ss.storage.y[0] = 42;
-      expected.y[0] = 42;
-      manager(loader).jsonStore.onWrite = function (storage) {
-        assert.equal(JSON.stringify(ss.storage),
-                         JSON.stringify(expected), "Should see the expected object");
-
-        // Delete a property
-        delete ss.storage.z[1];
-        delete expected.z[1];
-        manager(loader).jsonStore.onWrite = function (storage) {
-          assert.equal(JSON.stringify(ss.storage),
-                           JSON.stringify(expected), "Should see the expected object");
-
-          // Modify the new inner-object
-          ss.storage.x[2][0] = "baz";
-          expected.x[2][0] = "baz";
-          manager(loader).jsonStore.onWrite = function (storage) {
-            assert.equal(JSON.stringify(ss.storage),
-                             JSON.stringify(expected), "Should see the expected object");
-
-            manager(loader).jsonStore.onWrite = function (storage) {
-              assert.fail("Nothing should be written since `storage` was not changed.");
-            };
-            loader.unload();
-
-            // Load the module again and check the result
-            loader = Loader(module);
-            ss = loader.require("sdk/simple-storage");
-            assert.equal(JSON.stringify(ss.storage),
-                             JSON.stringify(expected), "Should see the expected object");
-            loader.unload();
-            file.remove(storeFilename);
-            prefs.reset(WRITE_PERIOD_PREF);
-            done();
-          };
-        };
-      };
-    };
-  };
-
-  ss.storage = {
-    x: [5, 7],
-    y: [7, 28],
-    z: [6, 2]
-  };
-  assert.equal(JSON.stringify(ss.storage),
-                   JSON.stringify(expected), "Should see the expected object");
-
-  loader.unload();
-};
-
-exports.testChangeInnerObject = function(assert, done) {
-  prefs.set(WRITE_PERIOD_PREF, 10);
-
-  let expected = {
-    x: {
-      a: 5,
-      b: 7
-    },
-    y: {
-      c: 7,
-      d: 28
-    },
-    z: {
-      e: 6,
-      f: 2
-    }
-  };
-
-  // Load the module, set a value.
-  let loader = Loader(module);
-  let ss = loader.require("sdk/simple-storage");
-  manager(loader).jsonStore.onWrite = function (storage) {
-    assert.ok(file.exists(storeFilename), "Store file should exist");
-
-    // Load the module again and check the result
-    loader = Loader(module);
-    ss = loader.require("sdk/simple-storage");
-    assert.equal(JSON.stringify(ss.storage),
-                     JSON.stringify(expected), "Should see the expected object");
-
-    // Add a property
-    ss.storage.x.g = {foo: "bar"};
-    expected.x.g = {foo: "bar"};
-    manager(loader).jsonStore.onWrite = function (storage) {
-      assert.equal(JSON.stringify(ss.storage),
-                       JSON.stringify(expected), "Should see the expected object");
-
-      // Modify a property
-      ss.storage.y.c = 42;
-      expected.y.c = 42;
-      manager(loader).jsonStore.onWrite = function (storage) {
-        assert.equal(JSON.stringify(ss.storage),
-                         JSON.stringify(expected), "Should see the expected object");
-
-        // Delete a property
-        delete ss.storage.z.f;
-        delete expected.z.f;
-        manager(loader).jsonStore.onWrite = function (storage) {
-          assert.equal(JSON.stringify(ss.storage),
-                           JSON.stringify(expected), "Should see the expected object");
-
-          // Modify the new inner-object
-          ss.storage.x.g.foo = "baz";
-          expected.x.g.foo = "baz";
-          manager(loader).jsonStore.onWrite = function (storage) {
-            assert.equal(JSON.stringify(ss.storage),
-                             JSON.stringify(expected), "Should see the expected object");
-
-            manager(loader).jsonStore.onWrite = function (storage) {
-              assert.fail("Nothing should be written since `storage` was not changed.");
-            };
-            loader.unload();
-
-            // Load the module again and check the result
-            loader = Loader(module);
-            ss = loader.require("sdk/simple-storage");
-            assert.equal(JSON.stringify(ss.storage),
-                             JSON.stringify(expected), "Should see the expected object");
-            loader.unload();
-            file.remove(storeFilename);
-            prefs.reset(WRITE_PERIOD_PREF);
-            done();
-          };
-        };
-      };
-    };
-  };
-
-  ss.storage = {
-    x: {
-      a: 5,
-      b: 7
-    },
-    y: {
-      c: 7,
-      d: 28
-    },
-    z: {
-      e: 6,
-      f: 2
-    }
-  };
-  assert.equal(JSON.stringify(ss.storage),
-                   JSON.stringify(expected), "Should see the expected object");
-
-  loader.unload();
-};
-
 exports.testSetNoSetRead = function (assert, done) {
   // Load the module, set a value.
   let loader = Loader(module);
   let ss = loader.require("sdk/simple-storage");
   manager(loader).jsonStore.onWrite = function (storage) {
     assert.ok(file.exists(storeFilename), "Store file should exist");
 
     // Load the module again but don't access ss.storage.
@@ -448,23 +264,22 @@ exports.testSetNoSetRead = function (ass
     manager(loader).jsonStore.onWrite = function (storage) {
       assert.fail("Nothing should be written since `storage` was not accessed.");
     };
     loader.unload();
 
     // Load the module a third time and make sure the value stuck.
     loader = Loader(module);
     ss = loader.require("sdk/simple-storage");
-    assert.equal(ss.storage.foo, val, "Value should persist");
     manager(loader).jsonStore.onWrite = function (storage) {
-      assert.fail("Nothing should be written since `storage` was not changed.");
+      file.remove(storeFilename);
+      done();
     };
+    assert.equal(ss.storage.foo, val, "Value should persist");
     loader.unload();
-    file.remove(storeFilename);
-    done();
   };
   let val = "foo";
   ss.storage.foo = val;
   assert.equal(ss.storage.foo, val, "Value read should be value set");
   loader.unload();
 };
 
 
@@ -475,23 +290,22 @@ function setGetRoot(assert, done, val, c
   let loader = Loader(module);
   let ss = loader.require("sdk/simple-storage");
   manager(loader).jsonStore.onWrite = function () {
     assert.ok(file.exists(storeFilename), "Store file should exist");
 
     // Load the module again and make sure the value stuck.
     loader = Loader(module);
     ss = loader.require("sdk/simple-storage");
-    assert.ok(compare(ss.storage, val), "Value should persist");
-    manager(loader).jsonStore.onWrite = function (storage) {
-      assert.fail("Nothing should be written since `storage` was not changed.");
+    manager(loader).jsonStore.onWrite = function () {
+      file.remove(storeFilename);
+      done();
     };
+    assert.ok(compare(ss.storage, val), "Value should persist");
     loader.unload();
-    file.remove(storeFilename);
-    done();
   };
   ss.storage = val;
   assert.ok(compare(ss.storage, val), "Value read should be value set");
   loader.unload();
 }
 
 function setGetRootError(assert, done, val, msg) {
   let pred = new RegExp("storage must be one of the following types: " +
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -93,16 +93,25 @@ const injectObjectAPI = function(api, ta
   // the `contentObj` without Xrays.
   try {
     Object.seal(Cu.waiveXrays(contentObj));
   } catch (ex) {}
   return contentObj;
 };
 
 /**
+ * Get the two-digit hexadecimal code for a byte
+ *
+ * @param {byte} charCode
+ */
+const toHexString = function(charCode) {
+  return ("0" + charCode.toString(16)).slice(-2);
+};
+
+/**
  * 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).
  *
  * See the documentation on the individual functions for details of the API.
  *
  * @param {nsIDOMWindow} targetWindow The content window to attach the API.
  */
 function injectLoopAPI(targetWindow) {
@@ -505,16 +514,52 @@ function injectLoopAPI(targetWindow) {
      */
     telemetryAdd: {
       enumerable: true,
       writable: true,
       value: function(histogramId, value) {
         Services.telemetry.getHistogramById(histogramId).add(value);
       }
     },
+
+    /**
+     * Compose a URL pointing to the location of an avatar by email address.
+     * At the moment we use the Gravatar service to match email addresses with
+     * avatars. This might change in the future as avatars might come from another
+     * source.
+     *
+     * @param {String} emailAddress Users' email address
+     * @param {Number} size         Size of the avatar image to return in pixels.
+     *                              Optional. Default value: 40.
+     * @return the URL pointing to an avatar matching the provided email address.
+     */
+    getUserAvatar: {
+      enumerable: true,
+      writable: true,
+      value: function(emailAddress, size = 40) {
+        if (!emailAddress) {
+          return "";
+        }
+
+        // Do the MD5 dance.
+        let hasher = Cc["@mozilla.org/security/hash;1"]
+                       .createInstance(Ci.nsICryptoHash);
+        hasher.init(Ci.nsICryptoHash.MD5);
+        let stringStream = Cc["@mozilla.org/io/string-input-stream;1"]
+                             .createInstance(Ci.nsIStringInputStream);
+        stringStream.data = emailAddress.trim().toLowerCase();
+        hasher.updateFromStream(stringStream, -1);
+        let hash = hasher.finish(false);
+        // Convert the binary hash data to a hex string.
+        let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
+
+        // Compose the Gravatar URL.
+        return "http://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
+      }
+    },
   };
 
   function onStatusChanged(aSubject, aTopic, aData) {
     let event = new targetWindow.CustomEvent("LoopStatusChanged");
     targetWindow.dispatchEvent(event)
   };
 
   function onDOMWindowDestroyed(aSubject, aTopic, aData) {
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.js
@@ -0,0 +1,195 @@
+/** @jsx React.DOM */
+
+/* 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/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  // Number of contacts to add to the list at the same time.
+  const CONTACTS_CHUNK_SIZE = 100;
+
+  const ContactDetail = React.createClass({displayName: 'ContactDetail',
+    propTypes: {
+      handleContactClick: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        React.DOM.li({onClick: this.handleContactClick, className: contactCSSClass}, 
+          React.DOM.div({className: "avatar"}, 
+            React.DOM.img({src: navigator.mozLoop.getUserAvatar(email.value)})
+          ), 
+          React.DOM.div({className: "details"}, 
+            React.DOM.div({className: "username"}, React.DOM.strong(null, names.firstName), " ", names.lastName, 
+              React.DOM.i({className: cx({"icon icon-google": this.props.contact.category[0] == "google"})}), 
+              React.DOM.i({className: cx({"icon icon-blocked": this.props.contact.blocked})})
+            ), 
+            React.DOM.div({className: "email"}, email.value)
+          ), 
+          React.DOM.div({className: "icons"}, 
+            React.DOM.i({className: "icon icon-video"}), 
+            React.DOM.i({className: "icon icon-caret-down"})
+          )
+        )
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({displayName: 'ContactsList',
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // 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);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    sortContacts: function(contact1, contact2) {
+      let comp = contact1.name[0].localeCompare(contact2.name[0]);
+      if (comp !== 0) {
+        return comp;
+      }
+      // If names are equal, compare against unique ids to make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return ContactDetail({key: item._guid, contact: item})
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      return (
+        React.DOM.div({className: "listWrapper"}, 
+          React.DOM.div({ref: "listSlider", className: "listPanels"}, 
+            React.DOM.div({className: "faded"}, 
+              React.DOM.ul(null, 
+                shownContacts.available ?
+                  shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+                  null, 
+                shownContacts.blocked ?
+                  React.DOM.h3({className: "header"}, mozL10n.get("contacts_blocked_contacts")) :
+                  null, 
+                shownContacts.blocked ?
+                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+                  null
+              )
+            )
+          )
+        )
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList
+  };
+})(_, document.mozL10n);
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -0,0 +1,195 @@
+/** @jsx React.DOM */
+
+/* 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/. */
+
+/*jshint newcap:false*/
+/*global loop:true, React */
+
+var loop = loop || {};
+loop.contacts = (function(_, mozL10n) {
+  "use strict";
+
+  // Number of contacts to add to the list at the same time.
+  const CONTACTS_CHUNK_SIZE = 100;
+
+  const ContactDetail = React.createClass({
+    propTypes: {
+      handleContactClick: React.PropTypes.func,
+      contact: React.PropTypes.object.isRequired
+    },
+
+    handleContactClick: function() {
+      if (this.props.handleContactClick) {
+        this.props.handleContactClick(this.props.key);
+      }
+    },
+
+    getContactNames: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      // NOTE: this method of finding a firstname and lastname is not i18n-proof.
+      let names = this.props.contact.name[0].split(" ");
+      return {
+        firstName: names.shift(),
+        lastName: names.join(" ")
+      };
+    },
+
+    getPreferredEmail: function() {
+      // The model currently does not enforce a name to be present, but we're
+      // going to assume it is awaiting more advanced validation of required fields
+      // by the model. (See bug 1069918)
+      let email = this.props.contact.email[0];
+      this.props.contact.email.some(function(address) {
+        if (address.pref) {
+          email = address;
+          return true;
+        }
+        return false;
+      });
+      return email;
+    },
+
+    render: function() {
+      let names = this.getContactNames();
+      let email = this.getPreferredEmail();
+      let cx = React.addons.classSet;
+      let contactCSSClass = cx({
+        contact: true,
+        blocked: this.props.contact.blocked
+      });
+
+      return (
+        <li onClick={this.handleContactClick} className={contactCSSClass}>
+          <div className="avatar">
+            <img src={navigator.mozLoop.getUserAvatar(email.value)} />
+          </div>
+          <div className="details">
+            <div className="username"><strong>{names.firstName}</strong> {names.lastName}
+              <i className={cx({"icon icon-google": this.props.contact.category[0] == "google"})} />
+              <i className={cx({"icon icon-blocked": this.props.contact.blocked})} />
+            </div>
+            <div className="email">{email.value}</div>
+          </div>
+          <div className="icons">
+            <i className="icon icon-video" />
+            <i className="icon icon-caret-down" />
+          </div>
+        </li>
+      );
+    }
+  });
+
+  const ContactsList = React.createClass({
+    getInitialState: function() {
+      return {
+        contacts: {}
+      };
+    },
+
+    componentDidMount: function() {
+      let contactsAPI = navigator.mozLoop.contacts;
+
+      contactsAPI.getAll((err, contacts) => {
+        if (err) {
+          throw err;
+        }
+
+        // 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);
+          });
+          if (contacts.length) {
+            setTimeout(addContactsInChunks, 0);
+          }
+        };
+
+        addContactsInChunks(contacts);
+
+        // Listen for contact changes/ updates.
+        contactsAPI.on("add", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+        contactsAPI.on("remove", (eventName, contact) => {
+          this.handleContactRemove(contact);
+        });
+        contactsAPI.on("removeAll", () => {
+          this.handleContactRemoveAll();
+        });
+        contactsAPI.on("update", (eventName, contact) => {
+          this.handleContactAddOrUpdate(contact);
+        });
+      });
+    },
+
+    handleContactAddOrUpdate: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      contacts[guid] = contact;
+      this.setState({});
+    },
+
+    handleContactRemove: function(contact) {
+      let contacts = this.state.contacts;
+      let guid = String(contact._guid);
+      if (!contacts[guid]) {
+        return;
+      }
+      delete contacts[guid];
+      this.setState({});
+    },
+
+    handleContactRemoveAll: function() {
+      this.setState({contacts: {}});
+    },
+
+    sortContacts: function(contact1, contact2) {
+      let comp = contact1.name[0].localeCompare(contact2.name[0]);
+      if (comp !== 0) {
+        return comp;
+      }
+      // If names are equal, compare against unique ids to make sure we have
+      // consistent ordering.
+      return contact1._guid - contact2._guid;
+    },
+
+    render: function() {
+      let viewForItem = item => {
+        return <ContactDetail key={item._guid} contact={item} />
+      };
+
+      let shownContacts = _.groupBy(this.state.contacts, function(contact) {
+        return contact.blocked ? "blocked" : "available";
+      });
+
+      return (
+        <div className="listWrapper">
+          <div ref="listSlider" className="listPanels">
+            <div className="faded">
+              <ul>
+                {shownContacts.available ?
+                  shownContacts.available.sort(this.sortContacts).map(viewForItem) :
+                  null}
+                {shownContacts.blocked ?
+                  <h3 className="header">{mozL10n.get("contacts_blocked_contacts")}</h3> :
+                  null}
+                {shownContacts.blocked ?
+                  shownContacts.blocked.sort(this.sortContacts).map(viewForItem) :
+                  null}
+              </ul>
+            </div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  return {
+    ContactsList: ContactsList
+  };
+})(_, document.mozL10n);
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var ContactsList = loop.contacts.ContactsList;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   /**
    * Panel router.
    * @type {loop.desktopRouter.DesktopRouter}
    */
   var router;
 
@@ -493,17 +494,17 @@ loop.panel = (function(_, mozL10n) {
           TabView({onSelect: this.selectTab}, 
             Tab({name: "call"}, 
               CallUrlResult({client: this.props.client, 
                              notifications: this.props.notifications, 
                              callUrl: this.props.callUrl}), 
               ToSView(null)
             ), 
             Tab({name: "contacts"}, 
-              React.DOM.span(null, "contacts")
+              ContactsList(null)
             )
           ), 
           React.DOM.div({className: "footer"}, 
             React.DOM.div({className: "user-details"}, 
               UserIdentity({displayName: displayName}), 
               AvailabilityDropdown(null)
             ), 
             AuthLink(null), 
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -9,16 +9,17 @@
 
 var loop = loop || {};
 loop.panel = (function(_, mozL10n) {
   "use strict";
 
   var sharedViews = loop.shared.views;
   var sharedModels = loop.shared.models;
   var sharedMixins = loop.shared.mixins;
+  var ContactsList = loop.contacts.ContactsList;
   var __ = mozL10n.get; // aliasing translation function as __ for concision
 
   /**
    * Panel router.
    * @type {loop.desktopRouter.DesktopRouter}
    */
   var router;
 
@@ -493,17 +494,17 @@ loop.panel = (function(_, mozL10n) {
           <TabView onSelect={this.selectTab}>
             <Tab name="call">
               <CallUrlResult client={this.props.client}
                              notifications={this.props.notifications}
                              callUrl={this.props.callUrl} />
               <ToSView />
             </Tab>
             <Tab name="contacts">
-              <span>contacts</span>
+              <ContactsList />
             </Tab>
           </TabView>
           <div className="footer">
             <div className="user-details">
               <UserIdentity displayName={displayName} />
               <AvailabilityDropdown />
             </div>
             <AuthLink />
--- a/browser/components/loop/content/panel.html
+++ b/browser/components/loop/content/panel.html
@@ -4,27 +4,29 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/.  -->
 <html>
   <head>
     <meta charset="utf-8">
     <title>Loop Panel</title>
     <link rel="stylesheet" type="text/css" href="loop/shared/css/reset.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/common.css">
     <link rel="stylesheet" type="text/css" href="loop/shared/css/panel.css">
+    <link rel="stylesheet" type="text/css" href="loop/shared/css/contacts.css">
   </head>
   <body class="panel">
 
     <div id="main"></div>
 
     <script type="text/javascript" src="loop/shared/libs/react-0.11.1.js"></script>
     <script type="text/javascript" src="loop/libs/l10n.js"></script>
     <script type="text/javascript" src="loop/shared/libs/jquery-2.1.0.js"></script>
     <script type="text/javascript" src="loop/shared/libs/lodash-2.4.1.js"></script>
     <script type="text/javascript" src="loop/shared/libs/backbone-1.1.2.js"></script>
 
     <script type="text/javascript" src="loop/shared/js/utils.js"></script>
     <script type="text/javascript" src="loop/shared/js/models.js"></script>
     <script type="text/javascript" src="loop/shared/js/mixins.js"></script>
     <script type="text/javascript" src="loop/shared/js/views.js"></script>
     <script type="text/javascript" src="loop/js/client.js"></script>
+    <script type="text/javascript;version=1.8" src="loop/js/contacts.js"></script>
     <script type="text/javascript" src="loop/js/panel.js"></script>
  </body>
 </html>
--- a/browser/components/loop/content/shared/css/common.css
+++ b/browser/components/loop/content/shared/css/common.css
@@ -390,8 +390,20 @@ p {
 
 .firefox-logo {
   margin: 0 auto; /* horizontal block centering */
   width: 100px;
   height: 100px;
   background: transparent url(../img/firefox-logo.png) no-repeat center center;
   background-size: contain;
 }
+
+.header {
+  padding: 5px 10px;
+  color: #888;
+  margin: 0;
+  border-top: 1px solid #CCC;
+  background: #EEE;
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+  height: 24px;
+}
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/css/contacts.css
@@ -0,0 +1,163 @@
+/* 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/. */
+
+.contact {
+  display: flex;
+  flex-direction: row;
+  position: relative;
+  padding: 5px 10px;
+  color: #666;
+  font-size: 13px;
+  align-items: center;
+}
+
+.contact:not(:first-child) {
+  border-top: 1px solid #ddd;
+}
+
+.contact.blocked > .details > .username {
+  color: #d74345;
+}
+
+.contact:hover {
+  background: #eee;
+}
+
+.contact.selected {
+  background: #ebebeb;
+}
+
+.contact:hover > .icons {
+  display: block;
+  z-index: 1000;
+}
+
+.contact > .avatar {
+  width: 40px;
+  height: 40px;
+  background: #ccc;
+  border-radius: 50%;
+  margin-right: 10px;
+  overflow: hidden;
+  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.3);
+  background-image: url("../img/audio-call-avatar.svg");
+  background-repeat: no-repeat;
+  background-color: #4ba6e7;
+  background-size: contain;
+}
+
+.contact > .avatar > img {
+  width: 100%;
+}
+
+.contact > .details > .username {
+  font-size: 12px;
+  line-height: 20px;
+  color: #222;
+  font-weight: normal;
+}
+
+.contact > .details > .username > strong {
+  font-weight: bold;
+}
+
+.contact > .details > .username > i.icon-blocked {
+  display: inline-block;
+  width: 10px;
+  height: 20px;
+  -moz-margin-start: 3px;
+  background-image: url("../img/icons-16x16.svg#block-red");
+  background-position: center;
+  background-size: 10px 10px;
+  background-repeat: no-repeat;
+}
+
+.contact > .details > .username > i.icon-google {
+  position: absolute;
+  right: 10px;
+  top: 35%;
+  width: 14px;
+  height: 14px;
+  border-radius: 50%;
+  background-image: url("../img/icons-16x16.svg#google");
+  background-position: center;
+  background-size: 16px 16px;
+  background-repeat: no-repeat;
+  background-color: fff;
+}
+
+.contact > .details > .email {
+  color: #999;
+  font-size: 11px;
+  line-height: 16px;
+}
+
+.listWrapper {
+  overflow-x: hidden;
+  overflow-y: auto;
+  /* Show six contacts and scroll for the rest */
+  max-height: 305px;
+}
+
+.listPanels {
+  display: flex;
+  width: 200%;
+  flex-direction: row;
+  transition: 200ms ease-in;
+  transition-property: transform;
+}
+
+.listPanels > div {
+  flex: 0 0 50%;
+}
+
+.list {
+  display: flex;
+  flex-direction: column;
+  transition: opacity 0.3s ease-in-out;
+}
+
+.list.faded {
+  opacity: 0.3;
+}
+
+.list h3 {
+  margin: 0;
+  border-bottom: none;
+  border-top: 1px solid #ccc;
+}
+
+.icons {
+  cursor: pointer;
+  display: none;
+  margin-left: auto;
+  padding: 12px 10px;
+  border-radius: 30px;
+  background: #7ed321;
+}
+
+.icons:hover {
+  background: #89e029;
+}
+
+.icons i {
+  margin: 0 5px;
+  display: inline-block;
+  background-position: center;
+  background-repeat: no-repeat;
+}
+
+.icons i.icon-video {
+  background-image: url("../img/icons-14x14.svg#video-white");
+  background-size: 14px 14px;
+  width: 16px;
+  height: 16px;
+}
+
+.icons i.icon-caret-down {
+  background-image: url("../img/icons-10x10.svg#dropdown-white");
+  background-size: 10px 10px;
+  width: 10px;
+  height: 16px;
+}
--- a/browser/components/loop/content/shared/css/panel.css
+++ b/browser/components/loop/content/shared/css/panel.css
@@ -34,16 +34,17 @@
 .tab-view > li {
   flex: 1;
   text-align: center;
   color: #ccc;
   border-right: 1px solid #ccc;
   padding: 0 10px;
   height: 16px;
   cursor: pointer;
+  overflow: hidden;
   background-repeat: no-repeat;
   background-size: 16px 16px;
   background-position: center;
 }
 
 .tab-view > li[data-tab-name="call"] {
   background-image: url("../img/icons-16x16.svg#precall");
 }
@@ -77,16 +78,17 @@
 }
 
 .tab.selected {
   display: block;
 }
 
 .share {
   background: #fbfbfb;
+  margin-bottom: 14px;
 }
 
 .share .description,
 .share .action input,
 .share > .action > .invite > .url-actions {
   margin: 14px 14px 0 14px;
 }
 
@@ -308,10 +310,9 @@ body[dir=rtl] .dropdown-menu-item {
   justify-content: space-between;
   align-content: stretch;
   align-items: flex-start;
   font-size: 1em;
   border-top: 1px solid #D1D1D1;
   background: #EAEAEA;
   color: #7F7F7F;
   padding: 14px;
-  margin-top: 14px;
 }
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-10x10.svg
@@ -0,0 +1,53 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 10 10"
+     enable-background="new 0 0 10 10"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: rgba(255, 255, 255, 0.8);
+}
+</style>
+<defs style="display:none">
+  <polygon id="close-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,1.717 8.336,0.049 5.024,3.369 1.663,0 0,1.668 
+    3.36,5.037 0.098,8.307 1.762,9.975 5.025,6.705 8.311,10 9.975,8.332 6.688,5.037"/>
+  <path id="dropdown-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9,3L4.984,7L1,3H9z"/>
+  <polygon id="expand-shape" fill-rule="evenodd" clip-rule="evenodd" points="10,0 4.838,0 6.506,1.669 0,8.175 1.825,10 8.331,3.494 
+    10,5.162"/>
+  <rect id="minimize-shape" y="3.6" fill-rule="evenodd" clip-rule="evenodd" width="10" height="2.8"/>
+</defs>
+<use id="close"               xlink:href="#close-shape"/>
+<use id="close-active"        xlink:href="#close-shape"/>
+<use id="close-disabled"      xlink:href="#close-shape"/>
+<use id="dropdown"            xlink:href="#dropdown-shape"/>
+<use id="dropdown-white"      xlink:href="#dropdown-shape"/>
+<use id="dropdown-active"     xlink:href="#dropdown-shape"/>
+<use id="dropdown-disabled"   xlink:href="#dropdown-shape"/>
+<use id="expand"              xlink:href="#expand-shape"/>
+<use id="expand-active"       xlink:href="#expand-shape"/>
+<use id="expand-disabled"     xlink:href="#expand-shape"/>
+<use id="minimize"            xlink:href="#minimize-shape"/>
+<use id="minimize-active"     xlink:href="#minimize-shape"/>
+<use id="minimize-disabled"   xlink:href="#minimize-shape"/>
+</svg>
new file mode 100644
--- /dev/null
+++ b/browser/components/loop/content/shared/img/icons-14x14.svg
@@ -0,0 +1,134 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink"
+     x="0px" y="0px"
+     viewBox="0 0 14 14"
+     enable-background="new 0 0 14 14"
+     xml:space="preserve">
+<style>
+use:not(:target) {
+  display: none;
+}
+
+use {
+  fill: #ccc;
+}
+
+use[id$="-hover"] {
+  fill: #444;
+}
+
+use[id$="-active"] {
+  fill: #0095dd;
+}
+
+use[id$="-white"] {
+  fill: #fff;
+}
+</style>
+<defs style="display:none">
+  <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M9.571,6.143v1.714c0,1.42-1.151,2.571-2.571,2.571
+    c-1.42,0-2.571-1.151-2.571-2.571V6.143H3.571v1.714c0,1.597,1.093,2.935,2.571,3.316v0.97H5.714c-0.56,0-1.034,0.358-1.211,0.857
+    h4.993c-0.177-0.499-0.651-0.857-1.211-0.857H7.857v-0.97c1.478-0.381,2.571-1.719,2.571-3.316V6.143H9.571z M7,10
+    c1.183,0,2.143-0.959,2.143-2.143V3.143C9.143,1.959,8.183,1,7,1C5.817,1,4.857,1.959,4.857,3.143v4.714C4.857,9.041,5.817,10,7,10
+    z"/>
+  <g id="facemute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.174,3.551L9.568,5.856V5.847L3.39,11.49h5.066
+      c0.613,0,1.111-0.533,1.111-1.19V8.526l2.606,2.304C12.4,11.071,12.71,11.142,13,11.078V3.302C12.71,3.239,12.4,3.309,12.174,3.551
+      z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M12.395,2.617l-0.001-0.001l-0.809-0.884l-2.102,1.92
+      C9.316,3.221,8.919,2.918,8.457,2.918H2.111C1.498,2.918,1,3.451,1,4.109v6.191c0,0.318,0.118,0.607,0.306,0.821l-0.288,0.263
+      l0.809,0.884l0.001,0.001l0.853-0.779l6.887-6.29L12.395,2.617z"/>
+  </g>
+  <path id="hangup-shape" fill-rule="evenodd" clip-rule="evenodd" d="M13,11.732c-0.602,0.52-1.254,0.946-1.941,1.267
+    c-1.825-0.337-4.164-1.695-6.264-3.795C2.696,7.106,1.339,4.769,1,2.945c0.321-0.688,0.748-1.341,1.268-1.944l2.528,2.855
+    C4.579,4.153,4.377,4.454,4.209,4.759L4.22,4.77C3.924,5.42,4.608,6.833,5.889,8.114c1.281,1.28,2.694,1.965,3.343,1.669
+    l0.011,0.011c0.305-0.168,0.606-0.37,0.904-0.587L13,11.732z"/>
+  <path id="incoming-shape" fill-rule="evenodd" clip-rule="evenodd" d="M2.745,7.558l0.637,0.669c0.04,0.041,0.085,0.073,0.134,0.1
+    l3.249,3.313c0.38,0.393,0.915,0.478,1.197,0.186l0.638-0.676c0.281-0.292,0.2-0.848-0.18-1.244L7.097,8.558h3.566
+    c0.419,0,0.759-0.34,0.759-0.759V6.28c0-0.419-0.34-0.759-0.759-0.759H7.059l1.42-1.443c0.381-0.392,0.461-0.945,0.18-1.234
+    l-0.637-0.67C7.74,1.883,7.204,1.966,6.824,2.359L3.55,5.688C3.487,5.717,3.43,5.755,3.381,5.806L2.745,6.482
+    c-0.131,0.137-0.183,0.332-0.162,0.54C2.562,7.229,2.613,7.423,2.745,7.558z"/>
+  <path id="link-shape" fill-rule="evenodd" clip-rule="evenodd" d="M7.359,6.107c0.757-0.757,0.757-1.995,0-2.752
+    L5.573,1.568c-0.757-0.757-1.995-0.757-2.752,0L1.568,2.82c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0L6.266,7.2L6.8,7.734L6.641,7.893c-0.757,0.757-0.757,1.995,0,2.752l1.787,1.787
+    c0.757,0.757,1.995,0.757,2.752,0l1.253-1.253c0.757-0.757,0.757-1.995,0-2.752l-1.787-1.787c-0.757-0.757-1.995-0.757-2.752,0
+    L7.734,6.8L7.2,6.266L7.359,6.107z M9.87,7.868l1.335,1.335c0.294,0.294,0.294,0.774,0,1.068l-0.934,0.934
+    c-0.294,0.294-0.774,0.294-1.068,0L7.868,9.87c-0.294-0.294-0.294-0.774,0-1.068L8.13,9.064c0.294,0.294,0.744,0.324,1.001,0.067
+    C9.388,8.874,9.358,8.424,9.064,8.13L8.802,7.868C9.096,7.574,9.577,7.574,9.87,7.868z M4.13,6.132L2.795,4.797
+    c-0.294-0.294-0.294-0.774,0-1.068l0.934-0.934c0.294-0.294,0.774-0.294,1.068,0L6.132,4.13c0.294,0.294,0.294,0.774,0,1.068
+    L5.86,4.926C5.567,4.632,5.116,4.602,4.859,4.859C4.602,5.116,4.632,5.567,4.926,5.86l0.272,0.272
+    C4.904,6.426,4.423,6.426,4.13,6.132z"/>
+  <g id="mute-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M5.186,9.492L5.49,9.188l3.822-3.822l2.354-2.354l-0.848-0.848
+      L9.312,3.669V3.142C9.312,1.959,8.352,1,7.169,1C5.986,1,5.026,1.959,5.026,3.142v4.715c0,0.032,0.001,0.064,0.002,0.096
+      L4.643,8.338c-0.03-0.156-0.046-0.317-0.046-0.481V6.142H3.741v1.715c0,0.414,0.073,0.81,0.208,1.176l-1.615,1.615l0.848,0.848
+      l1.398-1.398v0L5.186,9.492z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.312,7.857V6.045L5.829,9.528C6.196,9.824,6.662,10,7.169,10
+      C8.352,10,9.312,9.04,9.312,7.857z"/>
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M9.741,7.857c0,1.42-1.151,2.572-2.572,2.572
+      c-0.625,0-1.199-0.223-1.645-0.595l-0.605,0.605c0.395,0.344,0.87,0.599,1.393,0.734v0.97H5.884c-0.56,0-1.034,0.359-1.212,0.858
+      h4.994c-0.178-0.499-0.652-0.858-1.212-0.858H8.026v-0.97c1.478-0.38,2.572-1.718,2.572-3.316V6.142H9.741V7.857z"/>
+  </g>
+  <path id="pause-shape" fill-rule="evenodd" clip-rule="evenodd" d="M4.75,1h-1.5C2.836,1,2.5,1.336,2.5,1.75v10.5
+    C2.5,12.664,2.836,13,3.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C5.5,1.336,5.164,1,4.75,1z M10.75,1h-1.5
+    C8.836,1,8.5,1.336,8.5,1.75v10.5C8.5,12.664,8.836,13,9.25,13h1.5c0.414,0,0.75-0.336,0.75-0.75V1.75C11.5,1.336,11.164,1,10.75,1
+    z"/>
+  <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M12.175,3.347L9.568,5.651V3.905c0-0.657-0.497-1.19-1.111-1.19
+    H2.111C1.498,2.714,1,3.247,1,3.905v6.191c0,0.658,0.498,1.19,1.111,1.19h6.345c0.614,0,1.111-0.533,1.111-1.19V8.322l2.607,2.305
+    C12.4,10.867,12.71,10.938,13,10.874V3.099C12.71,3.035,12.4,3.106,12.175,3.347z"/>
+  <g id="volume-shape">
+    <path fill-rule="evenodd" clip-rule="evenodd" d="M3.513,4.404H1.896c-0.417,0-0.756,0.338-0.756,0.755v3.679
+      c0,0.417,0.338,0.755,0.756,0.755H3.51l2.575,2.575c0.261,0.261,0.596,0.4,0.938,0.422V1.409C6.682,1.431,6.346,1.57,6.085,1.831
+      L3.513,4.404z M8.555,5.995C8.619,6.32,8.653,6.656,8.653,7c0,0.344-0.034,0.679-0.098,1.004l0.218,0.142
+      C8.852,7.777,8.895,7.393,8.895,7c0-0.394-0.043-0.777-0.123-1.147L8.555,5.995z M12.224,3.6l-0.475,0.31
+      c0.359,0.962,0.557,2.003,0.557,3.09c0,1.087-0.198,2.128-0.557,3.09l0.475,0.31c0.41-1.054,0.635-2.201,0.635-3.4
+      C12.859,5.8,12.634,4.654,12.224,3.6z M10.061,5.012C10.25,5.642,10.353,6.308,10.353,7c0,0.691-0.103,1.358-0.293,1.987
+      l0.351,0.229C10.634,8.517,10.756,7.772,10.756,7c0-0.773-0.121-1.517-0.345-2.216L10.061,5.012z"/>
+    <path d="M7.164,12.74l-0.15-0.009c-0.389-0.024-0.754-0.189-1.028-0.463L3.452,9.735H1.896
+      C1.402,9.735,1,9.333,1,8.838V5.16c0-0.494,0.402-0.896,0.896-0.896h1.558l2.531-2.531C6.26,1.458,6.625,1.293,7.014,1.269
+      l0.15-0.009V12.74z M1.896,4.545c-0.339,0-0.615,0.276-0.615,0.615v3.679c0,0.339,0.276,0.615,0.615,0.615h1.672l2.616,2.616
+      c0.19,0.19,0.434,0.316,0.697,0.363V1.568C6.619,1.615,6.375,1.741,6.185,1.931L3.571,4.545H1.896z M12.292,10.612l-0.714-0.467
+      l0.039-0.105C11.981,9.067,12.165,8.044,12.165,7c0-1.044-0.184-2.067-0.548-3.041l-0.039-0.105l0.714-0.467l0.063,0.162
+      C12.783,4.649,13,5.81,13,7s-0.217,2.351-0.645,3.451L12.292,10.612z M11.92,10.033l0.234,0.153
+      c0.374-1.019,0.564-2.09,0.564-3.186s-0.19-2.167-0.564-3.186L11.92,3.966C12.27,4.94,12.447,5.96,12.447,7
+      C12.447,8.04,12.27,9.059,11.92,10.033z M10.489,9.435L9.895,9.047l0.031-0.101C10.116,8.315,10.212,7.66,10.212,7
+      c0-0.661-0.096-1.316-0.287-1.947L9.895,4.952l0.594-0.388l0.056,0.176C10.779,5.471,10.897,6.231,10.897,7
+      c0,0.769-0.118,1.529-0.351,2.259L10.489,9.435z M10.225,8.926l0.106,0.069C10.52,8.348,10.615,7.677,10.615,7
+      c0-0.677-0.095-1.348-0.284-1.996l-0.106,0.07C10.403,5.699,10.494,6.347,10.494,7C10.494,7.652,10.403,8.3,10.225,8.926z
+       M8.867,8.376L8.398,8.07l0.018-0.093C8.48,7.654,8.512,7.325,8.512,7S8.48,6.345,8.417,6.022L8.398,5.929l0.469-0.306l0.043,0.2
+      C8.994,6.211,9.036,6.607,9.036,7c0,0.393-0.042,0.789-0.126,1.176L8.867,8.376z"/>
+  </g>
+</defs>
+<use id="audio"               xlink:href="#audio-shape"/>
+<use id="audio-active"        xlink:href="#audio-shape"/>
+<use id="audio-disabled"      xlink:href="#audio-shape"/>
+<use id="facemute"            xlink:href="#facemute-shape"/>
+<use id="facemute-active"     xlink:href="#facemute-shape"/>
+<use id="facemute-disabled"   xlink:href="#facemute-shape"/>
+<use id="hangup"              xlink:href="#hangup-shape"/>
+<use id="hangup-active"       xlink:href="#hangup-shape"/>
+<use id="hangup-disabled"     xlink:href="#hangup-shape"/>
+<use id="incoming"            xlink:href="#incoming-shape"/>
+<use id="incoming-active"     xlink:href="#incoming-shape"/>
+<use id="incoming-disabled"   xlink:href="#incoming-shape"/>
+<use id="link"                xlink:href="#link-shape"/>
+<use id="link-active"         xlink:href="#link-shape"/>
+<use id="link-disabled"       xlink:href="#link-shape"/>
+<use id="mute"                xlink:href="#mute-shape"/>
+<use id="mute-active"         xlink:href="#mute-shape"/>
+<use id="mute-disabled"       xlink:href="#mute-shape"/>
+<use id="pause"               xlink:href="#pause-shape"/>
+<use id="pause-active"        xlink:href="#pause-shape"/>
+<use id="pause-disabled"      xlink:href="#pause-shape"/>
+<use id="video"               xlink:href="#video-shape"/>
+<use id="video-white"         xlink:href="#video-shape"/>
+<use id="video-active"        xlink:href="#video-shape"/>
+<use id="video-disabled"      xlink:href="#video-shape"/>
+<use id="volume"              xlink:href="#volume-shape"/>
+<use id="volume-active"       xlink:href="#volume-shape"/>
+<use id="volume-disabled"     xlink:href="#volume-shape"/>
+</svg>
--- a/browser/components/loop/content/shared/img/icons-16x16.svg
+++ b/browser/components/loop/content/shared/img/icons-16x16.svg
@@ -19,16 +19,20 @@ use {
 
 use[id$="-hover"] {
   fill: #444;
 }
 
 use[id$="-active"] {
   fill: #0095dd;
 }
+
+use[id$="-red"] {
+  fill: #d74345
+}
 </style>
 <defs style="display:none">
   <path id="audio-shape" fill-rule="evenodd" clip-rule="evenodd" d="M11.429,6.857v2.286c0,1.894-1.535,3.429-3.429,3.429
     c-1.894,0-3.429-1.535-3.429-3.429V6.857H3.429v2.286c0,2.129,1.458,3.913,3.429,4.422v1.293H6.286
     c-0.746,0-1.379,0.477-1.615,1.143h6.658c-0.236-0.665-0.869-1.143-1.615-1.143H9.143v-1.293c1.971-0.508,3.429-2.292,3.429-4.422
     V6.857H11.429z M8,12c1.578,0,2.857-1.279,2.857-2.857V2.857C10.857,1.279,9.578,0,8,0C6.422,0,5.143,1.279,5.143,2.857v6.286
     C5.143,10.721,6.422,12,8,12z"/>
   <path id="block-shape" fill-rule="evenodd" clip-rule="evenodd" d="M8,0C3.582,0,0,3.582,0,8c0,4.418,3.582,8,8,8
@@ -83,16 +87,17 @@ use[id$="-active"] {
   <path id="video-shape" fill-rule="evenodd" clip-rule="evenodd" d="M14.9,3.129l-3.476,3.073V3.873c0-0.877-0.663-1.587-1.482-1.587
     H1.482C0.663,2.286,0,2.996,0,3.873v8.254c0,0.877,0.663,1.587,1.482,1.587h8.461c0.818,0,1.482-0.711,1.482-1.587V9.762
     l3.476,3.073c0.3,0.321,0.714,0.416,1.1,0.331V2.798C15.614,2.713,15.2,2.808,14.9,3.129z"/>
 </defs>
 <use id="audio"               xlink:href="#audio-shape"/>
 <use id="audio-hover"         xlink:href="#audio-shape"/>
 <use id="audio-active"        xlink:href="#audio-shape"/>
 <use id="block"               xlink:href="#block-shape"/>
+<use id="block-red"           xlink:href="#block-shape"/>
 <use id="block-hover"         xlink:href="#block-shape"/>
 <use id="block-active"        xlink:href="#block-shape"/>
 <use id="contacts"            xlink:href="#contacts-shape"/>
 <use id="contacts-hover"      xlink:href="#contacts-shape"/>
 <use id="contacts-active"     xlink:href="#contacts-shape"/>
 <use id="google"              xlink:href="#google-shape"/>
 <use id="google-hover"        xlink:href="#google-shape"/>
 <use id="google-active"       xlink:href="#google-shape"/>
--- a/browser/components/loop/jar.mn
+++ b/browser/components/loop/jar.mn
@@ -11,22 +11,24 @@ browser.jar:
   content/browser/loop/libs/l10n.js                 (content/libs/l10n.js)
 
   # Desktop script
   content/browser/loop/js/client.js                 (content/js/client.js)
   content/browser/loop/js/desktopRouter.js          (content/js/desktopRouter.js)
   content/browser/loop/js/conversation.js           (content/js/conversation.js)
   content/browser/loop/js/otconfig.js               (content/js/otconfig.js)
   content/browser/loop/js/panel.js                  (content/js/panel.js)
+  content/browser/loop/js/contacts.js               (content/js/contacts.js)
 
   # Shared styles
   content/browser/loop/shared/css/reset.css         (content/shared/css/reset.css)
   content/browser/loop/shared/css/common.css        (content/shared/css/common.css)
   content/browser/loop/shared/css/panel.css         (content/shared/css/panel.css)
   content/browser/loop/shared/css/conversation.css  (content/shared/css/conversation.css)
+  content/browser/loop/shared/css/contacts.css      (content/shared/css/contacts.css)
 
   # Shared images
   content/browser/loop/shared/img/happy.png                     (content/shared/img/happy.png)
   content/browser/loop/shared/img/sad.png                       (content/shared/img/sad.png)
   content/browser/loop/shared/img/icon_32.png                   (content/shared/img/icon_32.png)
   content/browser/loop/shared/img/icon_64.png                   (content/shared/img/icon_64.png)
   content/browser/loop/shared/img/loading-icon.gif              (content/shared/img/loading-icon.gif)
   content/browser/loop/shared/img/audio-inverse-14x14.png       (content/shared/img/audio-inverse-14x14.png)
@@ -41,16 +43,18 @@ browser.jar:
   content/browser/loop/shared/img/video-inverse-14x14@2x.png    (content/shared/img/video-inverse-14x14@2x.png)
   content/browser/loop/shared/img/dropdown-inverse.png          (content/shared/img/dropdown-inverse.png)
   content/browser/loop/shared/img/dropdown-inverse@2x.png       (content/shared/img/dropdown-inverse@2x.png)
   content/browser/loop/shared/img/svg/glyph-settings-16x16.svg  (content/shared/img/svg/glyph-settings-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-account-16x16.svg   (content/shared/img/svg/glyph-account-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signin-16x16.svg    (content/shared/img/svg/glyph-signin-16x16.svg)
   content/browser/loop/shared/img/svg/glyph-signout-16x16.svg   (content/shared/img/svg/glyph-signout-16x16.svg)
   content/browser/loop/shared/img/audio-call-avatar.svg         (content/shared/img/audio-call-avatar.svg)
+  content/browser/loop/shared/img/icons-10x10.svg               (content/shared/img/icons-10x10.svg)
+  content/browser/loop/shared/img/icons-14x14.svg               (content/shared/img/icons-14x14.svg)
   content/browser/loop/shared/img/icons-16x16.svg               (content/shared/img/icons-16x16.svg)
 
   # Shared scripts
   content/browser/loop/shared/js/feedbackApiClient.js (content/shared/js/feedbackApiClient.js)
   content/browser/loop/shared/js/models.js            (content/shared/js/models.js)
   content/browser/loop/shared/js/router.js            (content/shared/js/router.js)
   content/browser/loop/shared/js/mixins.js            (content/shared/js/mixins.js)
   content/browser/loop/shared/js/views.js             (content/shared/js/views.js)
--- a/browser/components/loop/test/desktop-local/index.html
+++ b/browser/components/loop/test/desktop-local/index.html
@@ -37,16 +37,17 @@
   <script src="../../content/shared/js/models.js"></script>
   <script src="../../content/shared/js/router.js"></script>
   <script src="../../content/shared/js/mixins.js"></script>
   <script src="../../content/shared/js/views.js"></script>
   <script src="../../content/shared/js/websocket.js"></script>
   <script src="../../content/js/client.js"></script>
   <script src="../../content/js/desktopRouter.js"></script>
   <script src="../../content/js/conversation.js"></script>
+  <script type="text/javascript;version=1.8" src="../../content/js/contacts.js"></script>
   <script src="../../content/js/panel.js"></script>
 
   <!-- Test scripts -->
   <script src="client_test.js"></script>
   <script src="conversation_test.js"></script>
   <script src="panel_test.js"></script>
   <script>
     // Stop the default init functions running to avoid conflicts in tests
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -37,17 +37,23 @@ describe("loop.panel", function() {
       },
       get locale() {
         return "en-US";
       },
       setLoopCharPref: sandbox.stub(),
       getLoopCharPref: sandbox.stub().returns("unseen"),
       copyString: sandbox.stub(),
       noteCallUrlExpiry: sinon.spy(),
-      composeEmail: sinon.spy()
+      composeEmail: sinon.spy(),
+      contacts: {
+        getAll: function(callback) {
+          callback(null, []);
+        },
+        on: sandbox.stub()
+      }
     };
 
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
     sandbox.restore();
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -80,16 +80,19 @@ contacts_search_placesholder=Search…
 ## for where this appears on the UI
 new_contact_button=New Contact
 ## LOCALIZATION NOTE (new_contact_name_placeholder, new_contact_email_placeholder):
 ## These are the placeholders for the fields for entering a new contact
 ## See https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#contacts
 ## and click the 'New Contact' button to see the fields.
 new_contact_name_placeholder=Name
 new_contact_email_placeholder=Email
+
+contacts_blocked_contacts=Blocked Contacts
+
 ## LOCALIZATION NOTE (add_contact_button):
 ## This is the button to actually add the new contact
 ## See https://people.mozilla.org/~dhenein/labs/loop-mvp-spec/#contacts
 ## and click the 'New Contact' button to see the fields.
 add_contact_button=Add Contact
 ### LOCALIZATION NOTE (valid_email_text_description): This is displayed when
 ### the user enters an invalid email address, preventing the addition of the
 ### contact.
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -398,17 +398,16 @@ pref("privacy.item.downloads", true);
 pref("privacy.item.passwords", true);
 pref("privacy.item.sessions", true);
 pref("privacy.item.geolocation", true);
 pref("privacy.item.siteSettings", true);
 pref("privacy.item.syncAccount", true);
 
 // enable geo
 pref("geo.enabled", true);
-pref("app.geo.reportdata", 0);
 
 // content sink control -- controls responsiveness during page load
 // see https://bugzilla.mozilla.org/show_bug.cgi?id=481566#c9
 //pref("content.sink.enable_perf_mode",  2); // 0 - switch, 1 - interactive, 2 - perf
 //pref("content.sink.pending_event_mode", 0);
 //pref("content.sink.perf_deflect_count", 1000000);
 //pref("content.sink.perf_parse_time", 50000000);
 
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -1558,22 +1558,16 @@ public abstract class GeckoApp
                                            this);
 
         PrefsHelper.getPref("app.update.autodownload", new PrefsHelper.PrefHandlerBase() {
             @Override public void prefValue(String pref, String value) {
                 UpdateServiceHelper.registerForUpdates(GeckoApp.this, value);
             }
         });
 
-        PrefsHelper.getPref("app.geo.reportdata", new PrefsHelper.PrefHandlerBase() {
-            @Override public void prefValue(String pref, int value) {
-                // Acting on this pref is Bug 1036508; for now, do nothing.
-            }
-        });
-
         // Trigger the completion of the telemetry timer that wraps activity startup,
         // then grab the duration to give to FHR.
         mJavaUiStartupTimer.stop();
         final long javaDuration = mJavaUiStartupTimer.getElapsed();
 
         ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
             @Override
             public void run() {
--- a/mobile/android/base/preferences/GeckoPreferences.java
+++ b/mobile/android/base/preferences/GeckoPreferences.java
@@ -105,24 +105,25 @@ OnSharedPreferenceChangeListener
     // These match keys in resources/xml*/preferences*.xml
     private static final String PREFS_SEARCH_RESTORE_DEFAULTS = NON_PREF_PREFIX + "search.restore_defaults";
     private static final String PREFS_DATA_REPORTING_PREFERENCES = NON_PREF_PREFIX + "datareporting.preferences";
     private static final String PREFS_TELEMETRY_ENABLED = "toolkit.telemetry.enabled";
     private static final String PREFS_CRASHREPORTER_ENABLED = "datareporting.crashreporter.submitEnabled";
     private static final String PREFS_MENU_CHAR_ENCODING = "browser.menu.showCharacterEncoding";
     private static final String PREFS_MP_ENABLED = "privacy.masterpassword.enabled";
     private static final String PREFS_UPDATER_AUTODOWNLOAD = "app.update.autodownload";
-    private static final String PREFS_GEO_REPORTING = "app.geo.reportdata";
+    private static final String PREFS_GEO_REPORTING = NON_PREF_PREFIX + "app.geo.reportdata";
     private static final String PREFS_GEO_LEARN_MORE = NON_PREF_PREFIX + "geo.learn_more";
     private static final String PREFS_HEALTHREPORT_LINK = NON_PREF_PREFIX + "healthreport.link";
     private static final String PREFS_DEVTOOLS_REMOTE_ENABLED = "devtools.debugger.remote-enabled";
     private static final String PREFS_DISPLAY_REFLOW_ON_ZOOM = "browser.zoom.reflowOnZoom";
     private static final String PREFS_DISPLAY_TITLEBAR_MODE = "browser.chrome.titlebarMode";
     private static final String PREFS_SYNC = NON_PREF_PREFIX + "sync";
-    private static final String PREFS_STUMBLER_ENABLED = AppConstants.ANDROID_PACKAGE_NAME + ".STUMBLER_PREF";
+
+    private static final String ACTION_STUMBLER_UPLOAD_PREF = AppConstants.ANDROID_PACKAGE_NAME + ".STUMBLER_PREF";
 
     // This isn't a Gecko pref, even if it looks like one.
     private static final String PREFS_BROWSER_LOCALE = "locale";
 
     public static final String PREFS_RESTORE_SESSION = NON_PREF_PREFIX + "restoreSession3";
     public static final String PREFS_SUGGESTED_SITES = NON_PREF_PREFIX + "home_suggested_sites";
     public static final String PREFS_NEW_TABLET_UI = NON_PREF_PREFIX + "new_tablet_ui";
 
@@ -678,20 +679,19 @@ OnSharedPreferenceChangeListener
                     preferences.removePreference(pref);
                     i--;
                     continue;
                 } else if (!AppConstants.MOZ_CRASHREPORTER &&
                            PREFS_CRASHREPORTER_ENABLED.equals(key)) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
-                } else if (AppConstants.RELEASE_BUILD &&
-                            (PREFS_GEO_REPORTING.equals(key) ||
-                             PREFS_GEO_LEARN_MORE.equals(key))) {
-                    // We don't build wifi/cell tower collection in release builds, so hide the UI.
+                } else if (!AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED &&
+                           (PREFS_GEO_REPORTING.equals(key) ||
+                            PREFS_GEO_LEARN_MORE.equals(key))) {
                     preferences.removePreference(pref);
                     i--;
                     continue;
                 } else if (PREFS_DEVTOOLS_REMOTE_ENABLED.equals(key)) {
                     final Context thisContext = this;
                     pref.setOnPreferenceClickListener(new OnPreferenceClickListener() {
                         @Override
                         public boolean onPreferenceClick(Preference preference) {
@@ -864,36 +864,36 @@ OnSharedPreferenceChangeListener
 
     public static void broadcastHealthReportPrune(final Context context) {
         final Intent intent = new Intent(HealthReportConstants.ACTION_HEALTHREPORT_PRUNE);
         broadcastAction(context, intent);
     }
 
     /**
      * Broadcast the provided value as the value of the
-     * <code>PREFS_STUMBLER_ENABLED</code> pref.
+     * <code>PREFS_GEO_REPORTING</code> pref.
      */
     public static void broadcastStumblerPref(final Context context, final boolean value) {
-       Intent intent = new Intent(PREFS_STUMBLER_ENABLED)
+       Intent intent = new Intent(ACTION_STUMBLER_UPLOAD_PREF)
                 .putExtra("pref", PREFS_GEO_REPORTING)
                 .putExtra("branch", GeckoSharedPrefs.APP_PREFS_NAME)
                 .putExtra("enabled", value)
                 .putExtra("moz_mozilla_api_key", AppConstants.MOZ_STUMBLER_API_KEY);
        if (GeckoAppShell.getGeckoInterface() != null) {
            intent.putExtra("user_agent", GeckoAppShell.getGeckoInterface().getDefaultUAString());
        }
        if (!AppConstants.MOZILLA_OFFICIAL) {
            intent.putExtra("is_debug", true);
        }
        broadcastAction(context, intent);
     }
 
     /**
      * Broadcast the current value of the
-     * <code>PREFS_STUMBLER_ENABLED</code> pref.
+     * <code>PREFS_GEO_REPORTING</code> pref.
      */
     public static void broadcastStumblerPref(final Context context) {
         final boolean value = getBooleanPref(context, PREFS_GEO_REPORTING, false);
         broadcastStumblerPref(context, value);
     }
 
     /**
      * Return the value of the named preference in the default preferences file.
@@ -1316,32 +1316,17 @@ OnSharedPreferenceChangeListener
                         }
                     });
                 }
             }
 
             @Override
             public void prefValue(String prefName, final int value) {
                 final Preference pref = getField(prefName);
-                final CheckBoxPrefSetter prefSetter;
-                if (PREFS_GEO_REPORTING.equals(prefName)) {
-                    if (Versions.preICS) {
-                        prefSetter = new CheckBoxPrefSetter();
-                    } else {
-                        prefSetter = new TwoStatePrefSetter();
-                    }
-                    ThreadUtils.postToUiThread(new Runnable() {
-                        @Override
-                        public void run() {
-                            prefSetter.setBooleanPref(pref, value == 1);
-                        }
-                    });
-                } else {
-                    Log.w(LOGTAG, "Unhandled int value for pref [" + pref + "]");
-                }
+                Log.w(LOGTAG, "Unhandled int value for pref [" + pref + "]");
             }
 
             @Override
             public boolean isObserver() {
                 return true;
             }
 
             @Override
--- a/mobile/android/base/resources/xml/preferences_vendor.xml
+++ b/mobile/android/base/resources/xml/preferences_vendor.xml
@@ -30,17 +30,17 @@
                             android:title="@string/datareporting_telemetry_title"
                             android:summary="@string/datareporting_telemetry_summary" />
 
         <CheckBoxPreference android:key="datareporting.crashreporter.submitEnabled"
                             android:title="@string/datareporting_crashreporter_title_short"
                             android:summary="@string/datareporting_crashreporter_summary"
                             android:defaultValue="false" />
 
-        <CheckBoxPreference android:key="app.geo.reportdata"
+        <CheckBoxPreference android:key="android.not_a_preference.app.geo.reportdata"
                             android:title="@string/datareporting_wifi_title"
                             android:summary="@string/datareporting_wifi_geolocation_summary" />
 
         <org.mozilla.gecko.preferences.AlignRightLinkPreference android:key="android.not_a_preference.geo.learn_more"
                                                                 android:title="@string/pref_learn_more"
                                                                 android:persistent="false"
                                                                 url="https://location.services.mozilla.com/" />
 
--- a/mobile/android/base/tests/JavascriptTest.java
+++ b/mobile/android/base/tests/JavascriptTest.java
@@ -1,20 +1,16 @@
 package org.mozilla.gecko.tests;
 
+import org.json.JSONObject;
+import org.mozilla.gecko.Actions;
 import org.mozilla.gecko.tests.helpers.JavascriptBridge;
 import org.mozilla.gecko.tests.helpers.JavascriptMessageParser;
 
 import android.util.Log;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-import org.json.JSONObject;
-import org.mozilla.gecko.Actions;
-import org.mozilla.gecko.Assert;
 
 public class JavascriptTest extends BaseTest {
     private static final String LOGTAG = "JavascriptTest";
     private static final String EVENT_TYPE = JavascriptBridge.EVENT_TYPE;
 
     private final String javascriptUrl;
 
     public JavascriptTest(String javascriptUrl) {
@@ -27,18 +23,17 @@ public class JavascriptTest extends Base
 
         // We want to be waiting for Robocop messages before the page is loaded
         // because the test harness runs each test in the suite (and possibly
         // completes testing) before the page load event is fired.
         final Actions.EventExpecter expecter =
             mActions.expectGeckoEvent(EVENT_TYPE);
         mAsserter.dumpLog("Registered listener for " + EVENT_TYPE);
 
-        final String url = getAbsoluteUrl(StringHelper.ROBOCOP_JS_HARNESS_URL +
-                                          "?path=" + javascriptUrl);
+        final String url = getAbsoluteUrl(StringHelper.getHarnessUrlForJavascript(javascriptUrl));
         mAsserter.dumpLog("Loading JavaScript test from " + url);
         loadUrl(url);
 
         final JavascriptMessageParser testMessageParser =
                 new JavascriptMessageParser(mAsserter, false);
         try {
             while (!testMessageParser.isTestFinished()) {
                 if (Log.isLoggable(LOGTAG, Log.VERBOSE)) {
--- a/mobile/android/base/tests/StringHelper.java
+++ b/mobile/android/base/tests/StringHelper.java
@@ -99,17 +99,35 @@ public class StringHelper {
     public static final String ROBOCOP_GEOLOCATION_URL = "/robocop/robocop_geolocation.html";
     public static final String ROBOCOP_LOGIN_URL = "/robocop/robocop_login.html";
     public static final String ROBOCOP_OFFLINE_STORAGE_URL = "/robocop/robocop_offline_storage.html";
     public static final String ROBOCOP_PICTURE_LINK_URL = "/robocop/robocop_picture_link.html";
     public static final String ROBOCOP_SEARCH_URL = "/robocop/robocop_search.html";
     public static final String ROBOCOP_TEXT_PAGE_URL = "/robocop/robocop_text_page.html";
     public static final String ROBOCOP_ADOBE_FLASH_URL = "/robocop/robocop_adobe_flash.html";
     public static final String ROBOCOP_INPUT_URL = "/robocop/robocop_input.html";
-    public static final String ROBOCOP_JS_HARNESS_URL = "/robocop/robocop_javascript.html";
+
+    private static final String ROBOCOP_JS_HARNESS_URL = "/robocop/robocop_javascript.html";
+
+    /**
+     * Build a URL for loading a Javascript file in the Robocop Javascript
+     * harness.
+     * <p>
+     * We append a random slug to avoid caching: see
+     * <a href="https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache">https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache</a>.
+     *
+     * @param javascriptUrl to load.
+     * @return URL with harness wrapper.
+     */
+    public static String getHarnessUrlForJavascript(String javascriptUrl) {
+        // We include a slug to make sure we never cache the harness.
+        return ROBOCOP_JS_HARNESS_URL +
+                "?slug=" + System.currentTimeMillis() +
+                "&path=" + javascriptUrl;
+    }
 
     // Robocop page titles
     public static final String ROBOCOP_BIG_LINK_TITLE = "Big Link";
     public static final String ROBOCOP_BIG_MAILTO_TITLE = "Big Mailto";
     public static final String ROBOCOP_BLANK_PAGE_01_TITLE = "Browser Blank Page 01";
     public static final String ROBOCOP_BLANK_PAGE_02_TITLE = "Browser Blank Page 02";
     public static final String ROBOCOP_BLANK_PAGE_03_TITLE = "Browser Blank Page 03";
     public static final String ROBOCOP_BLANK_PAGE_04_TITLE = "Browser Blank Page 04";
--- a/mobile/android/base/tests/robocop_testharness.js
+++ b/mobile/android/base/tests/robocop_testharness.js
@@ -13,17 +13,21 @@ function _evalURI(uri, sandbox) {
   let req = SpecialPowers.Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
                          .createInstance();
 
   let baseURI = SpecialPowers.Services.io
                              .newURI(window.document.baseURI, window.document.characterSet, null);
   let theURI = SpecialPowers.Services.io
                             .newURI(uri, window.document.characterSet, baseURI);
 
-  req.open('GET', theURI.spec, false);
+  // We append a random slug to avoid caching: see
+  // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache.
+  req.open('GET', theURI.spec + ((/\?/).test(theURI.spec) ? "&slug=" : "?slug=") + (new Date()).getTime(), false);
+  req.setRequestHeader('Cache-Control', 'no-cache');
+  req.setRequestHeader('Pragma', 'no-cache');
   req.send();
 
   return SpecialPowers.Cu.evalInSandbox(req.responseText, sandbox, "1.8", uri, 1);
 }
 
 /**
  * Execute the Javascript file at `uri` in a testing sandbox populated
  * with the Javascript test harness.
--- a/mobile/android/base/tests/testEventDispatcher.java
+++ b/mobile/android/base/tests/testEventDispatcher.java
@@ -54,18 +54,17 @@ public class testEventDispatcher extends
                 NATIVE_EVENT, NATIVE_RESPONSE_EVENT, NATIVE_EXCEPTION_EVENT);
 
         js.disconnect();
         super.tearDown();
     }
 
     public void testEventDispatcher() {
         GeckoHelper.blockForReady();
-        NavigationHelper.enterAndLoadUrl(StringHelper.ROBOCOP_JS_HARNESS_URL +
-                                         "?path=" + TEST_JS);
+        NavigationHelper.enterAndLoadUrl(StringHelper.getHarnessUrlForJavascript(TEST_JS));
 
         js.syncCall("send_test_message", GECKO_EVENT);
         js.syncCall("send_message_for_response", GECKO_RESPONSE_EVENT, "success");
         js.syncCall("send_message_for_response", GECKO_RESPONSE_EVENT, "error");
         js.syncCall("send_test_message", NATIVE_EVENT);
         js.syncCall("send_message_for_response", NATIVE_RESPONSE_EVENT, "success");
         js.syncCall("send_message_for_response", NATIVE_RESPONSE_EVENT, "error");
         js.syncCall("send_test_message", NATIVE_EXCEPTION_EVENT);
--- a/mobile/android/base/tests/testGeckoRequest.java
+++ b/mobile/android/base/tests/testGeckoRequest.java
@@ -33,17 +33,17 @@ public class testGeckoRequest extends UI
     @Override
     public void tearDown() throws Exception {
         js.disconnect();
         super.tearDown();
     }
 
     public void testGeckoRequest() {
         GeckoHelper.blockForReady();
-        NavigationHelper.enterAndLoadUrl(StringHelper.ROBOCOP_JS_HARNESS_URL + "?path=" + TEST_JS);
+        NavigationHelper.enterAndLoadUrl(StringHelper.getHarnessUrlForJavascript(TEST_JS));
 
         // Register a listener for this request.
         js.syncCall("add_request_listener", REQUEST_EVENT);
 
         // Make sure we receive the expected response.
         checkFooRequest();
 
         // Try registering a second listener for this request, which should fail.
--- a/mobile/android/base/tests/testJavascriptBridge.java
+++ b/mobile/android/base/tests/testJavascriptBridge.java
@@ -27,18 +27,17 @@ public class testJavascriptBridge extend
     @Override
     public void tearDown() throws Exception {
         js.disconnect();
         super.tearDown();
     }
 
     public void testJavascriptBridge() {
         GeckoHelper.blockForReady();
-        NavigationHelper.enterAndLoadUrl(StringHelper.ROBOCOP_JS_HARNESS_URL +
-                                         "?path=" + TEST_JS);
+        NavigationHelper.enterAndLoadUrl(StringHelper.getHarnessUrlForJavascript(TEST_JS));
         js.syncCall("check_js_int_arg", 1);
     }
 
     public void checkJavaIntArg(final int int2) {
         // Async call from JS
         fAssertEquals("Integer argument matches", 2, int2);
         js.syncCall("check_js_double_arg", 3.0D);
     }
--- a/mobile/android/base/tests/testSettingsMenuItems.java
+++ b/mobile/android/base/tests/testSettingsMenuItems.java
@@ -165,22 +165,24 @@ public class testSettingsMenuItems exten
                 settingsMap.get(PATH_DISPLAY).add(newTabletUi);
             }
 
             // New tablet UI: we don't allow a page title option.
             if (NewTabletUI.isEnabled(getActivity())) {
                 settingsMap.get(PATH_DISPLAY).remove(TITLE_BAR_LABEL_ARR);
             }
 
-            // Anonymous cell tower/wifi collection - only built if *not* release build
-            String[] networkReportingUi = { "Mozilla Location Service", "Receives Wi-Fi and cellular location data when running in the background and shares it with Mozilla to improve our geolocation service" };
-            settingsMap.get(PATH_MOZILLA).add(networkReportingUi);
+            if (AppConstants.MOZ_STUMBLER_BUILD_TIME_ENABLED) {
+                // Anonymous cell tower/wifi collection
+                String[] networkReportingUi = { "Mozilla Location Service", "Receives Wi-Fi and cellular location data when running in the background and shares it with Mozilla to improve our geolocation service" };
+                settingsMap.get(PATH_MOZILLA).add(networkReportingUi);
 
-            String[] learnMoreUi = { "Learn more" };
-            settingsMap.get(PATH_MOZILLA).add(learnMoreUi);
+                String[] learnMoreUi = { "Learn more" };
+                settingsMap.get(PATH_MOZILLA).add(learnMoreUi);
+            }
         }
 
         // Automatic updates
         if (AppConstants.MOZ_UPDATER) {
             String[] autoUpdateUi = { "Download updates automatically", "Only over Wi-Fi", "Always", "Only over Wi-Fi", "Never" };
             settingsMap.get(PATH_CUSTOMIZE).add(autoUpdateUi);
         }
 
--- a/mobile/android/chrome/content/FindHelper.js
+++ b/mobile/android/chrome/content/FindHelper.js
@@ -73,13 +73,13 @@ var FindHelper = {
           Cu.reportError("Warning: selected tab changed during find!");
           // fall through and restore viewport on the initial tab anyway
         }
         this._targetTab.setViewport(JSON.parse(this._initialViewport));
         this._targetTab.sendViewportUpdate();
       }
     } else {
       // Disabled until bug 1014113 is fixed
-      // ZoomHelper.zoomToRect(aData.rect);
+      //ZoomHelper.zoomToRect(aData.rect, -1, false, true);
       this._viewportChanged = true;
     }
   }
 };
--- a/mobile/android/chrome/content/ZoomHelper.js
+++ b/mobile/android/chrome/content/ZoomHelper.js
@@ -76,71 +76,72 @@ var ZoomHelper = {
             dw > minDifference && dw < maxDifference);
   },
 
   /* Zoom to an element, optionally keeping a particular part of it
    * in view if it is really tall.
    */
   zoomToElement: function(aElement, aClickY = -1, aCanZoomOut = true, aCanScrollHorizontally = true) {
     let rect = ElementTouchHelper.getBoundingContentRect(aElement);
+    ZoomHelper.zoomToRect(rect, aClickY, aCanZoomOut, aCanScrollHorizontally, aElement);
+  },
 
+  zoomToRect: function(aRect, aClickY = -1, aCanZoomOut = true, aCanScrollHorizontally = true, aElement) {
     const margin = 15;
 
+    if(!aRect.h || !aRect.w) {
+      aRect.h = aRect.height;
+      aRect.w = aRect.width;
+    }
+
     let viewport = BrowserApp.selectedTab.getViewport();
-    rect = new Rect(aCanScrollHorizontally ? Math.max(viewport.cssPageLeft, rect.x - margin) : viewport.cssX,
-                    rect.y,
-                    aCanScrollHorizontally ? rect.w + 2 * margin : viewport.cssWidth,
-                    rect.h);
+    let bRect = new Rect(aCanScrollHorizontally ? Math.max(viewport.cssPageLeft, aRect.x - margin) : viewport.cssX,
+                         aRect.y,
+                         aCanScrollHorizontally ? aRect.w + 2 * margin : viewport.cssWidth,
+                         aRect.h);
     // constrict the rect to the screen's right edge
-    rect.width = Math.min(rect.width, viewport.cssPageRight - rect.x);
+    bRect.width = Math.min(bRect.width, viewport.cssPageRight - bRect.x);
 
     // if the rect is already taking up most of the visible area and is stretching the
     // width of the page, then we want to zoom out instead.
     if (aElement) {
       if (BrowserEventHandler.mReflozPref) {
         let zoomFactor = BrowserApp.selectedTab.getZoomToMinFontSize(aElement);
 
-        rect.width = zoomFactor <= 1.0 ? rect.width : gScreenWidth / zoomFactor;
-        rect.height = zoomFactor <= 1.0 ? rect.height : rect.height / zoomFactor;
-        if (zoomFactor == 1.0 || ZoomHelper.isRectZoomedIn(rect, viewport)) {
+        bRect.width = zoomFactor <= 1.0 ? bRect.width : gScreenWidth / zoomFactor;
+        bRect.height = zoomFactor <= 1.0 ? bRect.height : bRect.height / zoomFactor;
+        if (zoomFactor == 1.0 || ZoomHelper.isRectZoomedIn(bRect, viewport)) {
           if (aCanZoomOut) {
             ZoomHelper.zoomOut();
           }
           return;
         }
-      } else if (ZoomHelper.isRectZoomedIn(rect, viewport)) {
+      } else if (ZoomHelper.isRectZoomedIn(bRect, viewport)) {
         if (aCanZoomOut) {
           ZoomHelper.zoomOut();
         }
         return;
       }
-
-      ZoomHelper.zoomToRect(rect, aClickY);
     }
-  },
 
-  /* Zoom to a specific part of the screen defined by a rect,
-   * optionally keeping a particular part of it in view
-   * if it is really tall.
-   */
-  zoomToRect: function(aRect, aClickY = -1) {
-    let rect = new Rect(aRect.x,
-                        aRect.y,
-                        aRect.width,
-                        Math.min(aRect.width * viewport.cssHeight / viewport.cssWidth, aRect.height));
+    let rect = {};
 
     rect.type = "Browser:ZoomToRect";
+    rect.x = bRect.x;
+    rect.y = bRect.y;
+    rect.w = bRect.width;
+    rect.h = Math.min(bRect.width * viewport.cssHeight / viewport.cssWidth, bRect.height);
 
     if (aClickY >= 0) {
       // if the block we're zooming to is really tall, and we want to keep a particular
       // part of it in view, then adjust the y-coordinate of the target rect accordingly.
-      // the 1.2 multiplier is just a little fuzz to compensate for aRect including horizontal
+      // the 1.2 multiplier is just a little fuzz to compensate for bRect including horizontal
       // margins but not vertical ones.
       let cssTapY = viewport.cssY + aClickY;
-      if ((aRect.height > rect.h) && (cssTapY > rect.y + (rect.h * 1.2))) {
+      if ((bRect.height > rect.h) && (cssTapY > rect.y + (rect.h * 1.2))) {
         rect.y = cssTapY - (rect.h / 2);
       }
     }
 
     if (rect.w > viewport.cssWidth || rect.h > viewport.cssHeight) {
       BrowserEventHandler.resetMaxLineBoxWidth();
     }
 
--- a/mobile/android/confvars.sh
+++ b/mobile/android/confvars.sh
@@ -81,10 +81,14 @@ else
   MOZ_ANDROID_SEARCH_ACTIVITY=
 fi
 
 # Enable the share handler in pre-release builds.
 if test ! "$RELEASE_BUILD"; then
   MOZ_ANDROID_SHARE_OVERLAY=1
 fi
 
-# Don't enable the Mozilla Location Service stumbler.
-# MOZ_ANDROID_MLS_STUMBLER=1
+# Enable the Mozilla Location Service stumbler in Nightly.
+if test "$NIGHTLY_BUILD"; then
+  MOZ_ANDROID_MLS_STUMBLER=1
+else
+  MOZ_ANDROID_MLS_STUMBLER=
+fi
--- a/python/mozbuild/mozbuild/frontend/emitter.py
+++ b/python/mozbuild/mozbuild/frontend/emitter.py
@@ -845,21 +845,21 @@ class TreeMetadataEmitter(LoggingMixin):
             filtered = m.tests
 
             if filter_inactive:
                 # We return tests that don't exist because we want manifests
                 # defining tests that don't exist to result in error.
                 filtered = m.active_tests(exists=False, disabled=True,
                     **self.info)
 
-                missing = [t['name'] for t in filtered if not os.path.exists(t['path'])]
-                if missing:
-                    raise SandboxValidationError('Test manifest (%s) lists '
-                        'test that does not exist: %s' % (
-                        path, ', '.join(missing)), context)
+            missing = [t['name'] for t in filtered if not os.path.exists(t['path'])]
+            if missing:
+                raise SandboxValidationError('Test manifest (%s) lists '
+                    'test that does not exist: %s' % (
+                    path, ', '.join(missing)), context)
 
             out_dir = mozpath.join(install_prefix, manifest_reldir)
             if 'install-to-subdir' in defaults:
                 # This is terrible, but what are you going to do?
                 out_dir = mozpath.join(out_dir, defaults['install-to-subdir'])
                 obj.manifest_obj_relpath = mozpath.join(manifest_reldir,
                                                         defaults['install-to-subdir'],
                                                         mozpath.basename(path))
new file mode 100644
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/test-manifest-missing-test-file-unfiltered/moz.build
@@ -0,0 +1,4 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+XPCSHELL_TESTS_MANIFESTS += ['xpcshell.ini']
new file mode 100644
--- /dev/null
+++ b/python/mozbuild/mozbuild/test/frontend/data/test-manifest-missing-test-file-unfiltered/xpcshell.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+support-files = support/**
+
+[missing.js]
--- a/python/mozbuild/mozbuild/test/frontend/test_emitter.py
+++ b/python/mozbuild/mozbuild/test/frontend/test_emitter.py
@@ -496,16 +496,24 @@ class TestEmitterBasic(unittest.TestCase
     def test_test_manifest_missing_test_error(self):
         """Missing test files should result in error."""
         reader = self.reader('test-manifest-missing-test-file')
 
         with self.assertRaisesRegexp(SandboxValidationError,
             'lists test that does not exist: test_missing.html'):
             self.read_topsrcdir(reader)
 
+    def test_test_manifest_missing_test_error_unfiltered(self):
+        """Missing test files should result in error, even when the test list is not filtered."""
+        reader = self.reader('test-manifest-missing-test-file-unfiltered')
+
+        with self.assertRaisesRegexp(SandboxValidationError,
+            'lists test that does not exist: missing.js'):
+            self.read_topsrcdir(reader)
+
     def test_ipdl_sources(self):
         reader = self.reader('ipdl_sources')
         objs = self.read_topsrcdir(reader)
 
         ipdls = []
         for o in objs:
             if isinstance(o, IPDLFile):
                 ipdls.append('%s/%s' % (o.relativedir, o.basename))
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -2426,19 +2426,19 @@ this.AddonManagerPrivate = {
 
   setTelemetryDetails: function AMP_setTelemetryDetails(aProvider, aDetails) {
     AddonManagerInternal.telemetryDetails[aProvider] = aDetails;
   },
 
   // Start a timer, record a simple measure of the time interval when
   // timer.done() is called
   simpleTimer: function(aName) {
-    let startTime = Date.now();
+    let startTime = Cu.now();
     return {
-      done: () => this.recordSimpleMeasure(aName, Date.now() - startTime)
+      done: () => this.recordSimpleMeasure(aName, Math.round(Cu.now() - startTime))
     };
   },
 
   /**
    * Helper to call update listeners when no update is available.
    *
    * This can be used as an implementation for Addon.findUpdates() when
    * no update mechanism is available.
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -1489,17 +1489,17 @@ XPIState.prototype = {
   /**
    * Update the last modified time for an add-on on disk.
    * @param aFile: nsIFile path of the add-on.
    * @param aId: The add-on ID.
    * @return True if the time stamp has changed.
    */
   getModTime(aFile, aId) {
     let changed = false;
-    let scanStarted = Date.now();
+    let scanStarted = Cu.now();
     // For an unknown or enabled add-on, we do a full recursive scan.
     if (!('scanTime' in this) || this.enabled) {
       logger.debug('getModTime: Recursive scan of ' + aId);
       let [modFile, modTime, items] = recursiveLastModifiedTime(aFile);
       XPIProvider._mostRecentlyModifiedFile[aId] = modFile;
       XPIProvider.setTelemetry(aId, "scan_items", items);
       if (modTime != this.scanTime) {
         this.scanTime = modTime;
@@ -1534,17 +1534,17 @@ XPIState.prototype = {
         }
       } catch (e) {
         logger.warn("Can't get modified time of ${file}: ${e}", {file: aFile.path, e: e});
         changed = true;
         this.scanTime = 0;
       }
     }
     // Record duration of file-modified check
-    XPIProvider.setTelemetry(aId, "scan_MS", Date.now() - scanStarted);
+    XPIProvider.setTelemetry(aId, "scan_MS", Math.round(Cu.now() - scanStarted));
 
     return changed;
   },
 
   /**
    * Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true,
    * update the last-modified time. This should probably be made async, but for now we
    * don't want to maintain parallel sync and async versions of the scan.
@@ -3534,20 +3534,20 @@ this.XPIProvider = {
     {
       updated = this.installDistributionAddons(manifests);
       if (updated) {
         updateReasons.push("installDistributionAddons");
       }
     }
 
     // Telemetry probe added around getInstallState() to check perf
-    let telemetryCaptureTime = Date.now();
+    let telemetryCaptureTime = Cu.now();
     let installChanged = XPIStates.getInstallState();
     let telemetry = Services.telemetry;
-    telemetry.getHistogramById("CHECK_ADDONS_MODIFIED_MS").add(Date.now() - telemetryCaptureTime);
+    telemetry.getHistogramById("CHECK_ADDONS_MODIFIED_MS").add(Math.round(Cu.now() - telemetryCaptureTime));
     if (installChanged) {
       updateReasons.push("directoryState");
     }
 
     let haveAnyAddons = (XPIStates.size > 0);
 
     // If the schema appears to have changed then we should update the database
     if (DB_SCHEMA != Preferences.get(PREF_DB_SCHEMA, 0)) {