Merge mozilla-central to mozilla-inbound
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Thu, 18 Feb 2016 13:40:49 +0100
changeset 331893 e946775465e3ac2e2b5564f887f0dd54f1e5d6de
parent 331892 4391e6b0e891ebbb4e541cc34fad5b2fdc1961b1 (current diff)
parent 331857 fcd35e10fa17d9fd11d92be48ae9698c2a900f1c (diff)
child 331894 5658512697c472898db3411b1216f5e5788fc864
push id11113
push userrjesup@wgate.com
push dateThu, 18 Feb 2016 19:00:12 +0000
milestone47.0a1
Merge mozilla-central to mozilla-inbound
mobile/android/app/base/build.gradle
mobile/android/app/base/lint.xml
mobile/android/base/AndroidManifest.xml
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/helpers/FakeProfileTestCase.java
mobile/android/tests/background/junit4/resources/robolectric.properties
testing/taskcluster/tasks/branches/base_job_flags.yml
toolkit/components/places/tests/unit/test_removeVisitsByTimeframe.js
--- a/browser/base/content/sanitize.js
+++ b/browser/base/content/sanitize.js
@@ -131,43 +131,53 @@ Sanitizer.prototype = {
     // Cache the range of times to clear
     let range = null;
     // If we ignore timespan, clear everything,
     // otherwise, pick a range.
     if (!this.ignoreTimespan) {
       range = this.range || Sanitizer.getClearRange();
     }
 
+    // For performance reasons we start all the clear tasks at once, then wait
+    // for their promises later.
+    // Some of the clear() calls may raise exceptions (for example bug 265028),
+    // we catch and store them, but continue to sanitize as much as possible.
+    // Callers should check returned errors and give user feedback
+    // about items that could not be sanitized
+    let refObj = {};
+    TelemetryStopwatch.start("FX_SANITIZE_TOTAL", refObj);
+
+    let annotateError = (name, ex) => {
+      progress[name] = "failed";
+      seenError = true;
+      console.error("Error sanitizing " + name, ex);
+    };
+
+    // Array of objects in form { name, promise }.
+    // Name is the itemName and promise may be a promise, if the sanitization
+    // is asynchronous, or the function return value, if synchronous.
+    let promises = [];
     for (let itemName of itemsToClear) {
       let item = this.items[itemName];
-      if (!("clear" in item)) {
-        progress[itemName] = "`clear` not in item";
-        continue;
-      }
-      item.range = range;
-      // Some of these clear() may raise exceptions (see bug #265028)
-      // to sanitize as much as possible, we catch and store them,
-      // rather than fail fast.
-      // Callers should check returned errors and give user feedback
-      // about items that could not be sanitized
-      let refObj = {};
       try {
-        TelemetryStopwatch.start("FX_SANITIZE_TOTAL", refObj);
-        yield item.clear();
-        progress[itemName] = "cleared";
-      } catch(er) {
-        progress[itemName] = "failed";
-        seenError = true;
-        console.error("Error sanitizing " + itemName, er);
-      } finally {
-        TelemetryStopwatch.finish("FX_SANITIZE_TOTAL", refObj);
+        // Note we need to catch errors here, otherwise Promise.all would stop
+        // at the first rejection.
+        promises.push(item.clear(range)
+                          .then(() => progress[itemName] = "cleared",
+                                ex => annotateError(itemName, ex)));
+      } catch (ex) {
+        annotateError(itemName, ex);
       }
     }
+    yield Promise.all(promises);
 
     // Sanitization is complete.
+    TelemetryStopwatch.finish("FX_SANITIZE_TOTAL", refObj);
+    // Reset the inProgress preference since we were not killed during
+    // sanitization.
     Preferences.reset(Sanitizer.PREF_SANITIZE_IN_PROGRESS);
     progress = {};
     if (seenError) {
       throw new Error("Error sanitizing");
     }
   }),
 
   // Time span only makes sense in certain cases.  Consumers who want
@@ -175,95 +185,121 @@ Sanitizer.prototype = {
   // and can optionally specify a specific range.  If timespan is not ignored,
   // and range is not set, sanitize() will use the value of the timespan
   // pref to determine a range
   ignoreTimespan : true,
   range : null,
 
   items: {
     cache: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
+        let seenException;
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_CACHE", refObj);
 
-        var cache = Cc["@mozilla.org/netwerk/cache-storage-service;1"].
-                    getService(Ci.nsICacheStorageService);
         try {
           // Cache doesn't consult timespan, nor does it have the
           // facility for timespan-based eviction.  Wipe it.
+          let cache = Cc["@mozilla.org/netwerk/cache-storage-service;1"]
+                        .getService(Ci.nsICacheStorageService);
           cache.clear();
-        } catch(er) {}
+        } catch (ex) {
+          seenException = ex;
+        }
 
-        var imageCache = Cc["@mozilla.org/image/tools;1"].
-                         getService(Ci.imgITools).getImgCacheForDocument(null);
         try {
+          let imageCache = Cc["@mozilla.org/image/tools;1"]
+                             .getService(Ci.imgITools)
+                             .getImgCacheForDocument(null);
           imageCache.clearCache(false); // true=chrome, false=content
-        } catch(er) {}
+        } catch (ex) {
+          seenException = ex;
+        }
 
         TelemetryStopwatch.finish("FX_SANITIZE_CACHE", refObj);
-      }
+        if (seenException) {
+          throw seenException;
+        }
+      })
     },
 
     cookies: {
-      clear: Task.async(function* ()
-      {
+      clear: Task.async(function* (range) {
+        let seenException;
         let yieldCounter = 0;
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_COOKIES", refObj);
-        TelemetryStopwatch.start("FX_SANITIZE_COOKIES_2", refObj);
 
-        var cookieMgr = Components.classes["@mozilla.org/cookiemanager;1"]
-                                  .getService(Ci.nsICookieManager);
-        if (this.range) {
-          // Iterate through the cookies and delete any created after our cutoff.
-          var cookiesEnum = cookieMgr.enumerator;
-          while (cookiesEnum.hasMoreElements()) {
-            var cookie = cookiesEnum.getNext().QueryInterface(Ci.nsICookie2);
+        // Clear cookies.
+        TelemetryStopwatch.start("FX_SANITIZE_COOKIES_2", refObj);
+        try {
+          let cookieMgr = Components.classes["@mozilla.org/cookiemanager;1"]
+                                    .getService(Ci.nsICookieManager);
+          if (range) {
+            // Iterate through the cookies and delete any created after our cutoff.
+            let cookiesEnum = cookieMgr.enumerator;
+            while (cookiesEnum.hasMoreElements()) {
+              let cookie = cookiesEnum.getNext().QueryInterface(Ci.nsICookie2);
 
-            if (cookie.creationTime > this.range[0]) {
-              // This cookie was created after our cutoff, clear it
-              cookieMgr.remove(cookie.host, cookie.name, cookie.path, false);
+              if (cookie.creationTime > range[0]) {
+                // This cookie was created after our cutoff, clear it
+                cookieMgr.remove(cookie.host, cookie.name, cookie.path, false);
 
-              if (++yieldCounter % YIELD_PERIOD == 0) {
-                yield new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long
+                if (++yieldCounter % YIELD_PERIOD == 0) {
+                  yield new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long
+                }
               }
             }
           }
+          else {
+            // Remove everything
+            cookieMgr.removeAll();
+            yield new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long
+          }
+        } catch (ex) {
+          seenException = ex;
+        } finally {
+          TelemetryStopwatch.finish("FX_SANITIZE_COOKIES_2", refObj);
         }
-        else {
-          // Remove everything
-          cookieMgr.removeAll();
-          yield new Promise(resolve => setTimeout(resolve, 0)); // Don't block the main thread too long
-        }
-        TelemetryStopwatch.finish("FX_SANITIZE_COOKIES_2", refObj);
 
         // Clear deviceIds. Done asynchronously (returns before complete).
-        let mediaMgr = Components.classes["@mozilla.org/mediaManagerService;1"]
-                                 .getService(Ci.nsIMediaManagerService);
-        mediaMgr.sanitizeDeviceIds(this.range && this.range[0]);
+        try {
+          let mediaMgr = Components.classes["@mozilla.org/mediaManagerService;1"]
+                                   .getService(Ci.nsIMediaManagerService);
+          mediaMgr.sanitizeDeviceIds(range && range[0]);
+        } catch (ex) {
+          seenException = ex;
+        }
 
         // Clear plugin data.
         TelemetryStopwatch.start("FX_SANITIZE_PLUGINS", refObj);
-        yield this.promiseClearPluginCookies();
-        TelemetryStopwatch.finish("FX_SANITIZE_PLUGINS", refObj);
+        try {
+          yield this.promiseClearPluginCookies(range);
+        } catch (ex) {
+          seenException = ex;
+        } finally {
+          TelemetryStopwatch.finish("FX_SANITIZE_PLUGINS", refObj);
+        }
+
         TelemetryStopwatch.finish("FX_SANITIZE_COOKIES", refObj);
+        if (seenException) {
+          throw seenException;
+        }
       }),
 
-      promiseClearPluginCookies: Task.async(function*() {
+      promiseClearPluginCookies: Task.async(function* (range) {
         const phInterface = Ci.nsIPluginHost;
         const FLAG_CLEAR_ALL = phInterface.FLAG_CLEAR_ALL;
         let ph = Cc["@mozilla.org/plugin/host;1"].getService(phInterface);
 
         // Determine age range in seconds. (-1 means clear all.) We don't know
-        // that this.range[1] is actually now, so we compute age range based
-        // on the lower bound. If this.range results in a negative age, do
-        // nothing.
-        let age = this.range ? (Date.now() / 1000 - this.range[0] / 1000000) : -1;
-        if (!this.range || age >= 0) {
+        // that range[1] is actually now, so we compute age range based
+        // on the lower bound. If range results in a negative age, do nothing.
+        let age = range ? (Date.now() / 1000 - range[0] / 1000000) : -1;
+        if (!range || age >= 0) {
           let tags = ph.getPluginTags();
           for (let tag of tags) {
             try {
               let rv = yield new Promise(resolve =>
                 ph.clearSiteData(tag, null, FLAG_CLEAR_ALL, age, resolve)
               );
               // If the plugin doesn't support clearing by age, clear everything.
               if (rv == Components.results.NS_ERROR_PLUGIN_TIME_RANGE_NOT_SUPPORTED) {
@@ -275,208 +311,248 @@ Sanitizer.prototype = {
               // Ignore errors from plug-ins
             }
           }
         }
       })
     },
 
     offlineApps: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_OFFLINEAPPS", refObj);
-        Components.utils.import("resource:///modules/offlineAppCache.jsm");
-        OfflineAppCacheHelper.clear();
-        TelemetryStopwatch.finish("FX_SANITIZE_OFFLINEAPPS", refObj);
-      }
+        try {
+          Components.utils.import("resource:///modules/offlineAppCache.jsm");
+          // This doesn't wait for the cleanup to be complete.
+          OfflineAppCacheHelper.clear();
+        } finally {
+          TelemetryStopwatch.finish("FX_SANITIZE_OFFLINEAPPS", refObj);
+        }
+      })
     },
 
     history: {
-      clear: Task.async(function* ()
-      {
+      clear: Task.async(function* (range) {
+        let seenException;
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_HISTORY", refObj);
         try {
-          if (this.range) {
+          if (range) {
             yield PlacesUtils.history.removeVisitsByFilter({
-              beginDate: new Date(this.range[0] / 1000),
-              endDate: new Date(this.range[1] / 1000)
+              beginDate: new Date(range[0] / 1000),
+              endDate: new Date(range[1] / 1000)
             });
           } else {
             // Remove everything.
             yield PlacesUtils.history.clear();
           }
-
-          try {
-            let clearStartingTime = this.range ? String(this.range[0]) : "";
-            Services.obs.notifyObservers(null, "browser:purge-session-history", clearStartingTime);
-          } catch (e) { }
-
-          try {
-            let predictor = Components.classes["@mozilla.org/network/predictor;1"]
-                                      .getService(Components.interfaces.nsINetworkPredictor);
-            predictor.reset();
-          } catch (e) {
-            console.error("Error while resetting the predictor", e);
-          }
+        } catch (ex) {
+          seenException = ex;
         } finally {
           TelemetryStopwatch.finish("FX_SANITIZE_HISTORY", refObj);
         }
+
+        try {
+          let clearStartingTime = range ? String(range[0]) : "";
+          Services.obs.notifyObservers(null, "browser:purge-session-history", clearStartingTime);
+        } catch (ex) {
+          seenException = ex;
+        }
+
+        try {
+          let predictor = Components.classes["@mozilla.org/network/predictor;1"]
+                                    .getService(Components.interfaces.nsINetworkPredictor);
+          predictor.reset();
+        } catch (ex) {
+          seenException = ex;
+        }
+
+        if (seenException) {
+          throw seenException;
+        }
       })
     },
 
     formdata: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
+        let seenException;
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_FORMDATA", refObj);
-
-        // Clear undo history of all searchBars
-        var windowManager = Components.classes['@mozilla.org/appshell/window-mediator;1']
-                                      .getService(Components.interfaces.nsIWindowMediator);
-        var windows = windowManager.getEnumerator("navigator:browser");
-        while (windows.hasMoreElements()) {
-          let currentWindow = windows.getNext();
-          let currentDocument = currentWindow.document;
-          let searchBar = currentDocument.getElementById("searchbar");
-          if (searchBar)
-            searchBar.textbox.reset();
-          let tabBrowser = currentWindow.gBrowser;
-          if (!tabBrowser) {
-            // No tab browser? This means that it's too early during startup (typically,
-            // Session Restore hasn't completed yet). Since we don't have find
-            // bars at that stage and since Session Restore will not restore
-            // find bars further down during startup, we have nothing to clear.
-            continue;
+        try {
+          // Clear undo history of all searchBars
+          let windows = Services.wm.getEnumerator("navigator:browser");
+          while (windows.hasMoreElements()) {
+            let currentWindow = windows.getNext();
+            let currentDocument = currentWindow.document;
+            let searchBar = currentDocument.getElementById("searchbar");
+            if (searchBar)
+              searchBar.textbox.reset();
+            let tabBrowser = currentWindow.gBrowser;
+            if (!tabBrowser) {
+              // No tab browser? This means that it's too early during startup (typically,
+              // Session Restore hasn't completed yet). Since we don't have find
+              // bars at that stage and since Session Restore will not restore
+              // find bars further down during startup, we have nothing to clear.
+              continue;
+            }
+            for (let tab of tabBrowser.tabs) {
+              if (tabBrowser.isFindBarInitialized(tab))
+                tabBrowser.getFindBar(tab).clear();
+            }
+            // Clear any saved find value
+            tabBrowser._lastFindValue = "";
           }
-          for (let tab of tabBrowser.tabs) {
-            if (tabBrowser.isFindBarInitialized(tab))
-              tabBrowser.getFindBar(tab).clear();
-          }
-          // Clear any saved find value
-          tabBrowser._lastFindValue = "";
+        } catch (ex) {
+          seenException = ex;
         }
 
-        let change = { op: "remove" };
-        if (this.range) {
-          [ change.firstUsedStart, change.firstUsedEnd ] = this.range;
+        try {
+          let change = { op: "remove" };
+          if (range) {
+            [ change.firstUsedStart, change.firstUsedEnd ] = range;
+          }
+          yield new Promise(resolve => {
+            FormHistory.update(change, {
+              handleError(e) {
+                seenException = new Error("Error " + e.result + ": " + e.message);
+              },
+              handleCompletion() {
+                resolve();
+              }
+            });
+          });
+        } catch (ex) {
+          seenException = ex;
         }
-        FormHistory.update(change);
 
         TelemetryStopwatch.finish("FX_SANITIZE_FORMDATA", refObj);
-      }
+        if (seenException) {
+          throw seenException;
+        }
+      })
     },
 
     downloads: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_DOWNLOADS", refObj);
-        Task.spawn(function*() {
+        try {
           let filterByTime = null;
-          if (this.range) {
+          if (range) {
             // Convert microseconds back to milliseconds for date comparisons.
-            let rangeBeginMs = this.range[0] / 1000;
-            let rangeEndMs = this.range[1] / 1000;
+            let rangeBeginMs = range[0] / 1000;
+            let rangeEndMs = range[1] / 1000;
             filterByTime = download => download.startTime >= rangeBeginMs &&
                                        download.startTime <= rangeEndMs;
           }
 
           // Clear all completed/cancelled downloads
           let list = yield Downloads.getList(Downloads.ALL);
           list.removeFinished(filterByTime);
-          TelemetryStopwatch.finish("FX_SANITIZE_DOWNLOADS", refObj);
-        }.bind(this)).then(null, error => {
+        } finally {
           TelemetryStopwatch.finish("FX_SANITIZE_DOWNLOADS", refObj);
-          Components.utils.reportError(error);
-        });
-      }
+        }
+      })
     },
 
     sessions: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_SESSIONS", refObj);
 
-        // clear all auth tokens
-        var sdr = Components.classes["@mozilla.org/security/sdr;1"]
-                            .getService(Components.interfaces.nsISecretDecoderRing);
-        sdr.logoutAndTeardown();
+        try {
+          // clear all auth tokens
+          let sdr = Components.classes["@mozilla.org/security/sdr;1"]
+                              .getService(Components.interfaces.nsISecretDecoderRing);
+          sdr.logoutAndTeardown();
 
-        // clear FTP and plain HTTP auth sessions
-        var os = Components.classes["@mozilla.org/observer-service;1"]
-                           .getService(Components.interfaces.nsIObserverService);
-        os.notifyObservers(null, "net:clear-active-logins", null);
-
-        TelemetryStopwatch.finish("FX_SANITIZE_SESSIONS", refObj);
-      }
+          // clear FTP and plain HTTP auth sessions
+          Services.obs.notifyObservers(null, "net:clear-active-logins", null);
+        } finally {
+          TelemetryStopwatch.finish("FX_SANITIZE_SESSIONS", refObj);
+        }
+      })
     },
 
     siteSettings: {
-      clear: function ()
-      {
+      clear: Task.async(function* (range) {
+        let seenException;
         let refObj = {};
         TelemetryStopwatch.start("FX_SANITIZE_SITESETTINGS", refObj);
 
-        // Clear site-specific permissions like "Allow this site to open popups"
-        // we ignore the "end" range and hope it is now() - none of the
-        // interfaces used here support a true range anyway.
-        let startDateMS = this.range == null ? null : this.range[0] / 1000;
-        var pm = Components.classes["@mozilla.org/permissionmanager;1"]
-                           .getService(Components.interfaces.nsIPermissionManager);
-        if (startDateMS == null) {
-          pm.removeAll();
-        } else {
-          pm.removeAllSince(startDateMS);
+        let startDateMS = range ? range[0] / 1000 : null;
+
+        try {
+          // Clear site-specific permissions like "Allow this site to open popups"
+          // we ignore the "end" range and hope it is now() - none of the
+          // interfaces used here support a true range anyway.
+          if (startDateMS == null) {
+            Services.perms.removeAll();
+          } else {
+            Services.perms.removeAllSince(startDateMS);
+          }
+        } catch (ex) {
+          seenException = ex;
         }
 
-        // Clear site-specific settings like page-zoom level
-        var cps = Components.classes["@mozilla.org/content-pref/service;1"]
-                            .getService(Components.interfaces.nsIContentPrefService2);
-        if (startDateMS == null) {
-          cps.removeAllDomains(null);
-        } else {
-          cps.removeAllDomainsSince(startDateMS, null);
+        try {
+          // Clear site-specific settings like page-zoom level
+          let cps = Components.classes["@mozilla.org/content-pref/service;1"]
+                              .getService(Components.interfaces.nsIContentPrefService2);
+          if (startDateMS == null) {
+            cps.removeAllDomains(null);
+          } else {
+            cps.removeAllDomainsSince(startDateMS, null);
+          }
+        } catch (ex) {
+          seenException = ex;
         }
 
-        // Clear "Never remember passwords for this site", which is not handled by
-        // the permission manager
-        // (Note the login manager doesn't support date ranges yet, and bug
-        //  1058438 is calling for loginSaving stuff to end up in the
-        // permission manager)
-        var pwmgr = Components.classes["@mozilla.org/login-manager;1"]
-                              .getService(Components.interfaces.nsILoginManager);
-        var hosts = pwmgr.getAllDisabledHosts();
-        for (var host of hosts) {
-          pwmgr.setLoginSavingEnabled(host, true);
+        try {
+          // Clear "Never remember passwords for this site", which is not handled by
+          // the permission manager
+          // (Note the login manager doesn't support date ranges yet, and bug
+          //  1058438 is calling for loginSaving stuff to end up in the
+          // permission manager)
+          let hosts = Services.logins.getAllDisabledHosts();
+          for (let host of hosts) {
+            Services.logins.setLoginSavingEnabled(host, true);
+          }
+        } catch (ex) {
+          seenException = ex;
         }
 
-        // Clear site security settings - no support for ranges in this
-        // interface either, so we clearAll().
-        var sss = Cc["@mozilla.org/ssservice;1"]
-                    .getService(Ci.nsISiteSecurityService);
-        sss.clearAll();
+        try {
+          // Clear site security settings - no support for ranges in this
+          // interface either, so we clearAll().
+          let sss = Cc["@mozilla.org/ssservice;1"]
+                      .getService(Ci.nsISiteSecurityService);
+          sss.clearAll();
+        } catch (ex) {
+          seenException = ex;
+        }
 
         // Clear all push notification subscriptions
         try {
-          var push = Cc["@mozilla.org/push/Service;1"]
+          let push = Cc["@mozilla.org/push/Service;1"]
                        .getService(Ci.nsIPushService);
           push.clearForDomain("*", status => {
             if (!Components.isSuccessCode(status)) {
               dump("Error clearing Web Push data: " + status + "\n");
             }
           });
         } catch (e) {
           dump("Web Push may not be available.\n");
         }
 
         TelemetryStopwatch.finish("FX_SANITIZE_SITESETTINGS", refObj);
-      }
+        if (seenException) {
+          throw seenException;
+        }
+      })
     },
 
     openWindows: {
       privateStateForNewWindow: "non-private",
       _canCloseWindow: function(aWindow) {
         if (aWindow.CanCloseWindow()) {
           // We already showed PermitUnload for the window, so let's
           // make sure we don't do it again when we actually close the
@@ -485,17 +561,17 @@ Sanitizer.prototype = {
           return true;
         }
       },
       _resetAllWindowClosures: function(aWindowList) {
         for (let win of aWindowList) {
           win.skipNextCanClose = false;
         }
       },
-      clear: Task.async(function*() {
+      clear: Task.async(function* () {
         // NB: this closes all *browser* windows, not other windows like the library, about window,
         // browser console, etc.
 
         // Keep track of the time in case we get stuck in la-la-land because of onbeforeunload
         // dialogs
         let existingWindow = Services.appShell.hiddenDOMWindow;
         let startDate = existingWindow.performance.now();
 
@@ -665,26 +741,24 @@ Sanitizer.__defineGetter__("prefs", func
     : Sanitizer._prefs = Components.classes["@mozilla.org/preferences-service;1"]
                          .getService(Components.interfaces.nsIPrefService)
                          .getBranch(Sanitizer.PREF_DOMAIN);
 });
 
 // Shows sanitization UI
 Sanitizer.showUI = function(aParentWindow)
 {
-  var ww = Components.classes["@mozilla.org/embedcomp/window-watcher;1"]
-                     .getService(Components.interfaces.nsIWindowWatcher);
   let win = AppConstants.platform == "macosx" ?
     null: // make this an app-modal window on Mac
     aParentWindow;
-  ww.openWindow(win,
-                "chrome://browser/content/sanitize.xul",
-                "Sanitize",
-                "chrome,titlebar,dialog,centerscreen,modal",
-                null);
+  Services.ww.openWindow(win,
+                         "chrome://browser/content/sanitize.xul",
+                         "Sanitize",
+                         "chrome,titlebar,dialog,centerscreen,modal",
+                         null);
 };
 
 /**
  * Deletes privacy sensitive data in a batch, optionally showing the
  * sanitize UI, according to user preferences
  */
 Sanitizer.sanitize = function(aParentWindow)
 {
--- a/browser/base/content/test/general/browser_sanitize-timespans.js
+++ b/browser/base/content/test/general/browser_sanitize-timespans.js
@@ -37,26 +37,22 @@ function promiseDownloadRemoved(list) {
     }
   };
 
   list.addView(view);
 
   return deferred.promise;
 }
 
-function test() {
-  waitForExplicitFinish();
-
-  Task.spawn(function() {
-    yield setupDownloads();
-    yield setupFormHistory();
-    yield setupHistory();
-    yield onHistoryReady();
-  }).then(null, ex => ok(false, ex)).then(finish);
-}
+add_task(function* test() {
+  yield setupDownloads();
+  yield setupFormHistory();
+  yield setupHistory();
+  yield onHistoryReady();
+});
 
 function countEntries(name, message, check) {
   let deferred = Promise.defer();
 
   var obj = {};
   if (name !== null)
     obj.fieldname = name;
 
@@ -72,17 +68,17 @@ function countEntries(name, message, che
                                deferred.resolve();
                              }
                            },
                          });
 
   return deferred.promise;
 }
 
-function onHistoryReady() {
+function* onHistoryReady() {
   var hoursSinceMidnight = new Date().getHours();
   var minutesSinceMidnight = hoursSinceMidnight * 60 + new Date().getMinutes();
 
   // Should test cookies here, but nsICookieManager/nsICookieService
   // doesn't let us fake creation times.  bug 463127
   
   let s = new Sanitizer();
   s.ignoreTimespan = false;
@@ -95,23 +91,24 @@ function onHistoryReady() {
   itemPrefs.setBoolPref("formdata", true);
   itemPrefs.setBoolPref("offlineApps", false);
   itemPrefs.setBoolPref("passwords", false);
   itemPrefs.setBoolPref("sessions", false);
   itemPrefs.setBoolPref("siteSettings", false);
 
   let publicList = yield Downloads.getList(Downloads.PUBLIC);
   let downloadPromise = promiseDownloadRemoved(publicList);
+  let formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 10 minutes ago
   s.range = [now_uSec - 10*60*1000000, now_uSec];
-  s.sanitize();
+  yield s.sanitize();
   s.range = null;
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://10minutes.com"))),
      "Pretend visit to 10minutes.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://1hour.com"))),
      "Pretend visit to 1hour.com should should still exist");
   ok((yield promiseIsURIVisited(makeURI("http://1hour10minutes.com"))),
      "Pretend visit to 1hour10minutes.com should should still exist");
@@ -152,22 +149,23 @@ function onHistoryReady() {
   ok((yield downloadExists(publicList, "fakefile-2-hour-10-minutes")), "2 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
 
   if (minutesSinceMidnight > 10)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
 
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 1 hour
   Sanitizer.prefs.setIntPref("timeSpan", 1);
-  s.sanitize();
+  yield s.sanitize();
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://1hour.com"))),
      "Pretend visit to 1hour.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://1hour10minutes.com"))),
      "Pretend visit to 1hour10minutes.com should should still exist");
   ok((yield promiseIsURIVisited(makeURI("http://2hour.com"))),
      "Pretend visit to 2hour.com should should still exist");
@@ -201,23 +199,24 @@ function onHistoryReady() {
   ok((yield downloadExists(publicList, "fakefile-2-hour-10-minutes")), "2 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
 
   if (hoursSinceMidnight > 1)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
   
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 1 hour 10 minutes
   s.range = [now_uSec - 70*60*1000000, now_uSec];
-  s.sanitize();
+  yield s.sanitize();
   s.range = null;
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://1hour10minutes.com"))),
      "Pretend visit to 1hour10minutes.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://2hour.com"))),
      "Pretend visit to 2hour.com should should still exist");
   ok((yield promiseIsURIVisited(makeURI("http://2hour10minutes.com"))),
      "Pretend visit to 2hour10minutes.com should should still exist");
@@ -246,22 +245,23 @@ function onHistoryReady() {
   ok((yield downloadExists(publicList, "fakefile-2-hour")), "<2 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-2-hour-10-minutes")), "2 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
   if (minutesSinceMidnight > 70)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
 
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 2 hours
   Sanitizer.prefs.setIntPref("timeSpan", 2);
-  s.sanitize();
+  yield s.sanitize();
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://2hour.com"))),
      "Pretend visit to 2hour.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://2hour10minutes.com"))),
      "Pretend visit to 2hour10minutes.com should should still exist");
   ok((yield promiseIsURIVisited(makeURI("http://4hour.com"))),
      "Pretend visit to 4hour.com should should still exist");
@@ -284,25 +284,26 @@ function onHistoryReady() {
 
   ok(!(yield downloadExists(publicList, "fakefile-2-hour")), "<2 hour old download should now be deleted");
   ok((yield downloadExists(publicList, "fakefile-old")), "Year old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-2-hour-10-minutes")), "2 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
   if (hoursSinceMidnight > 2)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
-  
+
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 2 hours 10 minutes
   s.range = [now_uSec - 130*60*1000000, now_uSec];
-  s.sanitize();
+  yield s.sanitize();
   s.range = null;
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://2hour10minutes.com"))),
      "Pretend visit to 2hour10minutes.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://4hour.com"))),
      "Pretend visit to 4hour.com should should still exist");
   ok((yield promiseIsURIVisited(makeURI("http://4hour10minutes.com"))),
      "Pretend visit to 4hour10minutes.com should should still exist");
@@ -323,22 +324,23 @@ function onHistoryReady() {
   ok(!(yield downloadExists(publicList, "fakefile-2-hour-10-minutes")), "2 hour 10 minute old download should now be deleted");
   ok((yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should still be present");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-old")), "Year old download should still be present");
   if (minutesSinceMidnight > 130)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
 
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 4 hours
   Sanitizer.prefs.setIntPref("timeSpan", 3);
-  s.sanitize();
+  yield s.sanitize();
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://4hour.com"))),
      "Pretend visit to 4hour.com should now be deleted");
   ok((yield promiseIsURIVisited(makeURI("http://4hour10minutes.com"))),
      "Pretend visit to 4hour10minutes.com should should still exist");
   if (hoursSinceMidnight > 4) {
     ok((yield promiseIsURIVisited(makeURI("http://today.com"))),
@@ -355,23 +357,24 @@ function onHistoryReady() {
 
   ok(!(yield downloadExists(publicList, "fakefile-4-hour")), "<4 hour old download should now be deleted");
   ok((yield downloadExists(publicList, "fakefile-4-hour-10-minutes")), "4 hour 10 minute download should still be present");
   ok((yield downloadExists(publicList, "fakefile-old")), "Year old download should still be present");
   if (hoursSinceMidnight > 4)
     ok((yield downloadExists(publicList, "fakefile-today")), "'Today' download should still be present");
 
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear 4 hours 10 minutes
   s.range = [now_uSec - 250*60*1000000, now_uSec];
-  s.sanitize();
+  yield s.sanitize();
   s.range = null;
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://4hour10minutes.com"))),
      "Pretend visit to 4hour10minutes.com should now be deleted");
   if (minutesSinceMidnight > 250) {
     ok((yield promiseIsURIVisited(makeURI("http://today.com"))),
        "Pretend visit to today.com should still exist");
   }
@@ -390,22 +393,23 @@ function onHistoryReady() {
 
   // The 'Today' download might have been already deleted, in which case we
   // should not wait for a download removal notification.
   if (minutesSinceMidnight > 250) {
     downloadPromise = promiseDownloadRemoved(publicList);
   } else {
     downloadPromise = Promise.resolve();
   }
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Clear Today
   Sanitizer.prefs.setIntPref("timeSpan", 4);
-  s.sanitize();
+  yield s.sanitize();
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   // Be careful.  If we add our objectss just before midnight, and sanitize
   // runs immediately after, they won't be expired.  This is expected, but
   // we should not test in that case.  We cannot just test for opposite
   // condition because we could cross midnight just one moment after we
   // cache our time, then we would have an even worse random failure.
   var today = isToday(new Date(now_mSec));
@@ -418,22 +422,23 @@ function onHistoryReady() {
   }
 
   ok((yield promiseIsURIVisited(makeURI("http://before-today.com"))),
      "Pretend visit to before-today.com should still exist");
   yield countEntries("b4today", "b4today form entry should still exist", checkOne);
   ok((yield downloadExists(publicList, "fakefile-old")), "Year old download should still be present");
 
   downloadPromise = promiseDownloadRemoved(publicList);
+  formHistoryPromise = promiseFormHistoryRemoved();
 
   // Choose everything
   Sanitizer.prefs.setIntPref("timeSpan", 0);
-  s.sanitize();
+  yield s.sanitize();
 
-  yield promiseFormHistoryRemoved();
+  yield formHistoryPromise;
   yield downloadPromise;
 
   ok(!(yield promiseIsURIVisited(makeURI("http://before-today.com"))),
      "Pretend visit to before-today.com should now be deleted");
 
   yield countEntries("b4today", "b4today form entry should be deleted", checkZero);
 
   ok(!(yield downloadExists(publicList, "fakefile-old")), "Year old download should now be deleted");
@@ -467,17 +472,16 @@ function setupHistory() {
   today.setHours(0);
   today.setMinutes(0);
   today.setSeconds(1);
   addPlace(makeURI("http://today.com/"), "Today", today.getTime() * 1000);
 
   let lastYear = new Date();
   lastYear.setFullYear(lastYear.getFullYear() - 1);
   addPlace(makeURI("http://before-today.com/"), "Before Today", lastYear.getTime() * 1000);
-
   PlacesUtils.asyncHistory.updatePlaces(places, {
     handleError: () => ok(false, "Unexpected error in adding visit."),
     handleResult: () => { },
     handleCompletion: () => deferred.resolve()
   });
 
   return deferred.promise;
 }
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -113,34 +113,191 @@ extensions.registerSchemaAPI("tabs", nul
         let tab = event.originalTarget;
         let tabId = TabManager.getId(tab);
         let windowId = WindowManager.getId(tab.ownerDocument.defaultView);
         fire({tabId, windowId});
       }).api(),
 
       onCreated: new EventManager(context, "tabs.onCreated", fire => {
         let listener = event => {
+          if (event.detail.adoptedTab) {
+            // This tab is being created to adopt a tab from another window. We
+            // map this event to an onAttached, rather than onCreated, event.
+            return;
+          }
+
+          // We need to delay sending this event until the next tick, since the
+          // tab does not have its final index when the TabOpen event is dispatched.
           let tab = event.originalTarget;
-          fire(TabManager.convert(extension, tab));
+          Promise.resolve().then(() => {
+            fire(TabManager.convert(extension, tab));
+          });
         };
 
         let windowListener = window => {
+          if (window.arguments[0] instanceof window.XULElement) {
+            // If the first window argument is a XUL element, it means the
+            // window is about to adopt a tab from another window to replace its
+            // initial tab, which means we need to skip the onCreated event, and
+            // fire an onAttached event instead.
+            return;
+          }
+
           for (let tab of window.gBrowser.tabs) {
             fire(TabManager.convert(extension, tab));
           }
         };
 
         WindowListManager.addOpenListener(windowListener);
         AllWindowEvents.addListener("TabOpen", listener);
         return () => {
           WindowListManager.removeOpenListener(windowListener);
           AllWindowEvents.removeListener("TabOpen", listener);
         };
       }).api(),
 
+      onAttached: new EventManager(context, "tabs.onAttached", fire => {
+        let fireForTab = tab => {
+          let newWindowId = WindowManager.getId(tab.ownerDocument.defaultView);
+          fire(TabManager.getId(tab), {newWindowId, newPosition: tab._tPos});
+        };
+
+        let listener = event => {
+          if (event.detail.adoptedTab) {
+            // We need to delay sending this event until the next tick, since the
+            // tab does not have its final index when the TabOpen event is dispatched.
+            Promise.resolve().then(() => {
+              fireForTab(event.originalTarget);
+            });
+          }
+        };
+
+        let windowListener = window => {
+          if (window.arguments[0] instanceof window.XULElement) {
+            // If the first window argument is a XUL element, it means the
+            // window is about to adopt a tab from another window to replace its
+            // initial tab.
+            //
+            // Note that this event handler depends on running before the
+            // delayed startup code in browser.js, which is currently triggered
+            // by the first MozAfterPaint event. That code handles finally
+            // adopting the tab, and clears it from the arguments list in the
+            // process, so if we run later than it, we're too late.
+            let tab = window.arguments[0];
+
+            // We need to be sure to fire this event after the onDetached event
+            // for the original tab.
+            tab.addEventListener("TabClose", function listener(event) {
+              tab.removeEventListener("TabClose", listener);
+              Promise.resolve().then(() => {
+                fireForTab(event.detail.adoptedBy);
+              });
+            });
+          }
+        };
+
+        WindowListManager.addOpenListener(windowListener);
+        AllWindowEvents.addListener("TabOpen", listener);
+        return () => {
+          WindowListManager.removeOpenListener(windowListener);
+          AllWindowEvents.removeListener("TabOpen", listener);
+        };
+      }).api(),
+
+      onDetached: new EventManager(context, "tabs.onDetached", fire => {
+        let listener = event => {
+          if (event.detail.adoptedBy) {
+            let tab = event.originalTarget;
+            let oldWindowId = WindowManager.getId(tab.ownerDocument.defaultView);
+            fire(TabManager.getId(tab), {oldWindowId, oldPosition: tab._tPos});
+          }
+        };
+
+        AllWindowEvents.addListener("TabClose", listener);
+        return () => {
+          AllWindowEvents.removeListener("TabClose", listener);
+        };
+      }).api(),
+
+      onRemoved: new EventManager(context, "tabs.onRemoved", fire => {
+        let fireForTab = (tab, isWindowClosing) => {
+          let tabId = TabManager.getId(tab);
+          let windowId = WindowManager.getId(tab.ownerDocument.defaultView);
+
+          fire(tabId, {windowId, isWindowClosing});
+        };
+
+        let tabListener = event => {
+          // Only fire if this tab is not being moved to another window. If it
+          // is being adopted by another window, we fire an onDetached, rather
+          // than an onRemoved, event.
+          if (!event.detail.adoptedBy) {
+            fireForTab(event.originalTarget, false);
+          }
+        };
+
+        let windowListener = window => {
+          for (let tab of window.gBrowser.tabs) {
+            fireForTab(tab, true);
+          }
+        };
+
+        WindowListManager.addCloseListener(windowListener);
+        AllWindowEvents.addListener("TabClose", tabListener);
+        return () => {
+          WindowListManager.removeCloseListener(windowListener);
+          AllWindowEvents.removeListener("TabClose", tabListener);
+        };
+      }).api(),
+
+      onReplaced: ignoreEvent(context, "tabs.onReplaced"),
+
+      onMoved: new EventManager(context, "tabs.onMoved", fire => {
+        // There are certain circumstances where we need to ignore a move event.
+        //
+        // Namely, the first time the tab is moved after it's created, we need
+        // to report the final position as the initial position in the tab's
+        // onAttached or onCreated event. This is because most tabs are inserted
+        // in a temporary location and then moved after the TabOpen event fires,
+        // which generates a TabOpen event followed by a TabMove event, which
+        // does not match the contract of our API.
+        let ignoreNextMove = new WeakSet();
+
+        let openListener = event => {
+          ignoreNextMove.add(event.target);
+          // Remove the tab from the set on the next tick, since it will already
+          // have been moved by then.
+          Promise.resolve().then(() => {
+            ignoreNextMove.delete(event.target);
+          });
+        };
+
+        let moveListener = event => {
+          let tab = event.originalTarget;
+
+          if (ignoreNextMove.has(tab)) {
+            ignoreNextMove.delete(tab);
+            return;
+          }
+
+          fire(TabManager.getId(tab), {
+            windowId: WindowManager.getId(tab.ownerDocument.defaultView),
+            fromIndex: event.detail,
+            toIndex: tab._tPos,
+          });
+        };
+
+        AllWindowEvents.addListener("TabMove", moveListener);
+        AllWindowEvents.addListener("TabOpen", openListener);
+        return () => {
+          AllWindowEvents.removeListener("TabMove", moveListener);
+          AllWindowEvents.removeListener("TabOpen", openListener);
+        };
+      }).api(),
+
       onUpdated: new EventManager(context, "tabs.onUpdated", fire => {
         function sanitize(extension, changeInfo) {
           let result = {};
           let nonempty = false;
           for (let prop in changeInfo) {
             if ((prop != "favIconUrl" && prop != "url") || extension.hasPermission("tabs")) {
               nonempty = true;
               result[prop] = changeInfo[prop];
@@ -234,44 +391,16 @@ extensions.registerSchemaAPI("tabs", nul
         return () => {
           AllWindowEvents.removeListener("progress", progressListener);
           AllWindowEvents.removeListener("TabAttrModified", listener);
           AllWindowEvents.removeListener("TabPinned", listener);
           AllWindowEvents.removeListener("TabUnpinned", listener);
         };
       }).api(),
 
-      onReplaced: ignoreEvent(context, "tabs.onReplaced"),
-
-      onRemoved: new EventManager(context, "tabs.onRemoved", fire => {
-        let tabListener = event => {
-          let tab = event.originalTarget;
-          let tabId = TabManager.getId(tab);
-          let windowId = WindowManager.getId(tab.ownerDocument.defaultView);
-          let removeInfo = {windowId, isWindowClosing: false};
-          fire(tabId, removeInfo);
-        };
-
-        let windowListener = window => {
-          for (let tab of window.gBrowser.tabs) {
-            let tabId = TabManager.getId(tab);
-            let windowId = WindowManager.getId(window);
-            let removeInfo = {windowId, isWindowClosing: true};
-            fire(tabId, removeInfo);
-          }
-        };
-
-        WindowListManager.addCloseListener(windowListener);
-        AllWindowEvents.addListener("TabClose", tabListener);
-        return () => {
-          WindowListManager.removeCloseListener(windowListener);
-          AllWindowEvents.removeListener("TabClose", tabListener);
-        };
-      }).api(),
-
       create: function(createProperties) {
         return new Promise(resolve => {
           function createInWindow(window) {
             let url;
             if (createProperties.url !== null) {
               url = context.uri.resolve(createProperties.url);
             } else {
               url = window.BROWSER_NEW_TAB_URL;
@@ -641,12 +770,31 @@ extensions.registerSchemaAPI("tabs", nul
             // If the window we are moving is the same, just move the tab.
             gBrowser.moveTabTo(tab, insertionPoint);
           }
           tabsMoved.push(tab);
         }
 
         return Promise.resolve(tabsMoved.map(tab => TabManager.convert(extension, tab)));
       },
+
+      duplicate: function(tabId) {
+        let tab = TabManager.getTab(tabId);
+        if (!tab) {
+          return Promise.reject({message: `Invalid tab ID: ${tabId}`});
+        }
+
+        let gBrowser = tab.ownerDocument.defaultView.gBrowser;
+        let newTab = gBrowser.duplicateTab(tab);
+        gBrowser.moveTabTo(newTab, tab._tPos + 1);
+        gBrowser.selectTabAtIndex(newTab._tPos);
+
+        return new Promise(resolve => {
+          newTab.addEventListener("SSTabRestored", function listener() {
+            newTab.removeEventListener("SSTabRestored", listener);
+            return resolve(TabManager.convert(extension, newTab));
+          });
+        });
+      },
     },
   };
   return self;
 });
--- a/browser/components/extensions/ext-utils.js
+++ b/browser/components/extensions/ext-utils.js
@@ -466,21 +466,70 @@ ExtensionTabManager.prototype = {
   },
 };
 
 
 // Manages global mappings between XUL tabs and extension tab IDs.
 global.TabManager = {
   _tabs: new WeakMap(),
   _nextId: 1,
+  _initialized: false,
+
+  // We begin listening for TabOpen and TabClose events once we've started
+  // assigning IDs to tabs, so that we can remap the IDs of tabs which are moved
+  // between windows.
+  initListener() {
+    if (this._initialized) {
+      return;
+    }
+
+    AllWindowEvents.addListener("TabOpen", this);
+    AllWindowEvents.addListener("TabClose", this);
+    WindowListManager.addOpenListener(this.handleWindowOpen.bind(this));
+
+    this._initialized = true;
+  },
+
+  handleEvent(event) {
+    if (event.type == "TabOpen") {
+      let {adoptedTab} = event.detail;
+      if (adoptedTab) {
+        // This tab is being created to adopt a tab from a different window.
+        // Copy the ID from the old tab to the new.
+        this._tabs.set(event.target, this.getId(adoptedTab));
+      }
+    } else if (event.type == "TabClose") {
+      let {adoptedBy} = event.detail;
+      if (adoptedBy) {
+        // This tab is being closed because it was adopted by a new window.
+        // Copy its ID to the new tab, in case it was created as the first tab
+        // of a new window, and did not have an `adoptedTab` detail when it was
+        // opened.
+        this._tabs.set(adoptedBy, this.getId(event.target));
+      }
+    }
+  },
+
+  handleWindowOpen(window) {
+    if (window.arguments[0] instanceof window.XULElement) {
+      // If the first window argument is a XUL element, it means the
+      // window is about to adopt a tab from another window to replace its
+      // initial tab.
+      let adoptedTab = window.arguments[0];
+
+      this._tabs.set(window.gBrowser.tabs[0], this.getId(adoptedTab));
+    }
+  },
 
   getId(tab) {
     if (this._tabs.has(tab)) {
       return this._tabs.get(tab);
     }
+    this.initListener();
+
     let id = this._nextId++;
     this._tabs.set(tab, id);
     return id;
   },
 
   getBrowserId(browser) {
     let gBrowser = browser.ownerDocument.defaultView.gBrowser;
     // Some non-browser windows have gBrowser but not
--- a/browser/components/extensions/ext-windows.js
+++ b/browser/components/extensions/ext-windows.js
@@ -1,15 +1,17 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   EventManager,
 } = ExtensionUtils;
 
 extensions.registerSchemaAPI("windows", null, (extension, context) => {
   return {
@@ -63,17 +65,37 @@ extensions.registerSchemaAPI("windows", 
       create: function(createData) {
         function mkstr(s) {
           let result = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
           result.data = s;
           return result;
         }
 
         let args = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray);
-        if (createData.url !== null) {
+
+        if (createData.tabId !== null) {
+          if (createData.url !== null) {
+            return Promise.reject({message: "`tabId` may not be used in conjunction with `url`"});
+          }
+
+          let tab = TabManager.getTab(createData.tabId);
+          if (tab == null) {
+            return Promise.reject({message: `Invalid tab ID: ${createData.tabId}`});
+          }
+
+          // Private browsing tabs can only be moved to private browsing
+          // windows.
+          let incognito = PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser);
+          if (createData.incognito !== null && createData.incognito != incognito) {
+            return Promise.reject({message: "`incognito` property must match the incognito state of tab"});
+          }
+          createData.incognito = incognito;
+
+          args.AppendElement(tab);
+        } else if (createData.url !== null) {
           if (Array.isArray(createData.url)) {
             let array = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray);
             for (let url of createData.url) {
               array.AppendElement(mkstr(url));
             }
             args.AppendElement(array);
           } else {
             args.AppendElement(mkstr(createData.url));
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -19,24 +19,27 @@ support-files =
 [browser_ext_browserAction_popup.js]
 [browser_ext_popup_api_injection.js]
 [browser_ext_contextMenus.js]
 [browser_ext_getViews.js]
 [browser_ext_lastError.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_tabs_audio.js]
 [browser_ext_tabs_captureVisibleTab.js]
+[browser_ext_tabs_events.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
 [browser_ext_tabs_insertCSS.js]
 [browser_ext_tabs_query.js]
 [browser_ext_tabs_getCurrent.js]
 [browser_ext_tabs_create.js]
+[browser_ext_tabs_duplicate.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_onUpdated.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_move.js]
 [browser_ext_tabs_move_window.js]
+[browser_ext_windows_create_tabId.js]
 [browser_ext_windows_update.js]
 [browser_ext_contentscript_connect.js]
 [browser_ext_tab_runtimeConnect.js]
 [browser_ext_webNavigation_getFrames.js]
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js
@@ -0,0 +1,44 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* testDuplicateTab() {
+  yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/");
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background: function() {
+      browser.tabs.query({
+        lastFocusedWindow: true,
+      }, function(tabs) {
+        let source = tabs[1];
+        // By moving it 0, we check that the new tab is created next
+        // to the existing one.
+        browser.tabs.move(source.id, {index: 0}, () => {
+          browser.tabs.duplicate(source.id, (tab) => {
+            browser.test.assertEq("http://example.net/", tab.url);
+            // Should be the second tab, next to the one duplicated.
+            browser.test.assertEq(1, tab.index);
+            // Should be selected by default.
+            browser.test.assertTrue(tab.selected);
+            browser.test.notifyPass("tabs.duplicate");
+          });
+        });
+      });
+    },
+  });
+
+  yield extension.startup();
+  yield extension.awaitFinish("tabs.duplicate");
+  yield extension.unload();
+
+  while (window.gBrowser.tabs.length > 1) {
+    let tab = window.gBrowser.tabs[0];
+    if (tab.linkedBrowser.currentURI.spec === "http://example.net/") {
+      yield BrowserTestUtils.removeTab(tab);
+    }
+  }
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_events.js
@@ -0,0 +1,161 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* testTabEvents() {
+  function background() {
+    let events = [];
+    browser.tabs.onCreated.addListener(tab => {
+      events.push({type: "onCreated", tab});
+    });
+
+    browser.tabs.onAttached.addListener((tabId, info) => {
+      events.push(Object.assign({type: "onAttached", tabId}, info));
+    });
+
+    browser.tabs.onDetached.addListener((tabId, info) => {
+      events.push(Object.assign({type: "onDetached", tabId}, info));
+    });
+
+    browser.tabs.onRemoved.addListener((tabId, info) => {
+      events.push(Object.assign({type: "onRemoved", tabId}, info));
+    });
+
+    browser.tabs.onMoved.addListener((tabId, info) => {
+      events.push(Object.assign({type: "onMoved", tabId}, info));
+    });
+
+    function expectEvents(names) {
+      browser.test.log(`Expecting events: ${names.join(", ")}`);
+
+      return new Promise(resolve => {
+        setTimeout(resolve, 0);
+      }).then(() => {
+        browser.test.assertEq(names.length, events.length, "Got expected number of events");
+        for (let [i, name] of names.entries()) {
+          browser.test.assertEq(name, i in events && events[i].type,
+                                `Got expected ${name} event`);
+        }
+        return events.splice(0);
+      });
+    }
+
+    browser.test.log("Create second browser window");
+    let windowId;
+    Promise.all([
+      browser.windows.getCurrent(),
+      browser.windows.create({url: "about:blank"}),
+    ]).then(windows => {
+      windowId = windows[0].id;
+      let otherWindowId = windows[1].id;
+      let initialTab;
+
+      return expectEvents(["onCreated"]).then(([created]) => {
+        initialTab = created.tab;
+
+        browser.test.log("Create tab in window 1");
+        return browser.tabs.create({windowId, index: 0, url: "about:blank"});
+      }).then(tab => {
+        let oldIndex = tab.index;
+        browser.test.assertEq(0, oldIndex, "Tab has the expected index");
+
+        return expectEvents(["onCreated"]).then(([created]) => {
+          browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID");
+          browser.test.assertEq(oldIndex, created.tab.index, "Got expected tab index");
+
+          browser.test.log("Move tab to window 2");
+          return browser.tabs.move([tab.id], {windowId: otherWindowId, index: 0});
+        }).then(() => {
+          return expectEvents(["onDetached", "onAttached"]);
+        }).then(([detached, attached]) => {
+          browser.test.assertEq(oldIndex, detached.oldPosition, "Expected old index");
+          browser.test.assertEq(windowId, detached.oldWindowId, "Expected old window ID");
+
+          browser.test.assertEq(0, attached.newPosition, "Expected new index");
+          browser.test.assertEq(otherWindowId, attached.newWindowId, "Expected new window ID");
+
+          browser.test.log("Move tab within the same window");
+          return browser.tabs.move([tab.id], {index: 1});
+        }).then(([moved]) => {
+          browser.test.assertEq(1, moved.index, "Expected new index");
+
+          return expectEvents(["onMoved"]);
+        }).then(([moved]) => {
+          browser.test.assertEq(tab.id, moved.tabId, "Expected tab ID");
+          browser.test.assertEq(0, moved.fromIndex, "Expected old index");
+          browser.test.assertEq(1, moved.toIndex, "Expected new index");
+          browser.test.assertEq(otherWindowId, moved.windowId, "Expected window ID");
+
+          browser.test.log("Remove tab");
+          return browser.tabs.remove(tab.id);
+        }).then(() => {
+          return expectEvents(["onRemoved"]);
+        }).then(([removed]) => {
+          browser.test.assertEq(tab.id, removed.tabId, "Expected removed tab ID");
+          browser.test.assertEq(otherWindowId, removed.windowId, "Expected removed tab window ID");
+          // Note: We want to test for the actual boolean value false here.
+          browser.test.assertEq(false, removed.isWindowClosing, "Expected isWindowClosing value");
+
+          browser.test.log("Close second window");
+          return browser.windows.remove(otherWindowId);
+        }).then(() => {
+          return expectEvents(["onRemoved"]);
+        }).then(([removed]) => {
+          browser.test.assertEq(initialTab.id, removed.tabId, "Expected removed tab ID");
+          browser.test.assertEq(otherWindowId, removed.windowId, "Expected removed tab window ID");
+          browser.test.assertEq(true, removed.isWindowClosing, "Expected isWindowClosing value");
+        });
+      });
+    }).then(() => {
+      browser.test.log("Create additional tab in window 1");
+      return browser.tabs.create({windowId, url: "about:blank"});
+    }).then(tab => {
+      return expectEvents(["onCreated"]).then(() => {
+        browser.test.log("Create a new window, adopting the new tab");
+
+        // We have to explicitly wait for the event here, since its timing is
+        // not predictable.
+        let promiseAttached = new Promise(resolve => {
+          browser.tabs.onAttached.addListener(function listener(tabId) {
+            browser.tabs.onAttached.removeListener(listener);
+            resolve();
+          });
+        });
+
+        return Promise.all([
+          browser.windows.create({tabId: tab.id}),
+          promiseAttached,
+        ]);
+      }).then(([window]) => {
+        return expectEvents(["onDetached", "onAttached"]).then(([detached, attached]) => {
+          browser.test.assertEq(tab.id, detached.tabId, "Expected onDetached tab ID");
+
+          browser.test.assertEq(tab.id, attached.tabId, "Expected onAttached tab ID");
+          browser.test.assertEq(0, attached.newPosition, "Expected onAttached new index");
+          browser.test.assertEq(window.id, attached.newWindowId,
+                                "Expected onAttached new window id");
+
+          browser.test.log("Close the new window");
+          return browser.windows.remove(window.id);
+        });
+      });
+    }).then(() => {
+      browser.test.notifyPass("tabs-events");
+    }).catch(e => {
+      browser.test.fail(`${e} :: ${e.stack}`);
+      browser.test.notifyFail("tabs-events");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background,
+  });
+
+  yield extension.startup();
+  yield extension.awaitFinish("tabs-events");
+  yield extension.unload();
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js
@@ -0,0 +1,108 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(function* testWindowCreate() {
+  function background() {
+    let promiseTabAttached = () => {
+      return new Promise(resolve => {
+        browser.tabs.onAttached.addListener(function listener() {
+          browser.tabs.onAttached.removeListener(listener);
+          resolve();
+        });
+      });
+    };
+
+    let windowId;
+    browser.windows.getCurrent().then(window => {
+      windowId = window.id;
+
+      browser.test.log("Create additional tab in window 1");
+      return browser.tabs.create({windowId, url: "about:blank"});
+    }).then(tab => {
+      browser.test.log("Create a new window, adopting the new tab");
+
+      // Note that we want to check against actual boolean values for
+      // all of the `incognito` property tests.
+      browser.test.assertEq(false, tab.incognito, "Tab is not private");
+
+      return Promise.all([
+        promiseTabAttached(),
+        browser.windows.create({tabId: tab.id}),
+      ]);
+    }).then(([, window]) => {
+      browser.test.assertEq(false, window.incognito, "New window is not private");
+
+      browser.test.log("Close the new window");
+      return browser.windows.remove(window.id);
+    }).then(() => {
+      browser.test.log("Create a new private window");
+
+      return browser.windows.create({incognito: true});
+    }).then(privateWindow => {
+      browser.test.assertEq(true, privateWindow.incognito, "Private window is private");
+
+      browser.test.log("Create additional tab in private window");
+      return browser.tabs.create({windowId: privateWindow.id}).then(privateTab => {
+        browser.test.assertEq(true, privateTab.incognito, "Private tab is private");
+
+        browser.test.log("Create a new window, adopting the new private tab");
+
+        return Promise.all([
+          promiseTabAttached(),
+          browser.windows.create({tabId: privateTab.id}),
+        ]);
+      }).then(([, newWindow]) => {
+        browser.test.assertEq(true, newWindow.incognito, "New private window is private");
+
+        browser.test.log("Close the new private window");
+        return browser.windows.remove(newWindow.id);
+      }).then(() => {
+        browser.test.log("Close the private window");
+        return browser.windows.remove(privateWindow.id);
+      });
+    }).then(() => {
+      return browser.tabs.query({windowId, active: true});
+    }).then(([tab]) => {
+      browser.test.log("Try to create a window with both a tab and a URL");
+
+      return browser.windows.create({tabId: tab.id, url: "http://example.com/"}).then(
+        window => {
+          browser.test.fail("Create call should have failed");
+        },
+        error => {
+          browser.test.assertTrue(/`tabId` may not be used in conjunction with `url`/.test(error.message),
+                                  "Create call failed as expected");
+        }).then(() => {
+          browser.test.log("Try to create a window with both a tab and an invalid incognito setting");
+
+          return browser.windows.create({tabId: tab.id, incognito: true});
+        }).then(
+          window => {
+            browser.test.fail("Create call should have failed");
+          },
+          error => {
+            browser.test.assertTrue(/`incognito` property must match the incognito state of tab/.test(error.message),
+                                    "Create call failed as expected");
+          });
+    }).then(() => {
+      browser.test.notifyPass("window-create");
+    }).catch(e => {
+      browser.test.fail(`${e} :: ${e.stack}`);
+      browser.test.notifyFail("window-create");
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    background,
+  });
+
+  yield extension.startup();
+  yield extension.awaitFinish("window-create");
+  yield extension.unload();
+});
+
--- a/browser/components/newtab/PlacesProvider.jsm
+++ b/browser/components/newtab/PlacesProvider.jsm
@@ -25,16 +25,19 @@ XPCOMUtils.defineLazyGetter(this, "Event
   return EventEmitter;
 });
 
 XPCOMUtils.defineLazyGetter(this, "gPrincipal", function() {
   let uri = Services.io.newURI("about:newtab", null, null);
   return Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri);
 });
 
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
 // The maximum number of results PlacesProvider retrieves from history.
 const HISTORY_RESULTS_LIMIT = 100;
 
 /**
  * Singleton that checks if a given link should be displayed on about:newtab
  * or if we should rather not do it for security reasons. URIs that inherit
  * their caller's principal will be filtered.
  */
@@ -63,56 +66,16 @@ let LinkChecker = {
     } catch (e) {
       // We got a weird URI or one that would inherit the caller's principal.
       Cu.reportError(e);
     }
     return result;
   }
 };
 
-/**
- * Singleton that provides utility functions for links.
- * A link is a plain object that looks like this:
- *
- * {
- *   url: "http://www.mozilla.org/",
- *   title: "Mozilla",
- *   frecency: 1337,
- *   lastVisitDate: 1394678824766431,
- * }
- */
-const LinkUtils = {
-  _sortProperties: [
-    "frecency",
-    "lastVisitDate",
-    "url",
-  ],
-
-  /**
-   * Compares two links.
-   *
-   * @param {String} aLink1 The first link.
-   * @param {String} aLink2 The second link.
-   * @return {Number} A negative number if aLink1 is ordered before aLink2, zero if
-   *         aLink1 and aLink2 have the same ordering, or a positive number if
-   *         aLink1 is ordered after aLink2.
-   *         Order is ascending.
-   */
-  compareLinks: function LinkUtils_compareLinks(aLink1, aLink2) {
-    for (let prop of LinkUtils._sortProperties) {
-      if (!aLink1.hasOwnProperty(prop) || !aLink2.hasOwnProperty(prop)) {
-        throw new Error("Comparable link missing required property: " + prop);
-      }
-    }
-    return aLink2.frecency - aLink1.frecency ||
-           aLink2.lastVisitDate - aLink1.lastVisitDate ||
-           aLink1.url.localeCompare(aLink2.url);
-  },
-};
-
 /* Queries history to retrieve the most visited sites. Emits events when the
  * history changes.
  * Implements the EventEmitter interface.
  */
 let Links = function Links() {
   EventEmitter.decorate(this);
 };
 
@@ -187,85 +150,99 @@ Links.prototype = {
     PlacesUtils.history.removeObserver(this.historyObserver);
   },
 
   /**
    * Gets the current set of links delivered by this provider.
    *
    * @returns {Promise} Returns a promise with the array of links as payload.
    */
-  getLinks: function PlacesProvider_getLinks() {
-    let getLinksPromise = new Promise((resolve, reject) => {
-      let options = PlacesUtils.history.getNewQueryOptions();
-      options.maxResults = this.maxNumLinks;
+  getLinks: Task.async(function*() {
+    // Select a single page per host with highest frecency, highest recency.
+    // Choose N top such pages. Note +rev_host, to turn off optimizer per :mak
+    // suggestion.
+    let sqlQuery = `SELECT url, title, frecency,
+                          last_visit_date as lastVisitDate,
+                          "history" as type
+                   FROM moz_places
+                   WHERE frecency in (
+                     SELECT MAX(frecency) as frecency
+                     FROM moz_places
+                     WHERE hidden = 0 AND last_visit_date NOTNULL
+                     GROUP BY +rev_host
+                     ORDER BY frecency DESC
+                     LIMIT :limit
+                   )
+                   GROUP BY rev_host HAVING MAX(lastVisitDate)
+                   ORDER BY frecency DESC, lastVisitDate DESC, url`;
 
-      // Sort by frecency, descending.
-      options.sortingMode = Ci.nsINavHistoryQueryOptions
-        .SORT_BY_FRECENCY_DESCENDING;
+    let links = yield this.executePlacesQuery(sqlQuery, {
+                  columns: ["url", "title", "lastVisitDate", "frecency", "type"],
+                  params: {limit: this.maxNumLinks}
+                });
 
-      let links = [];
+    return links.filter(link => LinkChecker.checkLoadURI(link.url));
+  }),
 
-      let queryHandlers = {
-        handleResult: function(aResultSet) {
-          for (let row = aResultSet.getNextRow(); row; row = aResultSet.getNextRow()) {
-            let url = row.getResultByIndex(1);
-            if (LinkChecker.checkLoadURI(url)) {
-              let link = {
-                url: url,
-                title: row.getResultByIndex(2),
-                frecency: row.getResultByIndex(12),
-                lastVisitDate: row.getResultByIndex(5),
-                type: "history",
-              };
-              links.push(link);
+  /**
+   * Executes arbitrary query against places database
+   *
+   * @param {String} aSql
+   *        SQL query to execute
+   * @param {Object} [optional] aOptions
+   *        aOptions.columns - an array of column names. if supplied the returned
+   *        items will consist of objects keyed on column names. Otherwise
+   *        an array of raw values is returned in the select order
+   *        aOptions.param - an object of SQL binding parameters
+   *        aOptions.callback - a callback to handle query rows
+   *
+   * @returns {Promise} Returns a promise with the array of retrieved items
+   */
+  executePlacesQuery: Task.async(function*(aSql, aOptions={}) {
+    let {columns, params, callback} = aOptions;
+    let items = [];
+    let queryError = null;
+    let conn = yield PlacesUtils.promiseDBConnection();
+    yield conn.executeCached(aSql, params, aRow => {
+      try {
+        // check if caller wants to handle query raws
+        if (callback) {
+          callback(aRow);
+        }
+        // otherwise fill in the item and add items array
+        else {
+          let item = null;
+          // if columns array is given construct an object
+          if (columns && Array.isArray(columns)) {
+            item = {};
+            columns.forEach(column => {
+              item[column] = aRow.getResultByName(column);
+            });
+          } else {
+            // if no columns - make an array of raw values
+            item = [];
+            for (let i = 0; i < aRow.numEntries; i++) {
+              item.push(aRow.getResultByIndex(i));
             }
           }
-        },
-
-        handleError: function(aError) {
-          reject(aError);
-        },
-
-        handleCompletion: function(aReason) { // jshint ignore:line
-          // The Places query breaks ties in frecency by place ID descending, but
-          // that's different from how Links.compareLinks breaks ties, because
-          // compareLinks doesn't have access to place IDs.  It's very important
-          // that the initial list of links is sorted in the same order imposed by
-          // compareLinks, because Links uses compareLinks to perform binary
-          // searches on the list.  So, ensure the list is so ordered.
-          let i = 1;
-          let outOfOrder = [];
-          while (i < links.length) {
-            if (LinkUtils.compareLinks(links[i - 1], links[i]) > 0) {
-              outOfOrder.push(links.splice(i, 1)[0]);
-            } else {
-              i++;
-            }
-          }
-          for (let link of outOfOrder) {
-            i = BinarySearch.insertionIndexOf(LinkUtils.compareLinks, links, link);
-            links.splice(i, 0, link);
-          }
-
-          resolve(links);
+          items.push(item);
         }
-      };
-
-      // Execute the query.
-      let query = PlacesUtils.history.getNewQuery();
-      let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase);
-      db.asyncExecuteLegacyQueries([query], 1, options, queryHandlers);
+      } catch (e) {
+        queryError = e;
+        throw StopIteration;
+      }
     });
-
-    return getLinksPromise;
-  }
+    if (queryError) {
+      throw new Error(queryError);
+    }
+    return items;
+  }),
 };
 
 /**
  * Singleton that serves as the default link provider for the grid.
  */
 const gLinks = new Links(); // jshint ignore:line
 
 let PlacesProvider = {
   LinkChecker: LinkChecker,
-  LinkUtils: LinkUtils,
   links: gLinks,
 };
--- a/browser/components/newtab/tests/xpcshell/test_PlacesProvider.js
+++ b/browser/components/newtab/tests/xpcshell/test_PlacesProvider.js
@@ -26,16 +26,40 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 // ensure a profile exists
 do_get_profile();
 
 function run_test() {
   run_next_test();
 }
 
+// url prefix for test history population
+const TEST_URL = "https://mozilla.com/";
+// time when the test starts execution
+const TIME_NOW = (new Date()).getTime();
+
+// utility function to compute past timestap
+function timeDaysAgo(numDays) {
+  return TIME_NOW - (numDays * 24 * 60 * 60 * 1000);
+}
+
+// utility function to make a visit for insetion into places db
+function makeVisit(index, daysAgo, isTyped, domain=TEST_URL) {
+  let {
+    TRANSITION_TYPED,
+    TRANSITION_LINK
+  } = PlacesUtils.history;
+
+  return {
+    uri: NetUtil.newURI(`${domain}${index}`),
+    visitDate: timeDaysAgo(daysAgo),
+    transition: (isTyped) ? TRANSITION_TYPED : TRANSITION_LINK,
+  };
+}
+
 /** Test LinkChecker **/
 
 add_task(function test_LinkChecker_securityCheck() {
 
   let urls = [
     {url: "file://home/file/image.png", expected: false},
     {url: "resource:///modules/PlacesProvider.jsm", expected: false},
     {url: "javascript:alert('hello')", expected: false}, // jshint ignore:line
@@ -45,107 +69,16 @@ add_task(function test_LinkChecker_secur
     {url: "ftp://example.com", expected: true},
   ];
   for (let {url, expected} of urls) {
     let observed = PlacesProvider.LinkChecker.checkLoadURI(url);
     equal(observed , expected, `can load "${url}"?`);
   }
 });
 
-/** Test LinkUtils **/
-
-add_task(function test_LinkUtils_compareLinks() {
-
-  let fixtures = {
-    firstOlder: {
-      url: "http://www.mozilla.org/firstolder",
-      title: "Mozilla",
-      frecency: 1337,
-      lastVisitDate: 1394678824766431,
-    },
-    older: {
-      url: "http://www.mozilla.org/older",
-      title: "Mozilla",
-      frecency: 1337,
-      lastVisitDate: 1394678824766431,
-    },
-    newer: {
-      url: "http://www.mozilla.org/newer",
-      title: "Mozilla",
-      frecency: 1337,
-      lastVisitDate: 1494678824766431,
-    },
-    moreFrecent: {
-      url: "http://www.mozilla.org/moreFrecent",
-      title: "Mozilla",
-      frecency: 1337357,
-      lastVisitDate: 1394678824766431,
-    }
-  };
-
-  let links = [
-    // tests string ordering, f is before o
-    {link1: fixtures.firstOlder, link2: fixtures.older, expected: false},
-
-    // test identity
-    {link1: fixtures.older, link2: fixtures.older, expected: false},
-
-    // test ordering by date
-    {link1: fixtures.older, link2: fixtures.newer, expected: true},
-    {link1: fixtures.newer, link2: fixtures.older, expected: false},
-
-    // test frecency
-    {link1: fixtures.moreFrecent, link2: fixtures.older, expected: false},
-  ];
-
-  for (let {link1, link2, expected} of links) {
-    let observed = PlacesProvider.LinkUtils.compareLinks(link1, link2) > 0;
-    equal(observed , expected, `comparing ${link1.url} and ${link2.url}`);
-  }
-
-  // test error scenarios
-
-  let errorFixtures = {
-    missingFrecency: {
-      url: "http://www.mozilla.org/firstolder",
-      title: "Mozilla",
-      lastVisitDate: 1394678824766431,
-    },
-    missingVisitDate: {
-      url: "http://www.mozilla.org/firstolder",
-      title: "Mozilla",
-      frecency: 1337,
-    },
-    missingURL: {
-      title: "Mozilla",
-      frecency: 1337,
-      lastVisitDate: 1394678824766431,
-    }
-  };
-
-  let errorLinks = [
-    {link1: fixtures.older, link2: errorFixtures.missingFrecency},
-    {link2: fixtures.older, link1: errorFixtures.missingFrecency},
-    {link1: fixtures.older, link2: errorFixtures.missingVisitDate},
-    {link1: fixtures.older, link2: errorFixtures.missingURL},
-    {link1: errorFixtures.missingFrecency, link2: errorFixtures.missingVisitDate}
-  ];
-
-  let errorCount = 0;
-  for (let {link1, link2} of errorLinks) {
-    try {
-      let observed = PlacesProvider.LinkUtils.compareLinks(link1, link2) > 0; // jshint ignore:line
-    } catch (e) {
-      ok(true, `exception for comparison of ${link1.url} and ${link2.url}`);
-      errorCount += 1;
-    }
-  }
-  equal(errorCount, errorLinks.length);
-});
-
 /** Test Provider **/
 
 add_task(function* test_Links_getLinks() {
   yield PlacesTestUtils.clearHistory();
   let provider = PlacesProvider.links;
 
   let links = yield provider.getLinks();
   equal(links.length, 0, "empty history yields empty links");
@@ -157,51 +90,58 @@ add_task(function* test_Links_getLinks()
   links = yield provider.getLinks();
   equal(links.length, 1, "adding a visit yields a link");
   equal(links[0].url, testURI.spec, "added visit corresponds to added url");
 });
 
 add_task(function* test_Links_getLinks_Order() {
   yield PlacesTestUtils.clearHistory();
   let provider = PlacesProvider.links;
-  let {
-    TRANSITION_TYPED,
-    TRANSITION_LINK
-  } = PlacesUtils.history;
 
-  function timeDaysAgo(numDays) {
-    let now = new Date();
-    return now.getTime() - (numDays * 24 * 60 * 60 * 1000);
-  }
-
-  let timeEarlier = timeDaysAgo(0);
-  let timeLater = timeDaysAgo(2);
-
+  // all four visits must come from different domains to avoid deduplication
   let visits = [
-    // frecency 200
-    {uri: NetUtil.newURI("https://mozilla.com/0"), visitDate: timeEarlier, transition: TRANSITION_TYPED},
-    // sort by url, frecency 200
-    {uri: NetUtil.newURI("https://mozilla.com/1"), visitDate: timeEarlier, transition: TRANSITION_TYPED},
-    // sort by last visit date, frecency 200
-    {uri: NetUtil.newURI("https://mozilla.com/2"), visitDate: timeLater, transition: TRANSITION_TYPED},
-    // sort by frecency, frecency 10
-    {uri: NetUtil.newURI("https://mozilla.com/3"), visitDate: timeLater, transition: TRANSITION_LINK},
+    makeVisit(0, 0, true, "http://bar.com/"), // frecency 200, today
+    makeVisit(1, 0, true, "http://foo.com/"), // frecency 200, today
+    makeVisit(2, 2, true, "http://buz.com/"), // frecency 200, 2 days ago
+    makeVisit(3, 2, false, "http://aaa.com/"), // frecency 10, 2 days ago, transition
   ];
 
   let links = yield provider.getLinks();
   equal(links.length, 0, "empty history yields empty links");
   yield PlacesTestUtils.addVisits(visits);
 
   links = yield provider.getLinks();
   equal(links.length, visits.length, "number of links added is the same as obtain by getLinks");
   for (let i = 0; i < links.length; i++) {
     equal(links[i].url, visits[i].uri.spec, "links are obtained in the expected order");
   }
 });
 
+add_task(function* test_Links_getLinks_Deduplication() {
+  yield PlacesTestUtils.clearHistory();
+  let provider = PlacesProvider.links;
+
+  // all for visits must come from different domains to avoid deduplication
+  let visits = [
+    makeVisit(0, 2, true, "http://bar.com/"), // frecency 200, 2 days ago
+    makeVisit(1, 0, true, "http://bar.com/"), // frecency 200, today
+    makeVisit(2, 0, false, "http://foo.com/"), // frecency 10, today
+    makeVisit(3, 0, true, "http://foo.com/"), // frecency 200, today
+  ];
+
+  let links = yield provider.getLinks();
+  equal(links.length, 0, "empty history yields empty links");
+  yield PlacesTestUtils.addVisits(visits);
+
+  links = yield provider.getLinks();
+  equal(links.length, 2, "only two links must be left after deduplication");
+  equal(links[0].url, visits[1].uri.spec, "earliest link is present");
+  equal(links[1].url, visits[3].uri.spec, "most fresent link is present");
+});
+
 add_task(function* test_Links_onLinkChanged() {
   let provider = PlacesProvider.links;
   provider.init();
 
   let url = "https://example.com/onFrecencyChanged1";
   let linkChangedMsgCount = 0;
 
   let linkChangedPromise = new Promise(resolve => {
@@ -300,8 +240,127 @@ add_task(function* test_Links_onManyLink
 
   // trigger DecayFrecency
   PlacesUtils.history.QueryInterface(Ci.nsIObserver).
     observe(null, "idle-daily", "");
 
   yield promise;
   provider.destroy();
 });
+
+add_task(function* test_Links_execute_query() {
+  yield PlacesTestUtils.clearHistory();
+  let provider = PlacesProvider.links;
+
+  let visits = [
+    makeVisit(0, 0, true), // frecency 200, today
+    makeVisit(1, 0, true), // frecency 200, today
+    makeVisit(2, 2, true), // frecency 200, 2 days ago
+    makeVisit(3, 2, false), // frecency 10, 2 days ago, transition
+  ];
+
+  yield PlacesTestUtils.addVisits(visits);
+
+  function testItemValue(results, index, value) {
+    equal(results[index][0], `${TEST_URL}${value}`, "raw url");
+    equal(results[index][1], `test visit for ${TEST_URL}${value}`, "raw title");
+  }
+
+  function testItemObject(results, index, columnValues) {
+    Object.keys(columnValues).forEach(name => {
+      equal(results[index][name], columnValues[name], "object name " + name);
+    });
+  }
+
+  // select all 4 records
+  let results = yield provider.executePlacesQuery("select url, title from moz_places");
+  equal(results.length, 4, "expect 4 items");
+  // check for insert order sequence
+  for (let i = 0; i < results.length; i++) {
+    testItemValue(results, i, i);
+  }
+
+  // test parameter passing
+  results = yield provider.executePlacesQuery(
+              "select url, title from moz_places limit :limit",
+              {params: {limit: 2}}
+            );
+  equal(results.length, 2, "expect 2 items");
+  for (let i = 0; i < results.length; i++) {
+    testItemValue(results, i, i);
+  }
+
+  // test extracting items by name
+  results = yield provider.executePlacesQuery(
+              "select url, title from moz_places limit :limit",
+              {columns: ["url", "title"], params: {limit: 4}}
+            );
+  equal(results.length, 4, "expect 4 items");
+  for (let i = 0; i < results.length; i++) {
+    testItemObject(results, i, {
+      "url": `${TEST_URL}${i}`,
+      "title": `test visit for ${TEST_URL}${i}`,
+    });
+  }
+
+  // test ordering
+  results = yield provider.executePlacesQuery(
+              "select url, title, last_visit_date, frecency from moz_places " +
+              "order by frecency DESC, last_visit_date DESC, url DESC limit :limit",
+              {columns: ["url", "title", "last_visit_date", "frecency"], params: {limit: 4}}
+            );
+  equal(results.length, 4, "expect 4 items");
+  testItemObject(results, 0, {url: `${TEST_URL}1`});
+  testItemObject(results, 1, {url: `${TEST_URL}0`});
+  testItemObject(results, 2, {url: `${TEST_URL}2`});
+  testItemObject(results, 3, {url: `${TEST_URL}3`});
+
+  // test callback passing
+  results = [];
+  function handleRow(aRow) {
+    results.push({
+      url:              aRow.getResultByName("url"),
+      title:            aRow.getResultByName("title"),
+      last_visit_date:  aRow.getResultByName("last_visit_date"),
+      frecency:         aRow.getResultByName("frecency")
+    });
+  }
+  yield provider.executePlacesQuery(
+        "select url, title, last_visit_date, frecency from moz_places " +
+        "order by frecency DESC, last_visit_date DESC, url DESC",
+        {callback: handleRow}
+      );
+  equal(results.length, 4, "expect 4 items");
+  testItemObject(results, 0, {url: `${TEST_URL}1`});
+  testItemObject(results, 1, {url: `${TEST_URL}0`});
+  testItemObject(results, 2, {url: `${TEST_URL}2`});
+  testItemObject(results, 3, {url: `${TEST_URL}3`});
+
+  // negative test cases
+  // bad sql
+  try {
+    yield provider.executePlacesQuery("select from moz");
+    do_throw("bad sql should've thrown");
+  }
+  catch (e) {
+    do_check_true("expected failure - bad sql");
+  }
+  // missing bindings
+  try {
+    yield provider.executePlacesQuery("select * from moz_places limit :limit");
+    do_throw("bad sql should've thrown");
+  }
+  catch (e) {
+    do_check_true("expected failure - missing bidning");
+  }
+  // non-existent column name
+  try {
+    yield provider.executePlacesQuery("select * from moz_places limit :limit",
+                                     {columns: ["no-such-column"], params: {limit: 4}});
+    do_throw("bad sql should've thrown");
+  }
+  catch (e) {
+    do_check_true("expected failure - wrong column name");
+  }
+
+  // cleanup
+  yield PlacesTestUtils.clearHistory();
+});
--- a/browser/components/places/tests/unit/test_clearHistory_shutdown.js
+++ b/browser/components/places/tests/unit/test_clearHistory_shutdown.js
@@ -30,16 +30,18 @@ const UNEXPECTED_NOTIFICATIONS = [
 
 const FTP_URL = "ftp://localhost/clearHistoryOnShutdown/";
 
 // Send the profile-after-change notification to the form history component to ensure
 // that it has been initialized.
 var formHistoryStartup = Cc["@mozilla.org/satchel/form-history-startup;1"].
                          getService(Ci.nsIObserver);
 formHistoryStartup.observe(null, "profile-after-change", null);
+XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
+                                  "resource://gre/modules/FormHistory.jsm");
 
 var timeInMicroseconds = Date.now() * 1000;
 
 function run_test() {
   run_next_test();
 }
 
 add_task(function* test_execute() {
@@ -68,20 +70,25 @@ add_task(function* test_execute() {
   for (let aUrl of URIS) {
     yield PlacesTestUtils.addVisits({
       uri: uri(aUrl), visitDate: timeInMicroseconds++,
       transition: PlacesUtils.history.TRANSITION_TYPED
     });
   }
   do_print("Add cache.");
   yield storeCache(FTP_URL, "testData");
+  do_print("Add form history.");
+  yield addFormHistory();
+  Assert.equal((yield getFormHistoryCount()), 1, "Added form history");
 
   do_print("Simulate and wait shutdown.");
   yield shutdownPlaces();
 
+  Assert.equal((yield getFormHistoryCount()), 0, "Form history cleared");
+
   let stmt = DBConn(true).createStatement(
     "SELECT id FROM moz_places WHERE url = :page_url "
   );
 
   try {
     URIS.forEach(function(aUrl) {
       stmt.params.page_url = aUrl;
       do_check_false(stmt.executeStep());
@@ -91,16 +98,40 @@ add_task(function* test_execute() {
     stmt.finalize();
   }
 
   do_print("Check cache");
   // Check cache.
   yield checkCache(FTP_URL);
 });
 
+function addFormHistory() {
+  return new Promise(resolve => {
+    let now = Date.now() * 1000;
+    FormHistory.update({ op: "add",
+                         fieldname: "testfield",
+                         value: "test",
+                         timesUsed: 1,
+                         firstUsed: now,
+                         lastUsed: now
+                       },
+                       { handleCompletion(reason) { resolve(); } });
+  });
+}
+
+function getFormHistoryCount() {
+  return new Promise((resolve, reject) => {
+    let count = -1;
+    FormHistory.count({ fieldname: "testfield" },
+                      { handleResult(result) { count = result; },
+                        handleCompletion(reason) { resolve(count); }
+                      });
+  });
+}
+
 function storeCache(aURL, aContent) {
   let cache = Services.cache2;
   let storage = cache.diskCacheStorage(LoadContextInfo.default, false);
 
   return new Promise(resolve => {
     let storeCacheListener = {
       onCacheEntryCheck: function (entry, appcache) {
         return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED;
--- a/browser/modules/webrtcUI.jsm
+++ b/browser/modules/webrtcUI.jsm
@@ -347,24 +347,24 @@ function prompt(aBrowser, aRequest) {
   let options = {
     eventCallback: function(aTopic, aNewBrowser) {
       if (aTopic == "swapping")
         return true;
 
       let chromeDoc = this.browser.ownerDocument;
 
       if (aTopic == "shown") {
-        let PopupNotifications = chromeDoc.defaultView.PopupNotifications;
         let popupId = "Devices";
         if (requestTypes.length == 1 && (requestTypes[0] == "Microphone" ||
                                          requestTypes[0] == "AudioCapture"))
           popupId = "Microphone";
         if (requestTypes.indexOf("Screen") != -1)
           popupId = "Screen";
-        PopupNotifications.panel.firstChild.setAttribute("popupid", "webRTC-share" + popupId);
+        chromeDoc.getElementById("webRTC-shareDevices-notification")
+                 .setAttribute("popupid", "webRTC-share" + popupId);
       }
 
       if (aTopic != "showing")
         return false;
 
       // DENY_ACTION is handled immediately by MediaManager, but handling
       // of ALLOW_ACTION is delayed until the popupshowing event
       // to avoid granting permissions automatically to background tabs.
@@ -906,19 +906,20 @@ function updateBrowserSpecificIndicator(
       mm.sendAsyncMessage("webrtc:StopSharing", windowId);
     }
   }];
   let options = {
     hideNotNow: true,
     dismissed: true,
     eventCallback: function(aTopic, aNewBrowser) {
       if (aTopic == "shown") {
-        let PopupNotifications = this.browser.ownerDocument.defaultView.PopupNotifications;
         let popupId = captureState == "Microphone" ? "Microphone" : "Devices";
-        PopupNotifications.panel.firstChild.setAttribute("popupid", "webRTC-sharing" + popupId);
+        this.browser.ownerDocument
+            .getElementById("webRTC-sharingDevices-notification")
+            .setAttribute("popupid", "webRTC-sharing" + popupId);
       }
 
       if (aTopic == "swapping") {
         webrtcUI.swapBrowserForNotification(this.browser, aNewBrowser);
         return true;
       }
 
       return false;
@@ -945,18 +946,19 @@ function updateBrowserSpecificIndicator(
 
   let screenSharingNotif; // Used by action callbacks.
   let isBrowserSharing = aState.screen == "Browser";
   options = {
     hideNotNow: !isBrowserSharing,
     dismissed: true,
     eventCallback: function(aTopic, aNewBrowser) {
       if (aTopic == "shown") {
-        let PopupNotifications = this.browser.ownerDocument.defaultView.PopupNotifications;
-        PopupNotifications.panel.firstChild.setAttribute("popupid", "webRTC-sharingScreen");
+        this.browser.ownerDocument
+            .getElementById("webRTC-sharingScreen-notification")
+            .setAttribute("popupid", "webRTC-sharingScreen");
       }
 
       if (aTopic == "swapping") {
         webrtcUI.swapBrowserForNotification(this.browser, aNewBrowser);
         return true;
       }
 
       return false;
--- a/build.gradle
+++ b/build.gradle
@@ -20,17 +20,17 @@ buildDir "${topobjdir}/gradle/build"
 buildscript {
     repositories {
         maven {
             url gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORY
         }
     }
 
     dependencies {
-        classpath 'com.android.tools.build:gradle:1.3.0'
+        classpath 'com.android.tools.build:gradle:1.5.0'
         classpath('com.stanfy.spoon:spoon-gradle-plugin:1.0.4') {
             // Without these, we get errors linting.
             exclude module: 'guava'
         }
     }
 }
 
 task generateCodeAndResources(type:Exec) {
--- a/devtools/.eslintrc
+++ b/devtools/.eslintrc
@@ -87,17 +87,17 @@
     // Enforces spacing between keys and values in object literal properties.
     "key-spacing": [1, {"beforeColon": false, "afterColon": true}],
     // Allow mixed 'LF' and 'CRLF' as linebreaks.
     "linebreak-style": 0,
     // Don't enforce the maximum depth that blocks can be nested. The complexity
     // rule is a better rule to check this.
     "max-depth": 0,
     // Maximum length of a line.
-    "max-len": [1, 80, 2, {"ignoreUrls": true, "ignorePattern": "\\s*require\\s*\\(|^\\s*loader\\.lazy"}],
+    "max-len": [1, 80, 2, {"ignoreUrls": true, "ignorePattern": "\\s*require\\s*\\(|^\\s*loader\\.lazy|-\\*-"}],
     // Maximum depth callbacks can be nested.
     "max-nested-callbacks": [2, 3],
     // Don't limit the number of parameters that can be used in a function.
     "max-params": 0,
     // Don't limit the maximum number of statement allowed in a function. We
     // already have the complexity rule that's a better measurement.
     "max-statements": 0,
     // Require a capital letter for constructors, only check if all new
--- a/devtools/client/jsonview/viewer-config.js
+++ b/devtools/client/jsonview/viewer-config.js
@@ -18,19 +18,18 @@
  * See also: http://requirejs.org/docs/api.html#pathsfallbacks
  *
  * React module ID is using exactly the same (relative) path as the rest
  * of the code base, so it's consistent and modules can be easily reused.
  */
 require.config({
   baseUrl: ".",
   paths: {
+    "devtools/client/shared": "resource://devtools/client/shared",
     "devtools/client/shared/vendor/react": [
       "resource://devtools/client/shared/vendor/react-dev",
       "resource://devtools/client/shared/vendor/react"
     ],
-    "devtools/client/shared/vendor/react-dom":
-      "resource://devtools/client/shared/vendor/react-dom"
   }
 });
 
 // Load the main panel module
 requirejs(["json-viewer"]);
--- a/devtools/client/shared/browser-loader.js
+++ b/devtools/client/shared/browser-loader.js
@@ -105,21 +105,32 @@ function BrowserLoader(baseURI, window) 
       }
 
       return require(uri);
     },
     globals: {
       // Allow modules to use the window's console to ensure logs appear in a
       // tab toolbox, if one exists, instead of just the browser console.
       console: window.console,
-      // Make sure 'define' function exists. This allows reusing AMD modules.
-      define: function(callback) {
-        callback(this.require, this.exports, this.module);
-        return this.exports;
-      }
+      // Make sure `define` function exists.  This allows defining some modules
+      // in AMD format while retaining CommonJS compatibility through this hook.
+      // JSON Viewer needs modules in AMD format, as it currently uses RequireJS
+      // from a content document and can't access our usual loaders.  So, any
+      // modules shared with the JSON Viewer should include a define wrapper:
+      //
+      //   // Make this available to both AMD and CJS environments
+      //   define(function(require, exports, module) {
+      //     ... code ...
+      //   });
+      //
+      // Bug 1248830 will work out a better plan here for our content module
+      // loading needs, especially as we head towards devtools.html.
+      define(factory) {
+        factory(this.require, this.exports, this.module);
+      },
     }
   };
 
   if(hotReloadEnabled) {
     opts.loadModuleHook = (module, require) => {
       const { uri, exports } = module;
 
       if (exports.prototype &&
--- a/devtools/client/shared/widgets/TableWidget.js
+++ b/devtools/client/shared/widgets/TableWidget.js
@@ -1,43 +1,44 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
-const {Cc, Ci, Cu} = require("chrome");
+const {Ci, Cu} = require("chrome");
 const EventEmitter = require("devtools/shared/event-emitter");
 loader.lazyImporter(this, "setNamedTimeout",
   "resource://devtools/client/shared/widgets/ViewHelpers.jsm");
 loader.lazyImporter(this, "clearNamedTimeout",
   "resource://devtools/client/shared/widgets/ViewHelpers.jsm");
 
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const AFTER_SCROLL_DELAY = 100;
 
-// Different types of events emitted by the Various components of the TableWidget
+// Different types of events emitted by the Various components of the
+// TableWidget.
 const EVENTS = {
-  TABLE_CLEARED: "table-cleared",
+  CELL_EDIT: "cell-edit",
   COLUMN_SORTED: "column-sorted",
   COLUMN_TOGGLED: "column-toggled",
+  HEADER_CONTEXT_MENU: "header-context-menu",
+  ROW_CONTEXT_MENU: "row-context-menu",
   ROW_SELECTED: "row-selected",
   ROW_UPDATED: "row-updated",
-  HEADER_CONTEXT_MENU: "header-context-menu",
-  ROW_CONTEXT_MENU: "row-context-menu",
   SCROLL_END: "scroll-end"
 };
 Object.defineProperty(this, "EVENTS", {
   value: EVENTS,
   enumerable: true,
   writable: false
 });
 
-// Maximum number of character visible in any cell in the table. This is to avoid
-// making the cell take up all the space in a row.
+// Maximum number of character visible in any cell in the table. This is to
+// avoid making the cell take up all the space in a row.
 const MAX_VISIBLE_STRING_SIZE = 100;
 
 /**
  * A table widget with various features like resizble/toggleable columns,
  * sorting, keyboard navigation etc.
  *
  * @param {nsIDOMNode} node
  *        The container element for the table widget.
@@ -47,17 +48,17 @@ const MAX_VISIBLE_STRING_SIZE = 100;
  *        - uniqueId: the column which will be the unique identifier of each
  *                    entry in the table. Default: name.
  *        - emptyText: text to display when no entries in the table to display.
  *        - highlightUpdated: true to highlight the changed/added row.
  *        - removableColumns: Whether columns are removeable. If set to false,
  *                            the context menu in the headers will not appear.
  *        - firstColumn: key of the first column that should appear.
  */
-function TableWidget(node, options={}) {
+function TableWidget(node, options = {}) {
   EventEmitter.decorate(this);
 
   this.document = node.ownerDocument;
   this.window = this.document.defaultView;
   this._parent = node;
 
   let {initialColumns, emptyText, uniqueId, highlightUpdated, removableColumns,
        firstColumn} = options;
@@ -78,33 +79,34 @@ function TableWidget(node, options={}) {
   this.placeholder = this.document.createElementNS(XUL_NS, "label");
   this.placeholder.className = "plain table-widget-empty-text";
   this.placeholder.setAttribute("flex", "1");
   this._parent.appendChild(this.placeholder);
 
   this.items = new Map();
   this.columns = new Map();
 
-  // Setup the column headers context menu to allow users to hide columns at will
+  // Setup the column headers context menu to allow users to hide columns at
+  // will.
   if (this.removableColumns) {
-    this.onPopupCommand = this.onPopupCommand.bind(this)
+    this.onPopupCommand = this.onPopupCommand.bind(this);
     this.setupHeadersContextMenu();
   }
 
   if (initialColumns) {
     this.setColumns(initialColumns, uniqueId);
   } else if (this.emptyText) {
     this.setPlaceholderText(this.emptyText);
   }
 
   this.bindSelectedRow = (event, id) => {
     this.selectedRow = id;
   };
   this.on(EVENTS.ROW_SELECTED, this.bindSelectedRow);
-};
+}
 
 TableWidget.prototype = {
 
   items: null,
 
   /**
    * Getter for the headers context menu popup id.
    */
@@ -157,19 +159,19 @@ TableWidget.prototype = {
   /**
    * Sets the text to be shown when the table is empty.
    */
   setPlaceholderText: function(text) {
     this.placeholder.setAttribute("value", text);
   },
 
   /**
-   * Prepares the context menu for the headers of the table columns. This context
-   * menu allows users to toggle various columns, only with an exception of the
-   * unique columns and when only two columns are visible in the table.
+   * Prepares the context menu for the headers of the table columns. This
+   * context menu allows users to toggle various columns, only with an exception
+   * of the unique columns and when only two columns are visible in the table.
    */
   setupHeadersContextMenu: function() {
     let popupset = this.document.getElementsByTagName("popupset")[0];
     if (!popupset) {
       popupset = this.document.createElementNS(XUL_NS, "popupset");
       this.document.documentElement.appendChild(popupset);
     }
 
@@ -303,17 +305,18 @@ TableWidget.prototype = {
    */
   selectNextRow: function() {
     for (let column of this.columns.values()) {
       column.selectNextRow();
     }
   },
 
   /**
-   * Selects the previous row. Cycles over to the last row if first row is selected
+   * Selects the previous row. Cycles over to the last row if first row is
+   * selected.
    */
   selectPreviousRow: function() {
     for (let column of this.columns.values()) {
       column.selectPreviousRow();
     }
   },
 
   /**
@@ -425,19 +428,19 @@ TableWidget.prototype = {
     this.emit(EVENTS.COLUMN_SORTED, column);
     this.sortedOn = column;
 
     if (!this.items.size) {
       return;
     }
 
     let sortedItems = this.columns.get(column).sort([...this.items.values()]);
-    for (let [id, column] of this.columns) {
-      if (id != column) {
-        column.sort(sortedItems);
+    for (let [id, col] of this.columns) {
+      if (id != col) {
+        col.sort(sortedItems);
       }
     }
   },
 
   /**
    * Calls the afterScroll function when the user has stopped scrolling
    */
   onScroll: function() {
@@ -668,18 +671,18 @@ Column.prototype = {
     if (index == -1) {
       index = this.cells.length - 1;
     }
     this.selectRowAt(index);
   },
 
   /**
    * Pushes the `item` object into the column. If this column is sorted on,
-   * then inserts the object at the right position based on the column's id key's
-   * value.
+   * then inserts the object at the right position based on the column's id
+   * key's value.
    *
    * @returns {number}
    *          The index of the currently pushed item.
    */
   push: function(item) {
     let value = item[this.id];
 
     if (this.sorted) {
@@ -851,17 +854,17 @@ Column.prototype = {
    * for sorting.
    */
   onClick: function(event) {
     if (event.originalTarget == this.column) {
       return;
     }
 
     if (event.button == 0 && event.originalTarget == this.header) {
-      return this.table.sortBy(this.id);
+      this.table.sortBy(this.id);
     }
   },
 
   /**
    * Mousedown event handler for the column. Used to select rows.
    */
   onMousedown: function(event) {
     if (event.originalTarget == this.column ||
@@ -965,17 +968,17 @@ Cell.prototype = {
     this._value = value;
     if (value == null) {
       this.label.setAttribute("value", "");
       return;
     }
 
     if (!(value instanceof Ci.nsIDOMNode) &&
         value.length > MAX_VISIBLE_STRING_SIZE) {
-      value = value .substr(0, MAX_VISIBLE_STRING_SIZE) + "\u2026"; // …
+      value = value .substr(0, MAX_VISIBLE_STRING_SIZE) + "\u2026";
     }
 
     if (value instanceof Ci.nsIDOMNode) {
       this.label.removeAttribute("value");
 
       while (this.label.firstChild) {
         this.label.removeChild(this.label.firstChild);
       }
@@ -996,21 +999,21 @@ Cell.prototype = {
 
   /**
    * Flashes the cell for a brief time. This when done for ith cells in all
    * columns, makes it look like the row is being highlighted/flashed.
    */
   flash: function() {
     this.label.classList.remove("flash-out");
     // Cause a reflow so that the animation retriggers on adding back the class
-    let a = this.label.parentNode.offsetWidth;
+    let a = this.label.parentNode.offsetWidth; // eslint-disable-line
     this.label.classList.add("flash-out");
   },
 
   focus: function() {
     this.label.focus();
   },
 
   destroy: function() {
     this.label.remove();
     this.label = null;
   }
-}
+};
--- a/devtools/client/shared/widgets/TreeWidget.js
+++ b/devtools/client/shared/widgets/TreeWidget.js
@@ -1,32 +1,32 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
-const Services = require("Services")
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 const EventEmitter = require("devtools/shared/event-emitter");
 
 /**
  * A tree widget with keyboard navigation and collapsable structure.
  *
  * @param {nsIDOMNode} node
  *        The container element for the tree widget.
  * @param {Object} options
  *        - emptyText {string}: text to display when no entries in the table.
- *        - defaultType {string}: The default type of the tree items. For ex. 'js'
+ *        - defaultType {string}: The default type of the tree items. For ex.
+ *        'js'
  *        - sorted {boolean}: Defaults to true. If true, tree items are kept in
  *        lexical order. If false, items will be kept in insertion order.
  */
-function TreeWidget(node, options={}) {
+function TreeWidget(node, options = {}) {
   EventEmitter.decorate(this);
 
   this.document = node.ownerDocument;
   this.window = this.document.defaultView;
   this._parent = node;
 
   this.emptyText = options.emptyText || "";
   this.defaultType = options.defaultType;
@@ -38,17 +38,17 @@ function TreeWidget(node, options={}) {
   this.placeholder.className = "tree-widget-empty-text";
   this._parent.appendChild(this.placeholder);
 
   if (this.emptyText) {
     this.setPlaceholderText(this.emptyText);
   }
   // A map to hold all the passed attachment to each leaf in the tree.
   this.attachments = new Map();
-};
+}
 
 TreeWidget.prototype = {
 
   _selectedLabel: null,
   _selectedItem: null,
 
   /**
    * Select any node in the tree.
@@ -215,17 +215,18 @@ TreeWidget.prototype = {
   },
 
   clearSelection: function() {
     this.selectedItem = -1;
   },
 
   /**
    * Adds an item in the tree. The item can be added as a child to any node in
-   * the tree. The method will also create any subnode not present in the process.
+   * the tree. The method will also create any subnode not present in the
+   * process.
    *
    * @param {[string|object]} items
    *        An array of either string or objects where each increasing index
    *        represents an item corresponding to an equivalent depth in the tree.
    *        Each array element can be either just a string with the value as the
    *        id of of that item as well as the display value, or it can be an
    *        object with the following propeties:
    *          - id {string} The id of the item
@@ -261,17 +262,17 @@ TreeWidget.prototype = {
 
   /**
    * Removes the specified item and all of its child items from the tree.
    *
    * @param {array} item
    *        The array of ids leading up to the item.
    */
   remove: function(item) {
-    this.root.remove(item)
+    this.root.remove(item);
     this.attachments.delete(JSON.stringify(item));
     // Display the empty tree text
     if (this.root.items.size == 0 && this.emptyText) {
       this.setPlaceholderText(this.emptyText);
     }
   },
 
   /**
@@ -332,17 +333,17 @@ TreeWidget.prototype = {
   },
 
   /**
    * Keypress handler for this tree. Used to select next and previous visible
    * items, as well as collapsing and expanding any item.
    */
   onKeypress: function(event) {
     let currentSelected = this._selectedLabel;
-    switch(event.keyCode) {
+    switch (event.keyCode) {
       case event.DOM_VK_UP:
         this.selectPreviousItem();
         break;
 
       case event.DOM_VK_DOWN:
         this.selectNextItem();
         break;
 
@@ -399,34 +400,34 @@ module.exports.TreeWidget = TreeWidget;
  *        The parent item for this item.
  * @param {string|DOMElement} label
  *        Either the dom node to be used as the item, or the string to be
  *        displayed for this node in the tree
  * @param {string} type
  *        The type of the current node. For ex. "js"
  */
 function TreeItem(document, parent, label, type) {
-  this.document = document
+  this.document = document;
   this.node = this.document.createElementNS(HTML_NS, "li");
   this.node.setAttribute("tabindex", "0");
   this.isRoot = !parent;
   this.parent = parent;
   if (this.parent) {
     this.level = this.parent.level + 1;
   }
-  if (!!label) {
+  if (label) {
     this.label = this.document.createElementNS(HTML_NS, "div");
     this.label.setAttribute("empty", "true");
     this.label.setAttribute("level", this.level);
     this.label.className = "tree-widget-item";
     if (type) {
       this.label.setAttribute("type", type);
     }
     if (typeof label == "string") {
-      this.label.textContent = label
+      this.label.textContent = label;
     } else {
       this.label.appendChild(label);
     }
     this.node.appendChild(this.label);
   }
   this.children = this.document.createElementNS(HTML_NS, "ul");
   if (this.isRoot) {
     this.children.className = "tree-widget-container";
@@ -449,18 +450,18 @@ TreeItem.prototype = {
 
   parent: null,
 
   children: null,
 
   level: 0,
 
   /**
-   * Adds the item to the sub tree contained by this node. The item to be inserted
-   * can be a direct child of this node, or further down the tree.
+   * Adds the item to the sub tree contained by this node. The item to be
+   * inserted can be a direct child of this node, or further down the tree.
    *
    * @param {array} items
    *        Same as TreeWidget.add method's argument
    * @param {string} defaultType
    *        The default type of the item to be used when items[i].type is null
    * @param {boolean} sorted
    *        true if the tree items are inserted in a lexically sorted manner.
    *        Otherwise, false if the item are to be appended to their parent.
@@ -468,26 +469,28 @@ TreeItem.prototype = {
   add: function(items, defaultType, sorted) {
     if (items.length == this.level) {
       // This is the exit condition of recursive TreeItem.add calls
       return;
     }
     // Get the id and label corresponding to this level inside the tree.
     let id = items[this.level].id || items[this.level];
     if (this.items.has(id)) {
-      // An item with same id already exists, thus calling the add method of that
-      // child to add the passed node at correct position.
+      // An item with same id already exists, thus calling the add method of
+      // that child to add the passed node at correct position.
       this.items.get(id).add(items, defaultType, sorted);
       return;
     }
     // No item with the id `id` exists, so we create one and call the add
     // method of that item.
-    // The display string of the item can be the label, the id, or the item itself
-    // if its a plain string.
-    let label = items[this.level].label || items[this.level].id || items[this.level];
+    // The display string of the item can be the label, the id, or the item
+    // itself if its a plain string.
+    let label = items[this.level].label ||
+                items[this.level].id ||
+                items[this.level];
     let node = items[this.level].node;
     if (node) {
       // The item is supposed to be a DOMNode, so we fetch the textContent in
       // order to find the correct sorted location of this new item.
       label = node.textContent;
     }
     let treeItem = new TreeItem(this.document, this, node || label,
                                 items[this.level].type || defaultType);
--- a/devtools/client/storage/panel.js
+++ b/devtools/client/storage/panel.js
@@ -1,17 +1,16 @@
 /* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-const {Cu} = require("chrome");
 const EventEmitter = require("devtools/shared/event-emitter");
 
 loader.lazyRequireGetter(this, "StorageFront",
                         "devtools/server/actors/storage", true);
 loader.lazyRequireGetter(this, "StorageUI",
                          "devtools/client/storage/ui", true);
 
 var StoragePanel = this.StoragePanel =
--- a/devtools/client/storage/test/browser_storage_basic.js
+++ b/devtools/client/storage/test/browser_storage_basic.js
@@ -57,18 +57,18 @@ const testCases = [
    ["obj-s1"]],
   [["indexedDB", "https://sectest1.example.org", "idb-s2"],
    ["obj-s2"]],
   [["indexedDB", "https://sectest1.example.org", "idb-s1", "obj-s1"],
    [6, 7]],
   [["indexedDB", "https://sectest1.example.org", "idb-s2", "obj-s2"],
    [16]],
   [["Cache", "http://test1.example.org", "plop"],
-   [MAIN_DOMAIN + "404_cached_file.js", MAIN_DOMAIN + "browser_storage_basic.js"]],
-
+   [MAIN_DOMAIN + "404_cached_file.js",
+    MAIN_DOMAIN + "browser_storage_basic.js"]],
 ];
 
 /**
  * Test that the desired number of tree items are present
  */
 function testTree() {
   let doc = gPanelWindow.document;
   for (let item of testCases) {
--- a/devtools/client/storage/test/head.js
+++ b/devtools/client/storage/test/head.js
@@ -1,15 +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/. */
 
 "use strict";
 
-var { console } = Cu.import("resource://gre/modules/Console.jsm", {});
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
 var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
 var { TargetFactory } = require("devtools/client/framework/target");
 var promise = require("promise");
 var DevToolsUtils = require("devtools/shared/DevToolsUtils");
 
 const SPLIT_CONSOLE_PREF = "devtools.toolbox.splitconsoleEnabled";
 const STORAGE_PREF = "devtools.storage.enabled";
 const DUMPEMIT_PREF = "devtools.dump.emit";
@@ -87,23 +88,23 @@ function addTab(url) {
  */
 function* openTabAndSetupStorage(url) {
   let content = yield addTab(url);
 
   gWindow = content.wrappedJSObject;
 
   // Setup the async storages in main window and for all its iframes
   let callSetup = function*(win) {
-    if (typeof(win.setup) == "function") {
+    if (typeof (win.setup) == "function") {
       yield win.setup();
     }
-    for(var i = 0; i < win.frames.length; i++) {
+    for (let i = 0; i < win.frames.length; i++) {
       yield callSetup(win.frames[i]);
     }
-  }
+  };
   yield callSetup(gWindow);
 
   // open storage inspector
   return yield openStoragePanel();
 }
 
 /**
  * Open the toolbox, with the storage tool visible.
@@ -249,159 +250,155 @@ function* click(node) {
   }, 200);
 
   return def;
 }
 
 /**
  * Recursively expand the variables view up to a given property.
  *
- * @param aOptions
+ * @param options
  *        Options for view expansion:
  *        - rootVariable: start from the given scope/variable/property.
  *        - expandTo: string made up of property names you want to expand.
  *        For example: "body.firstChild.nextSibling" given |rootVariable:
  *        document|.
  * @return object
  *         A promise that is resolved only when the last property in |expandTo|
  *         is found, and rejected otherwise. Resolution reason is always the
  *         last property - |nextSibling| in the example above. Rejection is
  *         always the last property that was found.
  */
-function variablesViewExpandTo(aOptions) {
-  let root = aOptions.rootVariable;
-  let expandTo = aOptions.expandTo.split(".");
+function variablesViewExpandTo(options) {
+  let root = options.rootVariable;
+  let expandTo = options.expandTo.split(".");
   let lastDeferred = promise.defer();
 
-  function getNext(aProp) {
+  function getNext(prop) {
     let name = expandTo.shift();
-    let newProp = aProp.get(name);
+    let newProp = prop.get(name);
 
     if (expandTo.length > 0) {
       ok(newProp, "found property " + name);
       if (newProp && newProp.expand) {
         newProp.expand();
         getNext(newProp);
       } else {
-        lastDeferred.reject(aProp);
+        lastDeferred.reject(prop);
       }
     } else if (newProp) {
       lastDeferred.resolve(newProp);
     } else {
-      lastDeferred.reject(aProp);
+      lastDeferred.reject(prop);
     }
   }
 
-  function fetchError(aProp) {
-    lastDeferred.reject(aProp);
-  }
-
   if (root && root.expand) {
     root.expand();
     getNext(root);
   } else {
     lastDeferred.resolve(root);
   }
 
   return lastDeferred.promise;
 }
 
 /**
  * Find variables or properties in a VariablesView instance.
  *
- * @param array aRules
+ * @param array ruleArray
  *        The array of rules you want to match. Each rule is an object with:
  *        - name (string|regexp): property name to match.
  *        - value (string|regexp): property value to match.
  *        - dontMatch (boolean): make sure the rule doesn't match any property.
- * @param boolean aParsed
+ * @param boolean parsed
  *        true if we want to test the rules in the parse value section of the
  *        storage sidebar
  * @return object
  *         A promise object that is resolved when all the rules complete
  *         matching. The resolved callback is given an array of all the rules
  *         you wanted to check. Each rule has a new property: |matchedProp|
  *         which holds a reference to the Property object instance from the
  *         VariablesView. If the rule did not match, then |matchedProp| is
  *         undefined.
  */
-function findVariableViewProperties(aRules, aParsed) {
+function findVariableViewProperties(ruleArray, parsed) {
   // Initialize the search.
   function init() {
-    // If aParsed is true, we are checking rules in the parsed value section of
+    // If parsed is true, we are checking rules in the parsed value section of
     // the storage sidebar. That scope uses a blank variable as a placeholder
     // Thus, adding a blank parent to each name
-    if (aParsed) {
-      aRules = aRules.map(({name, value, dontMatch}) => {
+    if (parsed) {
+      ruleArray = ruleArray.map(({name, value, dontMatch}) => {
         return {name: "." + name, value, dontMatch};
       });
     }
     // Separate out the rules that require expanding properties throughout the
     // view.
     let expandRules = [];
-    let rules = aRules.filter((aRule) => {
-      if (typeof aRule.name == "string" && aRule.name.indexOf(".") > -1) {
-        expandRules.push(aRule);
+    let rules = ruleArray.filter(rule => {
+      if (typeof rule.name == "string" && rule.name.indexOf(".") > -1) {
+        expandRules.push(rule);
         return false;
       }
       return true;
     });
 
     // Search through the view those rules that do not require any properties to
     // be expanded. Build the array of matchers, outstanding promises to be
     // resolved.
     let outstanding = [];
 
     finder(rules, gUI.view, outstanding);
 
     // Process the rules that need to expand properties.
     let lastStep = processExpandRules.bind(null, expandRules);
 
-    // Return the results - a promise resolved to hold the updated aRules array.
-    let returnResults = onAllRulesMatched.bind(null, aRules);
+    // Return the results - a promise resolved to hold the updated ruleArray.
+    let returnResults = onAllRulesMatched.bind(null, ruleArray);
 
     return promise.all(outstanding).then(lastStep).then(returnResults);
   }
 
-  function onMatch(aProp, aRule, aMatched) {
-    if (aMatched && !aRule.matchedProp) {
-      aRule.matchedProp = aProp;
+  function onMatch(prop, rule, matched) {
+    if (matched && !rule.matchedProp) {
+      rule.matchedProp = prop;
     }
   }
 
-  function finder(rules, aView, aPromises) {
-    for (let scope of aView) {
+  function finder(rules, view, promises) {
+    for (let scope of view) {
       for (let [, prop] of scope) {
         for (let rule of rules) {
           let matcher = matchVariablesViewProperty(prop, rule);
-          aPromises.push(matcher.then(onMatch.bind(null, prop, rule)));
+          promises.push(matcher.then(onMatch.bind(null, prop, rule)));
         }
       }
     }
   }
 
   function processExpandRules(rules) {
     let rule = rules.shift();
     if (!rule) {
       return promise.resolve(null);
     }
 
     let deferred = promise.defer();
     let expandOptions = {
-      rootVariable: gUI.view.getScopeAtIndex(aParsed ? 1 : 0),
+      rootVariable: gUI.view.getScopeAtIndex(parsed ? 1 : 0),
       expandTo: rule.name
     };
 
-    variablesViewExpandTo(expandOptions).then(function onSuccess(aProp) {
+    variablesViewExpandTo(expandOptions).then(function onSuccess(prop) {
       let name = rule.name;
       let lastName = name.split(".").pop();
       rule.name = lastName;
 
-      let matched = matchVariablesViewProperty(aProp, rule);
-      return matched.then(onMatch.bind(null, aProp, rule)).then(function() {
+      let matched = matchVariablesViewProperty(prop, rule);
+      return matched.then(onMatch.bind(null, prop, rule)).then(function() {
         rule.name = name;
       });
     }, function onFailure() {
       return promise.resolve(null);
     }).then(processExpandRules.bind(null, rules)).then(function() {
       deferred.resolve(null);
     });
 
@@ -425,56 +422,56 @@ function findVariableViewProperties(aRul
 
   return init();
 }
 
 /**
  * Check if a given Property object from the variables view matches the given
  * rule.
  *
- * @param object aProp
+ * @param object prop
  *        The variable's view Property instance.
- * @param object aRule
+ * @param object rule
  *        Rules for matching the property. See findVariableViewProperties() for
  *        details.
  * @return object
  *         A promise that is resolved when all the checks complete. Resolution
  *         result is a boolean that tells your promise callback the match
  *         result: true or false.
  */
-function matchVariablesViewProperty(aProp, aRule) {
-  function resolve(aResult) {
-    return promise.resolve(aResult);
+function matchVariablesViewProperty(prop, rule) {
+  function resolve(result) {
+    return promise.resolve(result);
   }
 
-  if (!aProp) {
+  if (!prop) {
     return resolve(false);
   }
 
-  if (aRule.name) {
-    let match = aRule.name instanceof RegExp ?
-                aRule.name.test(aProp.name) :
-                aProp.name == aRule.name;
+  if (rule.name) {
+    let match = rule.name instanceof RegExp ?
+                rule.name.test(prop.name) :
+                prop.name == rule.name;
     if (!match) {
       return resolve(false);
     }
   }
 
-  if ("value" in aRule) {
-    let displayValue = aProp.displayValue;
-    if (aProp.displayValueClassName == "token-string") {
+  if ("value" in rule) {
+    let displayValue = prop.displayValue;
+    if (prop.displayValueClassName == "token-string") {
       displayValue = displayValue.substring(1, displayValue.length - 1);
     }
 
-    let match = aRule.value instanceof RegExp ?
-                aRule.value.test(displayValue) :
-                displayValue == aRule.value;
+    let match = rule.value instanceof RegExp ?
+                rule.value.test(displayValue) :
+                displayValue == rule.value;
     if (!match) {
-      info("rule " + aRule.name + " did not match value, expected '" +
-           aRule.value + "', found '" + displayValue + "'");
+      info("rule " + rule.name + " did not match value, expected '" +
+           rule.value + "', found '" + displayValue + "'");
       return resolve(false);
     }
   }
 
   return resolve(true);
 }
 
 /**
@@ -516,17 +513,17 @@ function* selectTableItem(id) {
 /**
  * Wait for eventName on target.
  * @param {Object} target An observable object that either supports on/off or
  * addEventListener/removeEventListener
  * @param {String} eventName
  * @param {Boolean} [useCapture] for addEventListener/removeEventListener
  * @return A promise that resolves when the event has been handled
  */
-function once(target, eventName, useCapture=false) {
+function once(target, eventName, useCapture = false) {
   info("Waiting for event: '" + eventName + "' on " + target + ".");
 
   let deferred = promise.defer();
 
   for (let [add, remove] of [
     ["addEventListener", "removeEventListener"],
     ["addListener", "removeListener"],
     ["on", "off"]
--- a/devtools/client/storage/ui.js
+++ b/devtools/client/storage/ui.js
@@ -64,16 +64,17 @@ var StorageUI = this.StorageUI = functio
   this.onHostSelect = this.onHostSelect.bind(this);
   this.tree.on("select", this.onHostSelect);
 
   let tableNode = this._panelDoc.getElementById("storage-table");
   this.table = new TableWidget(tableNode, {
     emptyText: L10N.getStr("table.emptyText"),
     highlightUpdated: true,
   });
+
   this.displayObjectSidebar = this.displayObjectSidebar.bind(this);
   this.table.on(TableWidget.EVENTS.ROW_SELECTED, this.displayObjectSidebar);
 
   this.handleScrollEnd = this.handleScrollEnd.bind(this);
   this.table.on(TableWidget.EVENTS.SCROLL_END, this.handleScrollEnd);
 
   this.sidebar = this._panelDoc.getElementById("storage-sidebar");
   this.sidebar.setAttribute("width", "300");
@@ -145,17 +146,17 @@ StorageUI.prototype = {
 
   /**
    * Event handler for "stores-cleared" event coming from the storage actor.
    *
    * @param {object} argument0
    *        An object containing which storage types were cleared
    */
   onCleared: function(response) {
-    let [type, host, db, objectStore] = this.tree.selectedItem;
+    let [type, host] = this.tree.selectedItem;
     if (response.hasOwnProperty(type) && response[type].indexOf(host) > -1) {
       this.table.clear();
       this.hideSidebar();
       this.emit("store-objects-cleared");
     }
   },
 
   /**
@@ -214,17 +215,17 @@ StorageUI.prototype = {
             if (name.length == 3) {
               name.splice(2, 1);
             }
             this.tree.add([type, host, ...name]);
             if (!this.tree.selectedItem) {
               this.tree.selectedItem = [type, host, name[0], name[1]];
               this.fetchStorageObjects(type, host, [JSON.stringify(name)], 1);
             }
-          } catch(ex) {
+          } catch (ex) {
             // Do nothing
           }
         }
 
         if (this.tree.isSelected([type, host])) {
           this.fetchStorageObjects(type, host, added[type][host], 1);
         }
       }
@@ -312,17 +313,19 @@ StorageUI.prototype = {
    * @param {number} reason
    *        3 for loading next 50 items, 2 for update, 1 for new row in an
    *        existing table and 0 when populating a table for the first time
    *        for the given host/type
    */
   fetchStorageObjects: function(type, host, names, reason) {
     let fetchOpts = reason === 3 ? {offset: this.itemOffset}
                                  : {};
-    this.storageTypes[type].getStoreObjects(host, names, fetchOpts).then(({data}) => {
+    let storageType = this.storageTypes[type];
+
+    storageType.getStoreObjects(host, names, fetchOpts).then(({data}) => {
       if (!data.length) {
         this.emit("store-objects-updated");
         return;
       }
       if (this.shouldResetColumns) {
         this.resetColumns(data[0], type);
       }
       this.populateTable(data, reason);
@@ -336,24 +339,25 @@ StorageUI.prototype = {
    *
    * @param {object} storageTypes
    *        List of storages and their corresponding hosts returned by the
    *        StorageFront.listStores call.
    */
   populateStorageTree: function(storageTypes) {
     this.storageTypes = {};
     for (let type in storageTypes) {
-      // Ignore `from` field, which is just a protocol.js implementation artifact
+      // Ignore `from` field, which is just a protocol.js implementation
+      // artifact.
       if (type === "from") {
         continue;
       }
       let typeLabel = type;
       try {
         typeLabel = L10N.getStr("tree.labels." + type);
-      } catch(e) {
+      } catch (e) {
         console.error("Unable to localize tree label type:" + type);
       }
       this.tree.add([{id: type, label: typeLabel, type: "store"}]);
       if (!storageTypes[type].hosts) {
         continue;
       }
       this.storageTypes[type] = storageTypes[type];
       for (let host in storageTypes[type].hosts) {
@@ -361,17 +365,17 @@ StorageUI.prototype = {
         for (let name of storageTypes[type].hosts[host]) {
           try {
             let names = JSON.parse(name);
             this.tree.add([type, host, ...names]);
             if (!this.tree.selectedItem) {
               this.tree.selectedItem = [type, host, names[0], names[1]];
               this.fetchStorageObjects(type, host, [name], 0);
             }
-          } catch(ex) {
+          } catch (ex) {
             // Do Nothing
           }
         }
         if (!this.tree.selectedItem) {
           this.tree.selectedItem = [type, host];
           this.fetchStorageObjects(type, host, null, 0);
         }
       }
@@ -571,18 +575,19 @@ StorageUI.prototype = {
     let uniqueKey = null;
     for (let key in data) {
       if (!uniqueKey) {
         this.table.uniqueId = uniqueKey = key;
       }
       columns[key] = key;
       try {
         columns[key] = L10N.getStr("table.headers." + type + "." + key);
-      } catch(e) {
-        console.error("Unable to localize table header type:" + type + " key:" + key);
+      } catch (e) {
+        console.error("Unable to localize table header type:" + type +
+                      " key:" + key);
       }
     }
     this.table.setColumns(columns, null, HIDDEN_COLUMNS);
     this.shouldResetColumns = false;
     this.hideSidebar();
   },
 
   /**
@@ -638,21 +643,23 @@ StorageUI.prototype = {
       event.preventDefault();
     }
   },
 
   /**
    * Handles endless scrolling for the table
    */
   handleScrollEnd: function() {
-    if (!this.shouldLoadMoreItems) return;
+    if (!this.shouldLoadMoreItems) {
+      return;
+    }
     this.shouldLoadMoreItems = false;
     this.itemOffset += 50;
 
     let item = this.tree.selectedItem;
-    let [type, host, db, objectStore] = item;
+    let [type, host] = item;
     let names = null;
     if (item.length > 2) {
       names = [JSON.stringify(item.slice(2))];
     }
     this.fetchStorageObjects(type, host, names, 3);
   }
 };
--- a/devtools/client/themes/computed.css
+++ b/devtools/client/themes/computed.css
@@ -2,30 +2,32 @@
 /* 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/. */
 
 #sidebar-panel-computedview {
   margin: 0;
   display : flex;
   flex-direction: column;
-  height: 100%;
   width: 100%;
+  /* Bug 1243598 - Reduce the container height by the tab height to make room
+     for the tabs above. */
+  height: calc(100% - 24px);
+  position: absolute;
 }
 
 #browser-style-checkbox {
   /* Bug 1200073 - extra space before the browser styles checkbox so
      they aren't squished together in a small window. */
   -moz-margin-start: 5px;
 }
 
 #propertyContainer {
   -moz-user-select: text;
   overflow: auto;
-  height: 0px;
   flex: auto;
 }
 
 .row-striped {
   background: var(--theme-body-background);
 }
 
 .property-view-hidden,
--- a/devtools/client/themes/fonts.css
+++ b/devtools/client/themes/fonts.css
@@ -1,25 +1,27 @@
 /* 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/. */
 
 #sidebar-panel-fontinspector {
+  margin: 0;
   display: flex;
   flex-direction: column;
-  height: 100%;
-  margin: 0;
   padding-bottom: 20px;
   width: 100%;
+  /* Bug 1243598 - Reduce the container height by the tab height to make room
+     for the tabs above. */
+  height: calc(100% - 24px);
+  position: absolute;
 }
 
 #font-container {
   overflow: auto;
   flex: auto;
-  height: 0px;
 }
 
 #all-fonts {
   padding: 0;
   margin: 0;
 }
 
 #font-showall {
--- a/devtools/client/themes/memory.css
+++ b/devtools/client/themes/memory.css
@@ -369,21 +369,29 @@ html, body, #app, #memory-tool {
 }
 
 .heap-tree-item-count,
 .heap-tree-item-total-count,
 .heap-tree-item-bytes,
 .heap-tree-item-total-bytes {
   width: 10%;
   /*
-   * Provision for up to :
-   * - 12 characters for the number part (10s of GB and spaces every 3 digits)
-   * - 4 chars for the percent part (the maximum length string is "100%")
+   * Provision for up to 19 characters:
+   *
+   *     GG_MMM_KKK_BBB_100%
+   *     |            |||  |
+   *     '------------'|'--'
+   *     14 ch for 10s | 4 ch for the largest % we will
+   *     of GB and     | normally see: "100%"
+   *     spaces every  |
+   *     3 digits      |
+   *                   |
+   *             A space between the number and percent
    */
-  min-width: 16ch;
+  min-width: 19ch;
 }
 
 .heap-tree-item-name {
   /**
    * Flex: contains an .arrow and some text, which need to be laid out
    * horizontally, vertically aligned in the middle of the container.
    */
   display: flex;
--- a/devtools/client/themes/rules.css
+++ b/devtools/client/themes/rules.css
@@ -12,25 +12,27 @@
   --rule-highlight-background-color: #594724;
   --rule-filter-icon: url(images/magnifying-glass.png);
 }
 
 #sidebar-panel-ruleview {
   margin: 0;
   display: flex;
   flex-direction: column;
-  height: 100%;
   width: 100%;
+  /* Bug 1243598 - Reduce the container height by the tab height to make room
+     for the tabs above. */
+  height: calc(100% - 24px);
+  position: absolute;
 }
 
 #ruleview-container {
   -moz-user-select: text;
   overflow: auto;
   flex: auto;
-  height: 0px;
 }
 
 #ruleview-container.non-interactive {
   pointer-events: none;
   visibility: collapse;
   transition: visibility 0.25s;
 }
 
--- a/devtools/client/webconsole/console-output.js
+++ b/devtools/client/webconsole/console-output.js
@@ -22,17 +22,17 @@ loader.lazyRequireGetter(this, "ObjectCl
 
 const Heritage = require("sdk/core/heritage");
 const URI = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService);
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const STRINGS_URI = "chrome://devtools/locale/webconsole.properties";
 
 const WebConsoleUtils = require("devtools/shared/webconsole/utils").Utils;
-const l10n = new WebConsoleUtils.l10n(STRINGS_URI);
+const l10n = new WebConsoleUtils.L10n(STRINGS_URI);
 
 const MAX_STRING_GRIP_LENGTH = 36;
 const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
 
 // Constants for compatibility with the Web Console output implementation before
 // bug 778766.
 // TODO: remove these once bug 778766 is fixed.
 const COMPAT = {
--- a/devtools/client/webconsole/hudservice.js
+++ b/devtools/client/webconsole/hudservice.js
@@ -20,17 +20,17 @@ loader.lazyRequireGetter(this, "Telemetr
 loader.lazyRequireGetter(this, "WebConsoleFrame", "devtools/client/webconsole/webconsole", true);
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
 loader.lazyRequireGetter(this, "DebuggerServer", "devtools/server/main", true);
 loader.lazyRequireGetter(this, "DebuggerClient", "devtools/shared/client/main", true);
 loader.lazyRequireGetter(this, "showDoorhanger", "devtools/client/shared/doorhanger", true);
 loader.lazyRequireGetter(this, "viewSource", "devtools/client/shared/view-source");
 
 const STRINGS_URI = "chrome://devtools/locale/webconsole.properties";
-var l10n = new WebConsoleUtils.l10n(STRINGS_URI);
+var l10n = new WebConsoleUtils.L10n(STRINGS_URI);
 
 const BROWSER_CONSOLE_WINDOW_FEATURES = "chrome,titlebar,toolbar,centerscreen,resizable,dialog=no";
 
 // The preference prefix for all of the Browser Console filters.
 const BROWSER_CONSOLE_FILTER_PREFS_PREFIX = "devtools.browserconsole.filter.";
 
 var gHudId = 0;
 
--- a/devtools/client/webconsole/test/head.js
+++ b/devtools/client/webconsole/test/head.js
@@ -33,17 +33,17 @@ const SEVERITY_WARNING = 1;
 const SEVERITY_INFO = 2;
 const SEVERITY_LOG = 3;
 
 // The indent of a console group in pixels.
 const GROUP_INDENT = 12;
 
 const WEBCONSOLE_STRINGS_URI = "chrome://devtools/locale/" +
                                "webconsole.properties";
-var WCUL10n = new WebConsoleUtils.l10n(WEBCONSOLE_STRINGS_URI);
+var WCUL10n = new WebConsoleUtils.L10n(WEBCONSOLE_STRINGS_URI);
 
 DevToolsUtils.testing = true;
 
 function loadTab(url) {
   let deferred = promise.defer();
 
   let tab = gBrowser.selectedTab = gBrowser.addTab(url);
   let browser = gBrowser.getBrowserForTab(tab);
--- a/devtools/client/webconsole/webconsole.js
+++ b/devtools/client/webconsole/webconsole.js
@@ -28,17 +28,17 @@ loader.lazyRequireGetter(this, "ObjectCl
 loader.lazyRequireGetter(this, "system", "devtools/shared/system");
 loader.lazyRequireGetter(this, "Timers", "sdk/timers");
 loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm");
 loader.lazyImporter(this, "VariablesViewController", "resource://devtools/client/shared/widgets/VariablesViewController.jsm");
 loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true);
 loader.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm");
 
 const STRINGS_URI = "chrome://devtools/locale/webconsole.properties";
-var l10n = new WebConsoleUtils.l10n(STRINGS_URI);
+var l10n = new WebConsoleUtils.L10n(STRINGS_URI);
 
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 
 const MIXED_CONTENT_LEARN_MORE = "https://developer.mozilla.org/docs/Security/MixedContent";
 
 const TRACKING_PROTECTION_LEARN_MORE = "https://developer.mozilla.org/Firefox/Privacy/Tracking_Protection";
 
 const INSECURE_PASSWORDS_LEARN_MORE = "https://developer.mozilla.org/docs/Security/InsecurePasswords";
@@ -592,26 +592,26 @@ WebConsoleFrame.prototype = {
     this.jsterm.inputNode.focus();
   },
 
   /**
    * Resizes the output node to fit the output wrapped.
    * We need this because it makes the layout a lot faster than
    * using -moz-box-flex and 100% width.  See Bug 1237368.
    */
-  resize: function(e) {
+  resize: function() {
     this.outputNode.style.width = this.outputWrapper.clientWidth + "px";
   },
 
   /**
    * Sets the focus to JavaScript input field when the web console tab is
    * selected or when there is a split console present.
    * @private
    */
-  _onPanelSelected: function(evt, id) {
+  _onPanelSelected: function() {
     this.jsterm.inputNode.focus();
   },
 
   /**
    * Initialize the default filter preferences.
    * @private
    */
   _initDefaultFilterPrefs: function() {
@@ -706,17 +706,17 @@ WebConsoleFrame.prototype = {
    *        A descriptor that contains info about the button. Contains "name",
    *        "category", and "prefKey" properties, and optionally a "severities"
    *        property.
    */
   _initFilterButtons: function() {
     let categories = this.document
                      .querySelectorAll(".webconsole-filter-button[category]");
     Array.forEach(categories, function(button) {
-      button.addEventListener("contextmenu", (event) => {
+      button.addEventListener("contextmenu", () => {
         button.open = true;
       }, false);
       button.addEventListener("click", this._toggleFilter, false);
 
       let someChecked = false;
       let severities = button.querySelectorAll("menuitem[prefKey]");
       Array.forEach(severities, function(menuItem) {
         menuItem.addEventListener("command", this._toggleFilter, false);
@@ -1019,17 +1019,17 @@ WebConsoleFrame.prototype = {
     let outputNode = this.outputNode;
     let doc = this.document;
 
     // Look for message nodes (".message") with the given preference key
     // (filter="error", filter="cssparser", etc.) and add or remove the
     // "filtered-by-type" class, which turns on or off the display.
 
     let attribute = WORKERTYPES_PREFKEYS.indexOf(prefKey) == -1
-                      ? 'filter' : 'workerType';
+                      ? "filter" : "workerType";
 
     let xpath = ".//*[contains(@class, 'message') and " +
       "@" + attribute + "='" + prefKey + "']";
     let result = doc.evaluate(xpath, outputNode, null,
       Ci.nsIDOMXPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
     for (let i = 0; i < result.snapshotLength; i++) {
       let node = result.snapshotItem(i);
       if (state) {
@@ -1102,32 +1102,29 @@ WebConsoleFrame.prototype = {
     if (isFiltered && node.classList.contains("inlined-variables-view")) {
       node.classList.add("hidden-message");
     }
 
     return isFiltered;
   },
 
   /**
-   * Merge the attributes of the two nodes that are about to be filtered.
-   * Increment the number of repeats of original.
+   * Merge the attributes of repeated nodes.
    *
    * @param nsIDOMNode original
    *        The Original Node. The one being merged into.
-   * @param nsIDOMNode filtered
-   *        The node being filtered out because it is repeated.
    */
-  mergeFilteredMessageNode: function(original, filtered) {
+  mergeFilteredMessageNode: function(original) {
     let repeatNode = original.getElementsByClassName("message-repeats")[0];
     if (!repeatNode) {
       // no repeat node, return early.
       return;
     }
 
-    let occurrences = parseInt(repeatNode.getAttribute("value")) + 1;
+    let occurrences = parseInt(repeatNode.getAttribute("value"), 10) + 1;
     repeatNode.setAttribute("value", occurrences);
     repeatNode.textContent = occurrences;
     let str = l10n.getStr("messageRepeats.tooltip2");
     repeatNode.title = PluralForm.get(occurrences, str)
                        .replace("#1", occurrences);
   },
 
   /**
@@ -1167,17 +1164,17 @@ WebConsoleFrame.prototype = {
       let lastRepeatNode =
         lastMessage.getElementsByClassName("message-repeats")[0];
       if (lastRepeatNode && lastRepeatNode._uid == uid) {
         dupeNode = lastMessage;
       }
     }
 
     if (dupeNode) {
-      this.mergeFilteredMessageNode(dupeNode, node);
+      this.mergeFilteredMessageNode(dupeNode);
       return dupeNode;
     }
 
     return null;
   },
 
   /**
    * Display cached messages that may have been collected before the UI is
@@ -1373,17 +1370,17 @@ WebConsoleFrame.prototype = {
         let repeatNode = node.getElementsByClassName("message-repeats")[0];
         repeatNode._uid += [...objectActors].join("-");
       }
     }
 
     let workerTypeID = CONSOLE_WORKER_IDS.indexOf(message.workerType);
     if (workerTypeID != -1) {
       node.workerType = WORKERTYPES_PREFKEYS[workerTypeID];
-      node.setAttribute('workerType', WORKERTYPES_PREFKEYS[workerTypeID]);
+      node.setAttribute("workerType", WORKERTYPES_PREFKEYS[workerTypeID]);
     }
 
     return node;
   },
 
   /**
    * Handle ConsoleAPICall objects received from the server. This method outputs
    * the window.console API call.
@@ -1401,29 +1398,29 @@ WebConsoleFrame.prototype = {
    * @param nsIScriptError scriptError
    *        The error message to report.
    * @return nsIDOMElement|undefined
    *         The message element to display in the Web Console output.
    */
   reportPageError: function(category, scriptError) {
     // Warnings and legacy strict errors become warnings; other types become
     // errors.
-    let severity = 'error';
+    let severity = "error";
     if (scriptError.warning || scriptError.strict) {
-      severity = 'warning';
+      severity = "warning";
     } else if (scriptError.info) {
-      severity = 'log';
+      severity = "log";
     }
 
     switch (category) {
       case CATEGORY_CSS:
-        category = 'css';
+        category = "css";
         break;
       case CATEGORY_SECURITY:
-        category = 'security';
+        category = "security";
         break;
       default:
         category = "js";
         break;
     }
 
     let objectActors = new Set();
 
@@ -1832,26 +1829,26 @@ WebConsoleFrame.prototype = {
    * @param string actorId
    *        The network event actor ID for which you want to update the message.
    * @return boolean
    *         |true| if the message node was updated, or |false| otherwise.
    */
   _updateNetMessage: function(actorId) {
     let networkInfo = this.webConsoleClient.getNetworkRequest(actorId);
     if (!networkInfo || !networkInfo.node) {
-      return;
+      return false;
     }
 
     let messageNode = networkInfo.node;
     let updates = networkInfo.updates;
     let hasEventTimings = updates.indexOf("eventTimings") > -1;
     let hasResponseStart = updates.indexOf("responseStart") > -1;
     let request = networkInfo.request;
     let methodText = (networkInfo.isXHR) ?
-                     request.method + ' XHR' : request.method;
+                     request.method + " XHR" : request.method;
     let response = networkInfo.response;
     let updated = false;
 
     if (hasEventTimings || hasResponseStart) {
       let status = [];
       if (response.httpVersion && response.status) {
         status = [response.httpVersion, response.status, response.statusText];
       }
@@ -2128,17 +2125,17 @@ WebConsoleFrame.prototype = {
    * @return object
    *         An object that holds the following properties:
    *         - node: the DOM element of the message.
    *         - isRepeated: the DOM element of the original message, if this is
    *         a repeated message, otherwise null.
    *         - visible: boolean that tells if the message is visible.
    */
   _outputMessageFromQueue: function(hudIdSupportsString, item) {
-    let [category, methodOrNode, args] = item;
+    let [, methodOrNode, args] = item;
 
     // The last object in the args array should be message
     // object or response packet received from the server.
     let message = (args && args.length) ? args[args.length - 1] : null;
 
     let node = typeof methodOrNode == "function" ?
                methodOrNode.apply(this, args || []) :
                methodOrNode;
@@ -2527,26 +2524,26 @@ WebConsoleFrame.prototype = {
     if (target) {
       locationNode.target = target;
     }
     locationNode.setAttribute("title", url);
     locationNode.className = "message-location theme-link devtools-monospace";
 
     // Make the location clickable.
     let onClick = () => {
-      let target = locationNode.target;
-      if (target == "scratchpad" || isScratchpad) {
+      let nodeTarget = locationNode.target;
+      if (nodeTarget == "scratchpad" || isScratchpad) {
         this.owner.viewSourceInScratchpad(url, line);
         return;
       }
 
       let category = locationNode.parentNode.category;
-      if (target == "styleeditor" || category == CATEGORY_CSS) {
+      if (nodeTarget == "styleeditor" || category == CATEGORY_CSS) {
         this.owner.viewSourceInStyleEditor(fullURL, line);
-      } else if (target == "jsdebugger" ||
+      } else if (nodeTarget == "jsdebugger" ||
                  category == CATEGORY_JS || category == CATEGORY_WEBDEV) {
         this.owner.viewSourceInDebugger(fullURL, line);
       } else {
         this.owner.viewSource(fullURL, line);
       }
     };
 
     if (fullURL) {
@@ -2681,18 +2678,18 @@ WebConsoleFrame.prototype = {
     }
   },
 
   /**
    * Copies the selected items to the system clipboard.
    *
    * @param object options
    *        - linkOnly:
-   *        An optional flag to copy only URL without timestamp and
-   *        other meta-information. Default is false.
+   *        An optional flag to copy only URL without other meta-information.
+   *        Default is false.
    *        - contextmenu:
    *        An optional flag to copy the last clicked item which brought
    *        up the context menu if nothing is selected. Default is false.
    */
   copySelectedItems: function(options) {
     options = options || { linkOnly: false, contextmenu: false };
 
     // Gather up the selected items and concatenate their clipboard text.
@@ -2702,17 +2699,16 @@ WebConsoleFrame.prototype = {
     if (!children.length && options.contextmenu) {
       children = [this._contextMenuHandler.lastClickedMessage];
     }
 
     for (let item of children) {
       // Ensure the selected item hasn't been filtered by type or string.
       if (!item.classList.contains("filtered-by-type") &&
           !item.classList.contains("filtered-by-string")) {
-        let timestampString = l10n.timestampString(item.timestamp);
         if (options.linkOnly) {
           strings.push(item.url);
         } else {
           strings.push(item.clipboardText);
         }
       }
     }
 
@@ -2834,24 +2830,24 @@ WebConsoleFrame.prototype = {
   },
 };
 
 /**
  * @see VariablesView.simpleValueEvalMacro
  */
 function simpleValueEvalMacro(item, currentString) {
   return VariablesView.simpleValueEvalMacro(item, currentString, "_self");
-};
+}
 
 /**
  * @see VariablesView.overrideValueEvalMacro
  */
 function overrideValueEvalMacro(item, currentString) {
   return VariablesView.overrideValueEvalMacro(item, currentString, "_self");
-};
+}
 
 /**
  * @see VariablesView.getterOrSetterEvalMacro
  */
 function getterOrSetterEvalMacro(item, currentString) {
   return VariablesView.getterOrSetterEvalMacro(item, currentString, "_self");
 }
 
@@ -3205,17 +3201,17 @@ JSTerm.prototype = {
    */
   execute: function(executeString, callback) {
     let deferred = promise.defer();
     let resultCallback = function(msg) {
       deferred.resolve(msg);
       if (callback) {
         callback(msg);
       }
-    }
+    };
 
     // attempt to execute the content of the inputNode
     executeString = executeString || this.getInputValue();
     if (!executeString) {
       return;
     }
 
     let selectedNodeActor = null;
@@ -3379,17 +3375,17 @@ JSTerm.prototype = {
 
     let openPromise;
     if (options.targetElement) {
       let deferred = promise.defer();
       openPromise = deferred.promise;
       let document = options.targetElement.ownerDocument;
       let iframe = document.createElementNS(XHTML_NS, "iframe");
 
-      iframe.addEventListener("load", function onIframeLoad(event) {
+      iframe.addEventListener("load", function onIframeLoad() {
         iframe.removeEventListener("load", onIframeLoad, true);
         iframe.style.visibility = "visible";
         deferred.resolve(iframe.contentWindow);
       }, true);
 
       iframe.flex = 1;
       iframe.style.visibility = "hidden";
       iframe.setAttribute("src", VARIABLES_VIEW_URL);
@@ -4382,18 +4378,18 @@ JSTerm.prototype = {
   onAutocompleteSelect: function() {
     // Render the suggestion only if the cursor is at the end of the input.
     if (this.inputNode.selectionStart != this.getInputValue().length) {
       return;
     }
 
     let currentItem = this.autocompletePopup.selectedItem;
     if (currentItem && this.lastCompletion.value) {
-      let suffix = currentItem.label.substring(this.lastCompletion.
-                                               matchProp.length);
+      let suffix =
+        currentItem.label.substring(this.lastCompletion.matchProp.length);
       this.updateCompleteNode(suffix);
     } else {
       this.updateCompleteNode("");
     }
   },
 
   /**
    * Clear the current completion information and close the autocomplete popup,
@@ -4416,18 +4412,18 @@ JSTerm.prototype = {
    *         True if there was a selected completion item and the input value
    *         was updated, false otherwise.
    */
   acceptProposedCompletion: function() {
     let updated = false;
 
     let currentItem = this.autocompletePopup.selectedItem;
     if (currentItem && this.lastCompletion.value) {
-      let suffix = currentItem.label.substring(this.lastCompletion.
-                                               matchProp.length);
+      let suffix =
+        currentItem.label.substring(this.lastCompletion.matchProp.length);
       let cursor = this.inputNode.selectionStart;
       let value = this.getInputValue();
       this.setInputValue(value.substr(0, cursor) +
         suffix + value.substr(cursor));
       let newCursor = cursor + suffix.length;
       this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
       updated = true;
     }
@@ -4580,17 +4576,19 @@ var Utils = {
    */
   logLimitForCategory: function(category) {
     let logLimit = DEFAULT_LOG_LIMIT;
 
     try {
       let prefName = CATEGORY_CLASS_FRAGMENTS[category];
       logLimit = Services.prefs.getIntPref("devtools.hud.loglimit." + prefName);
       logLimit = Math.max(logLimit, 1);
-    } catch (e) { }
+    } catch (e) {
+      // Ignore any exceptions
+    }
 
     return logLimit;
   },
 };
 
 // ////////////////////////////////////////////////////////////////////////////
 // CommandController
 // ////////////////////////////////////////////////////////////////////////////
--- a/devtools/server/actors/storage.js
+++ b/devtools/server/actors/storage.js
@@ -214,17 +214,17 @@ StorageActors.defaults = function(typeNa
 
     destroy: function() {
       if (observationTopic) {
         Services.obs.removeObserver(this, observationTopic, false);
       }
       events.off(this.storageActor, "window-ready", this.onWindowReady);
       events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed);
 
-      this.hostVsStores = null;
+      this.hostVsStores.clear();
       this.storageActor = null;
     },
 
     getNamesForHost: function(host) {
       return [...this.hostVsStores.get(host).keys()];
     },
 
     getValuesForHost: function(host, name) {
@@ -469,17 +469,17 @@ StorageActors.createActor({
 
     this.onWindowReady = this.onWindowReady.bind(this);
     this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
     events.on(this.storageActor, "window-ready", this.onWindowReady);
     events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed);
   },
 
   destroy: function() {
-    this.hostVsStores = null;
+    this.hostVsStores.clear();
 
     // We need to remove the cookie listeners early in E10S mode so we need to
     // use a conditional here to ensure that we only attempt to remove them in
     // single process mode.
     if (!DebuggerServer.isInChildProcess) {
       this.removeCookieObservers();
     }
 
@@ -711,27 +711,27 @@ var cookieHelpers = {
     return null;
   },
 
   observe: function(subject, topic, data) {
     switch (topic) {
       case "cookie-changed":
         let cookie = subject.QueryInterface(Ci.nsICookie2);
         cookieHelpers.onCookieChanged(cookie, topic, data);
-      break;
+        break;
     }
   },
 
   handleParentRequest: function(msg) {
     switch (msg.json.method) {
       case "onCookieChanged":
         let [cookie, topic, data] = msg.data.args;
         cookie = JSON.parse(cookie);
         cookieHelpers.onCookieChanged(cookie, topic, data);
-      break;
+        break;
     }
   },
 
   handleChildRequest: function(msg) {
     switch (msg.json.method) {
       case "getCookiesFromHost":
         let host = msg.data.args[0];
         let cookies = cookieHelpers.getCookiesFromHost(host);
@@ -759,19 +759,19 @@ exports.setupParentProcessForCookies = f
   mm.addMessageListener("storage:storage-cookie-request-parent",
                         cookieHelpers.handleChildRequest);
 
   DebuggerServer.once("disconnected-from-child:" + prefix,
                       handleMessageManagerDisconnected);
 
   gTrackedMessageManager.set("cookies", mm);
 
-  function handleMessageManagerDisconnected(evt, { mm: disconnected_mm }) {
+  function handleMessageManagerDisconnected(evt, { mm: disconnectedMm }) {
     // filter out not subscribed message managers
-    if (disconnected_mm !== mm || !gTrackedMessageManager.has("cookies")) {
+    if (disconnectedMm !== mm || !gTrackedMessageManager.has("cookies")) {
       return;
     }
 
     // Although "disconnected-from-child" implies that the child is already
     // disconnected this is not the case. The disconnection takes place after
     // this method has finished. This gives us chance to clean up items within
     // the parent process e.g. observers.
     cookieHelpers.removeCookieObservers();
@@ -789,17 +789,17 @@ exports.setupParentProcessForCookies = f
       args[0] = JSON.stringify(args[0]);
     }
 
     try {
       mm.sendAsyncMessage("storage:storage-cookie-request-child", {
         method: methodName,
         args: args
       });
-    } catch(e) {
+    } catch (e) {
       // We may receive a NS_ERROR_NOT_INITIALIZED if the target window has
       // been closed. This can legitimately happen in between test runs.
     }
   }
 };
 
 /**
  * Helper method to create the overriden object required in
@@ -832,30 +832,30 @@ function getObjectForLocalOrSessionStora
         return location.href;
       }
       return location.protocol + "//" + location.host;
     },
 
     populateStoresForHost: function(host, window) {
       try {
         this.hostVsStores.set(host, window[type]);
-      } catch(ex) {
+      } catch (ex) {
         // Exceptions happen when local or session storage is inaccessible
       }
       return null;
     },
 
     populateStoresForHosts: function() {
       this.hostVsStores = new Map();
       try {
         for (let window of this.windows) {
           this.hostVsStores.set(this.getHostName(window.location),
                                 window[type]);
         }
-      } catch(ex) {
+      } catch (ex) {
         // Exceptions happen when local or session storage is inaccessible
       }
       return null;
     },
 
     observe: function(subject, topic, data) {
       if (topic != "dom-storage2-changed" || data != type) {
         return null;
@@ -917,21 +917,16 @@ StorageActors.createActor({
  * The Session Storage actor and front.
  */
 StorageActors.createActor({
   typeName: "sessionStorage",
   observationTopic: "dom-storage2-changed",
   storeObjectType: "storagestoreobject"
 }, getObjectForLocalOrSessionStorage("sessionStorage"));
 
-
-let CacheAttributes = [
-  "url",
-  "status",
-];
 types.addDictType("cacheobject", {
   "url": "string",
   "status": "string"
 });
 
 // Array of Cache store objects
 types.addDictType("cachestoreobject", {
   total: "number",
@@ -940,23 +935,25 @@ types.addDictType("cachestoreobject", {
 });
 
 StorageActors.createActor({
   typeName: "Cache",
   storeObjectType: "cachestoreobject"
 }, {
   getCachesForHost: Task.async(function*(host) {
     let uri = Services.io.newURI(host, null, null);
-    let principal = Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri);
+    let principal =
+      Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri);
 
-    // The first argument tells if you want to get |content| cache or |chrome| cache.
+    // The first argument tells if you want to get |content| cache or |chrome|
+    // cache.
     // The |content| cache is the cache explicitely named by the web content
     // (service worker or web page).
-    // The |chrome| cache is the cache implicitely cached by the platform, hosting the
-    // source file of the service worker.
+    // The |chrome| cache is the cache implicitely cached by the platform,
+    // hosting the source file of the service worker.
     let { CacheStorage } = this.storageActor.window;
     let cache = new CacheStorage("content", principal);
     return cache;
   }),
 
   preListStores: Task.async(function*() {
     for (let host of this.hosts) {
       yield this.populateStoresForHost(host);
@@ -976,28 +973,32 @@ StorageActors.createActor({
     return {
       actor: this.actorID,
       hosts: hosts
     };
   },
 
   getNamesForHost: function(host) {
     // UI code expect each name to be a JSON string of an array :/
-    return [...this.hostVsStores.get(host).keys()].map(a => JSON.stringify([a]));
+    return [...this.hostVsStores.get(host).keys()].map(a => {
+      return JSON.stringify([a]);
+    });
   },
 
   getValuesForHost: Task.async(function*(host, name) {
-    if (!name) return [];
+    if (!name) {
+      return [];
+    }
     // UI is weird and expect a JSON stringified array... and pass it back :/
     name = JSON.parse(name)[0];
 
     let cache = this.hostVsStores.get(host).get(name);
     let requests = yield cache.keys();
     let results = [];
-    for(let request of requests) {
+    for (let request of requests) {
       let response = yield cache.match(request);
       // Unwrap the response to get access to all its properties if the
       // response happen to be 'opaque', when it is a Cross Origin Request.
       response = response.cloneUnfiltered();
       results.push(yield this.processEntry(request, response));
     }
     return results;
   }),
@@ -1011,17 +1012,17 @@ StorageActors.createActor({
 
   getHostName: function(location) {
     if (!location.host) {
       return location.href;
     }
     return location.protocol + "//" + location.host;
   },
 
-  populateStoresForHost: Task.async(function*(host, window) {
+  populateStoresForHost: Task.async(function*(host) {
     let storeMap = new Map();
     let caches = yield this.getCachesForHost(host);
     for (let name of (yield caches.keys())) {
       storeMap.set(name, (yield caches.open(name)));
     }
     this.hostVsStores.set(host, storeMap);
   }),
 
@@ -1175,17 +1176,17 @@ StorageActors.createActor({
     this.onWindowReady = this.onWindowReady.bind(this);
     this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
 
     events.on(this.storageActor, "window-ready", this.onWindowReady);
     events.on(this.storageActor, "window-destroyed", this.onWindowDestroyed);
   },
 
   destroy: function() {
-    this.hostVsStores = null;
+    this.hostVsStores.clear();
     this.objectsSize = null;
 
     events.off(this.storageActor, "window-ready", this.onWindowReady);
     events.off(this.storageActor, "window-destroyed", this.onWindowDestroyed);
   },
 
   getHostName: function(location) {
     if (!location.host) {
@@ -1376,17 +1377,17 @@ StorageActors.createActor({
         case "backToChild":
           let func = msg.json.args.shift();
           let deferred = unresolvedPromises.get(func);
 
           if (deferred) {
             unresolvedPromises.delete(func);
             deferred.resolve(msg.json.args[0]);
           }
-        break;
+          break;
       }
     });
 
     let unresolvedPromises = new Map();
     function callParentProcessAsync(methodName, ...args) {
       let deferred = promise.defer();
 
       unresolvedPromises.set(methodName, deferred);
@@ -1444,17 +1445,18 @@ var indexedDBHelpers = {
    */
   openWithOrigin: function(host, name) {
     let principal;
 
     if (/^(about:|chrome:)/.test(host)) {
       principal = Services.scriptSecurityManager.getSystemPrincipal();
     } else {
       let uri = Services.io.newURI(host, null, null);
-      principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
+      principal = Services.scriptSecurityManager
+                          .createCodebasePrincipal(uri, {});
     }
 
     return require("indexedDB").openForPrincipal(principal, name);
   },
 
     /**
    * Fetches all the databases and their metadata for the given `host`.
    */
@@ -1728,19 +1730,19 @@ exports.setupParentProcessForIndexedDB =
   mm.addMessageListener("storage:storage-indexedDB-request-parent",
                         indexedDBHelpers.handleChildRequest);
 
   DebuggerServer.once("disconnected-from-child:" + prefix,
                       handleMessageManagerDisconnected);
 
   gTrackedMessageManager.set("indexedDB", mm);
 
-  function handleMessageManagerDisconnected(evt, { mm: disconnected_mm }) {
+  function handleMessageManagerDisconnected(evt, { mm: disconnectedMm }) {
     // filter out not subscribed message managers
-    if (disconnected_mm !== mm || !gTrackedMessageManager.has("indexedDB")) {
+    if (disconnectedMm !== mm || !gTrackedMessageManager.has("indexedDB")) {
       return;
     }
 
     gTrackedMessageManager.delete("indexedDB");
 
     // unregister for director-script requests handlers from the parent process
     // (if any)
     mm.removeMessageListener("storage:storage-indexedDB-request-parent",
@@ -1797,18 +1799,18 @@ var StorageActor = exports.StorageActor 
 
     this.childActorPool = new Map();
     this.childWindowPool = new Set();
 
     // Fetch all the inner iframe windows in this tab.
     this.fetchChildWindows(this.parentActor.docShell);
 
     // Initialize the registered store types
-    for (let [store, actor] of storageTypePool) {
-      this.childActorPool.set(store, new actor(this));
+    for (let [store, ActorConstructor] of storageTypePool) {
+      this.childActorPool.set(store, new ActorConstructor(this));
     }
 
     // Notifications that help us keep track of newly added windows and windows
     // that got removed
     Services.obs.addObserver(this, "content-document-global-created", false);
     Services.obs.addObserver(this, "inner-window-destroyed", false);
     this.onPageChange = this.onPageChange.bind(this);
 
@@ -2084,15 +2086,15 @@ var StorageActor = exports.StorageActor 
     }
     return null;
   }
 });
 
 /**
  * Front for the Storage Actor.
  */
-var StorageFront = exports.StorageFront = protocol.FrontClass(StorageActor, {
+exports.StorageFront = protocol.FrontClass(StorageActor, {
   initialize: function(client, tabForm) {
     protocol.Front.prototype.initialize.call(this, client);
     this.actorID = tabForm.storageActor;
     this.manage(this);
   }
 });
--- a/devtools/server/actors/utils/TabSources.js
+++ b/devtools/server/actors/utils/TabSources.js
@@ -1,21 +1,21 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const { Ci, Cu } = require("chrome");
-const Services = require("Services");
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
 const { assert, fetch } = DevToolsUtils;
 const EventEmitter = require("devtools/shared/event-emitter");
 const { OriginalLocation, GeneratedLocation } = require("devtools/server/actors/common");
 const { resolve } = require("promise");
+const URL = require("URL");
 
 loader.lazyRequireGetter(this, "SourceActor", "devtools/server/actors/script", true);
 loader.lazyRequireGetter(this, "isEvalSource", "devtools/server/actors/script", true);
 loader.lazyRequireGetter(this, "SourceMapConsumer", "source-map", true);
 loader.lazyRequireGetter(this, "SourceMapGenerator", "source-map", true);
 
 /**
  * Manages the sources for a thread. Handles source maps, locations in the
@@ -253,19 +253,19 @@ TabSources.prototype = {
    * otherwise.
    *
    * @param String aURL
    *        The URL to test.
    * @returns Boolean
    */
   _isMinifiedURL: function (aURL) {
     try {
-      let url = Services.io.newURI(aURL, null, null)
-                           .QueryInterface(Ci.nsIURL);
-      return MINIFIED_SOURCE_REGEXP.test(url.fileName);
+      let url = new URL(aURL);
+      let pathname = url.pathname;
+      return MINIFIED_SOURCE_REGEXP.test(pathname.slice(pathname.lastIndexOf("/") + 1));
     } catch (e) {
       // Not a valid URL so don't try to parse out the filename, just test the
       // whole thing with the minified source regexp.
       return MINIFIED_SOURCE_REGEXP.test(aURL);
     }
   },
 
   /**
@@ -296,32 +296,38 @@ TabSources.prototype = {
 
     // Assume the source is inline if the element that introduced it is not a
     // script element, or does not have a src attribute.
     let element = aSource.element ? aSource.element.unsafeDereference() : null;
     if (element && (element.tagName !== "SCRIPT" || !element.hasAttribute("src"))) {
       spec.isInlineSource = true;
     } else {
       if (url) {
-        try {
-          let urlInfo = Services.io.newURI(url, null, null).QueryInterface(Ci.nsIURL);
-          if (urlInfo.fileExtension === "xml") {
-            // XUL inline scripts may not correctly have the
-            // `source.element` property, so do a blunt check here if
-            // it's an xml page.
-            spec.isInlineSource = true;
-          }
-          else if (urlInfo.fileExtension === "js") {
-            spec.contentType = "text/javascript";
-          }
-        } catch(ex) {
-          // There are a few special URLs that we know are JavaScript:
-          // inline `javascript:` and code coming from the console
-          if (url.indexOf("javascript:") === 0 || url === 'debugger eval code') {
-            spec.contentType = "text/javascript";
+        // There are a few special URLs that we know are JavaScript:
+        // inline `javascript:` and code coming from the console
+        if (url.indexOf("javascript:") === 0 || url === 'debugger eval code') {
+          spec.contentType = "text/javascript";
+        } else {
+          try {
+            let pathname = new URL(url).pathname;
+            let filename = pathname.slice(pathname.lastIndexOf("/") + 1);
+            let index = filename.lastIndexOf(".");
+            let extension = index >= 0 ? filename.slice(index + 1) : "";
+            if (extension === "xml") {
+              // XUL inline scripts may not correctly have the
+              // `source.element` property, so do a blunt check here if
+              // it's an xml page.
+              spec.isInlineSource = true;
+            }
+            else if (extension === "js") {
+              spec.contentType = "text/javascript";
+            }
+          } catch (e) {
+            // This only needs to be here because URL is not yet exposed to
+            // workers.
           }
         }
       }
       else {
         // Assume the content is javascript if there's no URL
         spec.contentType = "text/javascript";
       }
     }
@@ -473,18 +479,19 @@ TabSources.prototype = {
         ? aScriptURL
         : aAbsSourceMapURL);
     aSourceMap.sourceRoot = aSourceMap.sourceRoot
       ? this._normalize(aSourceMap.sourceRoot, base)
       : base;
   },
 
   _dirname: function (aPath) {
-    return Services.io.newURI(
-      ".", null, Services.io.newURI(aPath, null, null)).spec;
+    let url = new URL(aPath);
+    let href = url.href;
+    return href.slice(0, href.lastIndexOf("/"));
   },
 
   /**
    * Clears the source map cache. Source maps are cached by URL so
    * they can be reused across separate Debugger instances (once in
    * this cache, they will never be reparsed again). They are
    * also cached by Debugger.Source objects for usefulness. By default
    * this just removes the Debugger.Source cache, but you can remove
@@ -773,22 +780,22 @@ TabSources.prototype = {
     this.prettyPrintedSources.delete(aURL);
   },
 
   /**
    * Normalize multiple relative paths towards the base paths on the right.
    */
   _normalize: function (...aURLs) {
     assert(aURLs.length > 1, "Should have more than 1 URL");
-    let base = Services.io.newURI(aURLs.pop(), null, null);
+    let base = new URL(aURLs.pop());
     let url;
     while ((url = aURLs.pop())) {
-      base = Services.io.newURI(url, null, base);
+      base = new URL(url, base);
     }
-    return base.spec;
+    return base.href;
   },
 
   iter: function () {
     let actors = Object.keys(this._sourceMappedSourceActors).map(k => {
       return this._sourceMappedSourceActors[k];
     });
     for (let actor of this._sourceActors.values()) {
       if (!this._sourceMaps.has(actor.source)) {
--- a/devtools/server/actors/webconsole.js
+++ b/devtools/server/actors/webconsole.js
@@ -79,17 +79,17 @@ function WebConsoleActor(aConnection, aP
   this.traits = {
     customNetworkRequest: !this._parentIsContentActor,
     evaluateJSAsync: true,
     transferredResponseSize: true,
     selectedObjectActor: true, // 44+
   };
 }
 
-WebConsoleActor.l10n = new WebConsoleUtils.l10n("chrome://global/locale/console.properties");
+WebConsoleActor.l10n = new WebConsoleUtils.L10n("chrome://global/locale/console.properties");
 
 WebConsoleActor.prototype =
 {
   /**
    * Debugger instance.
    *
    * @see jsdebugger.jsm
    */
--- a/devtools/shared/Loader.jsm
+++ b/devtools/shared/Loader.jsm
@@ -66,16 +66,20 @@ XPCOMUtils.defineLazyGetter(loaderModule
     return {};
   }
 });
 
 XPCOMUtils.defineLazyGetter(loaderModules, "CSS", () => {
   return Cu.Sandbox(this, {wantGlobalProperties: ["CSS"]}).CSS;
 });
 
+XPCOMUtils.defineLazyGetter(loaderModules, "URL", () => {
+  return Cu.Sandbox(this, {wantGlobalProperties: ["URL"]}).URL;
+});
+
 var sharedGlobalBlocklist = ["sdk/indexed-db"];
 
 /**
  * Used when the tools should be loaded from the Firefox package itself.
  * This is the default case.
  */
 function BuiltinProvider() {}
 BuiltinProvider.prototype = {
@@ -389,16 +393,32 @@ DevToolsLoader.prototype = {
       loader: {
         lazyGetter: this.lazyGetter,
         lazyImporter: this.lazyImporter,
         lazyServiceGetter: this.lazyServiceGetter,
         lazyRequireGetter: this.lazyRequireGetter,
         id: this.id,
         main: this.main
       },
+      // Make sure `define` function exists.  This allows defining some modules
+      // in AMD format while retaining CommonJS compatibility through this hook.
+      // JSON Viewer needs modules in AMD format, as it currently uses RequireJS
+      // from a content document and can't access our usual loaders.  So, any
+      // modules shared with the JSON Viewer should include a define wrapper:
+      //
+      //   // Make this available to both AMD and CJS environments
+      //   define(function(require, exports, module) {
+      //     ... code ...
+      //   });
+      //
+      // Bug 1248830 will work out a better plan here for our content module
+      // loading needs, especially as we head towards devtools.html.
+      define(factory) {
+        factory(this.require, this.exports, this.module);
+      },
     };
     // Lazy define console in order to load Console.jsm only when it is used
     XPCOMUtils.defineLazyGetter(this._provider.globals, "console", () => {
       return Cu.import("resource://gre/modules/Console.jsm", {}).console;
     });
 
     this._provider.load();
     this.require = Loader.Require(this._provider.loader, { id: "devtools" });
--- a/devtools/shared/heapsnapshot/DominatorTreeNode.js
+++ b/devtools/shared/heapsnapshot/DominatorTreeNode.js
@@ -1,18 +1,20 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
+const { immutableUpdate } = require("resource://devtools/shared/ThreadSafeDevToolsUtils.js");
 const { Visitor, walk } = require("resource://devtools/shared/heapsnapshot/CensusUtils.js");
-const { immutableUpdate } = require("resource://devtools/shared/ThreadSafeDevToolsUtils.js");
+const { deduplicatePaths } = require("resource://devtools/shared/heapsnapshot/shortest-paths");
 
 const DEFAULT_MAX_DEPTH = 4;
 const DEFAULT_MAX_SIBLINGS = 15;
+const DEFAULT_MAX_NUM_PATHS = 5;
 
 /**
  * A single node in a dominator tree.
  *
  * @param {NodeId} nodeId
  * @param {NodeSize} retainedSize
  */
 function DominatorTreeNode(nodeId, label, shallowSize, retainedSize) {
@@ -29,16 +31,20 @@ function DominatorTreeNode(nodeId, label
   this.retainedSize = retainedSize;
 
   // The id of this node's parent or undefined if this node is the root.
   this.parentId = undefined;
 
   // An array of immediately dominated child `DominatorTreeNode`s, or undefined.
   this.children = undefined;
 
+  // An object of the form returned by `deduplicatePaths`, encoding the set of
+  // the N shortest retaining paths for this node as a graph.
+  this.shortestPaths = undefined;
+
   // True iff the `children` property does not contain every immediately
   // dominated node.
   //
   // * If children is an array and this property is true: the array does not
   //   contain the complete set of immediately dominated children.
   // * If children is an array and this property is false: the array contains
   //   the complete set of immediately dominated children.
   // * If children is undefined and this property is true: there exist
@@ -284,8 +290,47 @@ DominatorTreeNode.getNodeByIdAlongPath =
     }
 
     const nextId = path[i + 1];
     return find(node.children.find(c => c.nodeId === nextId), i + 1);
   }
 
   return find(tree, 0);
 };
+
+/**
+ * Find the shortest retaining paths for the given set of DominatorTreeNodes,
+ * and populate each node's `shortestPaths` property with them in place.
+ *
+ * @param {HeapSnapshot} snapshot
+ * @param {Object} breakdown
+ * @param {NodeId} start
+ * @param {Array<DominatorTreeNode>} treeNodes
+ * @param {Number} maxNumPaths
+ */
+DominatorTreeNode.attachShortestPaths = function (snapshot,
+                                                  breakdown,
+                                                  start,
+                                                  treeNodes,
+                                                  maxNumPaths = DEFAULT_MAX_NUM_PATHS) {
+  const idToTreeNode = new Map();
+  const targets = [];
+  for (let node of treeNodes) {
+    const id = node.nodeId;
+    idToTreeNode.set(id, node);
+    targets.push(id);
+  }
+
+  const shortestPaths = snapshot.computeShortestPaths(start,
+                                                      targets,
+                                                      maxNumPaths);
+
+  for (let [target, paths] of shortestPaths) {
+    const deduped = deduplicatePaths(target, paths);
+    deduped.nodes = deduped.nodes.map(id => {
+      const { label } =
+        DominatorTreeNode.getLabelAndShallowSize(id, snapshot, breakdown);
+      return { id, label };
+    });
+
+    idToTreeNode.get(target).shortestPaths = deduped;
+  }
+};
--- a/devtools/shared/heapsnapshot/HeapAnalysesClient.js
+++ b/devtools/shared/heapsnapshot/HeapAnalysesClient.js
@@ -221,16 +221,18 @@ HeapAnalysesClient.prototype.getDominato
  *        - {Object} breakdown
  *          The breakdown used to generate node labels.
  *        - {Number} startIndex
  *          The starting index within the full set of immediately dominated
  *          children of the children being requested. Children are always sorted
  *          by greatest to least retained size.
  *        - {Number} maxCount
  *          The maximum number of children to return.
+ *        - {Number} maxRetainingPaths
+ *          The maximum number of retaining paths to find for each node.
  *
  * @returns {Promise<Object>}
  *          A promise of an object with the following properties:
  *          - {Array<DominatorTreeNode>} nodes
  *            The requested nodes that are immediately dominated by the node
  *            identified by `opts.nodeId`.
  *          - {Boolean} moreChildrenAvailable
  *            True iff there are more children available after the returned
--- a/devtools/shared/heapsnapshot/HeapAnalysesWorker.js
+++ b/devtools/shared/heapsnapshot/HeapAnalysesWorker.js
@@ -152,44 +152,64 @@ workerHelper.createTask(self, "computeDo
 /**
  * @see HeapAnalysesClient.prototype.getDominatorTree
  */
 workerHelper.createTask(self, "getDominatorTree", request => {
   const {
     dominatorTreeId,
     breakdown,
     maxDepth,
-    maxSiblings
+    maxSiblings,
+    maxRetainingPaths,
   } = request;
 
   if (!(0 <= dominatorTreeId && dominatorTreeId < dominatorTrees.length)) {
     throw new Error(
       `There does not exist a DominatorTree with the id ${dominatorTreeId}`);
   }
 
   const dominatorTree = dominatorTrees[dominatorTreeId];
   const snapshot = dominatorTreeSnapshots[dominatorTreeId];
 
-  return DominatorTreeNode.partialTraversal(dominatorTree,
-                                            snapshot,
-                                            breakdown,
-                                            maxDepth,
-                                            maxSiblings);
+  const tree = DominatorTreeNode.partialTraversal(dominatorTree,
+                                                  snapshot,
+                                                  breakdown,
+                                                  maxDepth,
+                                                  maxSiblings);
+
+  const nodes = [];
+  (function getNodes(node) {
+    nodes.push(node);
+    if (node.children) {
+      for (let i = 0, length = node.children.length; i < length; i++) {
+        getNodes(node.children[i]);
+      }
+    }
+  }(tree));
+
+  DominatorTreeNode.attachShortestPaths(snapshot,
+                                        breakdown,
+                                        dominatorTree.root,
+                                        nodes,
+                                        maxRetainingPaths);
+
+  return tree;
 });
 
 /**
  * @see HeapAnalysesClient.prototype.getImmediatelyDominated
  */
 workerHelper.createTask(self, "getImmediatelyDominated", request => {
   const {
     dominatorTreeId,
     nodeId,
     breakdown,
     startIndex,
-    maxCount
+    maxCount,
+    maxRetainingPaths,
   } = request;
 
   if (!(0 <= dominatorTreeId && dominatorTreeId < dominatorTrees.length)) {
     throw new Error(
       `There does not exist a DominatorTree with the id ${dominatorTreeId}`);
   }
 
   const dominatorTree = dominatorTrees[dominatorTreeId];
@@ -223,10 +243,16 @@ workerHelper.createTask(self, "getImmedi
   do {
     path.push(id);
     id = dominatorTree.getImmediateDominator(id);
   } while (id !== null);
   path.reverse();
 
   const moreChildrenAvailable = childIds.length > end;
 
+  DominatorTreeNode.attachShortestPaths(snapshot,
+                                        breakdown,
+                                        dominatorTree.root,
+                                        nodes,
+                                        maxRetainingPaths);
+
   return { nodes, moreChildrenAvailable, path };
 });
--- a/devtools/shared/heapsnapshot/moz.build
+++ b/devtools/shared/heapsnapshot/moz.build
@@ -50,9 +50,10 @@ FINAL_LIBRARY = 'xul'
 
 DevToolsModules(
     'census-tree-node.js',
     'CensusUtils.js',
     'DominatorTreeNode.js',
     'HeapAnalysesClient.js',
     'HeapAnalysesWorker.js',
     'HeapSnapshotFileUtils.js',
+    'shortest-paths.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/shared/heapsnapshot/shortest-paths.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Compress a set of paths leading to `target` into a single graph, returned as
+ * a set of nodes and a set of edges.
+ *
+ * @param {NodeId} target
+ *        The target node passed to `HeapSnapshot.computeShortestPaths`.
+ *
+ * @param {Array<Path>} paths
+ *        An array of paths to `target`, as returned by
+ *        `HeapSnapshot.computeShortestPaths`.
+ *
+ * @returns {Object}
+ *          An object with two properties:
+ *          - edges: An array of unique objects of the form:
+ *              {
+ *                 from: <node ID>,
+ *                 to: <node ID>,
+ *                 name: <string or null>
+ *              }
+ *          - nodes: An array of unique node IDs. Every `from` and `to` id is
+ *            guaranteed to be in this array exactly once.
+ */
+exports.deduplicatePaths = function (target, paths) {
+  // Use this structure to de-duplicate edges among many retaining paths from
+  // start to target.
+  //
+  // Map<FromNodeId, Map<ToNodeId, Set<EdgeName>>>
+  const deduped = new Map();
+
+  function insert(from, to, name) {
+    let toMap = deduped.get(from);
+    if (!toMap) {
+      toMap = new Map();
+      deduped.set(from, toMap);
+    }
+
+    let nameSet = toMap.get(to);
+    if (!nameSet) {
+      nameSet = new Set();
+      toMap.set(to, nameSet);
+    }
+
+    nameSet.add(name);
+  }
+
+  for (let path of paths) {
+    const pathLength = path.length;
+    for (let i = 0; i < pathLength - 1; i++) {
+      insert(path[i].predecessor, path[i + 1].predecessor, path[i].edge);
+    }
+
+    insert(path[pathLength - 1].predecessor, target, path[pathLength - 1].edge);
+  }
+
+  const nodes = [target];
+  const edges = [];
+
+  for (let [from, toMap] of deduped) {
+    // If the second/third/etc shortest path contains the `target` anywhere
+    // other than the very last node, we could accidentally put the `target` in
+    // `nodes` more than once.
+    if (from !== target) {
+      nodes.push(from);
+    }
+
+    for (let [to, edgeNameSet] of toMap) {
+      for (let name of edgeNameSet) {
+        edges.push({ from, to, name });
+      }
+    }
+  }
+
+  return { nodes, edges };
+};
--- a/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js
+++ b/devtools/shared/heapsnapshot/tests/unit/head_heapsnapshot.js
@@ -18,16 +18,17 @@ const { Task } = Cu.import("resource://g
 
 const DevToolsUtils = require("devtools/shared/DevToolsUtils");
 const HeapAnalysesClient =
   require("devtools/shared/heapsnapshot/HeapAnalysesClient");
 const Services = require("Services");
 const { censusReportToCensusTreeNode } = require("devtools/shared/heapsnapshot/census-tree-node");
 const CensusUtils = require("devtools/shared/heapsnapshot/CensusUtils");
 const DominatorTreeNode = require("devtools/shared/heapsnapshot/DominatorTreeNode");
+const { deduplicatePaths } = require("devtools/shared/heapsnapshot/shortest-paths");
 const { LabelAndShallowSizeVisitor } = DominatorTreeNode;
 
 
 // Always log packets when running tests. runxpcshelltests.py will throw
 // the output away anyway, unless you give it the --verbose flag.
 if (Services.appInfo &&
     Services.appInfo.processType == Services.appInfo.PROCESS_TYPE_DEFAULT) {
   Services.prefs.setBoolPref("devtools.debugger.log", true);
@@ -370,8 +371,56 @@ function assertDominatorTreeNodeInsertio
   dumpn("New children: " + JSON.stringify(newChildren, null, 2));
   dumpn("Expected resulting tree: " + JSON.stringify(expected, null, 2));
 
   const actual = DominatorTreeNode.insert(tree, path, newChildren, moreChildrenAvailable);
   dumpn("Actual resulting tree: " + JSON.stringify(actual, null, 2));
 
   assertStructurallyEquivalent(actual, expected);
 }
+
+function assertDeduplicatedPaths({ target, paths, expectedNodes, expectedEdges }) {
+  dumpn("Deduplicating paths:");
+  dumpn("target = " + target);
+  dumpn("paths = " + JSON.stringify(paths, null, 2));
+  dumpn("expectedNodes = " + expectedNodes);
+  dumpn("expectedEdges = " + JSON.stringify(expectedEdges, null, 2));
+
+  const { nodes, edges } = deduplicatePaths(target, paths);
+
+  dumpn("Actual nodes = " + nodes);
+  dumpn("Actual edges = " + JSON.stringify(edges, null, 2));
+
+  equal(nodes.length, expectedNodes.length,
+        "actual number of nodes is equal to the expected number of nodes");
+
+  equal(edges.length, expectedEdges.length,
+        "actual number of edges is equal to the expected number of edges");
+
+  const expectedNodeSet = new Set(expectedNodes);
+  const nodeSet = new Set(nodes);
+  ok(nodeSet.size === nodes.length,
+     "each returned node should be unique");
+
+  for (let node of nodes) {
+    ok(expectedNodeSet.has(node), `the ${node} node was expected`);
+  }
+
+  for (let expectedEdge of expectedEdges) {
+    let count = 0;
+    for (let edge of edges) {
+      if (edge.from === expectedEdge.from &&
+          edge.to === expectedEdge.to &&
+          edge.name === expectedEdge.name) {
+        count++;
+      }
+    }
+    equal(count, 1,
+          "should have exactly one matching edge for the expected edge = " + JSON.stringify(edge));
+  }
+}
+
+/**
+ * Create a mock path entry for the given predecessor and edge.
+ */
+function pathEntry(predecessor, edge) {
+  return { predecessor, edge };
+}
new file mode 100644
--- /dev/null
+++ b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_attachShortestPaths_01.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that the DominatorTreeNode.attachShortestPaths function can correctly
+// attach the deduplicated shortest retaining paths for each node it is given.
+
+const startNodeId = 9999;
+const maxNumPaths = 2;
+
+// Mock data mapping node id to shortest paths to that node id.
+const shortestPaths = new Map([
+  [1000, [
+    [pathEntry(1100, "a"), pathEntry(1200, "b")],
+    [pathEntry(1100, "c"), pathEntry(1300, "d")],
+  ]],
+  [2000, [
+    [pathEntry(2100, "e"), pathEntry(2200, "f"), pathEntry(2300, "g")]
+  ]],
+  [3000, [
+    [pathEntry(3100, "h")],
+    [pathEntry(3100, "i")],
+    [pathEntry(3100, "j")],
+    [pathEntry(3200, "k")],
+    [pathEntry(3300, "l")],
+    [pathEntry(3400, "m")],
+  ]],
+]);
+
+const actual = [
+  makeTestDominatorTreeNode({ nodeId: 1000 }),
+  makeTestDominatorTreeNode({ nodeId: 2000 }),
+  makeTestDominatorTreeNode({ nodeId: 3000 }),
+];
+
+const expected = [
+  makeTestDominatorTreeNode({
+    nodeId: 1000,
+    shortestPaths: {
+      nodes: [
+        { id: 1000, label: ["SomeType-1000"] },
+        { id: 1100, label: ["SomeType-1100"] },
+        { id: 1200, label: ["SomeType-1200"] },
+        { id: 1300, label: ["SomeType-1300"] },
+      ],
+      edges: [
+        { from: 1100, to: 1200, name: "a" },
+        { from: 1100, to: 1300, name: "c" },
+        { from: 1200, to: 1000, name: "b" },
+        { from: 1300, to: 1000, name: "d" },
+      ]
+    }
+  }),
+
+  makeTestDominatorTreeNode({
+    nodeId: 2000,
+    shortestPaths: {
+      nodes: [
+        { id: 2000, label: ["SomeType-2000"] },
+        { id: 2100, label: ["SomeType-2100"] },
+        { id: 2200, label: ["SomeType-2200"] },
+        { id: 2300, label: ["SomeType-2300"] },
+      ],
+      edges: [
+        { from: 2100, to: 2200, name: "e" },
+        { from: 2200, to: 2300, name: "f" },
+        { from: 2300, to: 2000, name: "g" },
+      ]
+    }
+  }),
+
+  makeTestDominatorTreeNode({ nodeId: 3000,
+    shortestPaths: {
+      nodes: [
+        { id: 3000, label: ["SomeType-3000"] },
+        { id: 3100, label: ["SomeType-3100"] },
+        { id: 3200, label: ["SomeType-3200"] },
+        { id: 3300, label: ["SomeType-3300"] },
+        { id: 3400, label: ["SomeType-3400"] },
+      ],
+      edges: [
+        { from: 3100, to: 3000, name: "h" },
+        { from: 3100, to: 3000, name: "i" },
+        { from: 3100, to: 3000, name: "j" },
+        { from: 3200, to: 3000, name: "k" },
+        { from: 3300, to: 3000, name: "l" },
+        { from: 3400, to: 3000, name: "m" },
+      ]
+    }
+  }),
+];
+
+const breakdown = {
+  by: "internalType",
+  then: { by: "count", count: true, bytes: true }
+};
+
+const mockSnapshot = {
+  computeShortestPaths: (start, nodeIds, max) => {
+    equal(start, startNodeId);
+    equal(max, maxNumPaths);
+
+    return new Map(nodeIds.map(nodeId => {
+      const paths = shortestPaths.get(nodeId);
+      ok(paths, "Expected computeShortestPaths call for node id = " + nodeId);
+      return [nodeId, paths];
+    }));
+  },
+
+  describeNode: (bd, nodeId) => {
+    equal(bd, breakdown);
+    return {
+      ["SomeType-" + nodeId]: {
+        count: 1,
+        bytes: 10,
+      }
+    };
+  },
+};
+
+function run_test() {
+  DominatorTreeNode.attachShortestPaths(mockSnapshot,
+                                        breakdown,
+                                        startNodeId,
+                                        actual,
+                                        maxNumPaths);
+
+  dumpn("Expected = " + JSON.stringify(expected, null, 2));
+  dumpn("Actual = " + JSON.stringify(actual, null, 2));
+
+  assertStructurallyEquivalent(expected, actual);
+}
--- a/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js
+++ b/devtools/shared/heapsnapshot/tests/unit/test_DominatorTreeNode_partialTraversal_01.js
@@ -55,86 +55,93 @@ const breakdown = {
 const expected = {
   nodeId: 100,
   label: [
     "other",
     "SomeType"
   ],
   shallowSize: 10,
   retainedSize: 10,
+  shortestPaths: undefined,
   children: [
     {
       nodeId: 200,
       label: [
         "other",
         "SomeType"
       ],
       shallowSize: 10,
       retainedSize: 10,
       parentId: 100,
+      shortestPaths: undefined,
       children: [
         {
           nodeId: 500,
           label: [
             "other",
             "SomeType"
           ],
           shallowSize: 10,
           retainedSize: 10,
           parentId: 200,
           moreChildrenAvailable: false,
+          shortestPaths: undefined,
           children: undefined
         },
         {
           nodeId: 600,
           label: [
             "other",
             "SomeType"
           ],
           shallowSize: 10,
           retainedSize: 10,
           parentId: 200,
           moreChildrenAvailable: false,
+          shortestPaths: undefined,
           children: undefined
         }
       ],
       moreChildrenAvailable: true
     },
     {
       nodeId: 300,
       label: [
         "other",
         "SomeType"
       ],
       shallowSize: 10,
       retainedSize: 10,
       parentId: 100,
+      shortestPaths: undefined,
       children: [
         {
           nodeId: 800,
           label: [
             "other",
             "SomeType"
           ],
           shallowSize: 10,
           retainedSize: 10,
           parentId: 300,
           moreChildrenAvailable: false,
+          shortestPaths: undefined,
           children: undefined
         },
         {
           nodeId: 900,
           label: [
             "other",
             "SomeType"
           ],
           shallowSize: 10,
           retainedSize: 10,
           parentId: 300,
           moreChildrenAvailable: false,
+          shortestPaths: undefined,
           children: undefined
         }
       ],
       moreChildrenAvailable: false
     }
   ],
   moreChildrenAvailable: true,
   parentId: undefined,
--- a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js
+++ b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getDominatorTree_01.js
@@ -46,16 +46,23 @@ add_task(function* () {
     equal(typeof node.retainedSize, "number",
           "each node should have a retained size");
 
     ok(node.children === undefined || Array.isArray(node.children),
        "each node either has a list of children, or undefined meaning no children loaded");
     equal(typeof node.moreChildrenAvailable, "boolean",
           "each node should indicate if there are more children available or not");
 
+    equal(typeof node.shortestPaths, "object",
+          "Should have shortest paths");
+    equal(typeof node.shortestPaths.nodes, "object",
+          "Should have shortest paths' nodes");
+    equal(typeof node.shortestPaths.edges, "object",
+          "Should have shortest paths' edges");
+
     if (node.children) {
       node.children.forEach(checkTree);
     }
   }
 
   checkTree(partialTree);
 
   client.destroy();
--- a/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js
+++ b/devtools/shared/heapsnapshot/tests/unit/test_HeapAnalyses_getImmediatelyDominated_01.js
@@ -39,25 +39,43 @@ add_task(function* () {
   });
 
   ok(Array.isArray(response.nodes));
   ok(response.nodes.every(node => node.parentId === partialTree.nodeId));
   ok(response.moreChildrenAvailable);
   equal(response.path.length, 1);
   equal(response.path[0], partialTree.nodeId);
 
+  for (let node of response.nodes) {
+    equal(typeof node.shortestPaths, "object",
+          "Should have shortest paths");
+    equal(typeof node.shortestPaths.nodes, "object",
+          "Should have shortest paths' nodes");
+    equal(typeof node.shortestPaths.edges, "object",
+          "Should have shortest paths' edges");
+  }
+
   // Next, test getting a subset of children available.
   const secondResponse = yield client.getImmediatelyDominated({
     dominatorTreeId,
     breakdown,
     nodeId: partialTree.nodeId,
     startIndex: 0,
     maxCount: Infinity
   });
 
   ok(Array.isArray(secondResponse.nodes));
   ok(secondResponse.nodes.every(node => node.parentId === partialTree.nodeId));
   ok(!secondResponse.moreChildrenAvailable);
   equal(secondResponse.path.length, 1);
   equal(secondResponse.path[0], partialTree.nodeId);
 
+  for (let node of secondResponse.nodes) {
+    equal(typeof node.shortestPaths, "object",
+          "Should have shortest paths");
+    equal(typeof node.shortestPaths.nodes, "object",
+          "Should have shortest paths' nodes");
+    equal(typeof node.shortestPaths.edges, "object",
+          "Should have shortest paths' edges");
+  }
+
   client.destroy();
 });
new file mode 100644
--- /dev/null
+++ b/devtools/shared/heapsnapshot/tests/unit/test_deduplicatePaths_01.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test the behavior of the deduplicatePaths utility function.
+
+function edge(from, to, name) {
+  return { from, to, name };
+}
+
+function run_test() {
+  const a = 1;
+  const b = 2;
+  const c = 3;
+  const d = 4;
+  const e = 5;
+  const f = 6;
+  const g = 7;
+
+  dumpn("Single long path");
+  assertDeduplicatedPaths({
+    target: g,
+    paths: [
+      [
+        pathEntry(a, "e1"),
+        pathEntry(b, "e2"),
+        pathEntry(c, "e3"),
+        pathEntry(d, "e4"),
+        pathEntry(e, "e5"),
+        pathEntry(f, "e6"),
+      ]
+    ],
+    expectedNodes: [a, b, c, d, e, f, g],
+    expectedEdges: [
+      edge(a, b, "e1"),
+      edge(b, c, "e2"),
+      edge(c, d, "e3"),
+      edge(d, e, "e4"),
+      edge(e, f, "e5"),
+      edge(f, g, "e6"),
+    ]
+  });
+
+  dumpn("Multiple edges from and to the same nodes");
+  assertDeduplicatedPaths({
+    target: a,
+    paths: [
+      [pathEntry(b, "x")],
+      [pathEntry(b, "y")],
+      [pathEntry(b, "z")],
+    ],
+    expectedNodes: [a, b],
+    expectedEdges: [
+      edge(b, a, "x"),
+      edge(b, a, "y"),
+      edge(b, a, "z"),
+    ]
+  });
+
+  dumpn("Multiple paths sharing some nodes and edges");
+  assertDeduplicatedPaths({
+    target: g,
+    paths: [
+      [
+        pathEntry(a, "a->b"),
+        pathEntry(b, "b->c"),
+        pathEntry(c, "foo"),
+      ],
+      [
+        pathEntry(a, "a->b"),
+        pathEntry(b, "b->d"),
+        pathEntry(d, "bar"),
+      ],
+      [
+        pathEntry(a, "a->b"),
+        pathEntry(b, "b->e"),
+        pathEntry(e, "baz"),
+      ],
+    ],
+    expectedNodes: [a, b, c, d, e, g],
+    expectedEdges: [
+      edge(a, b, "a->b"),
+      edge(b, c, "b->c"),
+      edge(b, d, "b->d"),
+      edge(b, e, "b->e"),
+      edge(c, g, "foo"),
+      edge(d, g, "bar"),
+      edge(e, g, "baz"),
+    ]
+  });
+
+  dumpn("Second shortest path contains target itself");
+  assertDeduplicatedPaths({
+    target: g,
+    paths: [
+      [
+        pathEntry(a, "a->b"),
+        pathEntry(b, "b->g"),
+      ],
+      [
+        pathEntry(a, "a->b"),
+        pathEntry(b, "b->g"),
+        pathEntry(g, "g->f"),
+        pathEntry(f, "f->g"),
+      ],
+    ],
+    expectedNodes: [a, b, f, g],
+    expectedEdges: [
+      edge(a, b, "a->b"),
+      edge(b, g, "b->g"),
+      edge(g, f, "g->f"),
+      edge(f, g, "f->g"),
+    ]
+  });
+}
--- a/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini
+++ b/devtools/shared/heapsnapshot/tests/unit/xpcshell.ini
@@ -24,22 +24,24 @@ support-files =
 [test_census-tree-node-01.js]
 [test_census-tree-node-02.js]
 [test_census-tree-node-03.js]
 [test_census-tree-node-04.js]
 [test_census-tree-node-05.js]
 [test_census-tree-node-06.js]
 [test_census-tree-node-07.js]
 [test_census-tree-node-08.js]
+[test_deduplicatePaths_01.js]
 [test_DominatorTree_01.js]
 [test_DominatorTree_02.js]
 [test_DominatorTree_03.js]
 [test_DominatorTree_04.js]
 [test_DominatorTree_05.js]
 [test_DominatorTree_06.js]
+[test_DominatorTreeNode_attachShortestPaths_01.js]
 [test_DominatorTreeNode_getNodeByIdAlongPath_01.js]
 [test_DominatorTreeNode_insert_01.js]
 [test_DominatorTreeNode_insert_02.js]
 [test_DominatorTreeNode_insert_03.js]
 [test_DominatorTreeNode_LabelAndShallowSize_01.js]
 [test_DominatorTreeNode_LabelAndShallowSize_02.js]
 [test_DominatorTreeNode_LabelAndShallowSize_03.js]
 [test_DominatorTreeNode_LabelAndShallowSize_04.js]
--- a/devtools/shared/webconsole/utils.js
+++ b/devtools/shared/webconsole/utils.js
@@ -613,22 +613,21 @@ var WebConsoleUtils = {
 };
 
 exports.Utils = WebConsoleUtils;
 
 //////////////////////////////////////////////////////////////////////////
 // Localization
 //////////////////////////////////////////////////////////////////////////
 
-WebConsoleUtils.l10n = function WCU_l10n(aBundleURI)
-{
-  this._bundleUri = aBundleURI;
+WebConsoleUtils.L10n = function(bundleURI) {
+  this._bundleUri = bundleURI;
 };
 
-WebConsoleUtils.l10n.prototype = {
+WebConsoleUtils.L10n.prototype = {
   _stringBundle: null,
 
   get stringBundle()
   {
     if (!this._stringBundle) {
       this._stringBundle = Services.strings.createBundle(this._bundleUri);
     }
     return this._stringBundle;
--- a/devtools/shared/webconsole/worker-utils.js
+++ b/devtools/shared/webconsole/worker-utils.js
@@ -5,14 +5,14 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 // XXXworkers This file is loaded on the server side for worker debugging.
 // Since the server is running in the worker thread, it doesn't
 // have access to Services / Components.  This functionality
 // is stubbed out to prevent errors, and will need to implemented
 // for Bug 1209353.
 
-exports.Utils = { l10n: function() {} };
+exports.Utils = { L10n: function() {} };
 exports.ConsoleServiceListener = function() {};
 exports.ConsoleAPIListener = function() {};
 exports.addWebConsoleCommands = function() {};
 exports.ConsoleReflowListener = function() {};
 exports.CONSOLE_WORKER_IDS = [];
--- a/devtools/shared/worker/loader.js
+++ b/devtools/shared/worker/loader.js
@@ -489,16 +489,17 @@ this.worker = new WorkerDebuggerLoader({
     "rpc": rpc,
     "setImmediate": setImmediate
   },
   loadSubScript: loadSubScript,
   modules: {
     "Debugger": Debugger,
     "PromiseDebugging": PromiseDebugging,
     "Services": Object.create(null),
+    "URL": null,
     "chrome": chrome,
     "xpcInspector": xpcInspector
   },
   paths: {
     // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠
     "": "resource://gre/modules/commonjs/",
     // ⚠ DISCUSSION ON DEV-DEVELOPER-TOOLS REQUIRED BEFORE MODIFYING ⚠
     "devtools": "resource://devtools",
deleted file mode 100644
--- a/mobile/android/app/base/build.gradle
+++ /dev/null
@@ -1,149 +0,0 @@
-buildDir "${topobjdir}/gradle/build/mobile/android/base"
-
-apply plugin: 'com.android.library'
-
-android {
-    compileSdkVersion 23
-    buildToolsVersion "23.0.1"
-
-    defaultConfig {
-        targetSdkVersion 23
-        minSdkVersion 15
-        // Used by Robolectric based tests; see TestRunner.
-        buildConfigField 'String', 'BUILD_DIR', "\"${project.buildDir}\""
-    }
-
-    compileOptions {
-        sourceCompatibility JavaVersion.VERSION_1_7
-        targetCompatibility JavaVersion.VERSION_1_7
-    }
-
-    lintOptions {
-        abortOnError false
-    }
-
-    sourceSets {
-        main {
-            manifest.srcFile "${topsrcdir}/mobile/android/base/AndroidManifest.xml"
-
-            java {
-                srcDir "${topsrcdir}/mobile/android/base/java"
-                srcDir "${topsrcdir}/mobile/android/search/java"
-                srcDir "${topsrcdir}/mobile/android/javaaddons/java"
-                srcDir "${topsrcdir}/mobile/android/services/src/main/java"
-
-                if (mozconfig.substs.MOZ_ANDROID_MLS_STUMBLER) {
-                    srcDir "${topsrcdir}/mobile/android/stumbler/java"
-                }
-
-                if (!mozconfig.substs.MOZ_CRASHREPORTER) {
-                    exclude 'org/mozilla/gecko/CrashReporter.java'
-                }
-
-                if (!mozconfig.substs.MOZ_NATIVE_DEVICES) {
-                    exclude 'org/mozilla/gecko/ChromeCast.java'
-                    exclude 'org/mozilla/gecko/GeckoMediaPlayer.java'
-                    exclude 'org/mozilla/gecko/MediaPlayerManager.java'
-                }
-
-                if (mozconfig.substs.MOZ_WEBRTC) {
-                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/audio_device/android/java/src"
-                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/video_capture/android/java/src"
-                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/video_render/android/java/src"
-                }
-
-                if (mozconfig.substs.MOZ_INSTALL_TRACKING) {
-                    exclude 'org/mozilla/gecko/adjust/StubAdjustHelper.java'
-                } else {
-                    exclude 'org/mozilla/gecko/adjust/AdjustHelper.java'
-                }
-
-                srcDir "${project.buildDir}/generated/source/preprocessed_code" // See syncPreprocessedCode.
-            }
-
-            res {
-                srcDir "${topsrcdir}/${mozconfig.substs.MOZ_BRANDING_DIRECTORY}/res"
-                srcDir "${project.buildDir}/generated/source/preprocessed_resources" // See syncPreprocessedResources.
-                srcDir "${topsrcdir}/mobile/android/base/resources"
-                srcDir "${topsrcdir}/mobile/android/services/src/main/res"
-                if (mozconfig.substs.MOZ_CRASHREPORTER) {
-                    srcDir "${topsrcdir}/mobile/android/base/crashreporter/res"
-                }
-            }
-
-            assets {
-                srcDir "${topsrcdir}/mobile/android/app/assets"
-            }
-        }
-
-        test {
-            java {
-                srcDir "${topsrcdir}/mobile/android/tests/background/junit4/src"
-            }
-
-            resources {
-                srcDir "${topsrcdir}/mobile/android/tests/background/junit4/resources"
-            }
-        }
-    }
-}
-
-task syncPreprocessedCode(type: Sync, dependsOn: rootProject.generateCodeAndResources) {
-    into("${project.buildDir}/generated/source/preprocessed_code")
-    from("${topobjdir}/mobile/android/base/generated/preprocessed")
-}
-
-task syncPreprocessedResources(type: Sync, dependsOn: rootProject.generateCodeAndResources) {
-    into("${project.buildDir}/generated/source/preprocessed_resources")
-    from("${topobjdir}/mobile/android/base/res")
-}
-
-android.libraryVariants.all { variant ->
-    variant.preBuild.dependsOn syncPreprocessedCode
-    variant.preBuild.dependsOn syncPreprocessedResources
-}
-
-dependencies {
-    compile 'com.android.support:support-v4:23.0.1'
-    compile 'com.android.support:appcompat-v7:23.0.1'
-    compile 'com.android.support:recyclerview-v7:23.0.1'
-    compile 'com.android.support:design:23.0.1'
-
-    if (mozconfig.substs.MOZ_NATIVE_DEVICES) {
-        compile 'com.android.support:mediarouter-v7:23.0.1'
-        compile 'com.google.android.gms:play-services-basement:8.1.0'
-        compile 'com.google.android.gms:play-services-base:8.1.0'
-        compile 'com.google.android.gms:play-services-cast:8.1.0'
-    }
-
-    if (mozconfig.substs.MOZ_INSTALL_TRACKING) {
-        compile 'com.google.android.gms:play-services-ads:8.1.0'
-        compile 'com.google.android.gms:play-services-analytics:8.1.0'
-        compile 'com.google.android.gms:play-services-appindexing:8.1.0'
-        compile 'com.google.android.gms:play-services-basement:8.1.0'
-    }
-
-    if (mozconfig.substs.MOZ_ANDROID_GCM) {
-        compile 'com.google.android.gms:play-services-basement:8.1.0'
-        compile 'com.google.android.gms:play-services-base:8.1.0'
-        compile 'com.google.android.gms:play-services-gcm:8.1.0'
-    }
-
-    // Gradle based builds include LeakCanary. Mach based builds only include the no-op version of
-    // this library.
-    compile 'com.squareup.leakcanary:leakcanary-android:1.4-beta1'
-
-    compile project(':thirdparty')
-
-    testCompile 'junit:junit:4.12'
-    testCompile 'org.robolectric:robolectric:3.0'
-    testCompile 'org.simpleframework:simple-http:6.0.1'
-    testCompile 'org.mockito:mockito-core:1.10.19'
-}
-
-apply plugin: 'idea'
-
-idea {
-    module {
-    }
-}
--- a/mobile/android/app/build.gradle
+++ b/mobile/android/app/build.gradle
@@ -12,16 +12,18 @@ android {
         applicationId mozconfig.substs.ANDROID_PACKAGE_NAME
         testApplicationId 'org.mozilla.roboexample.test'
         testInstrumentationRunner 'org.mozilla.gecko.FennecInstrumentationTestRunner'
         manifestPlaceholders = [
             ANDROID_PACKAGE_NAME: mozconfig.substs.ANDROID_PACKAGE_NAME,
             MOZ_ANDROID_MIN_SDK_VERSION: mozconfig.substs.MOZ_ANDROID_MIN_SDK_VERSION,
             MOZ_ANDROID_SHARED_ID: "${mozconfig.substs.ANDROID_PACKAGE_NAME}.sharedID",
         ]
+        // Used by Robolectric based tests; see TestRunner.
+        buildConfigField 'String', 'BUILD_DIR', "\"${project.buildDir}\""
     }
 
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_1_7
         targetCompatibility JavaVersion.VERSION_1_7
     }
  
     dexOptions {
@@ -59,23 +61,76 @@ android {
         }
         // Automation builds.
         automation {
         }
     }
 
     sourceSets {
         main {
-            manifest.srcFile "${topobjdir}/mobile/android/base/AndroidManifest.xml"
+            manifest.srcFile "${project.buildDir}/generated/source/preprocessed_manifest/AndroidManifest.xml"
+
+            java {
+                srcDir "${topsrcdir}/mobile/android/base/java"
+                srcDir "${topsrcdir}/mobile/android/search/java"
+                srcDir "${topsrcdir}/mobile/android/javaaddons/java"
+                srcDir "${topsrcdir}/mobile/android/services/src/main/java"
+
+                if (mozconfig.substs.MOZ_ANDROID_MLS_STUMBLER) {
+                    srcDir "${topsrcdir}/mobile/android/stumbler/java"
+                }
+
+                if (!mozconfig.substs.MOZ_CRASHREPORTER) {
+                    exclude 'org/mozilla/gecko/CrashReporter.java'
+                }
+
+                if (!mozconfig.substs.MOZ_NATIVE_DEVICES) {
+                    exclude 'org/mozilla/gecko/ChromeCast.java'
+                    exclude 'org/mozilla/gecko/GeckoMediaPlayer.java'
+                    exclude 'org/mozilla/gecko/MediaPlayerManager.java'
+                }
+
+                if (mozconfig.substs.MOZ_WEBRTC) {
+                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/audio_device/android/java/src"
+                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/video_capture/android/java/src"
+                    srcDir "${topsrcdir}/media/webrtc/trunk/webrtc/modules/video_render/android/java/src"
+                }
+
+                if (mozconfig.substs.MOZ_INSTALL_TRACKING) {
+                    exclude 'org/mozilla/gecko/adjust/StubAdjustHelper.java'
+                } else {
+                    exclude 'org/mozilla/gecko/adjust/AdjustHelper.java'
+                }
+
+                srcDir "${project.buildDir}/generated/source/preprocessed_code" // See syncPreprocessedCode.
+            }
+
+            res {
+                srcDir "${topsrcdir}/${mozconfig.substs.MOZ_BRANDING_DIRECTORY}/res"
+                srcDir "${project.buildDir}/generated/source/preprocessed_resources" // See syncPreprocessedResources.
+                srcDir "${topsrcdir}/mobile/android/base/resources"
+                srcDir "${topsrcdir}/mobile/android/services/src/main/res"
+                if (mozconfig.substs.MOZ_CRASHREPORTER) {
+                    srcDir "${topsrcdir}/mobile/android/base/crashreporter/res"
+                }
+            }
+
             assets {
                 if (mozconfig.substs.MOZ_ANDROID_DISTRIBUTION_DIRECTORY && !mozconfig.substs.MOZ_ANDROID_PACKAGE_INSTALL_BOUNCER) {
                     // If we are packaging the bouncer, it will have the distribution, so don't put
                     // it in the main APK as well.
                     srcDir "${mozconfig.substs.MOZ_ANDROID_DISTRIBUTION_DIRECTORY}/assets"
                 }
+                srcDir "${topsrcdir}/mobile/android/app/assets"
+            }
+        }
+
+        test {
+            java {
+                srcDir "${topsrcdir}/mobile/android/tests/background/junit4/src"
             }
         }
 
         androidTest {
             java {
                 srcDir "${topsrcdir}/mobile/android/tests/browser/robocop/src"
                 srcDir "${topsrcdir}/mobile/android/tests/background/junit3/src"
                 srcDir "${topsrcdir}/mobile/android/tests/browser/junit3/src"
@@ -84,20 +139,65 @@ android {
             res {
                 srcDir "${topsrcdir}/mobile/android/tests/browser/robocop/res"
             }
             assets {
                 srcDir "${topsrcdir}/mobile/android/tests/browser/robocop/assets"
             }
         }
     }
+
+    testOptions {
+        unitTests.all {
+            // We'd like to use (Runtime.runtime.availableProcessors()/2), but
+            // we have tests that start test servers and the bound ports
+            // collide.  We'll fix this soon to have much faster test cycles.
+            maxParallelForks 1
+        }
+    }
 }
 
 dependencies {
-    compile project(':base')
+    compile 'com.android.support:support-v4:23.0.1'
+    compile 'com.android.support:appcompat-v7:23.0.1'
+    compile 'com.android.support:recyclerview-v7:23.0.1'
+    compile 'com.android.support:design:23.0.1'
+
+    if (mozconfig.substs.MOZ_NATIVE_DEVICES) {
+        compile 'com.android.support:mediarouter-v7:23.0.1'
+        compile 'com.google.android.gms:play-services-basement:8.1.0'
+        compile 'com.google.android.gms:play-services-base:8.1.0'
+        compile 'com.google.android.gms:play-services-cast:8.1.0'
+    }
+
+    if (mozconfig.substs.MOZ_INSTALL_TRACKING) {
+        compile 'com.google.android.gms:play-services-ads:8.1.0'
+        compile 'com.google.android.gms:play-services-analytics:8.1.0'
+        compile 'com.google.android.gms:play-services-appindexing:8.1.0'
+        compile 'com.google.android.gms:play-services-basement:8.1.0'
+    }
+
+    if (mozconfig.substs.MOZ_ANDROID_GCM) {
+        compile 'com.google.android.gms:play-services-basement:8.1.0'
+        compile 'com.google.android.gms:play-services-base:8.1.0'
+        compile 'com.google.android.gms:play-services-gcm:8.1.0'
+    }
+
+    // Gradle based builds include LeakCanary.  Gradle based tests include the no-op version.  Mach
+    // based builds only include the no-op version of this library.
+    compile 'com.squareup.leakcanary:leakcanary-android:1.4-beta1'
+    testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta1'
+
+    compile project(':thirdparty')
+
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.robolectric:robolectric:3.0'
+    testCompile 'org.simpleframework:simple-http:6.0.1'
+    testCompile 'org.mockito:mockito-core:1.10.19'
+
     // Including the Robotium JAR directly can cause issues with dexing.
     androidTestCompile 'com.jayway.android.robotium:robotium-solo:4.3.1'
 }
 
 task syncOmnijarFromDistDir(type: Sync) {
     into("${project.buildDir}/generated/omnijar")
     from("${topobjdir}/dist/fennec/assets") {
         include 'omni.ja'
@@ -123,16 +223,42 @@ task checkAssetsExistInDistDir<< {
 
 task syncAssetsFromDistDir(type: Sync, dependsOn: checkAssetsExistInDistDir) {
     into("${project.buildDir}/generated/assets")
     from("${topobjdir}/dist/fennec/assets") {
         exclude 'omni.ja'
     }
 }
 
+task syncPreprocessedCode(type: Sync, dependsOn: rootProject.generateCodeAndResources) {
+    into("${project.buildDir}/generated/source/preprocessed_code")
+    from("${topobjdir}/mobile/android/base/generated/preprocessed")
+}
+
+// The localization system uses the moz.build preprocessor to interpolate a .dtd
+// file of XML entity definitions into an XML file of elements referencing those
+// entities.  (Each locale produces its own .dtd file, backstopped by the en-US
+// .dtd file in tree.)  Android Studio (and IntelliJ) don't handle these inline
+// entities smoothly.  This filter merely expands the entities in place, making
+// them appear properly throughout the IDE.
+class ExpandXMLEntitiesFilter extends FilterReader {
+    ExpandXMLEntitiesFilter(Reader input) {
+        // Extremely inefficient, but whatever.
+        super(new StringReader(groovy.xml.XmlUtil.serialize(new XmlParser(false, false, true).parse(input))))
+    }
+}
+
+task syncPreprocessedResources(type: Sync, dependsOn: rootProject.generateCodeAndResources) {
+    into("${project.buildDir}/generated/source/preprocessed_resources")
+    from("${topobjdir}/mobile/android/base/res")
+    filesMatching('**/strings.xml') {
+        filter(ExpandXMLEntitiesFilter)
+    }
+}
+
 // The omnijar inputs are listed as resource directory inputs to a dummy JAR.
 // That arrangement labels them nicely in IntelliJ.  See the comment in the
 // :omnijar project for more context.
 evaluationDependsOn(':omnijar')
 
 task buildOmnijar(type:Exec) {
     dependsOn rootProject.generateCodeAndResources
 
@@ -155,17 +281,31 @@ task buildOmnijar(type:Exec) {
     errorOutput = standardOutput
     doLast {
         if (execResult.exitValue != 0) {
             throw new GradleException("Process '${commandLine}' finished with non-zero exit value ${execResult.exitValue}:\n\n${standardOutput.toString()}")
         }
     }
 }
 
+// It's not easy -- see the backout in Bug 1242213 -- to change the <manifest>
+// package for Fennec.  Gradle has grown a mechanism to achieve what we want for
+// Fennec, however, with applicationId.  To use the same manifest as moz.build,
+// we replace the package with org.mozilla.gecko (the eventual package) here.
+task rewriteManifestPackage(type: Copy, dependsOn: rootProject.generateCodeAndResources) {
+    into("${project.buildDir}/generated/source/preprocessed_manifest")
+    from("${topobjdir}/mobile/android/base/AndroidManifest.xml")
+    filter { it.replaceFirst(/package=".*?"/, 'package="org.mozilla.gecko"') }
+}
+
 android.applicationVariants.all { variant ->
+    variant.preBuild.dependsOn rewriteManifestPackage
+    variant.preBuild.dependsOn syncPreprocessedCode
+    variant.preBuild.dependsOn syncPreprocessedResources
+
     // Like 'local' or 'localOld'.
     def productFlavor = variant.productFlavors[0].name
     // Like 'debug' or 'release'.
     def buildType = variant.buildType.name
 
     // We insert omni.ja and the .so libraries into all local builds.
     if (!productFlavor.startsWith('local')) {
         return
rename from mobile/android/app/base/lint.xml
rename to mobile/android/app/lint.xml
rename from mobile/android/tests/background/junit4/resources/robolectric.properties
rename to mobile/android/app/src/main/resources/robolectric.properties
new file mode 100644
--- /dev/null
+++ b/mobile/android/app/src/test/java/org/mozilla/gecko/TestGeckoApplication.java
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko;
+
+import android.app.Application;
+
+import org.robolectric.TestLifecycleApplication;
+
+import java.lang.reflect.Method;
+
+/**
+ * GeckoApplication isn't test-lifecycle friendly: onCreate is called multiple times, which
+ * re-registers Gecko event listeners, which fails.  This class is magically named so that
+ * Robolectric uses it instead of the application defined in the Android manifest.  See
+ * http://robolectric.blogspot.ca/2013/04/the-test-lifecycle-in-20.html.
+ */
+public class TestGeckoApplication extends Application implements TestLifecycleApplication {
+  @Override public void beforeTest(Method method) {
+  }
+
+  @Override public void prepareTest(Object test) {
+  }
+
+  @Override public void afterTest(Method method) {
+  }
+}
deleted file mode 100644
--- a/mobile/android/base/AndroidManifest.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    package="org.mozilla.gecko">
-<!-- THIS IS NOT THE REAL MANIFEST!  This is for Gradle only.  See
-     AndroidManifest.xml.in. -->
-
-</manifest>
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -907,16 +907,17 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     'sync/net/AbstractBearerTokenAuthHeaderProvider.java',
     'sync/net/AuthHeaderProvider.java',
     'sync/net/BaseResource.java',
     'sync/net/BaseResourceDelegate.java',
     'sync/net/BasicAuthHeaderProvider.java',
     'sync/net/BearerAuthHeaderProvider.java',
     'sync/net/BrowserIDAuthHeaderProvider.java',
     'sync/net/ConnectionMonitorThread.java',
+    'sync/net/GzipNonChunkedCompressingEntity.java',
     'sync/net/HandleProgressException.java',
     'sync/net/HawkAuthHeaderProvider.java',
     'sync/net/HMACAuthHeaderProvider.java',
     'sync/net/HttpResponseObserver.java',
     'sync/net/MozResponse.java',
     'sync/net/Resource.java',
     'sync/net/ResourceDelegate.java',
     'sync/net/SRPConstants.java',
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -584,17 +584,17 @@ public class BrowserApp extends GeckoApp
         // layout, which GeckoApp takes care of.
         ((GeckoApplication) getApplication()).prepareLightweightTheme();
         super.onCreate(savedInstanceState);
 
         final Context appContext = getApplicationContext();
 
         if (AppConstants.MOZ_SWITCHBOARD) {
             // Initializes the default URLs the first time.
-            SwitchBoard.initDefaultServerUrls("https://switchboard-server.dev.mozaws.net/urls", "https://switchboard-server.dev.mozaws.net/v1", true);
+            SwitchBoard.initDefaultServerUrls("https://switchboard.services.mozilla.com/urls", "https://switchboard.services.mozilla.com/v1", true);
 
             final String switchboardUUID = ContextUtils.getStringExtra(intent, INTENT_KEY_SWITCHBOARD_UUID);
             SwitchBoard.setUUIDFromExtra(switchboardUUID);
 
             // Looks at the server if there are changes in the server URL that should be used in the future
             new AsyncConfigLoader(this, AsyncConfigLoader.UPDATE_SERVER, switchboardUUID).execute();
 
             // Loads the actual config. This can be done on app start or on app onResume() depending
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
@@ -195,16 +195,18 @@ public class TelemetryUploadService exte
             resource = new BaseResource(ping.getURL());
         } catch (final URISyntaxException e) {
             Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning.");
             return;
         }
 
         delegate.setResource(resource);
         resource.delegate = delegate;
+        resource.setShouldCompressUploadedEntity(true);
+        resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking.
 
         // We're in a background thread so we don't have any reason to do this asynchronously.
         // If we tried, onStartCommand would return and IntentService might stop itself before we finish.
         resource.postBlocking(ping.getPayload());
     }
 
     /**
      * @return the profile creation date in the format expected by TelemetryPingGenerator.
--- a/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
+++ b/mobile/android/base/java/org/mozilla/gecko/toolbar/ToolbarDisplayLayout.java
@@ -22,29 +22,26 @@ import org.mozilla.gecko.animation.Prope
 import org.mozilla.gecko.animation.ViewHelper;
 import org.mozilla.gecko.toolbar.BrowserToolbarTabletBase.ForwardButtonAnimation;
 import org.mozilla.gecko.util.ColorUtils;
 import org.mozilla.gecko.util.StringUtils;
 import org.mozilla.gecko.widget.themed.ThemedLinearLayout;
 import org.mozilla.gecko.widget.themed.ThemedTextView;
 
 import android.content.Context;
-import android.content.res.Resources;
 import android.os.SystemClock;
+import android.support.annotation.Nullable;
 import android.text.Spannable;
 import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
 import android.text.style.ForegroundColorSpan;
 import android.util.AttributeSet;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.View;
-import android.view.animation.AlphaAnimation;
-import android.view.animation.Animation;
-import android.view.animation.TranslateAnimation;
 import android.widget.Button;
 import android.widget.ImageButton;
 
 /**
 * {@code ToolbarDisplayLayout} is the UI for when the toolbar is in
 * display state. It's used to display the state of the currently selected
 * tab. It should always be updated through a single entry point
 * (updateFromTab) and should never track any tab events or gecko messages
@@ -79,21 +76,21 @@ public class ToolbarDisplayLayout extend
     }
 
     private enum UIMode {
         PROGRESS,
         DISPLAY
     }
 
     interface OnStopListener {
-        public Tab onStop();
+        Tab onStop();
     }
 
     interface OnTitleChangeListener {
-        public void onTitleChange(CharSequence title);
+        void onTitleChange(CharSequence title);
     }
 
     private final BrowserApp mActivity;
 
     private UIMode mUiMode;
 
     private boolean mIsAttached;
 
@@ -110,59 +107,53 @@ public class ToolbarDisplayLayout extend
     private final PageActionLayout mPageActionLayout;
 
     private final SiteIdentityPopup mSiteIdentityPopup;
     private int mSecurityImageLevel;
 
     // Security level constants, which map to the icons / levels defined in:
     // http://mxr.mozilla.org/mozilla-central/source/mobile/android/base/java/org/mozilla/gecko/resources/drawable/site_security_level.xml
     // Default level (unverified pages) - globe icon:
-    private final int LEVEL_DEFAULT_GLOBE = 0;
+    private static final int LEVEL_DEFAULT_GLOBE = 0;
     // Levels for displaying Mixed Content state icons.
-    private final int LEVEL_WARNING_MINOR = 3;
-    private final int LEVEL_LOCK_DISABLED = 4;
+    private static final int LEVEL_WARNING_MINOR = 3;
+    private static final int LEVEL_LOCK_DISABLED = 4;
     // Levels for displaying Tracking Protection state icons.
-    private final int LEVEL_SHIELD_ENABLED = 5;
-    private final int LEVEL_SHIELD_DISABLED = 6;
+    private static final int LEVEL_SHIELD_ENABLED = 5;
+    private static final int LEVEL_SHIELD_DISABLED = 6;
 
-    private final ForegroundColorSpan mUrlColor;
     private final ForegroundColorSpan mBlockedColor;
-    private final ForegroundColorSpan mDomainColor;
-    private final ForegroundColorSpan mPrivateDomainColor;
 
     public ToolbarDisplayLayout(Context context, AttributeSet attrs) {
         super(context, attrs);
         setOrientation(HORIZONTAL);
 
         mActivity = (BrowserApp) context;
 
         LayoutInflater.from(context).inflate(R.layout.toolbar_display_layout, this);
 
         mTitle = (ThemedTextView) findViewById(R.id.url_bar_title);
         mTitlePadding = mTitle.getPaddingRight();
 
-        final Resources res = getResources();
-
-        mUrlColor = new ForegroundColorSpan(ColorUtils.getColor(context, R.color.url_bar_urltext));
         mBlockedColor = new ForegroundColorSpan(ColorUtils.getColor(context, R.color.url_bar_blockedtext));
-        mDomainColor = new ForegroundColorSpan(ColorUtils.getColor(context, R.color.url_bar_domaintext));
-        mPrivateDomainColor = new ForegroundColorSpan(ColorUtils.getColor(context, R.color.url_bar_domaintext_private));
 
         mSiteSecurity = (ImageButton) findViewById(R.id.site_security);
 
         mSiteIdentityPopup = new SiteIdentityPopup(mActivity);
         mSiteIdentityPopup.setAnchor(this);
         mSiteIdentityPopup.setOnVisibilityChangeListener(mActivity);
 
         mStop = (ImageButton) findViewById(R.id.stop);
         mPageActionLayout = (PageActionLayout) findViewById(R.id.page_action_layout);
     }
 
     @Override
     public void onAttachedToWindow() {
+        super.onAttachedToWindow();
+
         mIsAttached = true;
 
         mSiteSecurity.setOnClickListener(new Button.OnClickListener() {
             @Override
             public void onClick(View view) {
                 mSiteIdentityPopup.show();
             }
         });
@@ -239,53 +230,50 @@ public class ToolbarDisplayLayout extend
         }
 
         final String url = tab.getURL();
 
         // Setting a null title will ensure we just see the
         // "Enter Search or Address" placeholder text.
         if (AboutPages.isTitlelessAboutPage(url)) {
             setTitle(null);
+            setContentDescription(null);
             return;
         }
 
         // Show the about:blocked page title in red, regardless of prefs
         if (tab.getErrorType() == Tab.ErrorType.BLOCKED) {
             final String title = tab.getDisplayTitle();
 
             final SpannableStringBuilder builder = new SpannableStringBuilder(title);
             builder.setSpan(mBlockedColor, 0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
 
             setTitle(builder);
+            setContentDescription(null);
             return;
         }
 
+        final String baseDomain = tab.getBaseDomain();
+
         String strippedURL = stripAboutReaderURL(url);
 
         if (mPrefs.shouldTrimUrls()) {
             strippedURL = StringUtils.stripCommonSubdomains(StringUtils.stripScheme(strippedURL));
         }
 
-        CharSequence title = strippedURL;
-
-        final String baseDomain = tab.getBaseDomain();
-        if (!TextUtils.isEmpty(baseDomain)) {
-            final SpannableStringBuilder builder = new SpannableStringBuilder(title);
+        // This value is not visible to screen readers but we rely on it when running UI tests. Screen
+        // readers will instead focus BrowserToolbar and read the "base domain" from there. UI tests
+        // will read the content description to obtain the full URL for performing assertions.
+        setContentDescription(strippedURL);
 
-            int index = title.toString().indexOf(baseDomain);
-            if (index > -1) {
-                builder.setSpan(mUrlColor, 0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
-                builder.setSpan(tab.isPrivate() ? mPrivateDomainColor : mDomainColor,
-                                index, index + baseDomain.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
-
-                title = builder;
-            }
+        if (!TextUtils.isEmpty(baseDomain)) {
+            setTitle(baseDomain);
+        } else {
+            setTitle(strippedURL);
         }
-
-        setTitle(title);
     }
 
     private String stripAboutReaderURL(final String url) {
         if (!AboutPages.isAboutReader(url)) {
             return url;
         }
 
         return ReaderModeUtils.getUrlFromAboutReader(url);
@@ -349,23 +337,23 @@ public class ToolbarDisplayLayout extend
             mSecurityImageLevel = imageLevel;
             mSiteSecurity.setImageLevel(mSecurityImageLevel);
             updatePageActions();
         }
 
         mTrackingProtectionEnabled = trackingMode == TrackingMode.TRACKING_CONTENT_BLOCKED;
     }
 
-    private void updateProgress(Tab tab) {
+    private void updateProgress(@Nullable Tab tab) {
         final boolean shouldShowThrobber = (tab != null &&
                                             tab.getState() == Tab.STATE_LOADING);
 
         updateUiMode(shouldShowThrobber ? UIMode.PROGRESS : UIMode.DISPLAY);
 
-        if (Tab.STATE_SUCCESS == tab.getState() && mTrackingProtectionEnabled) {
+        if (tab != null && Tab.STATE_SUCCESS == tab.getState() && mTrackingProtectionEnabled) {
             mActivity.showTrackingProtectionPromptIfApplicable();
         }
     }
 
     private void updateUiMode(UIMode uiMode) {
         if (mUiMode == uiMode) {
             return;
         }
--- a/mobile/android/base/resources/values/colors.xml
+++ b/mobile/android/base/resources/values/colors.xml
@@ -105,19 +105,16 @@
   <color name="url_bar_text_highlight_pb">#FFD06BFF</color>
   <color name="tab_row_pressed">#4D000000</color>
 
   <color name="textbox_background">#FFF</color>
   <color name="textbox_background_disabled">#DDD</color>
   <color name="textbox_stroke">#000</color>
   <color name="textbox_stroke_disabled">#666</color>
 
-  <color name="url_bar_urltext">#A6A6A6</color>
-  <color name="url_bar_domaintext">#000</color>
-  <color name="url_bar_domaintext_private">#FFF</color>
   <color name="url_bar_blockedtext">#b14646</color>
   <color name="url_bar_shadow">#12000000</color>
 
   <color name="home_button_bar_bg">#FFF5F7F9</color>
 
   <color name="panel_image_item_background">#D1D9E1</color>
   <color name="panel_icon_item_title_background">#32000000</color>
   <color name="panel_tab_text_normal">#FFBFBFBF</color>
--- a/mobile/android/mach_commands.py
+++ b/mobile/android/mach_commands.py
@@ -52,17 +52,27 @@ class MachCommands(MachCommandBase):
     @Command('gradle', category='devenv',
         description='Run gradle.',
         conditions=[conditions.is_android])
     @CommandArgument('args', nargs=argparse.REMAINDER)
     def gradle(self, args):
         # Avoid logging the command
         self.log_manager.terminal_handler.setLevel(logging.CRITICAL)
 
+        # We force the Gradle JVM to run with the UTF-8 encoding, since we
+        # filter strings.xml, which is really UTF-8; the ellipsis character is
+        # replaced with ??? in some encodings (including ASCII).  It's not yet
+        # possible to filter with encodings in Gradle
+        # (https://github.com/gradle/gradle/pull/520) and it's challenging to
+        # do our filtering with Gradle's Ant support.  Moreover, all of the
+        # Android tools expect UTF-8: see
+        # http://tools.android.com/knownissues/encoding.  See
+        # http://stackoverflow.com/a/21267635 for discussion of this approach.
         return self.run_process([self.substs['GRADLE']] + args,
+            append_env={'GRADLE_OPTS': '-Dfile.encoding=utf-8'},
             pass_thru=True, # Allow user to run gradle interactively.
             ensure_exit_code=False, # Don't throw on non-zero exit code.
             cwd=mozpath.join(self.topsrcdir))
 
     @Command('gradle-install', category='devenv',
         conditions=[REMOVED])
     def gradle_install(self):
         pass
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
@@ -25,16 +25,17 @@ import org.mozilla.gecko.background.comm
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 
 import ch.boye.httpclientandroidlib.Header;
 import ch.boye.httpclientandroidlib.HttpEntity;
 import ch.boye.httpclientandroidlib.HttpResponse;
 import ch.boye.httpclientandroidlib.HttpVersion;
 import ch.boye.httpclientandroidlib.client.AuthCache;
 import ch.boye.httpclientandroidlib.client.ClientProtocolException;
+import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity;
 import ch.boye.httpclientandroidlib.client.methods.HttpDelete;
 import ch.boye.httpclientandroidlib.client.methods.HttpGet;
 import ch.boye.httpclientandroidlib.client.methods.HttpPatch;
 import ch.boye.httpclientandroidlib.client.methods.HttpPost;
 import ch.boye.httpclientandroidlib.client.methods.HttpPut;
 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
 import ch.boye.httpclientandroidlib.client.methods.HttpUriRequest;
 import ch.boye.httpclientandroidlib.client.protocol.ClientContext;
@@ -75,16 +76,20 @@ public class BaseResource implements Res
 
   protected final URI uri;
   protected BasicHttpContext context;
   protected DefaultHttpClient client;
   public    ResourceDelegate delegate;
   protected HttpRequestBase request;
   public final String charset = "utf-8";
 
+  private boolean shouldGzipCompress = false;
+  // A hint whether uploaded payloads are chunked. Default true to use GzipCompressingEntity, which is built-in functionality.
+  private boolean shouldChunkUploadsHint = true;
+
   /**
    * We have very few writes (observers tend to be installed around sync
    * sessions) and many iterations (every HTTP request iterates observers), so
    * CopyOnWriteArrayList is a reasonable choice.
    */
   protected static final CopyOnWriteArrayList<WeakReference<HttpResponseObserver>>
     httpResponseObservers = new CopyOnWriteArrayList<>();
 
@@ -158,16 +163,44 @@ public class BaseResource implements Res
   }
 
   @Override
   public String getHostname() {
     return this.getURI().getHost();
   }
 
   /**
+   * Causes the Resource to compress the uploaded entity payload in requests with payloads (e.g. post, put)
+   * @param shouldCompress true if the entity should be compressed, false otherwise
+   */
+  public void setShouldCompressUploadedEntity(final boolean shouldCompress) {
+    shouldGzipCompress = shouldCompress;
+  }
+
+  /**
+   * Causes the Resource to chunk the uploaded entity payload in requests with payloads (e.g. post, put).
+   * Note: this flag is only a hint - chunking is not guaranteed.
+   *
+   * Chunking is currently supported with gzip compression.
+   *
+   * @param shouldChunk true if the transfer should be chunked, false otherwise
+   */
+  public void setShouldChunkUploadsHint(final boolean shouldChunk) {
+    shouldChunkUploadsHint = shouldChunk;
+  }
+
+  private HttpEntity getMaybeCompressedEntity(final HttpEntity entity) {
+    if (!shouldGzipCompress) {
+      return entity;
+    }
+
+    return shouldChunkUploadsHint ? new GzipCompressingEntity(entity) : new GzipNonChunkedCompressingEntity(entity);
+  }
+
+  /**
    * This shuts up HttpClient, which will otherwise debug log about there
    * being no auth cache in the context.
    */
   private static void addAuthCacheToContext(HttpUriRequest request, HttpContext context) {
     AuthCache authCache = new BasicAuthCache();                // Not thread safe.
     context.setAttribute(ClientContext.AUTH_CACHE, authCache);
   }
 
@@ -360,32 +393,35 @@ public class BaseResource implements Res
   public void delete() {
     Logger.debug(LOG_TAG, "HTTP DELETE " + this.uri.toASCIIString());
     this.go(new HttpDelete(this.uri));
   }
 
   @Override
   public void post(HttpEntity body) {
     Logger.debug(LOG_TAG, "HTTP POST " + this.uri.toASCIIString());
+    body = getMaybeCompressedEntity(body);
     HttpPost request = new HttpPost(this.uri);
     request.setEntity(body);
     this.go(request);
   }
 
   @Override
   public void patch(HttpEntity body) {
     Logger.debug(LOG_TAG, "HTTP PATCH " + this.uri.toASCIIString());
+    body = getMaybeCompressedEntity(body);
     HttpPatch request = new HttpPatch(this.uri);
     request.setEntity(body);
     this.go(request);
   }
 
   @Override
   public void put(HttpEntity body) {
     Logger.debug(LOG_TAG, "HTTP PUT " + this.uri.toASCIIString());
+    body = getMaybeCompressedEntity(body);
     HttpPut request = new HttpPut(this.uri);
     request.setEntity(body);
     this.go(request);
   }
 
   protected static StringEntity stringEntityWithContentTypeApplicationJSON(String s) {
     StringEntity e = new StringEntity(s, "UTF-8");
     e.setContentType("application/json");
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/GzipNonChunkedCompressingEntity.java
@@ -0,0 +1,92 @@
+/* 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/. */
+
+package org.mozilla.gecko.sync.net;
+
+import ch.boye.httpclientandroidlib.HttpEntity;
+import ch.boye.httpclientandroidlib.client.entity.GzipCompressingEntity;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Wrapping entity that compresses content when {@link #writeTo writing}.
+ *
+ * This differs from {@link GzipCompressingEntity} in that it does not chunk
+ * the sent data, therefore replacing the "Transfer-Encoding" HTTP header with
+ * the "Content-Length" header required by some servers.
+ *
+ * However, to measure the content length, the gzipped content will be temporarily
+ * stored in memory so be careful what content you send!
+ */
+public class GzipNonChunkedCompressingEntity extends GzipCompressingEntity {
+    final int MAX_BUFFER_SIZE_BYTES = 10 * 1000 * 1000; // 10 MB.
+
+    private byte[] gzippedContent;
+
+    public GzipNonChunkedCompressingEntity(final HttpEntity entity) {
+        super(entity);
+    }
+
+    /**
+     * @return content length for gzipped content or -1 if there is an error
+     */
+    @Override
+    public long getContentLength() {
+        try {
+            initBuffer();
+        } catch (final IOException e) {
+            // GzipCompressingEntity always returns -1 in which case a 'Content-Length' header is omitted.
+            // Presumably, without it the request will fail (either client-side or server-side).
+            return -1;
+        }
+        return gzippedContent.length;
+    }
+
+    @Override
+    public boolean isChunked() {
+        // "Content-Length" & chunked encoding are mutually exclusive:
+        //   https://en.wikipedia.org/wiki/Chunked_transfer_encoding
+        return false;
+    }
+
+    @Override
+    public InputStream getContent() throws IOException {
+        initBuffer();
+        return new ByteArrayInputStream(gzippedContent);
+    }
+
+    @Override
+    public void writeTo(final OutputStream outstream) throws IOException {
+        initBuffer();
+        outstream.write(gzippedContent);
+    }
+
+    private void initBuffer() throws IOException {
+        if (gzippedContent != null) {
+            return;
+        }
+
+        final long unzippedContentLength = wrappedEntity.getContentLength();
+        if (unzippedContentLength > MAX_BUFFER_SIZE_BYTES) {
+            throw new IOException(
+                    "Wrapped entity content length, " + unzippedContentLength + " bytes, exceeds max: " + MAX_BUFFER_SIZE_BYTES);
+        }
+
+        // The buffer size needed by the gzipped content should be smaller than this,
+        // but it's more efficient just to allocate one larger buffer than allocate
+        // twice if the gzipped content is too large for the default buffer.
+        final ByteArrayOutputStream s = new ByteArrayOutputStream((int) unzippedContentLength);
+        try {
+            super.writeTo(s);
+        } finally {
+            s.close();
+        }
+
+        gzippedContent = s.toByteArray();
+    }
+}
--- a/mobile/android/tests/background/junit3/background_junit3_sources.mozbuild
+++ b/mobile/android/tests/background/junit3/background_junit3_sources.mozbuild
@@ -23,17 +23,16 @@ background_junit3_sources = [
     'src/org/mozilla/gecko/background/db/TestPasswordsRepository.java',
     'src/org/mozilla/gecko/background/fxa/authenticator/TestAccountPickler.java',
     'src/org/mozilla/gecko/background/fxa/TestAccountLoader.java',
     'src/org/mozilla/gecko/background/fxa/TestBrowserIDKeyPairGeneration.java',
     'src/org/mozilla/gecko/background/helpers/AndroidSyncTestCase.java',
     'src/org/mozilla/gecko/background/helpers/BackgroundServiceTestCase.java',
     'src/org/mozilla/gecko/background/helpers/DBHelpers.java',
     'src/org/mozilla/gecko/background/helpers/DBProviderTestCase.java',
-    'src/org/mozilla/gecko/background/helpers/FakeProfileTestCase.java',
     'src/org/mozilla/gecko/background/nativecode/test/TestNativeCrypto.java',
     'src/org/mozilla/gecko/background/sync/AndroidSyncTestCaseWithAccounts.java',
     'src/org/mozilla/gecko/background/sync/helpers/BookmarkHelpers.java',
     'src/org/mozilla/gecko/background/sync/helpers/DefaultBeginDelegate.java',
     'src/org/mozilla/gecko/background/sync/helpers/DefaultCleanDelegate.java',
     'src/org/mozilla/gecko/background/sync/helpers/DefaultDelegate.java',
     'src/org/mozilla/gecko/background/sync/helpers/DefaultFetchDelegate.java',
     'src/org/mozilla/gecko/background/sync/helpers/DefaultFinishDelegate.java',
deleted file mode 100644
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/helpers/FakeProfileTestCase.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-package org.mozilla.gecko.background.helpers;
-
-import java.io.File;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.test.ActivityInstrumentationTestCase2;
-import java.util.UUID;
-
-import org.mozilla.gecko.background.common.GlobalConstants;
-
-import org.mozilla.gecko.background.common.TestUtils;
-
-public abstract class FakeProfileTestCase extends ActivityInstrumentationTestCase2<Activity> {
-
-  protected Context context;
-  protected File fakeProfileDirectory;
-  private String sharedPrefsName;
-
-  public FakeProfileTestCase() {
-    super(Activity.class);
-  }
-
-  /**
-   * Returns the profile cache suffix. This is computed once for each test function (in setUp()).
-   * Note that the return value is not cached.
-   */
-  protected String getCacheSuffix() {
-    return this.getClass().getName() + "-" + System.currentTimeMillis();
-  }
-
-  @Override
-  protected void setUp() throws Exception {
-    super.setUp();
-    context = getInstrumentation().getTargetContext();
-    File cache = context.getCacheDir();
-    fakeProfileDirectory = new File(cache.getAbsolutePath() + getCacheSuffix());
-    if (fakeProfileDirectory.exists()) {
-      TestUtils.deleteDirectoryRecursively(fakeProfileDirectory);
-    }
-    if (!fakeProfileDirectory.mkdir()) {
-      throw new IllegalStateException("Could not create temporary directory.");
-    }
-    // Class name of the form: ActivityInstrumentationTestCase2$FakeProfileTestCase$extendingClass.
-    sharedPrefsName = this.getClass().getName() + "-" + UUID.randomUUID();
-  }
-
-  @Override
-  protected void tearDown() throws Exception {
-    TestUtils.deleteDirectoryRecursively(fakeProfileDirectory);
-    super.tearDown();
-  }
-
-  public SharedPreferences getSharedPreferences() {
-    return context.getSharedPreferences(sharedPrefsName, GlobalConstants.SHARED_PREFERENCES_MODE);
-  }
-}
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/BaseTest.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/BaseTest.java
@@ -244,16 +244,32 @@ abstract class BaseTest extends BaseRobo
 
         @Override
         public boolean isSatisfied() {
             String textValue = mTextView.getText().toString();
             return mExpected.equals(textValue);
         }
     }
 
+    class VerifyContentDescription implements Condition {
+        private final View view;
+        private final String expected;
+
+        public VerifyContentDescription(View view, String expected) {
+            this.view = view;
+            this.expected = expected;
+        }
+
+        @Override
+        public boolean isSatisfied() {
+            final CharSequence actual = view.getContentDescription();
+            return TextUtils.equals(actual, expected);
+        }
+    }
+
     protected final String getAbsoluteUrl(String url) {
         return mBaseHostnameUrl + "/" + url.replaceAll("(^/)", "");
     }
 
     protected final String getAbsoluteRawUrl(String url) {
         return mBaseIpUrl + "/" + url.replaceAll("(^/)", "");
     }
 
@@ -466,16 +482,43 @@ abstract class BaseTest extends BaseRobo
             // Wait for the title to make sure it has been displayed in case the view
             // does not update fast enough
             waitForCondition(new VerifyTextViewText(urlBarTitle, expected), MAX_WAIT_VERIFY_PAGE_TITLE_MS);
             pageTitle = urlBarTitle.getText().toString();
         }
         mAsserter.is(pageTitle, expected, "Page title is correct");
     }
 
+    public final void verifyUrlInContentDescription(String url) {
+        mAsserter.isnot(url, null, "The url argument is not null");
+
+        final String expected;
+        if (mStringHelper.ABOUT_HOME_URL.equals(url)) {
+            expected = mStringHelper.ABOUT_HOME_TITLE;
+        } else if (url.startsWith(URL_HTTP_PREFIX)) {
+            expected = url.substring(URL_HTTP_PREFIX.length());
+        } else {
+            expected = url;
+        }
+
+        final View urlDisplayLayout = mSolo.getView(R.id.display_layout);
+        assertNotNull("ToolbarDisplayLayout is not null", urlDisplayLayout);
+
+        String actualUrl = null;
+
+        // Wait for the title to make sure it has been displayed in case the view
+        // does not update fast enough
+        waitForCondition(new VerifyContentDescription(urlDisplayLayout, expected), MAX_WAIT_VERIFY_PAGE_TITLE_MS);
+        if (urlDisplayLayout.getContentDescription() != null) {
+            actualUrl = urlDisplayLayout.getContentDescription().toString();
+        }
+
+        mAsserter.is(actualUrl, expected, "Url is correct");
+    }
+
     public final void verifyTabCount(int expectedTabCount) {
         Element tabCount = mDriver.findElement(getActivity(), R.id.tabs_counter);
         String tabCountText = tabCount.getText();
         int tabCountInt = Integer.parseInt(tabCountText);
         mAsserter.is(tabCountInt, expectedTabCount, "The correct number of tabs are opened");
     }
 
     public void verifyPinned(final boolean isPinned, final String gridItemTitle) {
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/components/AppMenuComponent.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/components/AppMenuComponent.java
@@ -5,16 +5,17 @@
 package org.mozilla.gecko.tests.components;
 
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertEquals;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertFalse;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertNotNull;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertTrue;
 
 import java.util.List;
+import java.util.concurrent.Callable;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.menu.MenuItemActionBar;
 import org.mozilla.gecko.menu.MenuItemDefault;
 import org.mozilla.gecko.tests.UITestContext;
 import org.mozilla.gecko.tests.helpers.DeviceHelper;
 import org.mozilla.gecko.tests.helpers.RobotiumHelper;
@@ -28,17 +29,17 @@ import android.widget.RelativeLayout;
 import com.jayway.android.robotium.solo.Condition;
 import com.jayway.android.robotium.solo.RobotiumUtils;
 import com.jayway.android.robotium.solo.Solo;
 
 /**
  * A class representing any interactions that take place on the app menu.
  */
 public class AppMenuComponent extends BaseComponent {
-    private static final long MAX_WAITTIME_FOR_MENU_UPDATE_IN_MS = 7500L;
+    private static final int MAX_WAITTIME_FOR_MENU_UPDATE_IN_MS = 7500;
 
     public enum MenuItem {
         FORWARD(R.string.forward),
         NEW_TAB(R.string.new_tab),
         PAGE(R.string.page),
         RELOAD(R.string.reload);
 
         private final int resourceID;
@@ -119,50 +120,53 @@ public class AppMenuComponent extends Ba
      * Try to find a MenuItemActionBar/MenuItemDefault with the given text set as contentDescription / text.
      *
      * When using legacy menus, make sure the menu has been opened to the appropriate level
      * (i.e. base menu or "More" menu) to ensure the appropriate menu views are in memory.
      * TODO: ^ Maybe we just need to have opened the "More" menu and the current one doesn't matter.
      *
      * This method is dependent on not having two views with equivalent contentDescription / text.
      */
-    private View findAppMenuItemView(String text) {
-        RobotiumHelper.waitForExactText(text, 1, MAX_WAITTIME_FOR_MENU_UPDATE_IN_MS);
+    private View findAppMenuItemView(final String text) {
+        return WaitHelper.waitFor(String.format("menu item view '%s'", text), new Callable<View>() {
+            @Override
+            public View call() throws Exception {
+                final List<View> views = mSolo.getViews();
 
-        final List<View> views = mSolo.getViews();
+                final List<MenuItemActionBar> menuItemActionBarList = RobotiumUtils.filterViews(MenuItemActionBar.class, views);
+                for (MenuItemActionBar menuItem : menuItemActionBarList) {
+                    if (TextUtils.equals(menuItem.getContentDescription(), text)) {
+                        return menuItem;
+                    }
+                }
 
-        final List<MenuItemActionBar> menuItemActionBarList = RobotiumUtils.filterViews(MenuItemActionBar.class, views);
-        for (MenuItemActionBar menuItem : menuItemActionBarList) {
-            if (TextUtils.equals(menuItem.getContentDescription(), text)) {
-                return menuItem;
-            }
-        }
+                final List<MenuItemDefault> menuItemDefaultList = RobotiumUtils.filterViews(MenuItemDefault.class, views);
+                for (MenuItemDefault menuItem : menuItemDefaultList) {
+                    if (TextUtils.equals(menuItem.getText(), text)) {
+                        return menuItem;
+                    }
+                }
 
-        final List<MenuItemDefault> menuItemDefaultList = RobotiumUtils.filterViews(MenuItemDefault.class, views);
-        for (MenuItemDefault menuItem : menuItemDefaultList) {
-            if (TextUtils.equals(menuItem.getText(), text)) {
-                return menuItem;
+                // On Android 2.3, menu items may be instances of
+                // com.android.internal.view.menu.ListMenuItemView, each with a child
+                // android.widget.RelativeLayout which in turn has a child
+                // TextView with the appropriate text.
+                final List<TextView> textViewList = RobotiumUtils.filterViews(TextView.class, views);
+                for (TextView textView : textViewList) {
+                    if (TextUtils.equals(textView.getText(), text)) {
+                        View relativeLayout = (View) textView.getParent();
+                        if (relativeLayout instanceof RelativeLayout) {
+                            View listMenuItemView = (View)relativeLayout.getParent();
+                            return listMenuItemView;
+                        }
+                    }
+                }
+                return null;
             }
-        }
-
-        // On Android 2.3, menu items may be instances of
-        // com.android.internal.view.menu.ListMenuItemView, each with a child
-        // android.widget.RelativeLayout which in turn has a child
-        // TextView with the appropriate text.
-        final List<TextView> textViewList = RobotiumUtils.filterViews(TextView.class, views);
-        for (TextView textView : textViewList) {
-            if (TextUtils.equals(textView.getText(), text)) {
-                View relativeLayout = (View) textView.getParent();
-                if (relativeLayout instanceof RelativeLayout) {
-                    View listMenuItemView = (View)relativeLayout.getParent();
-                    return listMenuItemView;
-                }
-            }
-        }
-        return null;
+        }, MAX_WAITTIME_FOR_MENU_UPDATE_IN_MS);
     }
 
     /**
      * Helper function to let Robotium locate and click menu item from legacy Android menu (devices with Android 2.x).
      *
      * Robotium will also try to open the menu if there are no open dialog.
      *
      * @param menuItemTitle, The title of menu item to open.
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/components/ToolbarComponent.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/components/ToolbarComponent.java
@@ -58,17 +58,19 @@ public class ToolbarComponent extends Ba
         if (mStringHelper.ABOUT_HOME_URL.equals(absoluteURL)) {
             expected = mStringHelper.ABOUT_HOME_TITLE;
         } else if (absoluteURL.startsWith(URL_HTTP_PREFIX)) {
             expected = absoluteURL.substring(URL_HTTP_PREFIX.length());
         } else {
             expected = absoluteURL;
         }
 
-        fAssertEquals("The Toolbar title is " + expected, expected, getTitle());
+        // Since we only display a shortened "base domain" (See bug 1236431) we use the content
+        // description to obtain the full URL.
+        fAssertEquals("The Toolbar title is " + expected, expected, getUrlFromContentDescription());
         return this;
     }
 
     public ToolbarComponent assertUrl(final String expected) {
         assertIsEditing();
         fAssertEquals("The Toolbar url is " + expected, expected, getUrlEditText().getText());
         return this;
     }
@@ -143,18 +145,25 @@ public class ToolbarComponent extends Ba
 
     /**
      * Returns the View for the edit cancel button in the browser toolbar.
      */
     private View getEditCancelButton() {
         return getToolbarView().findViewById(R.id.edit_cancel);
     }
 
-    private String getTitle() {
-        return getTitleHelper(true);
+    private String getUrlFromContentDescription() {
+        assertIsNotEditing();
+
+        final CharSequence contentDescription = getUrlDisplayLayout().getContentDescription();
+        if (contentDescription == null) {
+            return "";
+        } else {
+            return contentDescription.toString();
+        }
     }
 
     /**
      * Returns the title of the page. Note that this makes no assertions to Toolbar state and
      * may return a value that may never be visible to the user. Callers likely want to use
      * {@link assertTitle} instead.
      */
     public String getPotentiallyInconsistentTitle() {
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/helpers/WaitHelper.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/helpers/WaitHelper.java
@@ -3,16 +3,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.tests.helpers;
 
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertNotNull;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertTrue;
 
 import android.os.SystemClock;
+
+import java.util.concurrent.Callable;
 import java.util.regex.Pattern;
 
 import org.mozilla.gecko.Actions;
 import org.mozilla.gecko.Actions.EventExpecter;
 import org.mozilla.gecko.tests.UITestContext;
 import org.mozilla.gecko.tests.UITestContext.ComponentType;
 import org.mozilla.gecko.tests.components.ToolbarComponent;
 
@@ -66,16 +68,46 @@ public final class WaitHelper {
      * AssertionError if the duration is elapsed and the condition is not satisfied.
      */
     public static void waitFor(String message, final Condition condition, final int waitMillis) {
         message = "Waiting for " + message + " with timeout " + waitMillis + ".";
         fAssertTrue(message, sSolo.waitForCondition(condition, waitMillis));
     }
 
     /**
+     * Waits for the given Callable to return something that is not null, using the given wait
+     * duration; will throw an AssertionError if the duration is elapsed and the callable has not
+     * returned a non-null object.
+     *
+     * @return the value returned by the Callable. Or null if the duration has elapsed.
+     */
+    public static <V> V waitFor(String message, final Callable<V> callable, int waitMillis) {
+        sContext.dumpLog("WaitHelper", "Waiting for " + message + " with timeout " + waitMillis + ".");
+
+        final Object[] value = new Object[1];
+
+        Condition condition = new Condition() {
+            @Override
+            public boolean isSatisfied() {
+                try {
+                    V result = callable.call();
+                    value[0] = result;
+                    return result != null;
+                } catch (Exception e) {
+                    return false;
+                }
+            }
+        };
+
+        sSolo.waitForCondition(condition, waitMillis);
+
+        return (V) value[0];
+    }
+
+    /**
      * Waits for the Gecko event declaring the page has loaded. Takes in and runs a Runnable
      * that will perform the action that will cause the page to load.
      */
     public static void waitForPageLoad(final Runnable initiatingAction) {
         fAssertNotNull("initiatingAction is not null", initiatingAction);
 
         // Some changes to the UI occur in response to the same event we listen to for when
         // the page has finished loading (e.g. a page title update). As such, we ensure this
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testAboutPage.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testAboutPage.java
@@ -14,34 +14,34 @@ public class testAboutPage extends Pixel
 
     public void testAboutPage() {
         blockForGeckoReady();
 
         // Load the about: page and verify its title.
         String url = mStringHelper.ABOUT_SCHEME;
         loadAndPaint(url);
 
-        verifyUrlBarTitle(url);
+        verifyUrlInContentDescription(url);
 
         // Open a new page to remove the about: page from the current tab.
         url = getAbsoluteUrl(mStringHelper.ROBOCOP_BLANK_PAGE_01_URL);
         loadUrlAndWait(url);
 
         // At this point the page title should have been set.
-        verifyUrlBarTitle(url);
+        verifyUrlInContentDescription(url);
 
         // Set up listeners to catch the page load we're about to do.
         Actions.EventExpecter tabEventExpecter = mActions.expectGeckoEvent("Tab:Added");
         Actions.EventExpecter contentEventExpecter = mActions.expectGeckoEvent("DOMContentLoaded");
 
         selectSettingsItem(mStringHelper.MOZILLA_SECTION_LABEL, mStringHelper.ABOUT_LABEL);
 
         // Wait for the new tab and page to load
         tabEventExpecter.blockForEvent();
         contentEventExpecter.blockForEvent();
 
         tabEventExpecter.unregisterListener();
         contentEventExpecter.unregisterListener();
 
         // Make sure the about: page was loaded.
-        verifyUrlBarTitle(mStringHelper.ABOUT_SCHEME);
+        verifyUrlInContentDescription(mStringHelper.ABOUT_SCHEME);
     }
 }
--- a/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testPictureLinkContextMenu.java
+++ b/mobile/android/tests/browser/robocop/src/org/mozilla/gecko/tests/testPictureLinkContextMenu.java
@@ -17,17 +17,17 @@ public class testPictureLinkContextMenu 
         final String PICTURE_PAGE_TITLE = mStringHelper.ROBOCOP_PICTURE_LINK_TITLE;
         final String linkMenuItems [] = mStringHelper.CONTEXT_MENU_ITEMS_IN_NORMAL_TAB;
 
         blockForGeckoReady();
 
         PICTURE_PAGE_URL=getAbsoluteUrl(mStringHelper.ROBOCOP_PICTURE_LINK_URL);
         BLANK_PAGE_URL=getAbsoluteUrl(mStringHelper.ROBOCOP_BLANK_PAGE_02_URL);
         loadAndPaint(PICTURE_PAGE_URL);
-        verifyUrlBarTitle(PICTURE_PAGE_URL);
+        verifyUrlInContentDescription(PICTURE_PAGE_URL);
 
         switchTabs(imageTitle);
         verifyContextMenuItems(photoMenuItems);
         verifyTabs(tabs);
         switchTabs(imageTitle);
         verifyCopyOption(photoMenuItems[0], "Firefox.jpg"); // Test the "Copy Image Location" option
         switchTabs(imageTitle);
         verifyShareOption(photoMenuItems[1], PICTURE_PAGE_TITLE); // Test the "Share Image" option
--- a/services/sync/tps/extensions/tps/resource/modules/history.jsm
+++ b/services/sync/tps/extensions/tps/resource/modules/history.jsm
@@ -184,17 +184,24 @@ var HistoryEntry = {
     if ("uri" in item) {
       let uri = Services.io.newURI(item.uri, null, null);
       PlacesUtils.history.removePage(uri);
     }
     else if ("host" in item) {
       PlacesUtils.history.removePagesFromHost(item.host, false);
     }
     else if ("begin" in item && "end" in item) {
-      PlacesUtils.history.removeVisitsByTimeframe(
-          usSinceEpoch + (item.begin * 60 * 60 * 1000 * 1000),
-          usSinceEpoch + (item.end * 60 * 60 * 1000 * 1000));
+      let cb = Async.makeSpinningCallback();
+      let msSinceEpoch = parseInt(usSinceEpoch / 1000);
+      let filter = {
+        beginDate: new Date(msSinceEpoch + (item.begin * 60 * 60 * 1000)),
+        endDate: new Date(msSinceEpoch + (item.end * 60 * 60 * 1000))
+      };
+      PlacesUtils.history.removeVisitsByFilter(filter)
+      .catch(ex => Logger.AssertTrue(false, "An error occurred while deleting history: " + ex))
+      .then(result => {cb(null, result)}, err => {cb(err)});
+      Async.waitForSyncCallback(cb);
     }
     else {
       Logger.AssertTrue(false, "invalid entry in delete history");
     }
   },
 };
--- a/settings.gradle
+++ b/settings.gradle
@@ -24,22 +24,20 @@ if (json.substs.MOZ_BUILD_APP != 'mobile
 // Set the Android SDK location.  This is the *least specific* mechanism, which
 // is unfortunate: we'd prefer to use the *most specific* mechanism.  That is,
 // local.properties (first 'sdk.dir', then 'android.dir') and then the
 // environment variable ANDROID_HOME will override this.  That's unfortunate,
 // but it's hard to automatically arrange better.
 System.setProperty('android.home', json.substs.ANDROID_SDK_ROOT)
 
 include ':app'
-include ':base'
 include ':omnijar'
 include ':thirdparty'
 
 project(':app').projectDir = new File("${json.topsrcdir}/mobile/android/app")
-project(':base').projectDir = new File("${json.topsrcdir}/mobile/android/app/base")
 project(':omnijar').projectDir = new File("${json.topsrcdir}/mobile/android/app/omnijar")
 project(':thirdparty').projectDir = new File("${json.topsrcdir}/mobile/android/thirdparty")
 
 if (json.substs.MOZ_ANDROID_PACKAGE_INSTALL_BOUNCER) {
     include ':bouncer'
     project(':bouncer').projectDir = new File("${json.topsrcdir}/mobile/android/bouncer")
 }
 
--- a/testing/taskcluster/tasks/branches/base_job_flags.yml
+++ b/testing/taskcluster/tasks/branches/base_job_flags.yml
@@ -95,16 +95,17 @@ flags:
     - dolphin-512
     - dolphin-512-eng
     - aries
     - aries-ota
     - aries-eng
     - aries-dogfood
     - aries-noril
     - android-api-15
+    - android-api-15-frontend
     - android-partner-sample1
     - android-b2gdroid
     - linux
     - linux64
     - linux64-st-an
     - macosx64
     - macosx64-st-an
 
--- a/testing/taskcluster/tasks/branches/base_jobs.yml
+++ b/testing/taskcluster/tasks/branches/base_jobs.yml
@@ -157,16 +157,22 @@ builds:
       debug:
         task: tasks/builds/dbg_macosx64.yml
   macosx64-st-an:
     platforms:
       - MacOSX64 Static Analysis
     types:
       opt:
         task: tasks/builds/opt_macosx64_st-an.yml
+  android-api-15-frontend:
+    platforms:
+      - Android
+    types:
+      opt:
+        task: tasks/builds/android_api_15_frontend.yml
   android-b2gdroid:
     platforms:
       - Android
     types:
       opt:
         task: tasks/builds/android_api_15_b2gdroid.yml
 
 tests:
--- a/toolkit/components/extensions/.eslintrc
+++ b/toolkit/components/extensions/.eslintrc
@@ -162,31 +162,22 @@
     "no-octal": 2,
 
     // No redeclaring variables
     "no-redeclare": 2,
 
     // No unnecessary comparisons
     "no-self-compare": 2,
 
-    // No declaring variables from an outer scope
-    "no-shadow": 1,
-
-    // No declaring variables that hide things like arguments
-    "no-shadow-restricted-names": 2,
-
     // No spaces between function name and parentheses
     "no-spaced-func": 1,
 
     // No trailing whitespace
     "no-trailing-spaces": 2,
 
-    // No using undeclared variables
-    "no-undef": 2,
-
     // Error on newline where a semicolon is needed
     "no-unexpected-multiline": 2,
 
     // No unreachable statements
     "no-unreachable": 2,
 
     // No expressions where a statement is expected
     "no-unused-expressions": 2,
@@ -198,19 +189,16 @@
     "no-use-before-define": 2,
 
     // No using with
     "no-with": 2,
 
     // Always require semicolon at end of statement
     "semi": [2, "always"],
 
-    // Require space after keywords
-    "space-after-keywords": 2,
-
     // Require space before blocks
     "space-before-blocks": 2,
 
     // Never use spaces before function parentheses
     "space-before-function-paren": [2, {"anonymous": "never", "named": "never"}],
 
     // Require spaces before finally, catch, etc.
     "space-before-keywords": [2, "always"],
@@ -487,15 +475,12 @@
     "no-inner-declarations": 2,
 
     // Disallow usage of __iterator__ property
     "no-iterator": 2,
 
     // Disallow labels that share a name with a variable
     "no-label-var": 2,
 
-    // Disallow negation of the left operand of an in expression
-    "no-negated-in-lhs": 2,
-
     // Disallow creating new instances of String, Number, and Boolean
     "no-new-wrappers": 2,
   }
 }
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -199,17 +199,17 @@ class BaseContext {
   /**
    * Wraps the given promise so it can be safely returned to extension
    * code in this context.
    *
    * If `callback` is provided, however, it is used as a completion
    * function for the promise, and no promise is returned. In this case,
    * the callback is called when the promise resolves or rejects. In the
    * latter case, `lastError` is set to the rejection value, and the
-   * callback funciton must check `browser.runtime.lastError` or
+   * callback function must check `browser.runtime.lastError` or
    * `extension.runtime.lastError` in order to prevent it being reported
    * to the console.
    *
    * @param {Promise} promise The promise with which to wrap the
    *     callback. May resolve to a `SpreadArgs` instance, in which case
    *     each element will be used as a separate argument.
    *
    *     Unless the promise object belongs to the cloneScope global, its
--- a/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm
+++ b/toolkit/components/passwordmgr/InsecurePasswordUtils.jsm
@@ -20,17 +20,17 @@ Object.defineProperty(this, "WebConsoleU
   get: function() {
     return devtools.require("devtools/shared/webconsole/utils").Utils;
   },
   configurable: true,
   enumerable: true
 });
 
 const STRINGS_URI = "chrome://global/locale/security/security.properties";
-var l10n = new WebConsoleUtils.l10n(STRINGS_URI);
+var l10n = new WebConsoleUtils.L10n(STRINGS_URI);
 
 this.InsecurePasswordUtils = {
 
   _sendWebConsoleMessage : function (messageTag, domDoc) {
     /*
      * All web console messages are warnings for now so I decided to set the
      * flag here and save a bit of the flag creation in the callers.
      * It's easy to expose this later if needed
--- a/toolkit/components/places/Database.cpp
+++ b/toolkit/components/places/Database.cpp
@@ -289,279 +289,16 @@ CreateRoot(nsCOMPtr<mozIStorageConnectio
     ++itemPosition;
 
   return NS_OK;
 }
 
 
 } // namespace
 
-/**
- * An AsyncShutdown blocker in charge of shutting down places
- */
-class DatabaseShutdown final:
-    public nsIAsyncShutdownBlocker,
-    public nsIAsyncShutdownCompletionCallback,
-    public mozIStorageCompletionCallback
-{
-public:
-  NS_DECL_THREADSAFE_ISUPPORTS
-  NS_DECL_NSIASYNCSHUTDOWNBLOCKER
-  NS_DECL_NSIASYNCSHUTDOWNCOMPLETIONCALLBACK
-  NS_DECL_MOZISTORAGECOMPLETIONCALLBACK
-
-  explicit DatabaseShutdown(Database* aDatabase);
-
-  already_AddRefed<nsIAsyncShutdownClient> GetClient();
-
-  /**
-   * `true` if we have not started shutdown, i.e.  if
-   * `BlockShutdown()` hasn't been called yet, false otherwise.
-   */
-  static bool IsStarted() {
-    return sIsStarted;
-  }
-
-private:
-  nsCOMPtr<nsIAsyncShutdownBarrier> mBarrier;
-  nsCOMPtr<nsIAsyncShutdownClient> mParentClient;
-
-  // The owning database.
-  // The cycle is broken in method Complete(), once the connection
-  // has been closed by mozStorage.
-  RefPtr<Database> mDatabase;
-
-  // The current state, used both internally and for
-  // forensics/debugging purposes.
-  enum State {
-    NOT_STARTED,
-
-    // Execution of `BlockShutdown` in progress
-    // a. `BlockShutdown` is starting.
-    RECEIVED_BLOCK_SHUTDOWN,
-    // b. `BlockShutdown` is complete, waiting for clients.
-    CALLED_WAIT_CLIENTS,
-
-    // Execution of `Done` in progress
-    // a. `Done` is starting.
-    RECEIVED_DONE,
-    // b. We have notified observers that Places will close connection.
-    NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION,
-    // c. Execution of `Done` is complete, waiting for mozStorage shutdown.
-    CALLED_STORAGESHUTDOWN,
-
-    // Execution of `Complete` in progress
-    // a. `Complete` is starting.
-    RECEIVED_STORAGESHUTDOWN_COMPLETE,
-    // b. We have notified observers that Places as closed connection.
-    NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED,
-  };
-  State mState;
-
-  // As tests may resurrect a dead `Database`, we use a counter to
-  // give the instances of `DatabaseShutdown` unique names.
-  uint16_t mCounter;
-  static uint16_t sCounter;
-
-  static Atomic<bool> sIsStarted;
-
-  ~DatabaseShutdown() {}
-};
-uint16_t DatabaseShutdown::sCounter = 0;
-Atomic<bool> DatabaseShutdown::sIsStarted(false);
-
-DatabaseShutdown::DatabaseShutdown(Database* aDatabase)
-  : mDatabase(aDatabase)
-  , mState(NOT_STARTED)
-  , mCounter(sCounter++)
-{
-  MOZ_ASSERT(NS_IsMainThread());
-  nsCOMPtr<nsIAsyncShutdownService> asyncShutdownSvc = services::GetAsyncShutdown();
-  MOZ_ASSERT(asyncShutdownSvc);
-
-  if (asyncShutdownSvc) {
-    DebugOnly<nsresult> rv = asyncShutdownSvc->MakeBarrier(
-      NS_LITERAL_STRING("Places Database shutdown"),
-      getter_AddRefs(mBarrier)
-    );
-    MOZ_ASSERT(NS_SUCCEEDED(rv));
-  }
-}
-
-already_AddRefed<nsIAsyncShutdownClient>
-DatabaseShutdown::GetClient()
-{
-  nsCOMPtr<nsIAsyncShutdownClient> client;
-  if (mBarrier) {
-    DebugOnly<nsresult> rv = mBarrier->GetClient(getter_AddRefs(client));
-    MOZ_ASSERT(NS_SUCCEEDED(rv));
-  }
-  return client.forget();
-}
-
-// nsIAsyncShutdownBlocker::GetName
-NS_IMETHODIMP
-DatabaseShutdown::GetName(nsAString& aName)
-{
-  if (mCounter > 0) {
-    // During tests, we can end up with the Database singleton being resurrected.
-    // Make sure that each instance of DatabaseShutdown has a unique name.
-    nsPrintfCString name("Places DatabaseShutdown: Blocking profile-before-change (%x)", this);
-    aName = NS_ConvertUTF8toUTF16(name);
-  } else {
-    aName = NS_LITERAL_STRING("Places DatabaseShutdown: Blocking profile-before-change");
-  }
-  return NS_OK;
-}
-
-// nsIAsyncShutdownBlocker::GetState
-NS_IMETHODIMP DatabaseShutdown::GetState(nsIPropertyBag** aState)
-{
-  nsresult rv;
-  nsCOMPtr<nsIWritablePropertyBag2> bag =
-    do_CreateInstance("@mozilla.org/hash-property-bag;1", &rv);
-  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-
-  // Put `mState` in field `progress`
-  RefPtr<nsVariant> progress = new nsVariant();
-
-  rv = progress->SetAsUint8(mState);
-  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-
-  rv = bag->SetPropertyAsInterface(NS_LITERAL_STRING("progress"), progress);
-  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-
-  // Put `mBarrier`'s state in field `barrier`, if possible
-  if (!mBarrier) {
-    return NS_OK;
-  }
-  nsCOMPtr<nsIPropertyBag> barrierState;
-  rv = mBarrier->GetState(getter_AddRefs(barrierState));
-  if (NS_FAILED(rv)) {
-    return NS_OK;
-  }
-
-  RefPtr<nsVariant> barrier = new nsVariant();
-
-  rv = barrier->SetAsInterface(NS_GET_IID(nsIPropertyBag), barrierState);
-  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-
-  rv = bag->SetPropertyAsInterface(NS_LITERAL_STRING("Barrier"), barrier);
-  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-
-  return NS_OK;
-}
-
-
-// nsIAsyncShutdownBlocker::BlockShutdown
-//
-// Step 1 in shutdown, called during profile-before-change.
-// As a `nsIAsyncShutdownBarrier`, we now need to wait until all clients
-// of `this` barrier have completed their own shutdown.
-//
-// See `Done()` for step 2.
-NS_IMETHODIMP
-DatabaseShutdown::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
-{
-  mParentClient = aParentClient;
-  mState = RECEIVED_BLOCK_SHUTDOWN;
-  sIsStarted = true;
-
-  if (NS_WARN_IF(!mBarrier)) {
-    return NS_ERROR_NOT_AVAILABLE;
-  }
-
-  // Wait until all clients have removed their blockers, then proceed
-  // with own shutdown.
-  DebugOnly<nsresult> rv = mBarrier->Wait(this);
-  MOZ_ASSERT(NS_SUCCEEDED(rv));
-
-  mState = CALLED_WAIT_CLIENTS;
-  return NS_OK;
-}
-
-// nsIAsyncShutdownCompletionCallback::Done
-//
-// Step 2 in shutdown, called once all clients have removed their blockers.
-// We may now check sanity, inform observers, and close the database handler.
-//
-// See `Complete()` for step 3.
-NS_IMETHODIMP
-DatabaseShutdown::Done()
-{
-  mState = RECEIVED_DONE;
-
-  // Fire internal shutdown notifications.
-  nsCOMPtr<nsIObserverService> os = services::GetObserverService();
-  MOZ_ASSERT(os);
-  if (os) {
-    (void)os->NotifyObservers(nullptr, TOPIC_PLACES_WILL_CLOSE_CONNECTION, nullptr);
-  }
-  mState = NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION;
-
-  // At this stage, any use of this database is forbidden. Get rid of
-  // `gDatabase`. Note, however, that the database could be
-  // resurrected.  This can happen in particular during tests.
-  MOZ_ASSERT(Database::gDatabase == nullptr || Database::gDatabase == mDatabase);
-  Database::gDatabase = nullptr;
-
-  mDatabase->Shutdown();
-  mState = CALLED_STORAGESHUTDOWN;
-  return NS_OK;
-}
-
-
-// mozIStorageCompletionCallback::Complete
-//
-// Step 3 (and last step) of shutdown
-//
-// Called once the connection has been closed by mozStorage.
-// Inform observers of TOPIC_PLACES_CONNECTION_CLOSED.
-//
-NS_IMETHODIMP
-DatabaseShutdown::Complete(nsresult, nsISupports*)
-{
-  MOZ_ASSERT(NS_IsMainThread());
-  mState = RECEIVED_STORAGESHUTDOWN_COMPLETE;
-  mDatabase = nullptr;
-
-  nsresult rv;
-  if (mParentClient) {
-    // mParentClient may be nullptr in tests
-    rv = mParentClient->RemoveBlocker(this);
-    if (NS_WARN_IF(NS_FAILED(rv))) return rv;
-  }
-
-  nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
-  MOZ_ASSERT(os);
-  if (os) {
-    rv = os->NotifyObservers(nullptr,
-                             TOPIC_PLACES_CONNECTION_CLOSED,
-                             nullptr);
-    MOZ_ASSERT(NS_SUCCEEDED(rv));
-  }
-  mState = NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED;
-
-  if (NS_WARN_IF(!mBarrier)) {
-    return NS_ERROR_NOT_AVAILABLE;
-  }
-
-  NS_ReleaseOnMainThread(mBarrier.forget());
-  NS_ReleaseOnMainThread(mParentClient.forget());
-
-  return NS_OK;
-}
-
-NS_IMPL_ISUPPORTS(
-  DatabaseShutdown
-, nsIAsyncShutdownBlocker
-, nsIAsyncShutdownCompletionCallback
-, mozIStorageCompletionCallback
-)
-
 ////////////////////////////////////////////////////////////////////////////////
 //// Database
 
 PLACES_FACTORY_SINGLETON_IMPLEMENTATION(Database, gDatabase)
 
 NS_IMPL_ISUPPORTS(Database
 , nsIObserver
 , nsISupportsWeakReference
@@ -569,47 +306,53 @@ NS_IMPL_ISUPPORTS(Database
 
 Database::Database()
   : mMainThreadStatements(mMainConn)
   , mMainThreadAsyncStatements(mMainConn)
   , mAsyncThreadStatements(mMainConn)
   , mDBPageSize(0)
   , mDatabaseStatus(nsINavHistoryService::DATABASE_STATUS_OK)
   , mClosed(false)
-  , mConnectionShutdown(new DatabaseShutdown(this))
+  , mClientsShutdown(new ClientsShutdownBlocker())
+  , mConnectionShutdown(new ConnectionShutdownBlocker(this))
 {
   MOZ_ASSERT(!XRE_IsContentProcess(),
              "Cannot instantiate Places in the content process");
   // Attempting to create two instances of the service?
   MOZ_ASSERT(!gDatabase);
   gDatabase = this;
-
-  // Prepare async shutdown
-  nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetShutdownPhase();
-  MOZ_ASSERT(shutdownPhase);
-
-  if (shutdownPhase) {
-    DebugOnly<nsresult> rv = shutdownPhase->AddBlocker(
-      static_cast<nsIAsyncShutdownBlocker*>(mConnectionShutdown.get()),
-      NS_LITERAL_STRING(__FILE__),
-      __LINE__,
-      NS_LITERAL_STRING(""));
-    MOZ_ASSERT(NS_SUCCEEDED(rv));
-  }
 }
 
 already_AddRefed<nsIAsyncShutdownClient>
-Database::GetShutdownPhase()
+Database::GetProfileChangeTeardownPhase()
 {
   nsCOMPtr<nsIAsyncShutdownService> asyncShutdownSvc = services::GetAsyncShutdown();
   MOZ_ASSERT(asyncShutdownSvc);
   if (NS_WARN_IF(!asyncShutdownSvc)) {
     return nullptr;
   }
 
+  // Consumers of Places should shutdown before us, at profile-change-teardown.
+  nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase;
+  DebugOnly<nsresult> rv = asyncShutdownSvc->
+    GetProfileChangeTeardown(getter_AddRefs(shutdownPhase));
+  MOZ_ASSERT(NS_SUCCEEDED(rv));
+  return shutdownPhase.forget();
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+Database::GetProfileBeforeChangePhase()
+{
+  nsCOMPtr<nsIAsyncShutdownService> asyncShutdownSvc = services::GetAsyncShutdown();
+  MOZ_ASSERT(asyncShutdownSvc);
+  if (NS_WARN_IF(!asyncShutdownSvc)) {
+    return nullptr;
+  }
+
+  // Consumers of Places should shutdown before us, at profile-change-teardown.
   nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase;
   DebugOnly<nsresult> rv = asyncShutdownSvc->
     GetProfileBeforeChange(getter_AddRefs(shutdownPhase));
   MOZ_ASSERT(NS_SUCCEEDED(rv));
   return shutdownPhase.forget();
 }
 
 Database::~Database()
@@ -644,29 +387,29 @@ Database::GetStatement(const nsACString&
   }
   if (NS_IsMainThread()) {
     return mMainThreadStatements.GetCachedStatement(aQuery);
   }
   return mAsyncThreadStatements.GetCachedStatement(aQuery);
 }
 
 already_AddRefed<nsIAsyncShutdownClient>
-Database::GetConnectionShutdown()
+Database::GetClientsShutdown()
 {
-  MOZ_ASSERT(mConnectionShutdown);
-
-  return mConnectionShutdown->GetClient();
+  MOZ_ASSERT(mClientsShutdown);
+  return mClientsShutdown->GetClient();
 }
 
 // static
 already_AddRefed<Database>
 Database::GetDatabase()
 {
-  if (DatabaseShutdown::IsStarted())
+  if (PlacesShutdownBlocker::IsStarted()) {
     return nullptr;
+  }
   return GetSingleton();
 }
 
 nsresult
 Database::Init()
 {
   MOZ_ASSERT(NS_IsMainThread());
 
@@ -727,16 +470,46 @@ Database::Init()
   // Notify we have finished database initialization.
   // Enqueue the notification, so if we init another service that requires
   // nsNavHistoryService we don't recursive try to get it.
   RefPtr<PlacesEvent> completeEvent =
     new PlacesEvent(TOPIC_PLACES_INIT_COMPLETE);
   rv = NS_DispatchToMainThread(completeEvent);
   NS_ENSURE_SUCCESS(rv, rv);
 
+  // At this point we know the Database object points to a valid connection
+  // and we need to setup async shutdown.
+  {
+    // First of all Places clients should block profile-change-teardown.
+    nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileChangeTeardownPhase();
+    MOZ_ASSERT(shutdownPhase);
+    if (shutdownPhase) {
+      DebugOnly<nsresult> rv = shutdownPhase->AddBlocker(
+        static_cast<nsIAsyncShutdownBlocker*>(mClientsShutdown.get()),
+        NS_LITERAL_STRING(__FILE__),
+        __LINE__,
+        NS_LITERAL_STRING(""));
+      MOZ_ASSERT(NS_SUCCEEDED(rv));
+    }
+  }
+
+  {
+    // Then connection closing should block profile-before-change.
+    nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileBeforeChangePhase();
+    MOZ_ASSERT(shutdownPhase);
+    if (shutdownPhase) {
+      DebugOnly<nsresult> rv = shutdownPhase->AddBlocker(
+        static_cast<nsIAsyncShutdownBlocker*>(mConnectionShutdown.get()),
+        NS_LITERAL_STRING(__FILE__),
+        __LINE__,
+        NS_LITERAL_STRING(""));
+      MOZ_ASSERT(NS_SUCCEEDED(rv));
+    }
+  }
+
   // Finally observe profile shutdown notifications.
   nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
   if (os) {
     (void)os->AddObserver(this, TOPIC_PROFILE_CHANGE_TEARDOWN, true);
   }
 
   return NS_OK;
 }
@@ -1854,29 +1627,28 @@ Database::MigrateV30Up() {
   NS_ENSURE_SUCCESS(rv, rv);
 
   return NS_OK;
 }
 
 void
 Database::Shutdown()
 {
-
   // As the last step in the shutdown path, finalize the database handle.
   MOZ_ASSERT(NS_IsMainThread());
   MOZ_ASSERT(!mClosed);
 
-  // Break cycle
-  nsCOMPtr<mozIStorageCompletionCallback> closeListener = mConnectionShutdown.forget();
+  // Break cycles with the shutdown blockers.
+  mClientsShutdown = nullptr;
+  nsCOMPtr<mozIStorageCompletionCallback> connectionShutdown = mConnectionShutdown.forget();
 
   if (!mMainConn) {
-    // The connection has never been initialized. Just mark it
-    // as closed.
+    // The connection has never been initialized. Just mark it as closed.
     mClosed = true;
-    (void)closeListener->Complete(NS_OK, nullptr);
+    (void)connectionShutdown->Complete(NS_OK, nullptr);
     return;
   }
 
 #ifdef DEBUG
   { // Sanity check for missing guids.
     bool haveNullGuids = false;
     nsCOMPtr<mozIStorageStatement> stmt;
 
@@ -1925,30 +1697,29 @@ Database::Shutdown()
     new FinalizeStatementCacheProxy<mozIStorageStatement>(
           mAsyncThreadStatements,
           NS_ISUPPORTS_CAST(nsIObserver*, this)
         );
   DispatchToAsyncThread(event);
 
   mClosed = true;
 
-  (void)mMainConn->AsyncClose(closeListener);
+  (void)mMainConn->AsyncClose(connectionShutdown);
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 //// nsIObserver
 
 NS_IMETHODIMP
 Database::Observe(nsISupports *aSubject,
                   const char *aTopic,
                   const char16_t *aData)
 {
   MOZ_ASSERT(NS_IsMainThread());
-  if (strcmp(aTopic, TOPIC_PROFILE_CHANGE_TEARDOWN) == 0 ||
-      strcmp(aTopic, TOPIC_SIMULATE_PLACES_MUST_CLOSE_1) == 0) {
+  if (strcmp(aTopic, TOPIC_PROFILE_CHANGE_TEARDOWN) == 0) {
     // Tests simulating shutdown may cause multiple notifications.
     if (IsShutdownStarted()) {
       return NS_OK;
     }
 
     nsCOMPtr<nsIObserverService> os = services::GetObserverService();
     NS_ENSURE_STATE(os);
 
@@ -1967,30 +1738,50 @@ Database::Observe(nsISupports *aSubject,
           nsCOMPtr<nsIObserver> observer = do_QueryInterface(supports);
           (void)observer->Observe(observer, TOPIC_PLACES_INIT_COMPLETE, nullptr);
         }
       }
     }
 
     // Notify all Places users that we are about to shutdown.
     (void)os->NotifyObservers(nullptr, TOPIC_PLACES_SHUTDOWN, nullptr);
-  } else if (strcmp(aTopic, TOPIC_SIMULATE_PLACES_MUST_CLOSE_2) == 0) {
+  } else if (strcmp(aTopic, TOPIC_SIMULATE_PLACES_SHUTDOWN) == 0) {
+    // This notification is (and must be) only used by tests that are trying
+    // to simulate Places shutdown out of the normal shutdown path.
+
     // Tests simulating shutdown may cause re-entrance.
     if (IsShutdownStarted()) {
       return NS_OK;
     }
 
-    // Since we are going through shutdown of Database,
-    // we don't need to block actual shutdown anymore.
-    nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetShutdownPhase();
-    if (shutdownPhase) {
-      shutdownPhase->RemoveBlocker(mConnectionShutdown.get());
+    // We are simulating a shutdown, so invoke the shutdown blockers,
+    // wait for them, then proceed with connection shutdown.
+    // Since we are already going through shutdown, but it's not the real one,
+    // we won't need to block the real one anymore, so we can unblock it.
+    {
+      nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileChangeTeardownPhase();
+      if (shutdownPhase) {
+        shutdownPhase->RemoveBlocker(mClientsShutdown.get());
+      }
+      (void)mClientsShutdown->BlockShutdown(nullptr);
     }
 
-    return mConnectionShutdown->BlockShutdown(nullptr);
+    // Spin the events loop until the clients are done.
+    // Note, this is just for tests, specifically test_clearHistory_shutdown.js
+    while (mClientsShutdown->State() != PlacesShutdownBlocker::States::RECEIVED_DONE) {
+      (void)NS_ProcessNextEvent();
+    }
+
+    {
+      nsCOMPtr<nsIAsyncShutdownClient> shutdownPhase = GetProfileBeforeChangePhase();
+      if (shutdownPhase) {
+        shutdownPhase->RemoveBlocker(mConnectionShutdown.get());
+      }
+      (void)mConnectionShutdown->BlockShutdown(nullptr);
+    }
   }
   return NS_OK;
 }
 
 
 
 } // namespace places
 } // namespace mozilla
--- a/toolkit/components/places/Database.h
+++ b/toolkit/components/places/Database.h
@@ -9,16 +9,17 @@
 #include "nsWeakReference.h"
 #include "nsIInterfaceRequestorUtils.h"
 #include "nsIObserver.h"
 #include "nsIAsyncShutdown.h"
 #include "mozilla/storage.h"
 #include "mozilla/storage/StatementCache.h"
 #include "mozilla/Attributes.h"
 #include "nsIEventTarget.h"
+#include "Shutdown.h"
 
 // This is the schema version. Update it at any schema change and add a
 // corresponding migrateVxx method below.
 #define DATABASE_SCHEMA_VERSION 30
 
 // Fired after Places inited.
 #define TOPIC_PLACES_INIT_COMPLETE "places-init-complete"
 // Fired when initialization fails due to a locked database.
@@ -36,21 +37,17 @@
 // cleanup tasks should run at this stage, nothing should be added to the
 // database, nor APIs should be called.
 #define TOPIC_PLACES_WILL_CLOSE_CONNECTION "places-will-close-connection"
 // Fired when the connection has gone, nothing will work from now on.
 #define TOPIC_PLACES_CONNECTION_CLOSED "places-connection-closed"
 
 // Simulate profile-before-change. This topic may only be used by
 // calling `observe` directly on the database. Used for testing only.
-#define TOPIC_SIMULATE_PLACES_MUST_CLOSE_1 "test-simulate-places-shutdown-phase-1"
-
-// Simulate profile-before-change. This topic may only be used by
-// calling `observe` directly on the database. Used for testing only.
-#define TOPIC_SIMULATE_PLACES_MUST_CLOSE_2 "test-simulate-places-shutdown-phase-2"
+#define TOPIC_SIMULATE_PLACES_SHUTDOWN "test-simulate-places-shutdown"
 
 class nsIRunnable;
 
 namespace mozilla {
 namespace places {
 
 enum JournalMode {
   // Default SQLite journal mode.
@@ -59,17 +56,18 @@ enum JournalMode {
   // We fallback to this mode when WAL is unavailable.
 , JOURNAL_TRUNCATE
   // Unsafe in case of crashes on database swap or low memory.
 , JOURNAL_MEMORY
   // Can reduce number of fsyncs.  We try to use this mode by default.
 , JOURNAL_WAL
 };
 
-class DatabaseShutdown;
+class ClientsShutdownBlocker;
+class ConnectionShutdownBlocker;
 
 class Database final : public nsIObserver
                      , public nsSupportsWeakReference
 {
   typedef mozilla::storage::StatementCache<mozIStorageStatement> StatementCache;
   typedef mozilla::storage::StatementCache<mozIStorageAsyncStatement> AsyncStatementCache;
 
 public:
@@ -82,17 +80,17 @@ public:
    * Initializes the database connection and the schema.
    * In case of corruption the database is copied to a backup file and replaced.
    */
   nsresult Init();
 
   /**
    * The AsyncShutdown client used by clients of this API to be informed of shutdown.
    */
-  already_AddRefed<nsIAsyncShutdownClient> GetConnectionShutdown();
+  already_AddRefed<nsIAsyncShutdownClient> GetClientsShutdown();
 
   /**
    * Getter to use when instantiating the class.
    *
    * @return Singleton instance of this class.
    */
   static already_AddRefed<Database> GetDatabase();
 
@@ -264,17 +262,17 @@ protected:
   nsresult MigrateV25Up();
   nsresult MigrateV26Up();
   nsresult MigrateV27Up();
   nsresult MigrateV28Up();
   nsresult MigrateV30Up();
 
   nsresult UpdateBookmarkRootTitles();
 
-  friend class DatabaseShutdown;
+  friend class ConnectionShutdownBlocker;
 
 private:
   ~Database();
 
   /**
    * Singleton getter, invoked by class instantiation.
    */
   static already_AddRefed<Database> GetSingleton();
@@ -287,27 +285,29 @@ private:
   mutable AsyncStatementCache mMainThreadAsyncStatements;
   mutable StatementCache mAsyncThreadStatements;
 
   int32_t mDBPageSize;
   uint16_t mDatabaseStatus;
   bool mClosed;
 
   /**
-   * Determine at which shutdown phase we need to start shutting down
-   * the Database.
+   * Phases for shutting down the Database.
+   * See Shutdown.h for further details about the shutdown procedure.
    */
-  already_AddRefed<nsIAsyncShutdownClient> GetShutdownPhase();
+  already_AddRefed<nsIAsyncShutdownClient> GetProfileChangeTeardownPhase();
+  already_AddRefed<nsIAsyncShutdownClient> GetProfileBeforeChangePhase();
 
   /**
-   * A companion object in charge of shutting down the mozStorage
-   * connection once all clients have disconnected.
+   * Blockers in charge of waiting for the Places clients and then shutting
+   * down the mozStorage connection.
+   * See Shutdown.h for further details about the shutdown procedure.
    *
-   * Cycles between `this` and `mConnectionShutdown` are broken
-   * in `Shutdown()`.
+   * Cycles with these are broken in `Shutdown()`.
    */
-  RefPtr<DatabaseShutdown> mConnectionShutdown;
+  RefPtr<ClientsShutdownBlocker> mClientsShutdown;
+  RefPtr<ConnectionShutdownBlocker> mConnectionShutdown;
 };
 
 } // namespace places
 } // namespace mozilla
 
 #endif // mozilla_places_Database_h_
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/Shutdown.cpp
@@ -0,0 +1,229 @@
+/* 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/. */
+
+#include "Shutdown.h"
+#include "mozilla/unused.h"
+
+namespace mozilla {
+namespace places {
+
+uint16_t PlacesShutdownBlocker::sCounter = 0;
+Atomic<bool> PlacesShutdownBlocker::sIsStarted(false);
+
+PlacesShutdownBlocker::PlacesShutdownBlocker(const nsString& aName)
+  : mName(aName)
+  , mState(NOT_STARTED)
+  , mCounter(sCounter++)
+{
+  MOZ_ASSERT(NS_IsMainThread());
+  // During tests, we can end up with the Database singleton being resurrected.
+  // Make sure that each instance of DatabaseShutdown has a unique name.
+  if (mCounter > 1) {
+    mName.AppendInt(mCounter);
+  }
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::GetName(nsAString& aName)
+{
+  aName = mName;
+  return NS_OK;
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::GetState(nsIPropertyBag** aState)
+{
+  nsresult rv;
+  nsCOMPtr<nsIWritablePropertyBag2> bag =
+    do_CreateInstance("@mozilla.org/hash-property-bag;1", &rv);
+  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+
+  // Put `mState` in field `progress`
+  RefPtr<nsVariant> progress = new nsVariant();
+  rv = progress->SetAsUint8(mState);
+  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+  rv = bag->SetPropertyAsInterface(NS_LITERAL_STRING("progress"), progress);
+  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+
+  // Put `mBarrier`'s state in field `barrier`, if possible
+  if (!mBarrier) {
+    return NS_OK;
+  }
+  nsCOMPtr<nsIPropertyBag> barrierState;
+  rv = mBarrier->GetState(getter_AddRefs(barrierState));
+  if (NS_FAILED(rv)) {
+    return NS_OK;
+  }
+
+  RefPtr<nsVariant> barrier = new nsVariant();
+  rv = barrier->SetAsInterface(NS_GET_IID(nsIPropertyBag), barrierState);
+  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+  rv = bag->SetPropertyAsInterface(NS_LITERAL_STRING("Barrier"), barrier);
+  if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+
+  return NS_OK;
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+PlacesShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+  MOZ_ASSERT(false, "should always be overridden");
+  return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMPL_ISUPPORTS(
+  PlacesShutdownBlocker,
+  nsIAsyncShutdownBlocker
+)
+
+////////////////////////////////////////////////////////////////////////////////
+
+ClientsShutdownBlocker::ClientsShutdownBlocker()
+  : PlacesShutdownBlocker(NS_LITERAL_STRING("Places Clients shutdown"))
+{
+  MOZ_ASSERT(NS_IsMainThread());
+  // Create a barrier that will be exposed to clients through GetClient(), so
+  // they can block Places shutdown.
+  nsCOMPtr<nsIAsyncShutdownService> asyncShutdown = services::GetAsyncShutdown();
+  MOZ_ASSERT(asyncShutdown);
+  if (asyncShutdown) {
+    nsCOMPtr<nsIAsyncShutdownBarrier> barrier;
+    MOZ_ALWAYS_TRUE(NS_SUCCEEDED(asyncShutdown->MakeBarrier(mName, getter_AddRefs(barrier))));
+    mBarrier = new nsMainThreadPtrHolder<nsIAsyncShutdownBarrier>(barrier);
+  }
+}
+
+already_AddRefed<nsIAsyncShutdownClient>
+ClientsShutdownBlocker::GetClient()
+{
+  nsCOMPtr<nsIAsyncShutdownClient> client;
+  if (mBarrier) {
+    MOZ_ALWAYS_TRUE(NS_SUCCEEDED(mBarrier->GetClient(getter_AddRefs(client))));
+  }
+  return client.forget();
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+ClientsShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+  MOZ_ASSERT(NS_IsMainThread());
+  mParentClient = new nsMainThreadPtrHolder<nsIAsyncShutdownClient>(aParentClient);
+  mState = RECEIVED_BLOCK_SHUTDOWN;
+
+  if (NS_WARN_IF(!mBarrier)) {
+    return NS_ERROR_NOT_AVAILABLE;
+  }
+
+  // Wait until all the clients have removed their blockers.
+  MOZ_ALWAYS_TRUE(NS_SUCCEEDED(mBarrier->Wait(this)));
+
+  mState = CALLED_WAIT_CLIENTS;
+  return NS_OK;
+}
+
+// nsIAsyncShutdownCompletionCallback
+NS_IMETHODIMP
+ClientsShutdownBlocker::Done()
+{
+  // At this point all the clients are done, we can stop blocking the shutdown
+  // phase.
+  mState = RECEIVED_DONE;
+
+  // mParentClient is nullptr in tests.
+  if (mParentClient) {
+    nsresult rv = mParentClient->RemoveBlocker(this);
+    if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+    mParentClient = nullptr;
+  }
+  mBarrier = nullptr;
+  return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS_INHERITED(
+  ClientsShutdownBlocker,
+  PlacesShutdownBlocker,
+  nsIAsyncShutdownCompletionCallback
+)
+
+////////////////////////////////////////////////////////////////////////////////
+
+ConnectionShutdownBlocker::ConnectionShutdownBlocker(Database* aDatabase)
+  : PlacesShutdownBlocker(NS_LITERAL_STRING("Places Clients shutdown"))
+  , mDatabase(aDatabase)
+{
+  // Do nothing.
+}
+
+// nsIAsyncShutdownBlocker
+NS_IMETHODIMP
+ConnectionShutdownBlocker::BlockShutdown(nsIAsyncShutdownClient* aParentClient)
+{
+  MOZ_ASSERT(NS_IsMainThread());
+  mParentClient = new nsMainThreadPtrHolder<nsIAsyncShutdownClient>(aParentClient);
+  mState = RECEIVED_BLOCK_SHUTDOWN;
+  // Annotate that Database shutdown started.
+  sIsStarted = true;
+
+  // Fire internal database closing notification.
+  nsCOMPtr<nsIObserverService> os = services::GetObserverService();
+  MOZ_ASSERT(os);
+  if (os) {
+    Unused << os->NotifyObservers(nullptr, TOPIC_PLACES_WILL_CLOSE_CONNECTION, nullptr);
+  }
+  mState = NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION;
+
+  // At this stage, any use of this database is forbidden. Get rid of
+  // `gDatabase`. Note, however, that the database could be
+  // resurrected.  This can happen in particular during tests.
+  MOZ_ASSERT(Database::gDatabase == nullptr || Database::gDatabase == mDatabase);
+  Database::gDatabase = nullptr;
+
+  // Database::Shutdown will invoke Complete once the connection is closed.
+  mDatabase->Shutdown();
+  mState = CALLED_STORAGESHUTDOWN;
+  return NS_OK;
+}
+
+// mozIStorageCompletionCallback
+NS_IMETHODIMP
+ConnectionShutdownBlocker::Complete(nsresult, nsISupports*)
+{
+  MOZ_ASSERT(NS_IsMainThread());
+  mState = RECEIVED_STORAGESHUTDOWN_COMPLETE;
+
+  // The connection is closed, the Database has no more use, so we can break
+  // possible cycles.
+  mDatabase = nullptr;
+
+  // Notify the connection has gone.
+  nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService();
+  MOZ_ASSERT(os);
+  if (os) {
+    MOZ_ALWAYS_TRUE(NS_SUCCEEDED(os->NotifyObservers(nullptr,
+                                                     TOPIC_PLACES_CONNECTION_CLOSED,
+                                                     nullptr)));
+  }
+  mState = NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED;
+
+  // mParentClient is nullptr in tests
+  if (mParentClient) {
+    nsresult rv = mParentClient->RemoveBlocker(this);
+    if (NS_WARN_IF(NS_FAILED(rv))) return rv;
+    mParentClient = nullptr;
+  }
+  return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS_INHERITED(
+  ConnectionShutdownBlocker,
+  PlacesShutdownBlocker,
+  mozIStorageCompletionCallback
+)
+
+} // namespace places
+} // namespace mozilla
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/Shutdown.h
@@ -0,0 +1,171 @@
+/* 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/. */
+
+#ifndef mozilla_places_Shutdown_h_
+#define mozilla_places_Shutdown_h_
+
+#include "nsIAsyncShutdown.h"
+#include "Database.h"
+#include "nsProxyRelease.h"
+
+namespace mozilla {
+namespace places {
+
+class Database;
+
+/**
+ * This is most of the code responsible for Places shutdown.
+ *
+ * PHASE 1 (Legacy clients shutdown)
+ * The shutdown procedure begins when the Database singleton receives
+ * profile-change-teardown (note that tests will instead notify nsNavHistory,
+ * that forwards the notification to the Database instance).
+ * Database::Observe first of all checks if initialization was completed
+ * properly, to avoid race conditions, then it notifies "places-shutdown" to
+ * legacy clients. Legacy clients are supposed to start and complete any
+ * shutdown critical work in the same tick, since we won't wait for them.
+
+ * PHASE 2 (Modern clients shutdown)
+ * Modern clients should instead register as a blocker by passing a promise to
+ * nsPIPlacesDatabase::shutdownClient (for example see sanitize.js), so they
+ * block Places shutdown until the promise is resolved.
+ * When profile-change-teardown is observed by async shutdown, it calls
+ * ClientsShutdownBlocker::BlockShutdown. This class is registered as a teardown
+ * phase blocker in Database::Init (see Database::mClientsShutdown).
+ * ClientsShutdownBlocker::BlockShudown waits for all the clients registered
+ * through nsPIPlacesDatabase::shutdownClient. When all the clients are done,
+ * its `Done` method is invoked, and it stops blocking the shutdown phase, so
+ * that it can continue.
+ *
+ * PHASE 3 (Connection shutdown)
+ * ConnectionBlocker is registered as a profile-before-change blocker in
+ * Database::Init (see Database::mConnectionShutdown).
+ * When profile-before-change is observer by async shutdown, it calls
+ * ConnectionShutdownBlocker::BlockShutdown.
+ * This is the last chance for any Places internal work, like privacy cleanups,
+ * before the connection is closed. This a places-will-close-connection
+ * notification is sent to legacy clients that must complete any operation in
+ * the same tick, since we won't wait for them.
+ * Then the control is passed to Database::Shutdown, that executes some sanity
+ * checks, clears cached statements and proceeds with asyncClose.
+ * Once the connection is definitely closed, Database will call back
+ * ConnectionBlocker::Complete. At this point a final
+ * places-connection-closed notification is sent, for testing purposes.
+ */
+
+/**
+ * A base AsyncShutdown blocker in charge of shutting down Places.
+ */
+class PlacesShutdownBlocker : public nsIAsyncShutdownBlocker
+{
+public:
+  NS_DECL_THREADSAFE_ISUPPORTS
+  NS_DECL_NSIASYNCSHUTDOWNBLOCKER
+
+  explicit PlacesShutdownBlocker(const nsString& aName);
+
+  /**
+   * `true` if we have not started shutdown, i.e.  if
+   * `BlockShutdown()` hasn't been called yet, false otherwise.
+   */
+  static bool IsStarted() {
+    return sIsStarted;
+  }
+
+  // The current state, used internally and for forensics/debugging purposes.
+  // Not all the states make sense for all the derived classes.
+  enum States {
+    NOT_STARTED,
+    // Execution of `BlockShutdown` in progress.
+    RECEIVED_BLOCK_SHUTDOWN,
+
+    // Values specific to ClientsShutdownBlocker
+    // a. Set while we are waiting for clients to do their job and unblock us.
+    CALLED_WAIT_CLIENTS,
+    // b. Set when all the clients are done.
+    RECEIVED_DONE,
+
+    // Values specific to ConnectionShutdownBlocker
+    // a. Set after we notified observers that Places is closing the connection.
+    NOTIFIED_OBSERVERS_PLACES_WILL_CLOSE_CONNECTION,
+    // b. Set after we pass control to Database::Shutdown, and wait for it to
+    // close the connection and call our `Complete` method when done.
+    CALLED_STORAGESHUTDOWN,
+    // c. Set when Database has closed the connection and passed control to
+    // us through `Complete`.
+    RECEIVED_STORAGESHUTDOWN_COMPLETE,
+    // d. We have notified observers that Places has closed the connection.
+    NOTIFIED_OBSERVERS_PLACES_CONNECTION_CLOSED,
+  };
+  States State() {
+    return mState;
+  }
+
+protected:
+  // The blocker name, also used as barrier name.
+  nsString mName;
+  // The current state, see States.
+  States mState;
+  // The barrier optionally used to wait for clients.
+  nsMainThreadPtrHandle<nsIAsyncShutdownBarrier> mBarrier;
+  // The parent object who registered this as a blocker.
+  nsMainThreadPtrHandle<nsIAsyncShutdownClient> mParentClient;
+
+  // As tests may resurrect a dead `Database`, we use a counter to
+  // give the instances of `PlacesShutdownBlocker` unique names.
+  uint16_t mCounter;
+  static uint16_t sCounter;
+
+  static Atomic<bool> sIsStarted;
+
+  virtual ~PlacesShutdownBlocker() {}
+};
+
+/**
+ * Blocker also used to wait for clients, through an owned barrier.
+ */
+class ClientsShutdownBlocker final : public PlacesShutdownBlocker
+                                   , public nsIAsyncShutdownCompletionCallback
+{
+public:
+  NS_DECL_ISUPPORTS_INHERITED
+  NS_DECL_NSIASYNCSHUTDOWNCOMPLETIONCALLBACK
+
+  explicit ClientsShutdownBlocker();
+
+  NS_IMETHOD BlockShutdown(nsIAsyncShutdownClient* aParentClient) override;
+
+  already_AddRefed<nsIAsyncShutdownClient> GetClient();
+
+private:
+  ~ClientsShutdownBlocker() {}
+};
+
+/**
+ * Blocker used to wait when closing the database connection.
+ */
+class ConnectionShutdownBlocker final : public PlacesShutdownBlocker
+                                      , public mozIStorageCompletionCallback
+{
+public:
+  NS_DECL_ISUPPORTS_INHERITED
+  NS_DECL_MOZISTORAGECOMPLETIONCALLBACK
+
+  NS_IMETHOD BlockShutdown(nsIAsyncShutdownClient* aParentClient) override;
+
+  explicit ConnectionShutdownBlocker(mozilla::places::Database* aDatabase);
+
+private:
+  ~ConnectionShutdownBlocker() {}
+
+  // The owning database.
+  // The cycle is broken in method Complete(), once the connection
+  // has been closed by mozStorage.
+  RefPtr<mozilla::places::Database> mDatabase;
+};
+
+} // namespace places
+} // namespace mozilla
+
+#endif // mozilla_places_Shutdown_h_
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -42,16 +42,17 @@ if CONFIG['MOZ_PLACES']:
         'nsAnnotationService.cpp',
         'nsFaviconService.cpp',
         'nsNavBookmarks.cpp',
         'nsNavHistory.cpp',
         'nsNavHistoryQuery.cpp',
         'nsNavHistoryResult.cpp',
         'nsPlacesModule.cpp',
         'PlaceInfo.cpp',
+        'Shutdown.cpp',
         'SQLFunctions.cpp',
         'VisitInfo.cpp',
     ]
 
     LOCAL_INCLUDES += [
         '../build',
     ]
 
--- a/toolkit/components/places/nsIBrowserHistory.idl
+++ b/toolkit/components/places/nsIBrowserHistory.idl
@@ -74,16 +74,18 @@ interface nsIBrowserHistory : nsISupport
      * Any pages that becomes unvisited as a result will also be deleted.
      *
      * @param aBeginTime
      *        Microseconds from epoch, representing the initial time.
      * @param aEndTime
      *        Microseconds from epoch, representing the final time.
      *
      * @note The removal happens in a batch.
+     *
+     * @deprecated Please use PlacesUtils.history.removeVisitsByFilter instead
      */
     void removeVisitsByTimeframe(in PRTime aBeginTime,
                                  in PRTime aEndTime);
 
     /**
      * Removes all existing pages from global history.
      * Visits are removed synchronously, but pages are expired asynchronously
      * off the main-thread.
--- a/toolkit/components/places/nsNavHistory.cpp
+++ b/toolkit/components/places/nsNavHistory.cpp
@@ -2698,16 +2698,18 @@ nsNavHistory::RemovePagesByTimeframe(PRT
  * @param aBeginTime
  *        The start of the timeframe, inclusive
  * @param aEndTime
  *        The end of the timeframe, inclusive
  */
 NS_IMETHODIMP
 nsNavHistory::RemoveVisitsByTimeframe(PRTime aBeginTime, PRTime aEndTime)
 {
+  PLACES_WARN_DEPRECATED();
+
   NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
 
   nsresult rv;
 
   // Build a list of place IDs whose visits fall entirely within the timespan.
   // These places will be deleted by the call to CleanupPlacesOnVisitsDelete
   // below.
   nsCString deletePlaceIdsQueryString;
@@ -2965,17 +2967,17 @@ nsNavHistory::GetDBConnection(mozIStorag
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsNavHistory::GetShutdownClient(nsIAsyncShutdownClient **_shutdownClient)
 {
   NS_ENSURE_ARG_POINTER(_shutdownClient);
-  RefPtr<nsIAsyncShutdownClient> client = mDB->GetConnectionShutdown();
+  RefPtr<nsIAsyncShutdownClient> client = mDB->GetClientsShutdown();
   MOZ_ASSERT(client);
   client.forget(_shutdownClient);
 
   return NS_OK;
 }
 
 NS_IMETHODIMP
 nsNavHistory::AsyncExecuteLegacyQueries(nsINavHistoryQuery** aQueries,
@@ -3077,18 +3079,17 @@ nsNavHistory::NotifyOnPageExpired(nsIURI
 
 NS_IMETHODIMP
 nsNavHistory::Observe(nsISupports *aSubject, const char *aTopic,
                     const char16_t *aData)
 {
   NS_ASSERTION(NS_IsMainThread(), "This can only be called on the main thread");
   if (strcmp(aTopic, TOPIC_PROFILE_TEARDOWN) == 0 ||
       strcmp(aTopic, TOPIC_PROFILE_CHANGE) == 0 ||
-      strcmp(aTopic, TOPIC_SIMULATE_PLACES_MUST_CLOSE_1) == 0 ||
-      strcmp(aTopic, TOPIC_SIMULATE_PLACES_MUST_CLOSE_2) == 0) {
+      strcmp(aTopic, TOPIC_SIMULATE_PLACES_SHUTDOWN) == 0) {
     // These notifications are used by tests to simulate a Places shutdown.
     // They should just be forwarded to the Database handle.
     mDB->Observe(aSubject, aTopic, aData);
   }
 
   else if (strcmp(aTopic, TOPIC_PLACES_CONNECTION_CLOSED) == 0) {
       // Don't even try to notify observers from this point on, the category
       // cache would init services that could try to use our APIs.
--- a/toolkit/components/places/tests/head_common.js
+++ b/toolkit/components/places/tests/head_common.js
@@ -380,20 +380,20 @@ function promiseTopicObserved(aTopic)
  * Simulates a Places shutdown.
  */
 var shutdownPlaces = function() {
   do_print("shutdownPlaces: starting");
   let promise = new Promise(resolve => {
     Services.obs.addObserver(resolve, "places-connection-closed", false);
   });
   let hs = PlacesUtils.history.QueryInterface(Ci.nsIObserver);
-  hs.observe(null, "test-simulate-places-shutdown-phase-1", null);
-  do_print("shutdownPlaces: sent test-simulate-places-shutdown-phase-1");
-  hs.observe(null, "test-simulate-places-shutdown-phase-2", null);
-  do_print("shutdownPlaces: sent test-simulate-places-shutdown-phase-2");
+  hs.observe(null, "profile-change-teardown", null);
+  do_print("shutdownPlaces: sent profile-change-teardown");
+  hs.observe(null, "test-simulate-places-shutdown", null);
+  do_print("shutdownPlaces: sent test-simulate-places-shutdown");
   return promise.then(() => {
     do_print("shutdownPlaces: complete");
   });
 };
 
 const FILENAME_BOOKMARKS_HTML = "bookmarks.html";
 const FILENAME_BOOKMARKS_JSON = "bookmarks-" +
   (PlacesBackups.toISODateString(new Date())) + ".json";
rename from toolkit/components/places/tests/unit/test_removeVisitsByTimeframe.js
rename to toolkit/components/places/tests/history/test_removeVisits.js
--- a/toolkit/components/places/tests/unit/test_removeVisitsByTimeframe.js
+++ b/toolkit/components/places/tests/history/test_removeVisits.js
@@ -1,53 +1,52 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
-/* vim:set ts=2 sw=2 sts=2 et: */
-/* 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 NOW = Date.now() * 1000;
+const JS_NOW = Date.now();
+const DB_NOW = JS_NOW * 1000;
 const TEST_URI = uri("http://example.com/");
 const PLACE_URI = uri("place:queryType=0&sort=8&maxResults=10");
 
 function* cleanup() {
   yield PlacesTestUtils.clearHistory();
   yield PlacesUtils.bookmarks.eraseEverything();
   // This is needed to remove place: entries.
   DBConn().executeSimpleSQL("DELETE FROM moz_places");
 }
 
 add_task(function* remove_visits_outside_unbookmarked_uri() {
   do_print("*** TEST: Remove some visits outside valid timeframe from an unbookmarked URI");
 
   do_print("Add 10 visits for the URI from way in the past.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - 1000 - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
 
   do_print("Remove visits using timerange outside the URI's visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 10),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that all visits still exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
   opts.resultType = opts.RESULTS_AS_VISIT;
   opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
   let root = PlacesUtils.history.executeQuery(query, opts).root;
   root.containerOpen = true;
   do_check_eq(root.childCount, 10);
   for (let i = 0; i < root.childCount; i++) {
     let visitTime = root.getChild(i).time;
-    do_check_eq(visitTime, NOW - 1000 - i);
+    do_check_eq(visitTime, DB_NOW - 100000 - (i * 1000));
   }
   root.containerOpen = false;
 
   do_print("asyncHistory.isURIVisited should return true.");
   do_check_true(yield promiseIsURIVisited(TEST_URI));
 
   yield PlacesTestUtils.promiseAsyncUpdates();
   do_print("Frecency should be positive.")
@@ -57,44 +56,48 @@ add_task(function* remove_visits_outside
 });
 
 add_task(function* remove_visits_outside_bookmarked_uri() {
   do_print("*** TEST: Remove some visits outside valid timeframe from a bookmarked URI");
 
   do_print("Add 10 visits for the URI from way in the past.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - 1000 - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - 100000 - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
   do_print("Bookmark the URI.");
   PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
                                        TEST_URI,
                                        PlacesUtils.bookmarks.DEFAULT_INDEX,
                                        "bookmark title");
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("Remove visits using timerange outside the URI's visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 10),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that all visits still exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
   opts.resultType = opts.RESULTS_AS_VISIT;
   opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
   let root = PlacesUtils.history.executeQuery(query, opts).root;
   root.containerOpen = true;
   do_check_eq(root.childCount, 10);
   for (let i = 0; i < root.childCount; i++) {
     let visitTime = root.getChild(i).time;
-    do_check_eq(visitTime, NOW - 1000 - i);
+    do_check_eq(visitTime, DB_NOW - 100000 - (i * 1000));
   }
   root.containerOpen = false;
 
   do_print("asyncHistory.isURIVisited should return true.");
   do_check_true(yield promiseIsURIVisited(TEST_URI));
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("Frecency should be positive.")
@@ -104,38 +107,42 @@ add_task(function* remove_visits_outside
 });
 
 add_task(function* remove_visits_unbookmarked_uri() {
   do_print("*** TEST: Remove some visits from an unbookmarked URI");
 
   do_print("Add 10 visits for the URI from now to 9 usecs in the past.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
 
   do_print("Remove the 5 most recent visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 4, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 4),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that only the older 5 visits still exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
   opts.resultType = opts.RESULTS_AS_VISIT;
   opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
   let root = PlacesUtils.history.executeQuery(query, opts).root;
   root.containerOpen = true;
   do_check_eq(root.childCount, 5);
   for (let i = 0; i < root.childCount; i++) {
     let visitTime = root.getChild(i).time;
-    do_check_eq(visitTime, NOW - i - 5);
+    do_check_eq(visitTime, DB_NOW - (i * 1000) - 5000);
   }
   root.containerOpen = false;
 
   do_print("asyncHistory.isURIVisited should return true.");
   do_check_true(yield promiseIsURIVisited(TEST_URI));
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("Frecency should be positive.")
@@ -145,44 +152,48 @@ add_task(function* remove_visits_unbookm
 });
 
 add_task(function* remove_visits_bookmarked_uri() {
   do_print("*** TEST: Remove some visits from a bookmarked URI");
 
   do_print("Add 10 visits for the URI from now to 9 usecs in the past.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
   do_print("Bookmark the URI.");
   PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
                                        TEST_URI,
                                        PlacesUtils.bookmarks.DEFAULT_INDEX,
                                        "bookmark title");
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("Remove the 5 most recent visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 4, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 4),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that only the older 5 visits still exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
   opts.resultType = opts.RESULTS_AS_VISIT;
   opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
   let root = PlacesUtils.history.executeQuery(query, opts).root;
   root.containerOpen = true;
   do_check_eq(root.childCount, 5);
   for (let i = 0; i < root.childCount; i++) {
     let visitTime = root.getChild(i).time;
-    do_check_eq(visitTime, NOW - i - 5);
+    do_check_eq(visitTime, DB_NOW - (i * 1000) - 5000);
   }
   root.containerOpen = false;
 
   do_print("asyncHistory.isURIVisited should return true.");
   do_check_true(yield promiseIsURIVisited(TEST_URI));
   yield PlacesTestUtils.promiseAsyncUpdates()
 
   do_print("Frecency should be positive.")
@@ -192,22 +203,26 @@ add_task(function* remove_visits_bookmar
 });
 
 add_task(function* remove_all_visits_unbookmarked_uri() {
   do_print("*** TEST: Remove all visits from an unbookmarked URI");
 
   do_print("Add some visits for the URI.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
 
   do_print("Remove all visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 10),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should no longer exist in moz_places.");
   do_check_false(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that no visits exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
@@ -219,70 +234,39 @@ add_task(function* remove_all_visits_unb
   root.containerOpen = false;
 
   do_print("asyncHistory.isURIVisited should return false.");
   do_check_false(yield promiseIsURIVisited(TEST_URI));
 
   yield cleanup();
 });
 
-add_task(function* remove_all_visits_unbookmarked_place_uri() {
-  do_print("*** TEST: Remove all visits from an unbookmarked place: URI");
-  do_print("Add some visits for the URI.");
-  let visits = [];
-  for (let i = 0; i < 10; i++) {
-    visits.push({ uri: PLACE_URI, visitDate: NOW - i });
-  }
-  yield PlacesTestUtils.addVisits(visits);
-
-  do_print("Remove all visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
-  yield PlacesTestUtils.promiseAsyncUpdates();
-
-  do_print("URI should still exist in moz_places.");
-  do_check_true(page_in_database(PLACE_URI.spec));
-
-  do_print("Run a history query and check that no visits exist.");
-  let query = PlacesUtils.history.getNewQuery();
-  let opts = PlacesUtils.history.getNewQueryOptions();
-  opts.resultType = opts.RESULTS_AS_VISIT;
-  opts.sortingMode = opts.SORT_BY_DATE_DESCENDING;
-  let root = PlacesUtils.history.executeQuery(query, opts).root;
-  root.containerOpen = true;
-  do_check_eq(root.childCount, 0);
-  root.containerOpen = false;
-
-  do_print("asyncHistory.isURIVisited should return false.");
-  do_check_false(yield promiseIsURIVisited(PLACE_URI));
-  yield PlacesTestUtils.promiseAsyncUpdates();
-
-  do_print("Frecency should be zero.")
-  do_check_eq(frecencyForUrl(PLACE_URI.spec), 0);
-
-  yield cleanup();
-});
-
 add_task(function* remove_all_visits_bookmarked_uri() {
   do_print("*** TEST: Remove all visits from a bookmarked URI");
 
   do_print("Add some visits for the URI.");
   let visits = [];
   for (let i = 0; i < 10; i++) {
-    visits.push({ uri: TEST_URI, visitDate: NOW - i });
+    visits.push({ uri: TEST_URI, visitDate: DB_NOW - (i * 1000) });
   }
   yield PlacesTestUtils.addVisits(visits);
   do_print("Bookmark the URI.");
   PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId,
                                        TEST_URI,
                                        PlacesUtils.bookmarks.DEFAULT_INDEX,
                                        "bookmark title");
   yield PlacesTestUtils.promiseAsyncUpdates();
+  let initialFrecency = frecencyForUrl(TEST_URI);
 
   do_print("Remove all visits.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 10),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
 
   do_print("Run a history query and check that no visits exist.");
   let query = PlacesUtils.history.getNewQuery();
   let opts = PlacesUtils.history.getNewQueryOptions();
@@ -295,33 +279,37 @@ add_task(function* remove_all_visits_boo
 
   do_print("asyncHistory.isURIVisited should return false.");
   do_check_false(yield promiseIsURIVisited(TEST_URI));
 
   do_print("nsINavBookmarksService.isBookmarked should return true.");
   do_check_true(PlacesUtils.bookmarks.isBookmarked(TEST_URI));
   yield PlacesTestUtils.promiseAsyncUpdates();
 
-  do_print("Frecency should be negative.")
-  do_check_true(frecencyForUrl(TEST_URI) < 0);
+  do_print("Frecency should be smaller.")
+  do_check_true(frecencyForUrl(TEST_URI) < initialFrecency);
 
   yield cleanup();
 });
 
 add_task(function* remove_all_visits_bookmarked_uri() {
   do_print("*** TEST: Remove some visits from a zero frecency URI retains zero frecency");
 
   do_print("Add some visits for the URI.");
   yield PlacesTestUtils.addVisits([
-    { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: (NOW - 86400000000) },
-    { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: NOW }
+    { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: (DB_NOW - 86400000000000) },
+    { uri: TEST_URI, transition: TRANSITION_FRAMED_LINK, visitDate: DB_NOW }
   ]);
 
   do_print("Remove newer visit.");
-  PlacesUtils.history.removeVisitsByTimeframe(NOW - 10, NOW);
+  let filter = {
+    beginDate: new Date(JS_NOW - 10),
+    endDate: new Date(JS_NOW)
+  };
+  yield PlacesUtils.history.removeVisitsByFilter(filter);
   yield PlacesTestUtils.promiseAsyncUpdates();
 
   do_print("URI should still exist in moz_places.");
   do_check_true(page_in_database(TEST_URI.spec));
   do_print("Frecency should be zero.")
   do_check_eq(frecencyForUrl(TEST_URI), 0);
 
   yield cleanup();
--- a/toolkit/components/places/tests/history/xpcshell.ini
+++ b/toolkit/components/places/tests/history/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head = head_history.js
 tail =
 
 [test_remove.js]
+[test_removeVisits.js]
 [test_removeVisitsByFilter.js]
--- a/toolkit/components/places/tests/unit/xpcshell.ini
+++ b/toolkit/components/places/tests/unit/xpcshell.ini
@@ -68,17 +68,17 @@ skip-if = os == "android"
 [test_async_history_api.js]
 [test_async_in_batchmode.js]
 [test_async_transactions.js]
 skip-if = (os == "win" && os_version == "5.1") # Bug 1158887
 [test_autocomplete_stopSearch_no_throw.js]
 [test_bookmark_catobs.js]
 [test_bookmarks_json.js]
 [test_bookmarks_html.js]
-[test_bookmarks_html_corrupt.js]
+[test_bookmarks_html_corrupt.js]
 [test_bookmarks_html_import_tags.js]
 [test_bookmarks_html_singleframe.js]
 [test_bookmarks_restore_notification.js]
 [test_bookmarks_setNullTitle.js]
 [test_broken_folderShortcut_result.js]
 [test_browserhistory.js]
 [test_bug636917_isLivemark.js]
 [test_childlessTags.js]
@@ -129,17 +129,16 @@ skip-if = true
 [test_preventive_maintenance.js]
 # Bug 676989: test hangs consistently on Android
 skip-if = os == "android"
 [test_preventive_maintenance_checkAndFixDatabase.js]
 # Bug 676989: test hangs consistently on Android
 skip-if = os == "android"
 [test_preventive_maintenance_runTasks.js]
 [test_promiseBookmarksTree.js]
-[test_removeVisitsByTimeframe.js]
 # Bug 676989: test hangs consistently on Android
 skip-if = os == "android"
 [test_resolveNullBookmarkTitles.js]
 [test_result_sort.js]
 [test_sql_guid_functions.js]
 [test_svg_favicon.js]
 [test_tag_autocomplete_search.js]
 [test_tagging.js]