Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Mon, 24 Aug 2015 20:57:36 -0400
changeset 259145 04b8c412d9f58fb6194c58dcaa66bf278bbd53cf
parent 259106 08015770c9d687f93fc7b13970d32e457b680d2a (current diff)
parent 259144 0c36b5a077ee47c75b35235d2fac47def2d7969f (diff)
child 259146 f3df9cd1701f617418ebf1a7a5947150d5d24939
child 259151 f30a434924330155bc7f7219d6f8d9f9340304a5
child 259167 392740237dbea7aaae84ca9ce0d3e72367466e87
child 259194 00ca1f2a491076f8b22de263ac54671c604605de
push id29269
push userryanvm@gmail.com
push dateTue, 25 Aug 2015 00:57:59 +0000
treeherdermozilla-central@04b8c412d9f5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone43.0a1
first release with
nightly linux32
04b8c412d9f5 / 43.0a1 / 20150825030212 / files
nightly linux64
04b8c412d9f5 / 43.0a1 / 20150825030212 / files
nightly mac
04b8c412d9f5 / 43.0a1 / 20150825030212 / files
nightly win32
04b8c412d9f5 / 43.0a1 / 20150825030212 / files
nightly win64
04b8c412d9f5 / 43.0a1 / 20150825030212 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c. a=merge
browser/components/preferences/aboutPermissions.js
browser/components/translation/test/browser_translation_fhr.js
browser/components/translation/test/unit/test_healthreport.js
browser/themes/linux/tabbrowser/tab-separator.png
browser/themes/osx/tabbrowser/tab-separator.png
browser/themes/osx/tabbrowser/tab-separator@2x.png
browser/themes/windows/tabbrowser/tab-separator-XP.png
browser/themes/windows/tabbrowser/tab-separator-luna-blue.png
browser/themes/windows/tabbrowser/tab-separator.png
caps/nsIScriptSecurityManager.idl
mobile/android/base/moz.build
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1441,16 +1441,20 @@ pref("devtools.performance.ui.invert-fla
 pref("devtools.performance.ui.flatten-tree-recursion", true);
 pref("devtools.performance.ui.show-platform-data", false);
 pref("devtools.performance.ui.show-idle-blocks", true);
 pref("devtools.performance.ui.enable-memory", false);
 pref("devtools.performance.ui.enable-allocations", false);
 pref("devtools.performance.ui.enable-framerate", true);
 pref("devtools.performance.ui.enable-jit-optimizations", false);
 
+// Temporary pref disabling memory flame views
+// TODO remove once we have flame charts via bug 1148663
+pref("devtools.performance.ui.enable-memory-flame", false);
+
 // Enable experimental options in the UI only in Nightly
 #if defined(NIGHTLY_BUILD)
 pref("devtools.performance.ui.experimental", true);
 #else
 pref("devtools.performance.ui.experimental", false);
 #endif
 
 // The default cache UI setting
@@ -1870,20 +1874,19 @@ pref("experiments.supported", true);
 // Enable GMP support in the addon manager.
 pref("media.gmp-provider.enabled", true);
 
 pref("browser.apps.URL", "https://marketplace.firefox.com/discovery/");
 
 #ifdef NIGHTLY_BUILD
 pref("browser.polaris.enabled", false);
 pref("privacy.trackingprotection.ui.enabled", false);
-pref("privacy.trackingprotection.introURL", "https://support.mozilla.org/kb/tracking-protection-firefox");
 #endif
 pref("privacy.trackingprotection.introCount", 0);
-pref("privacy.trackingprotection.introURL", "https://support.mozilla.org/kb/tracking-protection-firefox");
+pref("privacy.trackingprotection.introURL", "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/tracking-protection-pbm");
 
 #ifndef RELEASE_BUILD
 // At the moment, autostart.2 is used, while autostart.1 is unused.
 // We leave it here set to false to reset users' defaults and allow
 // us to change everybody to true in the future, when desired.
 pref("browser.tabs.remote.autostart.1", false);
 pref("browser.tabs.remote.autostart.2", true);
 #endif
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -1,13 +1,39 @@
 # -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
 # 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/.
 
+// Removes a doorhanger notification if all of the installs it was notifying
+// about have ended in some way.
+function removeNotificationOnEnd(notification, installs) {
+  let count = installs.length;
+
+  function maybeRemove(install) {
+    install.removeListener(this);
+
+    if (--count == 0) {
+      // Check that the notification is still showing
+      let current = PopupNotifications.getNotification(notification.id, notification.browser);
+      if (current === notification)
+        notification.remove();
+    }
+  }
+
+  for (let install of installs) {
+    install.addListener({
+      onDownloadCancelled: maybeRemove,
+      onDownloadFailed: maybeRemove,
+      onInstallFailed: maybeRemove,
+      onInstallEnded: maybeRemove
+    });
+  }
+}
+
 const gXPInstallObserver = {
   _findChildShell: function (aDocShell, aSoughtShell)
   {
     if (aDocShell == aSoughtShell)
       return aDocShell;
 
     var node = aDocShell.QueryInterface(Components.interfaces.nsIDocShellTreeItem);
     for (var i = 0; i < node.childCount; ++i) {
@@ -38,45 +64,55 @@ const gXPInstallObserver = {
       if (pending) {
         pending.push(installInfo);
       } else {
         this.pendingInstalls.set(browser, [installInfo]);
       }
       return;
     }
 
+    let showNextConfirmation = () => {
+      // Make sure the browser is still alive.
+      if (gBrowser.browsers.indexOf(browser) == -1)
+        return;
+
+      let pending = this.pendingInstalls.get(browser);
+      if (pending && pending.length)
+        this.showInstallConfirmation(browser, pending.shift());
+    }
+
+    // If all installs have already been cancelled in some way then just show
+    // the next confirmation
+    if (installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)) {
+      showNextConfirmation();
+      return;
+    }
+
     const anchorID = "addons-notification-icon";
 
     // Make notifications persist a minimum of 30 seconds
     var options = {
       displayURI: installInfo.originatingURI,
       timeout: Date.now() + 30000,
     };
 
     let cancelInstallation = () => {
       if (installInfo) {
-        for (let install of installInfo.installs)
-          install.cancel();
+        for (let install of installInfo.installs) {
+          // The notification may have been closed because the add-ons got
+          // cancelled elsewhere, only try to cancel those that are still
+          // pending install.
+          if (install.state != AddonManager.STATE_CANCELLED)
+            install.cancel();
+        }
       }
 
       this.acceptInstallation = null;
 
-      let tab = gBrowser.getTabForBrowser(browser);
-      if (tab)
-        tab.removeEventListener("TabClose", cancelInstallation);
-
-      window.removeEventListener("unload", cancelInstallation);
-
-      // Make sure the browser is still alive.
-      if (gBrowser.browsers.indexOf(browser) == -1)
-        return;
-
-      let pending = this.pendingInstalls.get(browser);
-      if (pending && pending.length)
-        this.showInstallConfirmation(browser, pending.shift());
+      showNextConfirmation();
     };
 
     let unsigned = installInfo.installs.filter(i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING);
     let someUnsigned = unsigned.length > 0 && unsigned.length < installInfo.installs.length;
 
     options.eventCallback = (aEvent) => {
       switch (aEvent) {
         case "removed":
@@ -160,23 +196,23 @@ const gXPInstallObserver = {
     if (height) {
       let notification = document.getElementById("addon-install-confirmation-notification");
       notification.style.minHeight = height + "px";
     }
 
     let tab = gBrowser.getTabForBrowser(browser);
     if (tab) {
       gBrowser.selectedTab = tab;
-      tab.addEventListener("TabClose", cancelInstallation);
     }
 
-    window.addEventListener("unload", cancelInstallation);
+    let popup = PopupNotifications.show(browser, "addon-install-confirmation",
+                                        messageString, anchorID, null, null,
+                                        options);
 
-    PopupNotifications.show(browser, "addon-install-confirmation", messageString,
-                            anchorID, null, null, options);
+    removeNotificationOnEnd(popup, installInfo.installs);
 
     Services.telemetry
             .getHistogramById("SECURITY_UI")
             .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
   },
 
   observe: function (aSubject, aTopic, aData)
   {
@@ -217,33 +253,46 @@ const gXPInstallObserver = {
             gPrefService.setBoolPref("xpinstall.enabled", true);
           }
         };
       }
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
       break; }
+    case "addon-install-origin-blocked": {
+      messageString = gNavigatorBundle.getFormattedString("xpinstallPromptMessage",
+                        [brandShortName]);
+
+      let secHistogram = Components.classes["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry).getHistogramById("SECURITY_UI");
+      secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED);
+      let popup = PopupNotifications.show(browser, notificationID,
+                                          messageString, anchorID,
+                                          null, null, options);
+      removeNotificationOnEnd(popup, installInfo.installs);
+      break; }
     case "addon-install-blocked": {
       messageString = gNavigatorBundle.getFormattedString("xpinstallPromptMessage",
                         [brandShortName]);
 
       let secHistogram = Components.classes["@mozilla.org/base/telemetry;1"].getService(Ci.nsITelemetry).getHistogramById("SECURITY_UI");
       action = {
         label: gNavigatorBundle.getString("xpinstallPromptAllowButton"),
         accessKey: gNavigatorBundle.getString("xpinstallPromptAllowButton.accesskey"),
         callback: function() {
           secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH);
           installInfo.install();
         }
       };
 
       secHistogram.add(Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED);
-      PopupNotifications.show(browser, notificationID, messageString, anchorID,
-                              action, null, options);
+      let popup = PopupNotifications.show(browser, notificationID,
+                                          messageString, anchorID,
+                                          action, null, options);
+      removeNotificationOnEnd(popup, installInfo.installs);
       break; }
     case "addon-install-started": {
       let needsDownload = function needsDownload(aInstall) {
         return aInstall.state != AddonManager.STATE_DOWNLOADED;
       }
       // If all installs have already been downloaded then there is no need to
       // show the download progress
       if (!installInfo.installs.some(needsDownload))
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -49,18 +49,16 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "mozIAsyncFavicons");
 XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
                                    "@mozilla.org/network/dns-service;1",
                                    "nsIDNSService");
 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
                                   "resource://gre/modules/LightweightThemeManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Pocket",
                                   "resource:///modules/Pocket.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "NewTabURL",
-                                  "resource:///modules/NewTabURL.jsm");
 
 // Can't use XPCOMUtils for these because the scripts try to define the variables
 // on window, and so the defineProperty inside defineLazyGetter fails.
 Object.defineProperty(window, "pktApi", {
   get: function() {
     // Avoid this getter running again:
     delete window.pktApi;
     Services.scriptloader.loadSubScript("chrome://browser/content/pocket/pktApi.js", window);
@@ -1250,16 +1248,17 @@ var gBrowserInit = {
     // Bug 778855 - Perf regression if we do this here. To be addressed in bug 779008.
     setTimeout(function() { SafeBrowsing.init(); }, 2000);
 #endif
 
     Services.obs.addObserver(gSessionHistoryObserver, "browser:purge-session-history", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-started", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked", false);
+    Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-failed", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation", false);
     Services.obs.addObserver(gXPInstallObserver, "addon-install-complete", false);
     window.messageManager.addMessageListener("Browser:URIFixup", gKeywordURIFixup);
 
     BrowserOffline.init();
     OfflineApps.init();
     IndexedDBPromptHelper.init();
@@ -1566,16 +1565,17 @@ var gBrowserInit = {
       gBrowserThumbnails.uninit();
       LoopUI.uninit();
       FullZoom.destroy();
 
       Services.obs.removeObserver(gSessionHistoryObserver, "browser:purge-session-history");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-disabled");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-started");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-blocked");
+      Services.obs.removeObserver(gXPInstallObserver, "addon-install-origin-blocked");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-failed");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-confirmation");
       Services.obs.removeObserver(gXPInstallObserver, "addon-install-complete");
       window.messageManager.removeMessageListener("Browser:URIFixup", gKeywordURIFixup);
       window.messageManager.removeMessageListener("Browser:LoadURI", RedirectLoad);
 
       try {
         gPrefService.removeObserver(gHomeButton.prefDomain, gHomeButton);
--- a/browser/base/content/test/general/browser_bug553455.js
+++ b/browser/base/content/test/general/browser_bug553455.js
@@ -189,18 +189,17 @@ function test_disabled_install() {
       }
       catch (e) {
         ok(false, "xpinstall.enabled should be set");
       }
 
       gBrowser.removeTab(gBrowser.selectedTab);
 
       AddonManager.getAllInstalls(function(aInstalls) {
-        is(aInstalls.length, 1, "Should have been one install created");
-        aInstalls[0].cancel();
+        is(aInstalls.length, 0, "Shouldn't be any pending installs");
 
         runNextTest();
       });
     });
 
     // Click on Enable
     EventUtils.synthesizeMouseAtCenter(notification.button, {});
   });
@@ -669,18 +668,20 @@ function test_url() {
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
       accept_install_dialog();
     });
   });
 
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  });
 },
 
 function test_localfile() {
   // Wait for the install to fail
   Services.obs.addObserver(function() {
     Services.obs.removeObserver(arguments.callee, "addon-install-failed");
 
     // Wait for the browser code to add the failure notification
@@ -698,18 +699,20 @@ function test_localfile() {
 
   var cr = Components.classes["@mozilla.org/chrome/chrome-registry;1"]
                      .getService(Components.interfaces.nsIChromeRegistry);
   try {
     var path = cr.convertChromeURL(makeURI(CHROMEROOT + "corrupt.xpi")).spec;
   } catch (ex) {
     var path = CHROMEROOT + "corrupt.xpi";
   }
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(path);
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(path);
+  });
 },
 
 function test_tabclose() {
   if (!Preferences.get("xpinstall.customConfirmationUI", false)) {
     runNextTest();
     return;
   }
 
@@ -727,18 +730,80 @@ function test_tabclose() {
           });
         });
 
         gBrowser.removeTab(gBrowser.selectedTab);
       });
     });
   });
 
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  });
+},
+
+// Add-ons should be cancelled and the install notification destroyed when
+// navigating to a new origin
+function test_tabnavigate() {
+  if (!Preferences.get("xpinstall.customConfirmationUI", false)) {
+    runNextTest();
+    return;
+  }
+
+  // Wait for the progress notification
+  wait_for_progress_notification(aPanel => {
+    // Wait for the install confirmation dialog
+    wait_for_install_dialog(() => {
+      wait_for_notification_close(() => {
+        AddonManager.getAllInstalls(aInstalls => {
+          is(aInstalls.length, 0, "Should be no pending install");
+
+          Services.perms.remove(makeURI("http://example.com/"), "install");
+          loadPromise.then(() => {
+            gBrowser.removeTab(gBrowser.selectedTab);
+            runNextTest();
+          });
+        });
+      });
+
+      let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+      gBrowser.loadURI("about:blank");
+    });
+  });
+
+  var pm = Services.perms;
+  pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
+
+  var triggers = encodeURIComponent(JSON.stringify({
+    "Extension XPI": "unsigned.xpi"
+  }));
   gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
+},
+
+function test_urlbar() {
+  wait_for_notification("addon-install-origin-blocked", function(aPanel) {
+    let notification = aPanel.childNodes[0];
+
+    is(notification.button.label, "", "Button to allow install should be hidden.");
+
+    wait_for_notification_close(() => {
+      runNextTest();
+    });
+
+    gBrowser.removeCurrentTab();
+  });
+
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gURLBar.value = TESTROOT + "unsigned.xpi";
+    gURLBar.focus();
+    EventUtils.synthesizeKey("VK_RETURN", {});
+  });
 },
 
 function test_wronghost() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.addEventListener("load", function() {
     if (gBrowser.currentURI.spec != TESTROOT2 + "enabled.html")
       return;
 
@@ -865,22 +930,26 @@ function test_renotify_blocked() {
   wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
 
     wait_for_notification_close(function () {
       info("Timeouts after this probably mean bug 589954 regressed");
       executeSoon(function () {
         wait_for_notification("addon-install-blocked", function(aPanel) {
           AddonManager.getAllInstalls(function(aInstalls) {
-          is(aInstalls.length, 2, "Should be two pending installs");
-            aInstalls[0].cancel();
-            aInstalls[1].cancel();
+            is(aInstalls.length, 2, "Should be two pending installs");
+
+            wait_for_notification_close(() => {
+              AddonManager.getAllInstalls(function(aInstalls) {
+                is(aInstalls.length, 0, "Should have cancelled the installs");
+                runNextTest();
+              });
+            });
 
             info("Closing browser tab");
-            wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
 
         gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
       });
     });
 
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -689,63 +689,103 @@ BrowserGlue.prototype = {
       }
 
       let brandBundle = win.document.getElementById("bundle_brand");
       let brandShortName = brandBundle.getString("brandShortName");
       let message = win.gNavigatorBundle.getFormattedString("addonwatch.slow", [addon.name, brandShortName]);
       let notificationBox = win.document.getElementById("global-notificationbox");
       let notificationId = 'addon-slow:' + addonId;
       let notification = notificationBox.getNotificationWithValue(notificationId);
-      if(notification) {
+
+      // Monitor the response of users
+      const STATE_WARNING_DISPLAYED = 0;
+      const STATE_USER_PICKED_DISABLE = 1;
+      const STATE_USER_PICKED_IGNORE_FOR_NOW = 2;
+      const STATE_USER_PICKED_IGNORE_FOREVER = 3;
+      const STATE_USER_CLOSED_NOTIFICATION = 4;
+
+      let update = function(response) {
+        Services.telemetry.getHistogramById("SLOW_ADDON_WARNING_STATES").add(response);
+      }
+
+      let complete = false;
+      let start = Date.now();
+      let done = function(response) {
+        // Only report the first reason for closing.
+        if (complete) {
+          return;
+        }
+        complete = true;
+        update(response);
+        Services.telemetry.getHistogramById("SLOW_ADDON_WARNING_RESPONSE_TIME").add(Date.now() - start);
+      };
+
+      update(STATE_WARNING_DISPLAYED);
+
+      if (notification) {
         notification.label = message;
       } else {
         let buttons = [
           {
             label: win.gNavigatorBundle.getFormattedString("addonwatch.disable.label", [addon.name]),
             accessKey: "", // workaround for bug 1192901
             callback: function() {
+              done(STATE_USER_PICKED_DISABLE);
               addon.userDisabled = true;
-              if (addon.pendingOperations != addon.PENDING_NONE) {
-                let restartMessage = win.gNavigatorBundle.getFormattedString("addonwatch.restart.message", [addon.name, brandShortName]);
-                let restartButton = [
-                  {
-                    label: win.gNavigatorBundle.getFormattedString("addonwatch.restart.label", [brandShortName]),
-                    accessKey: win.gNavigatorBundle.getString("addonwatch.restart.accesskey"),
-                    callback: function() {
-                      let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"]
-                        .getService(Ci.nsIAppStartup);
-                      appStartup.quit(appStartup.eForceQuit | appStartup.eRestart);
-                    }
+              if (addon.pendingOperations == addon.PENDING_NONE) {
+                return;
+              }
+              let restartMessage = win.gNavigatorBundle.getFormattedString("addonwatch.restart.message", [addon.name, brandShortName]);
+              let restartButton = [
+                {
+                  label: win.gNavigatorBundle.getFormattedString("addonwatch.restart.label", [brandShortName]),
+                  accessKey: win.gNavigatorBundle.getString("addonwatch.restart.accesskey"),
+                  callback: function() {
+                    let appStartup = Cc["@mozilla.org/toolkit/app-startup;1"]
+                      .getService(Ci.nsIAppStartup);
+                    appStartup.quit(appStartup.eForceQuit | appStartup.eRestart);
                   }
-                ];
-                const priority = notificationBox.PRIORITY_WARNING_MEDIUM;
-                notificationBox.appendNotification(restartMessage, "restart-" + addonId, "",
-                                                   priority, restartButton);
-              }
+                }
+              ];
+              const priority = notificationBox.PRIORITY_WARNING_MEDIUM;
+              notificationBox.appendNotification(restartMessage, "restart-" + addonId, "",
+                                                 priority, restartButton);
             }
           },
           {
             label: win.gNavigatorBundle.getString("addonwatch.ignoreSession.label"),
             accessKey: win.gNavigatorBundle.getString("addonwatch.ignoreSession.accesskey"),
             callback: function() {
+              done(STATE_USER_PICKED_IGNORE_FOR_NOW);
               AddonWatcher.ignoreAddonForSession(addonId);
             }
           },
           {
             label: win.gNavigatorBundle.getString("addonwatch.ignorePerm.label"),
             accessKey: win.gNavigatorBundle.getString("addonwatch.ignorePerm.accesskey"),
             callback: function() {
+              done(STATE_USER_PICKED_IGNORE_FOREVER);
               AddonWatcher.ignoreAddonPermanently(addonId);
             }
           },
         ];
 
         const priority = notificationBox.PRIORITY_WARNING_MEDIUM;
-        notificationBox.appendNotification(message, notificationId, "",
-                                             priority, buttons);
+        notification = notificationBox.appendNotification(
+          message, notificationId, "",
+          priority, buttons,
+          function(topic) {
+            if (topic == "removed") {
+              // Other callbacks are called before this one and only the first
+              // call to `done` is taken into account, so if this call to `done`
+              // gets through, this means that the user has closed the notification
+              // manually.
+              done(STATE_USER_CLOSED_NOTIFICATION);
+            }
+          });
       }
     };
     AddonManager.getAddonByID(addonId, addonCallback);
   },
 
   // runs on startup, before the first command line handler is invoked
   // (i.e. before the first window is opened)
   _finalUIStartup: function BG__finalUIStartup() {
--- a/browser/components/preferences/in-content/sync.xul
+++ b/browser/components/preferences/in-content/sync.xul
@@ -206,20 +206,20 @@
             <label id="noFxaCaption">&signedOut.caption;</label>
             <description id="noFxaDescription" flex="1">&signedOut.description;</description>
             <hbox class="fxaAccountBox">
               <vbox>
                 <image class="fxaFirefoxLogo"/>
               </vbox>
               <vbox flex="1">
                 <label id="signedOutAccountBoxTitle">&signedOut.accountBox.title;</label>
-                <description class="fxaAccountBoxButtons">
+                <hbox class="fxaAccountBoxButtons" align="center">
                   <button id="noFxaSignUp">&signedOut.accountBox.create;</button>
                   <button id="noFxaSignIn">&signedOut.accountBox.signin;</button>
-                </description>
+                </hbox>
               </vbox>
             </hbox>
           </vbox>
         </groupbox>
       </vbox>
       <vbox>
         <image class="fxaSyncIllustration"/>
       </vbox>
@@ -246,20 +246,20 @@
               <vbox>
                 <image id="fxaProfileImage"
                     onclick="gSyncPane.openChangeProfileImage();" hidden="true"
                     tooltiptext="&profilePicture.tooltip;" class="actionable"/>
               </vbox>
               <vbox flex="1">
                 <label id="fxaEmailAddress1"/>
                 <label id="fxaDisplayName" hidden="true"/>
-                <description class="fxaAccountBoxButtons">
-                  <button id="verifiedManage">&manage.label;</button>
-                  <button id="fxaUnlinkButton">&disconnect.label;</button>
-                </description>
+                <hbox class="fxaAccountBoxButtons" align="center">
+                  <vbox flex="1"><button id="fxaUnlinkButton">&disconnect.label;</button></vbox>
+                  <vbox flex="1"><label id="verifiedManage" class="text-link">&manageAccount.label;</label></vbox>
+                </hbox>
               </vbox>
             </hbox>
 
             <!-- logged in to an unverified account -->
             <hbox id="fxaLoginUnverified" class="fxaAccountBox">
               <vbox>
                 <image id="fxaProfileImage"/>
               </vbox>
@@ -267,20 +267,20 @@
                 <hbox>
                   <vbox><image id="fxaLoginRejectedWarning"/></vbox>
                   <description flex="1">
                     &signedInUnverified.beforename.label;
                     <label id="fxaEmailAddress2"/>
                     &signedInUnverified.aftername.label;
                   </description>
                 </hbox>
-                <description class="fxaAccountBoxButtons">
+                <hbox class="fxaAccountBoxButtons" align="center">
                   <button id="verifyFxaAccount">&verify.label;</button>
                   <button id="unverifiedUnlinkFxaAccount">&forget.label;</button>
-                </description>
+                </hbox>
               </vbox>
             </hbox>
 
             <!-- logged in locally but server rejected credentials -->
             <hbox id="fxaLoginRejected" class="fxaAccountBox">
               <vbox>
                 <image id="fxaProfileImage"/>
               </vbox>
@@ -288,20 +288,20 @@
                 <hbox>
                   <vbox><image id="fxaLoginRejectedWarning"/></vbox>
                   <description flex="1">
                     &signedInLoginFailure.beforename.label;
                     <label id="fxaEmailAddress3"/>
                     &signedInLoginFailure.aftername.label;
                   </description>
                 </hbox>
-                <description class="fxaAccountBoxButtons">
+                <hbox class="fxaAccountBoxButtons" align="center">
                   <button id="rejectReSignIn">&signIn.label;</button>
                   <button id="rejectUnlinkFxaAccount">&forget.label;</button>
-                </description>
+                </hbox>
               </vbox>
             </hbox>
           </deck>
         </groupbox>
         <groupbox id="syncOptions">
           <caption><label>&signedIn.engines.label;</label></caption>
           <hbox id="fxaSyncEngines">
             <vbox align="start" flex="1">
--- a/browser/components/preferences/sync.xul
+++ b/browser/components/preferences/sync.xul
@@ -226,18 +226,17 @@
 
             <deck id="fxaLoginStatus">
 
               <!-- logged in and verified and all is good -->
               <hbox>
                 <label id="fxaEmailAddress1"/>
                 <vbox>
                   <label class="text-link"
-                         onclick="gSyncPane.manageFirefoxAccount();"
-                         value="&manage.label;"/>
+                         onclick="gSyncPane.manageFirefoxAccount();"/>
                 </vbox>
                 <spacer flex="1"/>
                 <vbox>
                   <button id="fxaUnlinkButton"
                           oncommand="gSyncPane.unlinkFirefoxAccount(true);"
                           label="&disconnect.label;"/>
                 </vbox>
               </hbox>
--- a/browser/components/privatebrowsing/content/aboutPrivateBrowsing.css
+++ b/browser/components/privatebrowsing/content/aboutPrivateBrowsing.css
@@ -1,13 +1,11 @@
-%if 0
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-%endif
 
 @import url("chrome://global/skin/in-content/common.css");
 
 body {
   min-height: 100vh;
   display: flex;
   flex-direction: column;
   align-items: center;
--- a/browser/components/privatebrowsing/jar.mn
+++ b/browser/components/privatebrowsing/jar.mn
@@ -1,8 +1,8 @@
 # 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/.
 
 browser.jar:
-*   content/browser/aboutPrivateBrowsing.css              (content/aboutPrivateBrowsing.css)
-    content/browser/aboutPrivateBrowsing.xhtml            (content/aboutPrivateBrowsing.xhtml) 
+    content/browser/aboutPrivateBrowsing.css              (content/aboutPrivateBrowsing.css)
+    content/browser/aboutPrivateBrowsing.xhtml            (content/aboutPrivateBrowsing.xhtml)
     content/browser/aboutPrivateBrowsing.js               (content/aboutPrivateBrowsing.js)
--- a/browser/components/translation/Translation.jsm
+++ b/browser/components/translation/Translation.jsm
@@ -1,33 +1,28 @@
 /* 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";
 
 this.EXPORTED_SYMBOLS = [
   "Translation",
-  "TranslationProvider",
+  "TranslationTelemetry",
 ];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 const TRANSLATION_PREF_SHOWUI = "browser.translation.ui.show";
+const TRANSLATION_PREF_DETECT_LANG = "browser.translation.detectLanguage";
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
-Cu.import("resource://gre/modules/Metrics.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm", this);
 
-const DAILY_COUNTER_FIELD = {type: Metrics.Storage.FIELD_DAILY_COUNTER};
-const DAILY_LAST_TEXT_FIELD = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT};
-const DAILY_LAST_NUMERIC_FIELD = {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC};
-
-
 this.Translation = {
   STATE_OFFER: 0,
   STATE_TRANSLATING: 1,
   STATE_TRANSLATED: 2,
   STATE_ERROR: 3,
   STATE_UNAVAILABLE: 4,
 
   serviceUnavailable: false,
@@ -50,21 +45,21 @@ this.Translation = {
     if (aData.state == this.STATE_OFFER) {
       if (aData.detectedLanguage == this.defaultTargetLanguage) {
         // Detected language is the same as the user's locale.
         return;
       }
 
       if (this.supportedSourceLanguages.indexOf(aData.detectedLanguage) == -1) {
         // Detected language is not part of the supported languages.
-        TranslationHealthReport.recordMissedTranslationOpportunity(aData.detectedLanguage);
+        TranslationTelemetry.recordMissedTranslationOpportunity(aData.detectedLanguage);
         return;
       }
 
-      TranslationHealthReport.recordTranslationOpportunity(aData.detectedLanguage);
+      TranslationTelemetry.recordTranslationOpportunity(aData.detectedLanguage);
     }
 
     if (!Services.prefs.getBoolPref(TRANSLATION_PREF_SHOWUI))
       return;
 
     if (!aBrowser.translationUI)
       aBrowser.translationUI = new TranslationUI(aBrowser);
     let trUI = aBrowser.translationUI;
@@ -146,22 +141,22 @@ TranslationUI.prototype = {
         (this.state == Translation.STATE_TRANSLATED &&
          this.translatedFrom == aFrom && this.translatedTo == aTo)) {
       // Nothing to do.
       return;
     }
 
     if (this.state == Translation.STATE_OFFER) {
       if (this.detectedLanguage != aFrom)
-        TranslationHealthReport.recordDetectedLanguageChange(true);
+        TranslationTelemetry.recordDetectedLanguageChange(true);
     } else {
       if (this.translatedFrom != aFrom)
-        TranslationHealthReport.recordDetectedLanguageChange(false);
+        TranslationTelemetry.recordDetectedLanguageChange(false);
       if (this.translatedTo != aTo)
-        TranslationHealthReport.recordTargetLanguageChange();
+        TranslationTelemetry.recordTargetLanguageChange();
     }
 
     this.state = Translation.STATE_TRANSLATING;
     this.translatedFrom = aFrom;
     this.translatedTo = aTo;
 
     this.browser.messageManager.sendAsyncMessage(
       "Translation:TranslateDocument",
@@ -214,17 +209,17 @@ TranslationUI.prototype = {
     this._state = val;
   },
 
   originalShown: true,
   showOriginalContent: function() {
     this.originalShown = true;
     this.showURLBarIcon();
     this.browser.messageManager.sendAsyncMessage("Translation:ShowOriginal");
-    TranslationHealthReport.recordShowOriginalContent();
+    TranslationTelemetry.recordShowOriginalContent();
   },
 
   showTranslatedContent: function() {
     this.originalShown = false;
     this.showURLBarIcon();
     this.browser.messageManager.sendAsyncMessage("Translation:ShowTranslation");
   },
 
@@ -243,398 +238,203 @@ TranslationUI.prototype = {
     // service is temporarily unavailable.
     if (Translation.serviceUnavailable)
       return false;
 
     // Check if we should never show the infobar for this language.
     let neverForLangs =
       Services.prefs.getCharPref("browser.translation.neverForLanguages");
     if (neverForLangs.split(",").indexOf(this.detectedLanguage) != -1) {
-      TranslationHealthReport.recordAutoRejectedTranslationOffer();
+      TranslationTelemetry.recordAutoRejectedTranslationOffer();
       return false;
     }
 
     // or if we should never show the infobar for this domain.
     let perms = Services.perms;
     if (perms.testExactPermission(aURI, "translate") ==  perms.DENY_ACTION) {
-      TranslationHealthReport.recordAutoRejectedTranslationOffer();
+      TranslationTelemetry.recordAutoRejectedTranslationOffer();
       return false;
     }
 
     return true;
   },
 
   receiveMessage: function(msg) {
     switch (msg.name) {
       case "Translation:Finished":
         if (msg.data.success) {
           this.originalShown = false;
           this.state = Translation.STATE_TRANSLATED;
           this.showURLBarIcon();
 
           // Record the number of characters translated.
-          TranslationHealthReport.recordTranslation(msg.data.from, msg.data.to,
+          TranslationTelemetry.recordTranslation(msg.data.from, msg.data.to,
                                                     msg.data.characterCount);
         } else if (msg.data.unavailable) {
           Translation.serviceUnavailable = true;
           this.state = Translation.STATE_UNAVAILABLE;
         } else {
           this.state = Translation.STATE_ERROR;
         }
         break;
     }
   },
 
   infobarClosed: function() {
     if (this.state == Translation.STATE_OFFER)
-      TranslationHealthReport.recordDeniedTranslationOffer();
+      TranslationTelemetry.recordDeniedTranslationOffer();
   }
 };
 
 /**
- * Helper methods for recording translation data in FHR.
+ * Uses telemetry histograms for collecting statistics on the usage of the
+ * translation component.
+ *
+ * NOTE: Metrics are only recorded if the user enabled the telemetry option.
  */
-let TranslationHealthReport = {
+this.TranslationTelemetry = {
+
+  init: function () {
+    // Constructing histograms.
+    const plain = (id) => Services.telemetry.getHistogramById(id);
+    const keyed = (id) => Services.telemetry.getKeyedHistogramById(id);
+    this.HISTOGRAMS = {
+      OPPORTUNITIES         : () => plain("TRANSLATION_OPPORTUNITIES"),
+      OPPORTUNITIES_BY_LANG : () => keyed("TRANSLATION_OPPORTUNITIES_BY_LANGUAGE"),
+      PAGES                 : () => plain("TRANSLATED_PAGES"),
+      PAGES_BY_LANG         : () => keyed("TRANSLATED_PAGES_BY_LANGUAGE"),
+      CHARACTERS            : () => plain("TRANSLATED_CHARACTERS"),
+      DENIED                : () => plain("DENIED_TRANSLATION_OFFERS"),
+      AUTO_REJECTED         : () => plain("AUTO_REJECTED_TRANSLATION_OFFERS"),
+      SHOW_ORIGINAL         : () => plain("REQUESTS_OF_ORIGINAL_CONTENT"),
+      TARGET_CHANGES        : () => plain("CHANGES_OF_TARGET_LANGUAGE"),
+      DETECTION_CHANGES     : () => plain("CHANGES_OF_DETECTED_LANGUAGE"),
+      SHOW_UI               : () => plain("SHOULD_TRANSLATION_UI_APPEAR"),
+      DETECT_LANG           : () => plain("SHOULD_AUTO_DETECT_LANGUAGE"),
+    };
+
+    // Capturing the values of flags at the startup.
+    this.recordPreferences();
+  },
+
   /**
    * Record a translation opportunity in the health report.
    * @param language
    *        The language of the page.
    */
   recordTranslationOpportunity: function (language) {
-    this._withProvider(provider => provider.recordTranslationOpportunity(language));
-   },
+    return this._recordOpportunity(language, true);
+  },
 
   /**
    * Record a missed translation opportunity in the health report.
    * A missed opportunity is when the language detected is not part
    * of the supported languages.
    * @param language
    *        The language of the page.
    */
   recordMissedTranslationOpportunity: function (language) {
-    this._withProvider(provider => provider.recordMissedTranslationOpportunity(language));
+    return this._recordOpportunity(language, false);
   },
 
   /**
    * Record an automatically rejected translation offer in the health
    * report. A translation offer is automatically rejected when a user
    * has previously clicked "Never translate this language" or "Never
    * translate this site", which results in the infobar not being shown for
    * the translation opportunity.
    *
    * These translation opportunities should still be recorded in addition to
    * recording the automatic rejection of the offer.
    */
   recordAutoRejectedTranslationOffer: function () {
-    this._withProvider(provider => provider.recordAutoRejectedTranslationOffer());
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.AUTO_REJECTED().add();
   },
 
    /**
    * Record a translation in the health report.
    * @param langFrom
    *        The language of the page.
    * @param langTo
    *        The language translated to
    * @param numCharacters
    *        The number of characters that were translated
    */
   recordTranslation: function (langFrom, langTo, numCharacters) {
-    this._withProvider(provider => provider.recordTranslation(langFrom, langTo, numCharacters));
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.PAGES().add();
+    this.HISTOGRAMS.PAGES_BY_LANG().add(langFrom + " -> " + langTo);
+    this.HISTOGRAMS.CHARACTERS().add(numCharacters);
   },
 
   /**
    * Record a change of the detected language in the health report. This should
    * only be called when actually executing a translation, not every time the
    * user changes in the language in the UI.
    *
    * @param beforeFirstTranslation
    *        A boolean indicating if we are recording a change of detected
    *        language before translating the page for the first time. If we
    *        have already translated the page from the detected language and
    *        the user has manually adjusted the detected language false should
    *        be passed.
    */
   recordDetectedLanguageChange: function (beforeFirstTranslation) {
-    this._withProvider(provider => provider.recordDetectedLanguageChange(beforeFirstTranslation));
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.DETECTION_CHANGES().add(beforeFirstTranslation);
   },
 
   /**
    * Record a change of the target language in the health report. This should
    * only be called when actually executing a translation, not every time the
    * user changes in the language in the UI.
    */
   recordTargetLanguageChange: function () {
-    this._withProvider(provider => provider.recordTargetLanguageChange());
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.TARGET_CHANGES().add();
   },
 
   /**
    * Record a denied translation offer.
    */
   recordDeniedTranslationOffer: function () {
-    this._withProvider(provider => provider.recordDeniedTranslationOffer());
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.DENIED().add();
   },
 
   /**
    * Record a "Show Original" command use.
    */
   recordShowOriginalContent: function () {
-    this._withProvider(provider => provider.recordShowOriginalContent());
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.SHOW_ORIGINAL().add();
   },
 
   /**
-   * Retrieve the translation provider and pass it to the given function.
-   *
-   * @param callback
-   *        The function that will be passed the translation provider.
+   * Record the state of translation preferences.
    */
-  _withProvider: function (callback) {
-    try {
-      let reporter = Cc["@mozilla.org/datareporting/service;1"]
-                        .getService().wrappedJSObject.healthReporter;
+  recordPreferences: function () {
+    if (!this._canRecord) return;
+    if (Services.prefs.getBoolPref(TRANSLATION_PREF_SHOWUI)) {
+      this.HISTOGRAMS.SHOW_UI().add(1);
+    }
+    if (Services.prefs.getBoolPref(TRANSLATION_PREF_DETECT_LANG)) {
+      this.HISTOGRAMS.DETECT_LANG().add(1);
+    }
+  },
 
-      if (reporter) {
-        reporter.onInit().then(function () {
-          callback(reporter.getProvider("org.mozilla.translation"));
-        }, Cu.reportError);
-      } else {
-        callback(null);
-      }
-    } catch (ex) {
-      Cu.reportError(ex);
-    }
+  _recordOpportunity: function(language, success) {
+    if (!this._canRecord) return;
+    this.HISTOGRAMS.OPPORTUNITIES().add(success);
+    this.HISTOGRAMS.OPPORTUNITIES_BY_LANG().add(language, success);
+  },
+
+  /**
+   * A shortcut for reading the telemetry preference.
+   *
+   */
+  _canRecord: function () {
+    return Services.prefs.getBoolPref("toolkit.telemetry.enabled");
   }
 };
 
-/**
- * Holds usage data about the Translation feature.
- *
- * This is a special telemetry measurement that is transmitted in the FHR
- * payload. Data will only be recorded/transmitted when both telemetry and
- * FHR are enabled. Additionally, if telemetry was previously enabled but
- * is currently disabled, old recorded data will not be transmitted.
- */
-function TranslationMeasurement1() {
-  Metrics.Measurement.call(this);
-
-  this._serializers[this.SERIALIZE_JSON].singular =
-    this._wrapJSONSerializer(this._serializers[this.SERIALIZE_JSON].singular);
-
-  this._serializers[this.SERIALIZE_JSON].daily =
-    this._wrapJSONSerializer(this._serializers[this.SERIALIZE_JSON].daily);
-}
-
-TranslationMeasurement1.prototype = Object.freeze({
-  __proto__: Metrics.Measurement.prototype,
-
-  name: "translation",
-  version: 1,
-
-  fields: {
-    translationOpportunityCount: DAILY_COUNTER_FIELD,
-    missedTranslationOpportunityCount: DAILY_COUNTER_FIELD,
-    pageTranslatedCount: DAILY_COUNTER_FIELD,
-    charactersTranslatedCount: DAILY_COUNTER_FIELD,
-    translationOpportunityCountsByLanguage: DAILY_LAST_TEXT_FIELD,
-    missedTranslationOpportunityCountsByLanguage: DAILY_LAST_TEXT_FIELD,
-    pageTranslatedCountsByLanguage: DAILY_LAST_TEXT_FIELD,
-    detectedLanguageChangedBefore: DAILY_COUNTER_FIELD,
-    detectedLanguageChangedAfter: DAILY_COUNTER_FIELD,
-    targetLanguageChanged: DAILY_COUNTER_FIELD,
-    deniedTranslationOffer: DAILY_COUNTER_FIELD,
-    showOriginalContent: DAILY_COUNTER_FIELD,
-    detectLanguageEnabled: DAILY_LAST_NUMERIC_FIELD,
-    showTranslationUI: DAILY_LAST_NUMERIC_FIELD,
-    autoRejectedTranslationOffer: DAILY_COUNTER_FIELD,
-  },
-
-  shouldIncludeField: function (field) {
-    if (!Services.prefs.getBoolPref("toolkit.telemetry.enabled")) {
-      // This measurement should only be included when telemetry is
-      // enabled, so we will not include any fields.
-      return false;
-    }
-
-    return field in this._fields;
-  },
-
-  _getDailyLastTextFieldAsJSON: function(name, date) {
-    let id = this.fieldID(name);
-
-    return this.storage.getDailyLastTextFromFieldID(id, date).then((data) => {
-      if (data.hasDay(date)) {
-        data = JSON.parse(data.getDay(date));
-      } else {
-        data = {};
-      }
-
-      return data;
-    });
-  },
-
-  _wrapJSONSerializer: function (serializer) {
-    let _parseInPlace = function(o, k) {
-      if (k in o) {
-        o[k] = JSON.parse(o[k]);
-      }
-    };
-
-    return function (data) {
-      let result = serializer(data);
-
-      // Special case the serialization of these fields so that
-      // they are sent as objects, not stringified objects.
-      _parseInPlace(result, "translationOpportunityCountsByLanguage");
-      _parseInPlace(result, "missedTranslationOpportunityCountsByLanguage");
-      _parseInPlace(result, "pageTranslatedCountsByLanguage");
-
-      return result;
-    }
-  }
-});
-
-this.TranslationProvider = function () {
-  Metrics.Provider.call(this);
-}
-
-TranslationProvider.prototype = Object.freeze({
-  __proto__: Metrics.Provider.prototype,
-
-  name: "org.mozilla.translation",
-
-  measurementTypes: [
-    TranslationMeasurement1,
-  ],
-
-  recordTranslationOpportunity: function (language, date=new Date()) {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("translationOpportunityCount", date);
-
-      let langCounts = yield m._getDailyLastTextFieldAsJSON(
-        "translationOpportunityCountsByLanguage", date);
-
-      langCounts[language] = (langCounts[language] || 0) + 1;
-      langCounts = JSON.stringify(langCounts);
-
-      yield m.setDailyLastText("translationOpportunityCountsByLanguage",
-                               langCounts, date);
-
-    }.bind(this));
-  },
-
-  recordMissedTranslationOpportunity: function (language, date=new Date()) {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("missedTranslationOpportunityCount", date);
-
-      let langCounts = yield m._getDailyLastTextFieldAsJSON(
-        "missedTranslationOpportunityCountsByLanguage", date);
-
-      langCounts[language] = (langCounts[language] || 0) + 1;
-      langCounts = JSON.stringify(langCounts);
-
-      yield m.setDailyLastText("missedTranslationOpportunityCountsByLanguage",
-                               langCounts, date);
-
-    }.bind(this));
-  },
-
-  recordAutoRejectedTranslationOffer: function (date=new Date()) {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("autoRejectedTranslationOffer", date);
-    }.bind(this));
-  },
-
-  recordTranslation: function (langFrom, langTo, numCharacters, date=new Date()) {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("pageTranslatedCount", date);
-      yield m.incrementDailyCounter("charactersTranslatedCount", date,
-                                    numCharacters);
-
-      let langCounts = yield m._getDailyLastTextFieldAsJSON(
-        "pageTranslatedCountsByLanguage", date);
-
-      let counts = langCounts[langFrom] || {};
-      counts["total"] = (counts["total"] || 0) + 1;
-      counts[langTo] = (counts[langTo] || 0) + 1;
-      langCounts[langFrom] = counts;
-      langCounts = JSON.stringify(langCounts);
-
-      yield m.setDailyLastText("pageTranslatedCountsByLanguage",
-                               langCounts, date);
-    }.bind(this));
-  },
-
-  recordDetectedLanguageChange: function (beforeFirstTranslation) {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      if (beforeFirstTranslation) {
-          yield m.incrementDailyCounter("detectedLanguageChangedBefore");
-        } else {
-          yield m.incrementDailyCounter("detectedLanguageChangedAfter");
-        }
-    }.bind(this));
-  },
-
-  recordTargetLanguageChange: function () {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("targetLanguageChanged");
-    }.bind(this));
-  },
-
-  recordDeniedTranslationOffer: function () {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("deniedTranslationOffer");
-    }.bind(this));
-  },
-
-  recordShowOriginalContent: function () {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      yield m.incrementDailyCounter("showOriginalContent");
-    }.bind(this));
-  },
-
-  collectDailyData: function () {
-    let m = this.getMeasurement(TranslationMeasurement1.prototype.name,
-                                TranslationMeasurement1.prototype.version);
-
-    return this._enqueueTelemetryStorageTask(function* recordTask() {
-      let detectLanguageEnabled = Services.prefs.getBoolPref("browser.translation.detectLanguage");
-      yield m.setDailyLastNumeric("detectLanguageEnabled", detectLanguageEnabled ? 1 : 0);
-
-      let showTranslationUI = Services.prefs.getBoolPref("browser.translation.ui.show");
-      yield m.setDailyLastNumeric("showTranslationUI", showTranslationUI ? 1 : 0);
-    }.bind(this));
-  },
-
-  _enqueueTelemetryStorageTask: function (task) {
-    if (!Services.prefs.getBoolPref("toolkit.telemetry.enabled")) {
-      // This measurement should only be included when telemetry is
-      // enabled, so don't record any data.
-      return Promise.resolve(null);
-    }
-
-    return this.enqueueStorageOperation(() => {
-      return Task.spawn(task);
-    });
-  }
-});
+this.TranslationTelemetry.init();
--- a/browser/components/translation/test/browser.ini
+++ b/browser/components/translation/test/browser.ini
@@ -3,12 +3,12 @@ support-files =
   bing.sjs
   yandex.sjs
   fixtures/bug1022725-fr.html
   fixtures/result-da39a3ee5e.txt
   fixtures/result-yandex-d448894848.json
 
 [browser_translation_bing.js]
 [browser_translation_yandex.js]
-[browser_translation_fhr.js]
+[browser_translation_telemetry.js]
 skip-if = e10s
 [browser_translation_infobar.js]
 [browser_translation_exceptions.js]
deleted file mode 100644
--- a/browser/components/translation/test/browser_translation_fhr.js
+++ /dev/null
@@ -1,244 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
-"use strict";
-
-let tmp = {};
-Cu.import("resource:///modules/translation/Translation.jsm", tmp);
-let {Translation} = tmp;
-
-let MetricsChecker = {
-  _metricsTime: new Date(),
-  _midnightError: new Error("Getting metrics around midnight may fail sometimes"),
-
-  updateMetrics: Task.async(function* () {
-    let svc = Cc["@mozilla.org/datareporting/service;1"].getService();
-    let reporter = svc.wrappedJSObject.healthReporter;
-    yield reporter.onInit();
-
-    // Get the provider.
-    let provider = reporter.getProvider("org.mozilla.translation");
-    let measurement = provider.getMeasurement("translation", 1);
-    let values = yield measurement.getValues();
-
-    let metricsTime = new Date();
-    let day = values.days.getDay(metricsTime);
-    if (!day) {
-      // This should never happen except when the test runs at midnight.
-      throw this._midnightError;
-    }
-
-    // .get() may return `undefined`, which we can't compute.
-    this._metrics = {
-      pageCount: day.get("pageTranslatedCount") || 0,
-      charCount: day.get("charactersTranslatedCount") || 0,
-      deniedOffers: day.get("deniedTranslationOffer") || 0,
-      showOriginal: day.get("showOriginalContent") || 0,
-      detectedLanguageChangedBefore: day.get("detectedLanguageChangedBefore") || 0,
-      detectedLanguageChangeAfter: day.get("detectedLanguageChangedAfter") || 0,
-      targetLanguageChanged: day.get("targetLanguageChanged") || 0,
-      autoRejectedOffers: day.get("autoRejectedTranslationOffer") || 0
-    };
-    this._metricsTime = metricsTime;
-  }),
-
-  checkAdditions: Task.async(function* (additions) {
-    let prevMetrics = this._metrics, prevMetricsTime = this._metricsTime;
-    try {
-      yield this.updateMetrics();
-    } catch(ex if ex == this._midnightError) {
-      return;
-    }
-
-    // Check that it's still the same day of the month as when we started. This
-    // prevents intermittent failures when the test starts before and ends after
-    // midnight.
-    if (this._metricsTime.getDate() != prevMetricsTime.getDate()) {
-      for (let metric of Object.keys(prevMetrics)) {
-        prevMetrics[metric] = 0;
-      }
-    }
-
-    for (let metric of Object.keys(additions)) {
-      Assert.equal(prevMetrics[metric] + additions[metric], this._metrics[metric]);
-    }
-  })
-};
-add_task(function* setup() {
-  Services.prefs.setBoolPref("toolkit.telemetry.enabled", true);
-  Services.prefs.setBoolPref("browser.translation.detectLanguage", true);
-  Services.prefs.setBoolPref("browser.translation.ui.show", true);
-
-  registerCleanupFunction(() => {
-    Services.prefs.clearUserPref("toolkit.telemetry.enabled");
-    Services.prefs.clearUserPref("browser.translation.detectLanguage");
-    Services.prefs.clearUserPref("browser.translation.ui.show");
-  });
-
-  // Make sure there are some initial metrics in place when the test starts.
-  yield translate("<h1>Hallo Welt!</h1>", "de");
-  yield MetricsChecker.updateMetrics();
-});
-
-add_task(function* test_fhr() {
-  // Translate a page.
-  yield translate("<h1>Hallo Welt!</h1>", "de");
-
-  // Translate another page.
-  yield translate("<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", "de");
-  yield MetricsChecker.checkAdditions({ pageCount: 1, charCount: 21, deniedOffers: 0});
-});
-
-add_task(function* test_deny_translation_metric() {
-  function* offerAndDeny(elementAnonid) {
-    let tab = yield offerTranslatationFor("<h1>Hallo Welt!</h1>", "de", "en");
-    getInfobarElement(tab.linkedBrowser, elementAnonid).doCommand();
-    yield MetricsChecker.checkAdditions({ deniedOffers: 1 });
-    gBrowser.removeTab(tab);
-  }
-
-  yield offerAndDeny("notNow");
-  yield offerAndDeny("neverForSite");
-  yield offerAndDeny("neverForLanguage");
-  yield offerAndDeny("closeButton");
-
-  // Test that the close button doesn't record a denied translation if
-  // the infobar is not in its "offer" state.
-  let tab = yield translate("<h1>Hallo Welt!</h1>", "de", false);
-  yield MetricsChecker.checkAdditions({ deniedOffers: 0 });
-  gBrowser.removeTab(tab);
-});
-
-add_task(function* test_show_original() {
-  let tab =
-    yield translate("<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", "de", false);
-  yield MetricsChecker.checkAdditions({ pageCount: 1, showOriginal: 0 });
-  getInfobarElement(tab.linkedBrowser, "showOriginal").doCommand();
-  yield MetricsChecker.checkAdditions({ pageCount: 0, showOriginal: 1 });
-  gBrowser.removeTab(tab);
-});
-
-add_task(function* test_language_change() {
-  for (let i of Array(4)) {
-    let tab = yield offerTranslatationFor("<h1>Hallo Welt!</h1>", "fr");
-    let browser = tab.linkedBrowser;
-    // In the offer state, translation is executed by the Translate button,
-    // so we expect just a single recoding.
-    let detectedLangMenulist = getInfobarElement(browser, "detectedLanguage");
-    simulateUserSelectInMenulist(detectedLangMenulist, "de");
-    simulateUserSelectInMenulist(detectedLangMenulist, "it");
-    simulateUserSelectInMenulist(detectedLangMenulist, "de");
-    yield acceptTranslationOffer(tab);
-
-    // In the translated state, a change in the form or to menulists
-    // triggers re-translation right away.
-    let fromLangMenulist = getInfobarElement(browser, "fromLanguage");
-    simulateUserSelectInMenulist(fromLangMenulist, "it");
-    simulateUserSelectInMenulist(fromLangMenulist, "de");
-
-    // Selecting the same item shouldn't count.
-    simulateUserSelectInMenulist(fromLangMenulist, "de");
-
-    let toLangMenulist = getInfobarElement(browser, "toLanguage");
-    simulateUserSelectInMenulist(toLangMenulist, "fr");
-    simulateUserSelectInMenulist(toLangMenulist, "en");
-    simulateUserSelectInMenulist(toLangMenulist, "it");
-
-    // Selecting the same item shouldn't count.
-    simulateUserSelectInMenulist(toLangMenulist, "it");
-
-    // Setting the target language to the source language is a no-op,
-    // so it shouldn't count.
-    simulateUserSelectInMenulist(toLangMenulist, "de");
-
-    gBrowser.removeTab(tab);
-  }
-  yield MetricsChecker.checkAdditions({
-    detectedLanguageChangedBefore: 4,
-    detectedLanguageChangeAfter: 8,
-    targetLanguageChanged: 12
-  });
-});
-
-add_task(function* test_never_offer_translation() {
-  Services.prefs.setCharPref("browser.translation.neverForLanguages", "fr");
-
-  let tab = yield offerTranslatationFor("<h1>Hallo Welt!</h1>", "fr");
-
-  yield MetricsChecker.checkAdditions({
-    autoRejectedOffers: 1,
-  });
-
-  gBrowser.removeTab(tab);
-  Services.prefs.clearUserPref("browser.translation.neverForLanguages")
-});
-
-function getInfobarElement(browser, anonid) {
-  let notif = browser.translationUI
-                     .notificationBox.getNotificationWithValue("translation");
-  return notif._getAnonElt(anonid);
-}
-
-function offerTranslatationFor(text, from) {
-  return Task.spawn(function* task_offer_translation() {
-    // Create some content to translate.
-    let tab = gBrowser.selectedTab =
-      gBrowser.addTab("data:text/html;charset=utf-8," + text);
-
-    // Wait until that's loaded.
-    let browser = tab.linkedBrowser;
-    yield promiseBrowserLoaded(browser);
-
-    // Send a translation offer.
-    Translation.documentStateReceived(browser, {state: Translation.STATE_OFFER,
-                                                originalShown: true,
-                                                detectedLanguage: from});
-
-    return tab;
-  });
-}
-
-function acceptTranslationOffer(tab) {
-  return Task.spawn(function* task_accept_translation_offer() {
-    let browser = tab.linkedBrowser;
-    getInfobarElement(browser, "translate").doCommand();
-    yield waitForMessage(browser, "Translation:Finished");
-  });
-}
-
-function translate(text, from, closeTab = true) {
-  return Task.spawn(function* task_translate() {
-    let tab = yield offerTranslatationFor(text, from);
-    yield acceptTranslationOffer(tab);
-    if (closeTab) {
-      gBrowser.removeTab(tab);
-    } else {
-      return tab;
-    }
-  });
-}
-
-function waitForMessage({messageManager}, name) {
-  return new Promise(resolve => {
-    messageManager.addMessageListener(name, function onMessage() {
-      messageManager.removeMessageListener(name, onMessage);
-      resolve();
-    });
-  });
-}
-
-function promiseBrowserLoaded(browser) {
-  return new Promise(resolve => {
-    browser.addEventListener("load", function onLoad(event) {
-      if (event.target == browser.contentDocument) {
-        browser.removeEventListener("load", onLoad, true);
-        resolve();
-      }
-    }, true);
-  });
-}
-
-function simulateUserSelectInMenulist(menulist, value) {
-  menulist.value = value;
-  menulist.doCommand();
-}
new file mode 100644
--- /dev/null
+++ b/browser/components/translation/test/browser_translation_telemetry.js
@@ -0,0 +1,299 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let tmp = {};
+Cu.import("resource:///modules/translation/Translation.jsm", tmp);
+let {Translation, TranslationTelemetry} = tmp;
+const Telemetry = Services.telemetry;
+
+let MetricsChecker = {
+  HISTOGRAMS: {
+    OPPORTUNITIES         : Services.telemetry.getHistogramById("TRANSLATION_OPPORTUNITIES"),
+    OPPORTUNITIES_BY_LANG : Services.telemetry.getKeyedHistogramById("TRANSLATION_OPPORTUNITIES_BY_LANGUAGE"),
+    PAGES                 : Services.telemetry.getHistogramById("TRANSLATED_PAGES"),
+    PAGES_BY_LANG         : Services.telemetry.getKeyedHistogramById("TRANSLATED_PAGES_BY_LANGUAGE"),
+    CHARACTERS            : Services.telemetry.getHistogramById("TRANSLATED_CHARACTERS"),
+    DENIED                : Services.telemetry.getHistogramById("DENIED_TRANSLATION_OFFERS"),
+    AUTO_REJECTED         : Services.telemetry.getHistogramById("AUTO_REJECTED_TRANSLATION_OFFERS"),
+    SHOW_ORIGINAL         : Services.telemetry.getHistogramById("REQUESTS_OF_ORIGINAL_CONTENT"),
+    TARGET_CHANGES        : Services.telemetry.getHistogramById("CHANGES_OF_TARGET_LANGUAGE"),
+    DETECTION_CHANGES     : Services.telemetry.getHistogramById("CHANGES_OF_DETECTED_LANGUAGE"),
+    SHOW_UI               : Services.telemetry.getHistogramById("SHOULD_TRANSLATION_UI_APPEAR"),
+    DETECT_LANG           : Services.telemetry.getHistogramById("SHOULD_AUTO_DETECT_LANGUAGE"),
+  },
+
+  reset: function(){
+    for (let i of Object.keys(this.HISTOGRAMS)) {
+      this.HISTOGRAMS[i].clear();
+    }
+    this.updateMetrics();
+  },
+
+  updateMetrics: function () {
+    this._metrics = {
+      opportunitiesCount: this.HISTOGRAMS.OPPORTUNITIES.snapshot().sum || 0,
+      pageCount: this.HISTOGRAMS.PAGES.snapshot().sum || 0,
+      charCount: this.HISTOGRAMS.CHARACTERS.snapshot().sum || 0,
+      deniedOffers: this.HISTOGRAMS.DENIED.snapshot().sum || 0,
+      autoRejectedOffers: this.HISTOGRAMS.AUTO_REJECTED.snapshot().sum || 0,
+      showOriginal: this.HISTOGRAMS.SHOW_ORIGINAL.snapshot().sum || 0,
+      detectedLanguageChangedBefore: this.HISTOGRAMS.DETECTION_CHANGES.snapshot().counts[1] || 0,
+      detectedLanguageChangeAfter: this.HISTOGRAMS.DETECTION_CHANGES.snapshot().counts[0] || 0,
+      targetLanguageChanged: this.HISTOGRAMS.TARGET_CHANGES.snapshot().sum || 0,
+      showUI: this.HISTOGRAMS.SHOW_UI.snapshot().sum || 0,
+      detectLang: this.HISTOGRAMS.DETECT_LANG.snapshot().sum || 0,
+      // Metrics for Keyed histograms are estimated below.
+      opportunitiesCountByLang: {},
+      pageCountByLang: {}
+    };
+
+    let opportunities = this.HISTOGRAMS.OPPORTUNITIES_BY_LANG.snapshot();
+    let pages = this.HISTOGRAMS.PAGES_BY_LANG.snapshot();
+    for (let source of Translation.supportedSourceLanguages) {
+      this._metrics.opportunitiesCountByLang[source] = opportunities[source] ?
+        opportunities[source].sum : 0;
+      for (let target of Translation.supportedTargetLanguages) {
+        if (source === target) continue;
+        let key = source + " -> " + target;
+        this._metrics.pageCountByLang[key] = pages[key] ? pages[key].sum : 0;
+      }
+    }
+  },
+
+  /**
+   * A recurrent loop for making assertions about collected metrics.
+   */
+  _assertionLoop: function (prevMetrics, metrics, additions){
+    for (let metric of Object.keys(additions)) {
+      let addition = additions[metric];
+      // Allows nesting metrics. Useful for keyed histograms.
+      if (typeof addition === 'object') {
+        this._assertionLoop(prevMetrics[metric], metrics[metric], addition);
+        continue;
+      }
+      Assert.equal(prevMetrics[metric] + addition, metrics[metric]);
+    }
+  },
+
+  checkAdditions: function (additions) {
+    let prevMetrics = this._metrics;
+    this.updateMetrics();
+    this._assertionLoop(prevMetrics, this._metrics, additions);
+  }
+
+};
+
+function getInfobarElement(browser, anonid) {
+  let notif = browser.translationUI
+                     .notificationBox.getNotificationWithValue("translation");
+  return notif._getAnonElt(anonid);
+}
+
+let offerTranslationFor = Task.async(function*(text, from) {
+  // Create some content to translate.
+  const dataUrl = "data:text/html;charset=utf-8," + text;
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, dataUrl);
+
+  let browser = gBrowser.getBrowserForTab(tab);
+
+  // Send a translation offer.
+  Translation.documentStateReceived(browser, {state: Translation.STATE_OFFER,
+                                              originalShown: true,
+                                              detectedLanguage: from});
+
+  return tab;
+});
+
+let acceptTranslationOffer = Task.async(function*(tab) {
+  let browser = tab.linkedBrowser;
+  getInfobarElement(browser, "translate").doCommand();
+  yield waitForMessage(browser, "Translation:Finished");
+});
+
+let translate = Task.async(function*(text, from, closeTab = true) {
+  let tab = yield offerTranslationFor(text, from);
+  yield acceptTranslationOffer(tab);
+  if (closeTab) {
+    gBrowser.removeTab(tab);
+  } else {
+    return tab;
+  }
+});
+
+function waitForMessage({messageManager}, name) {
+  return new Promise(resolve => {
+    messageManager.addMessageListener(name, function onMessage() {
+      messageManager.removeMessageListener(name, onMessage);
+      resolve();
+    });
+  });
+}
+
+function simulateUserSelectInMenulist(menulist, value) {
+  menulist.value = value;
+  menulist.doCommand();
+}
+
+add_task(function* setup() {
+  const setupPrefs = prefs => {
+    let prefsBackup = {};
+    for (let p of prefs) {
+      prefsBackup[p] = Services.prefs.setBoolPref;
+      Services.prefs.setBoolPref(p, true);
+    }
+    return prefsBackup;
+  };
+
+  const restorePrefs = (prefs, backup) => {
+    for (let p of prefs) {
+      Services.prefs.setBoolPref(p, backup[p]);
+    }
+  };
+
+  const prefs = [
+    "toolkit.telemetry.enabled",
+    "browser.translation.detectLanguage",
+    "browser.translation.ui.show"
+  ];
+
+  let prefsBackup = setupPrefs(prefs);
+
+  let oldCanRecord = Telemetry.canRecordExtended;
+  Telemetry.canRecordExtended = true;
+
+  registerCleanupFunction(() => {
+    restorePrefs(prefs, prefsBackup);
+    Telemetry.canRecordExtended = oldCanRecord;
+  });
+
+  // Reset histogram metrics.
+  MetricsChecker.reset();
+});
+
+add_task(function* test_telemetry() {
+  // Translate a page.
+  yield translate("<h1>Привет, мир!</h1>", "ru");
+
+  // Translate another page.
+  yield translate("<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", "de");
+  yield MetricsChecker.checkAdditions({
+    opportunitiesCount: 2,
+    opportunitiesCountByLang: { "ru" : 1, "de" : 1 },
+    pageCount: 1,
+    pageCountByLang: { "de -> en" : 1 },
+    charCount: 21,
+    deniedOffers: 0
+  });
+});
+
+add_task(function* test_deny_translation_metric() {
+  function* offerAndDeny(elementAnonid) {
+    let tab = yield offerTranslationFor("<h1>Hallo Welt!</h1>", "de", "en");
+    getInfobarElement(tab.linkedBrowser, elementAnonid).doCommand();
+    yield MetricsChecker.checkAdditions({ deniedOffers: 1 });
+    gBrowser.removeTab(tab);
+  }
+
+  yield offerAndDeny("notNow");
+  yield offerAndDeny("neverForSite");
+  yield offerAndDeny("neverForLanguage");
+  yield offerAndDeny("closeButton");
+
+  // Test that the close button doesn't record a denied translation if
+  // the infobar is not in its "offer" state.
+  let tab = yield translate("<h1>Hallo Welt!</h1>", "de", false);
+  yield MetricsChecker.checkAdditions({ deniedOffers: 0 });
+  gBrowser.removeTab(tab);
+});
+
+add_task(function* test_show_original() {
+  let tab =
+    yield translate("<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", "de", false);
+  yield MetricsChecker.checkAdditions({ pageCount: 1, showOriginal: 0 });
+  getInfobarElement(tab.linkedBrowser, "showOriginal").doCommand();
+  yield MetricsChecker.checkAdditions({ pageCount: 0, showOriginal: 1 });
+  gBrowser.removeTab(tab);
+});
+
+add_task(function* test_language_change() {
+  for (let i of Array(4)) {
+    let tab = yield offerTranslationFor("<h1>Hallo Welt!</h1>", "fr");
+    let browser = tab.linkedBrowser;
+    // In the offer state, translation is executed by the Translate button,
+    // so we expect just a single recoding.
+    let detectedLangMenulist = getInfobarElement(browser, "detectedLanguage");
+    simulateUserSelectInMenulist(detectedLangMenulist, "de");
+    simulateUserSelectInMenulist(detectedLangMenulist, "it");
+    simulateUserSelectInMenulist(detectedLangMenulist, "de");
+    yield acceptTranslationOffer(tab);
+
+    // In the translated state, a change in the form or to menulists
+    // triggers re-translation right away.
+    let fromLangMenulist = getInfobarElement(browser, "fromLanguage");
+    simulateUserSelectInMenulist(fromLangMenulist, "it");
+    simulateUserSelectInMenulist(fromLangMenulist, "de");
+
+    // Selecting the same item shouldn't count.
+    simulateUserSelectInMenulist(fromLangMenulist, "de");
+
+    let toLangMenulist = getInfobarElement(browser, "toLanguage");
+    simulateUserSelectInMenulist(toLangMenulist, "fr");
+    simulateUserSelectInMenulist(toLangMenulist, "en");
+    simulateUserSelectInMenulist(toLangMenulist, "it");
+
+    // Selecting the same item shouldn't count.
+    simulateUserSelectInMenulist(toLangMenulist, "it");
+
+    // Setting the target language to the source language is a no-op,
+    // so it shouldn't count.
+    simulateUserSelectInMenulist(toLangMenulist, "de");
+
+    gBrowser.removeTab(tab);
+  }
+  yield MetricsChecker.checkAdditions({
+    detectedLanguageChangedBefore: 4,
+    detectedLanguageChangeAfter: 8,
+    targetLanguageChanged: 12
+  });
+});
+
+add_task(function* test_never_offer_translation() {
+  Services.prefs.setCharPref("browser.translation.neverForLanguages", "fr");
+
+  let tab = yield offerTranslationFor("<h1>Hallo Welt!</h1>", "fr");
+
+  yield MetricsChecker.checkAdditions({
+    autoRejectedOffers: 1,
+  });
+
+  gBrowser.removeTab(tab);
+  Services.prefs.clearUserPref("browser.translation.neverForLanguages");
+});
+
+add_task(function* test_translation_preferences() {
+
+  let preferenceChecks = {
+    "browser.translation.ui.show" : [
+      {value: false, expected: {showUI: 0}},
+      {value: true, expected: {showUI: 1}}
+    ],
+    "browser.translation.detectLanguage" : [
+      {value: false, expected: {detectLang: 0}},
+      {value: true, expected: {detectLang: 1}}
+    ],
+  };
+
+  for (let preference of Object.keys(preferenceChecks)) {
+    for (let check of preferenceChecks[preference]) {
+      MetricsChecker.reset();
+      Services.prefs.setBoolPref(preference, check.value);
+      // Preference metrics are collected once when the provider is initialized.
+      TranslationTelemetry.init();
+      yield MetricsChecker.checkAdditions(check.expected);
+    }
+    Services.prefs.clearUserPref(preference);
+  }
+
+});
deleted file mode 100644
--- a/browser/components/translation/test/unit/test_healthreport.js
+++ /dev/null
@@ -1,398 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-"use strict";
-
-const {utils: Cu} = Components;
-
-Cu.import("resource://gre/modules/Services.jsm", this);
-Cu.import("resource://gre/modules/Metrics.jsm", this);
-Cu.import("resource:///modules/translation/Translation.jsm", this);
-Cu.import("resource://testing-common/services/healthreport/utils.jsm", this);
-
-// At end of test, restore original state.
-const ORIGINAL_TELEMETRY_ENABLED = Services.prefs.getBoolPref("toolkit.telemetry.enabled");
-
-function run_test() {
-  run_next_test();
-}
-
-add_test(function setup() {
-  do_get_profile();
-  Services.prefs.setBoolPref("toolkit.telemetry.enabled", true);
-
-  run_next_test();
-});
-
-do_register_cleanup(function() {
-  Services.prefs.setBoolPref("toolkit.telemetry.enabled",
-                             ORIGINAL_TELEMETRY_ENABLED);
-});
-
-add_task(function test_constructor() {
-  let provider = new TranslationProvider();
-});
-
-// Provider can initialize and de-initialize properly.
-add_task(function* test_init() {
-  let storage = yield Metrics.Storage("init");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-  yield provider.shutdown();
-  yield storage.close();
-});
-
-// Test recording translation opportunities.
-add_task(function* test_translation_opportunity() {
-  let storage = yield Metrics.Storage("opportunity");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-
-  // Initially nothing should be configured.
-  let now = new Date();
-  let m = provider.getMeasurement("translation", 1);
-  let values = yield m.getValues();
-  Assert.equal(values.days.size, 0);
-  Assert.ok(!values.days.hasDay(now));
-
-  // Record an opportunity.
-  yield provider.recordTranslationOpportunity("fr", now);
-
-  values = yield m.getValues();
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  let day = values.days.getDay(now);
-  Assert.ok(day.has("translationOpportunityCount"));
-  Assert.equal(day.get("translationOpportunityCount"), 1);
-
-  Assert.ok(day.has("translationOpportunityCountsByLanguage"));
-  let countsByLanguage = JSON.parse(day.get("translationOpportunityCountsByLanguage"));
-  Assert.equal(countsByLanguage["fr"], 1);
-
-  // Record a missed opportunity.
-  yield provider.recordMissedTranslationOpportunity("it", now);
-
-  values = yield m.getValues();
-  day = values.days.getDay(now);
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  Assert.ok(day.has("missedTranslationOpportunityCount"));
-  Assert.equal(day.get("missedTranslationOpportunityCount"), 1);
-
-  Assert.ok(day.has("missedTranslationOpportunityCountsByLanguage"));
-  let missedCountsByLanguage = JSON.parse(day.get("missedTranslationOpportunityCountsByLanguage"));
-  Assert.equal(missedCountsByLanguage["it"], 1);
-
-  // Record more opportunities.
-  yield provider.recordTranslationOpportunity("fr", now);
-  yield provider.recordTranslationOpportunity("fr", now);
-  yield provider.recordTranslationOpportunity("es", now);
-
-  yield provider.recordMissedTranslationOpportunity("it", now);
-  yield provider.recordMissedTranslationOpportunity("cs", now);
-  yield provider.recordMissedTranslationOpportunity("fi", now);
-
-  values = yield m.getValues();
-  day = values.days.getDay(now);
-  Assert.ok(day.has("translationOpportunityCount"));
-  Assert.equal(day.get("translationOpportunityCount"), 4);
-  Assert.ok(day.has("missedTranslationOpportunityCount"));
-  Assert.equal(day.get("missedTranslationOpportunityCount"), 4);
-
-  Assert.ok(day.has("translationOpportunityCountsByLanguage"));
-  countsByLanguage = JSON.parse(day.get("translationOpportunityCountsByLanguage"));
-  Assert.equal(countsByLanguage["fr"], 3);
-  Assert.equal(countsByLanguage["es"], 1);
-
-  Assert.ok(day.has("missedTranslationOpportunityCountsByLanguage"));
-  missedCountsByLanguage = JSON.parse(day.get("missedTranslationOpportunityCountsByLanguage"));
-  Assert.equal(missedCountsByLanguage["it"], 2);
-  Assert.equal(missedCountsByLanguage["cs"], 1);
-  Assert.equal(missedCountsByLanguage["fi"], 1);
-
-  yield provider.shutdown();
-  yield storage.close();
-});
-
-// Test recording a translation.
-add_task(function* test_record_translation() {
-  let storage = yield Metrics.Storage("translation");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-  let now = new Date();
-
-  // Record a translation.
-  yield provider.recordTranslation("fr", "es", 1000, now);
-
-  let m = provider.getMeasurement("translation", 1);
-  let values = yield m.getValues();
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  let day = values.days.getDay(now);
-  Assert.ok(day.has("pageTranslatedCount"));
-  Assert.equal(day.get("pageTranslatedCount"), 1);
-  Assert.ok(day.has("charactersTranslatedCount"));
-  Assert.equal(day.get("charactersTranslatedCount"), 1000);
-
-  Assert.ok(day.has("pageTranslatedCountsByLanguage"));
-  let countsByLanguage = JSON.parse(day.get("pageTranslatedCountsByLanguage"));
-  Assert.ok("fr" in countsByLanguage);
-  Assert.equal(countsByLanguage["fr"]["total"], 1);
-  Assert.equal(countsByLanguage["fr"]["es"], 1);
-
-  // Record more translations.
-  yield provider.recordTranslation("fr", "es", 1, now);
-  yield provider.recordTranslation("fr", "en", 2, now);
-  yield provider.recordTranslation("es", "en", 4, now);
-
-  values = yield m.getValues();
-  day = values.days.getDay(now);
-  Assert.ok(day.has("pageTranslatedCount"));
-  Assert.equal(day.get("pageTranslatedCount"), 4);
-  Assert.ok(day.has("charactersTranslatedCount"));
-  Assert.equal(day.get("charactersTranslatedCount"), 1007);
-
-  Assert.ok(day.has("pageTranslatedCountsByLanguage"));
-  countsByLanguage = JSON.parse(day.get("pageTranslatedCountsByLanguage"));
-  Assert.ok("fr" in countsByLanguage);
-  Assert.equal(countsByLanguage["fr"]["total"], 3);
-  Assert.equal(countsByLanguage["fr"]["es"], 2);
-  Assert.equal(countsByLanguage["fr"]["en"], 1);
-  Assert.ok("es" in countsByLanguage);
-  Assert.equal(countsByLanguage["es"]["total"], 1);
-  Assert.equal(countsByLanguage["es"]["en"], 1);
-
-  yield provider.shutdown();
-  yield storage.close();
-});
-
-// Test recording changing languages.
-add_task(function* test_record_translation() {
-  let storage = yield Metrics.Storage("translation");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-  let now = new Date();
-
-  // Record a change to the source language changes before translation.
-  yield provider.recordDetectedLanguageChange(true);
-
-  // Record two changes to the source language changes after translation.
-  yield provider.recordDetectedLanguageChange(false);
-  yield provider.recordDetectedLanguageChange(false);
-
-  // Record two changes to the target language.
-  yield provider.recordTargetLanguageChange();
-  yield provider.recordTargetLanguageChange();
-
-  let m = provider.getMeasurement("translation", 1);
-  let values = yield m.getValues();
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  let day = values.days.getDay(now);
-
-  Assert.ok(day.has("detectedLanguageChangedBefore"));
-  Assert.equal(day.get("detectedLanguageChangedBefore"), 1);
-
-  Assert.ok(day.has("detectedLanguageChangedAfter"));
-  Assert.equal(day.get("detectedLanguageChangedAfter"), 2);
-  Assert.ok(day.has("targetLanguageChanged"));
-  Assert.equal(day.get("targetLanguageChanged"), 2);
-
-  yield provider.shutdown();
-  yield storage.close();
-});
-
-function* test_simple_counter(aProviderFuncName, aCounterName) {
-  let storage = yield Metrics.Storage("translation");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-  let now = new Date();
-
-  yield provider[aProviderFuncName]();
-  yield provider[aProviderFuncName]();
-
-  let m = provider.getMeasurement("translation", 1);
-  let values = yield m.getValues();
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  let day = values.days.getDay(now);
-
-  Assert.ok(day.has(aCounterName));
-  Assert.equal(day.get(aCounterName), 2);
-
-  yield provider.shutdown();
-  yield storage.close();
-}
-
-add_task(function* test_denied_translation_offer() {
-  yield test_simple_counter("recordDeniedTranslationOffer", "deniedTranslationOffer");
-});
-
-add_task(function* test_show_original() {
-  yield test_simple_counter("recordShowOriginalContent", "showOriginalContent");  
-});
-
-add_task(function* test_show_original() {
-  yield test_simple_counter("recordAutoRejectedTranslationOffer",
-                            "autoRejectedTranslationOffer");
-});
-
-add_task(function* test_collect_daily() {
-  let storage = yield Metrics.Storage("translation");
-  let provider = new TranslationProvider();
-  yield provider.init(storage);
-  let now = new Date();
-
-  // Set the prefs we test here to `false` initially.
-  const kPrefDetectLanguage = "browser.translation.detectLanguage";
-  const kPrefShowUI = "browser.translation.ui.show";
-  Services.prefs.setBoolPref(kPrefDetectLanguage, false);
-  Services.prefs.setBoolPref(kPrefShowUI, false);
-
-  // Initially nothing should be configured.
-  yield provider.collectDailyData();
-
-  let m = provider.getMeasurement("translation", 1);
-  let values = yield m.getValues();
-  Assert.equal(values.days.size, 1);
-  Assert.ok(values.days.hasDay(now));
-  let day = values.days.getDay(now);
-  Assert.ok(day.has("detectLanguageEnabled"));
-  Assert.ok(day.has("showTranslationUI"));
-
-  // Changes to the repective prefs should be picked up.
-  Services.prefs.setBoolPref(kPrefDetectLanguage, true);
-  Services.prefs.setBoolPref(kPrefShowUI, true);
-
-  yield provider.collectDailyData();
-
-  values = yield m.getValues();
-  day = values.days.getDay(now);
-  Assert.equal(day.get("detectLanguageEnabled"), 1);
-  Assert.equal(day.get("showTranslationUI"), 1);
-
-  yield provider.shutdown();
-  yield storage.close();
-});
-
-// Test the payload after recording with telemetry enabled.
-add_task(function* test_healthreporter_json() {
-  Services.prefs.setBoolPref("toolkit.telemetry.enabled", true);
-
-  let reporter = yield getHealthReporter("healthreporter_json");
-  yield reporter.init();
-  try {
-    let now = new Date();
-    let provider = new TranslationProvider();
-    yield reporter._providerManager.registerProvider(provider);
-
-    yield provider.recordTranslationOpportunity("fr", now);
-    yield provider.recordDetectedLanguageChange(true);
-    yield provider.recordTranslation("fr", "en", 1000, now);
-    yield provider.recordDetectedLanguageChange(false);
-
-    yield provider.recordTranslationOpportunity("es", now);
-    yield provider.recordTranslation("es", "en", 1000, now);
-
-    yield provider.recordDeniedTranslationOffer();
-
-    yield provider.recordAutoRejectedTranslationOffer();
-
-    yield provider.recordShowOriginalContent();
-
-    yield reporter.collectMeasurements();
-    let payload = yield reporter.getJSONPayload(true);
-    let today = reporter._formatDate(now);
-
-    Assert.ok(today in payload.data.days);
-    let day = payload.data.days[today];
-
-    Assert.ok("org.mozilla.translation.translation" in day);
-
-    let translations = day["org.mozilla.translation.translation"];
-
-    Assert.equal(translations["translationOpportunityCount"], 2);
-    Assert.equal(translations["pageTranslatedCount"], 2);
-    Assert.equal(translations["charactersTranslatedCount"], 2000);
-
-    Assert.ok("translationOpportunityCountsByLanguage" in translations);
-    Assert.equal(translations["translationOpportunityCountsByLanguage"]["fr"], 1);
-    Assert.equal(translations["translationOpportunityCountsByLanguage"]["es"], 1);
-
-    Assert.ok("pageTranslatedCountsByLanguage" in translations);
-    Assert.ok("fr" in translations["pageTranslatedCountsByLanguage"]);
-    Assert.equal(translations["pageTranslatedCountsByLanguage"]["fr"]["total"], 1);
-    Assert.equal(translations["pageTranslatedCountsByLanguage"]["fr"]["en"], 1);
-
-    Assert.ok("es" in translations["pageTranslatedCountsByLanguage"]);
-    Assert.equal(translations["pageTranslatedCountsByLanguage"]["es"]["total"], 1);
-    Assert.equal(translations["pageTranslatedCountsByLanguage"]["es"]["en"], 1);
-
-    Assert.ok("detectedLanguageChangedBefore" in translations);
-    Assert.equal(translations["detectedLanguageChangedBefore"], 1);
-    Assert.ok("detectedLanguageChangedAfter" in translations);
-    Assert.equal(translations["detectedLanguageChangedAfter"], 1);
-    
-    Assert.ok("deniedTranslationOffer" in translations);
-    Assert.equal(translations["deniedTranslationOffer"], 1);
-
-    Assert.ok("autoRejectedTranslationOffer" in translations);
-    Assert.equal(translations["autoRejectedTranslationOffer"], 1);
-
-    Assert.ok("showOriginalContent" in translations);
-    Assert.equal(translations["showOriginalContent"], 1);
-  } finally {
-    reporter._shutdown();
-  }
-});
-
-// Test the payload after recording with telemetry disabled.
-add_task(function* test_healthreporter_json2() {
-  Services.prefs.setBoolPref("toolkit.telemetry.enabled", false);
-
-  let reporter = yield getHealthReporter("healthreporter_json");
-  yield reporter.init();
-  try {
-    let now = new Date();
-    let provider = new TranslationProvider();
-    yield reporter._providerManager.registerProvider(provider);
-
-    yield provider.recordTranslationOpportunity("fr", now);
-    yield provider.recordDetectedLanguageChange(true);
-    yield provider.recordTranslation("fr", "en", 1000, now);
-    yield provider.recordDetectedLanguageChange(false);
-
-    yield provider.recordTranslationOpportunity("es", now);
-    yield provider.recordTranslation("es", "en", 1000, now);
-
-    yield provider.recordDeniedTranslationOffer();
-
-    yield provider.recordAutoRejectedTranslationOffer();
-
-    yield provider.recordShowOriginalContent();
-
-    yield reporter.collectMeasurements();
-    let payload = yield reporter.getJSONPayload(true);
-    let today = reporter._formatDate(now);
-
-    Assert.ok(today in payload.data.days);
-    let day = payload.data.days[today];
-
-    Assert.ok("org.mozilla.translation.translation" in day);
-
-    let translations = day["org.mozilla.translation.translation"];
-
-    Assert.ok(!("translationOpportunityCount" in translations));
-    Assert.ok(!("pageTranslatedCount" in translations));
-    Assert.ok(!("charactersTranslatedCount" in translations));
-    Assert.ok(!("translationOpportunityCountsByLanguage" in translations));
-    Assert.ok(!("pageTranslatedCountsByLanguage" in translations));
-    Assert.ok(!("detectedLanguageChangedBefore" in translations));
-    Assert.ok(!("detectedLanguageChangedAfter" in translations));
-    Assert.ok(!("deniedTranslationOffer" in translations));
-    Assert.ok(!("autoRejectedTranslationOffer" in translations));
-    Assert.ok(!("showOriginalContent" in translations));
-  } finally {
-    reporter._shutdown();
-  }
-});
--- a/browser/components/translation/test/unit/xpcshell.ini
+++ b/browser/components/translation/test/unit/xpcshell.ini
@@ -1,8 +1,7 @@
 [DEFAULT]
 head = 
 tail = 
 firefox-appdir = browser
 skip-if = toolkit == 'android' || toolkit == 'gonk'
 
 [test_cld2.js]
-[test_healthreport.js]
--- a/browser/components/uitour/UITour.jsm
+++ b/browser/components/uitour/UITour.jsm
@@ -1980,40 +1980,40 @@ this.UITour = {
       return;
     }
 
     CustomizableUI.addWidgetToArea(aTarget.widgetName, CustomizableUI.AREA_NAVBAR);
     this.sendPageCallback(aMessageManager, aCallbackID);
   },
 
   _addAnnotationPanelMutationObserver: function(aPanelEl) {
-#ifdef XP_LINUX
-    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
-    if (observer) {
-      return;
+    if (AppConstants.platform == "linux") {
+      let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+      if (observer) {
+        return;
+      }
+      let win = aPanelEl.ownerDocument.defaultView;
+      observer = new win.MutationObserver(this._annotationMutationCallback);
+      this._annotationPanelMutationObservers.set(aPanelEl, observer);
+      let observerOptions = {
+        attributeFilter: ["height", "width"],
+        attributes: true,
+      };
+      observer.observe(aPanelEl, observerOptions);
     }
-    let win = aPanelEl.ownerDocument.defaultView;
-    observer = new win.MutationObserver(this._annotationMutationCallback);
-    this._annotationPanelMutationObservers.set(aPanelEl, observer);
-    let observerOptions = {
-      attributeFilter: ["height", "width"],
-      attributes: true,
-    };
-    observer.observe(aPanelEl, observerOptions);
-#endif
   },
 
   _removeAnnotationPanelMutationObserver: function(aPanelEl) {
-#ifdef XP_LINUX
-    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
-    if (observer) {
-      observer.disconnect();
-      this._annotationPanelMutationObservers.delete(aPanelEl);
+    if (AppConstants.platform == "linux") {
+      let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+      if (observer) {
+        observer.disconnect();
+        this._annotationPanelMutationObservers.delete(aPanelEl);
+      }
     }
-#endif
   },
 
 /**
  * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
  * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
  * set on the panel.
  */
   _annotationMutationCallback: function(aMutations) {
@@ -2160,103 +2160,105 @@ const UITourHealthReport = {
       version: 1,
       tagName: tag,
       tagValue: value,
     },
     {
       addClientId: true,
       addEnvironment: true,
     });
-#ifdef MOZ_SERVICES_HEALTHREPORT
-    Task.spawn(function*() {
-      let reporter = Cc["@mozilla.org/datareporting/service;1"]
-                       .getService()
-                       .wrappedJSObject
-                       .healthReporter;
+
+    if (AppConstants.MOZ_SERVICES_HEALTHREPORT) {
+      Task.spawn(function*() {
+        let reporter = Cc["@mozilla.org/datareporting/service;1"]
+                         .getService()
+                         .wrappedJSObject
+                         .healthReporter;
 
-      // This can happen if the FHR component of the data reporting service is
-      // disabled. This is controlled by a pref that most will never use.
-      if (!reporter) {
-        return;
-      }
+        // This can happen if the FHR component of the data reporting service is
+        // disabled. This is controlled by a pref that most will never use.
+        if (!reporter) {
+          return;
+        }
 
-      yield reporter.onInit();
+        yield reporter.onInit();
 
-      // Get the UITourMetricsProvider instance from the Health Reporter
-      reporter.getProvider("org.mozilla.uitour").recordTreatmentTag(tag, value);
-    });
-#endif
+        // Get the UITourMetricsProvider instance from the Health Reporter
+        reporter.getProvider("org.mozilla.uitour").recordTreatmentTag(tag, value);
+      });
+    }
   }
 };
 
-#ifdef MOZ_SERVICES_HEALTHREPORT
-const DAILY_DISCRETE_TEXT_FIELD = Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT;
-
-this.UITourMetricsProvider = function() {
-  Metrics.Provider.call(this);
-}
-
-UITourMetricsProvider.prototype = Object.freeze({
-  __proto__: Metrics.Provider.prototype,
-
-  name: "org.mozilla.uitour",
-
-  measurementTypes: [
-    UITourTreatmentMeasurement1,
-  ],
-
-  recordTreatmentTag: function(tag, value) {
-    let m = this.getMeasurement(UITourTreatmentMeasurement1.prototype.name,
-                                UITourTreatmentMeasurement1.prototype.version);
-    let field = tag;
-
-    if (this.storage.hasFieldFromMeasurement(m.id, field,
-                                             DAILY_DISCRETE_TEXT_FIELD)) {
-      let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
-      return this.enqueueStorageOperation(function recordKnownField() {
-        return this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
-      }.bind(this));
-    }
-
-    // Otherwise, we first need to create the field.
-    return this.enqueueStorageOperation(function recordField() {
-      // This function has to return a promise.
-      return Task.spawn(function () {
-        let fieldID = yield this.storage.registerField(m.id, field,
-                                                       DAILY_DISCRETE_TEXT_FIELD);
-        yield this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
-      }.bind(this));
-    }.bind(this));
-  },
-});
-
 function UITourTreatmentMeasurement1() {
   Metrics.Measurement.call(this);
 
   this._serializers = {};
   this._serializers[this.SERIALIZE_JSON] = {
     //singular: We don't need a singular serializer because we have none of this data
     daily: this._serializeJSONDaily.bind(this)
   };
 
 }
 
-UITourTreatmentMeasurement1.prototype = Object.freeze({
-  __proto__: Metrics.Measurement.prototype,
+if (AppConstants.MOZ_SERVICES_HEALTHREPORT) {
+
+  const DAILY_DISCRETE_TEXT_FIELD = Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT;
+
+  this.UITourMetricsProvider = function() {
+    Metrics.Provider.call(this);
+  }
+
+  UITourMetricsProvider.prototype = Object.freeze({
+    __proto__: Metrics.Provider.prototype,
+
+    name: "org.mozilla.uitour",
 
-  name: "treatment",
-  version: 1,
+    measurementTypes: [
+      UITourTreatmentMeasurement1,
+    ],
 
-  // our fields are dynamic
-  fields: { },
+    recordTreatmentTag: function(tag, value) {
+      let m = this.getMeasurement(UITourTreatmentMeasurement1.prototype.name,
+                                  UITourTreatmentMeasurement1.prototype.version);
+      let field = tag;
+
+      if (this.storage.hasFieldFromMeasurement(m.id, field,
+                                               DAILY_DISCRETE_TEXT_FIELD)) {
+        let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
+        return this.enqueueStorageOperation(function recordKnownField() {
+          return this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
+        }.bind(this));
+      }
 
-  // We need a custom serializer because the default one doesn't accept unknown fields
-  _serializeJSONDaily: function(data) {
-    let result = {_v: this.version };
+      // Otherwise, we first need to create the field.
+      return this.enqueueStorageOperation(function recordField() {
+        // This function has to return a promise.
+        return Task.spawn(function () {
+          let fieldID = yield this.storage.registerField(m.id, field,
+                                                         DAILY_DISCRETE_TEXT_FIELD);
+          yield this.storage.addDailyDiscreteTextFromFieldID(fieldID, value);
+        }.bind(this));
+      }.bind(this));
+    },
+  });
+
+  UITourTreatmentMeasurement1.prototype = Object.freeze({
+    __proto__: Metrics.Measurement.prototype,
 
-    for (let [field, data] of data) {
-      result[field] = data;
-    }
+    name: "treatment",
+    version: 1,
+
+    // our fields are dynamic
+    fields: { },
 
-    return result;
-  }
-});
-#endif
+    // We need a custom serializer because the default one doesn't accept unknown fields
+    _serializeJSONDaily: function(data) {
+      let result = {_v: this.version };
+
+      for (let [field, data] of data) {
+        result[field] = data;
+      }
+
+      return result;
+    }
+  });
+}
--- a/browser/components/uitour/moz.build
+++ b/browser/components/uitour/moz.build
@@ -1,13 +1,13 @@
 # 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/.
 
-EXTRA_PP_JS_MODULES += [
+EXTRA_JS_MODULES += [
     'UITour.jsm',
 ]
 
 JAR_MANIFESTS += ['jar.mn']
 
 BROWSER_CHROME_MANIFESTS += [
     'test/browser.ini',
 ]
--- a/browser/devtools/animationinspector/animation-controller.js
+++ b/browser/devtools/animationinspector/animation-controller.js
@@ -58,23 +58,55 @@ let shutdown = Task.async(function*() {
   if (typeof AnimationsPanel !== "undefined") {
     yield AnimationsPanel.destroy();
   }
   gToolbox = gInspector = null;
 });
 
 // This is what makes the sidebar widget able to load/unload the panel.
 function setPanel(panel) {
-  return startup(panel).catch(Cu.reportError);
+  return startup(panel).catch(e => console.error(e));
 }
 function destroy() {
-  return shutdown().catch(Cu.reportError);
+  return shutdown().catch(e => console.error(e));
 }
 
 /**
+ * Get all the server-side capabilities (traits) so the UI knows whether or not
+ * features should be enabled/disabled.
+ * @param {Target} target The current toolbox target.
+ * @return {Object} An object with boolean properties.
+ */
+let getServerTraits = Task.async(function*(target) {
+  let config = [{
+    name: "hasToggleAll", actor: "animations", method: "toggleAll"
+  }, {
+    name: "hasSetCurrentTime", actor: "animationplayer", method: "setCurrentTime"
+  }, {
+    name: "hasMutationEvents", actor: "animations", method: "stopAnimationPlayerUpdates"
+  }, {
+    name: "hasSetPlaybackRate", actor: "animationplayer", method: "setPlaybackRate"
+  }, {
+    name: "hasTargetNode", actor: "domwalker", method: "getNodeFromActor"
+  }, {
+    name: "hasSetCurrentTimes", actor: "animations", method: "setCurrentTimes"
+  }];
+
+  let traits = {};
+  for (let {name, actor, method} of config) {
+    traits[name] = yield target.actorHasMethod(actor, method);
+  }
+
+  // Special pref-based UI trait.
+  traits.isNewUI = Services.prefs.getBoolPref("devtools.inspector.animationInspectorV3");
+
+  return traits;
+});
+
+/**
  * The animationinspector controller's job is to retrieve AnimationPlayerFronts
  * from the server. It is also responsible for keeping the list of players up to
  * date when the node selection changes in the inspector, as well as making sure
  * no updates are done when the animationinspector sidebar panel is not visible.
  *
  * AnimationPlayerFronts are available in AnimationsController.animationPlayers.
  *
  * Note also that all AnimationPlayerFronts handled by the controller are set to
@@ -101,26 +133,17 @@ let AnimationsController = {
     this.onPanelVisibilityChange = this.onPanelVisibilityChange.bind(this);
     this.onNewNodeFront = this.onNewNodeFront.bind(this);
     this.onAnimationMutations = this.onAnimationMutations.bind(this);
 
     let target = gToolbox.target;
     this.animationsFront = new AnimationsFront(target.client, target.form);
 
     // Expose actor capabilities.
-    this.hasToggleAll = yield target.actorHasMethod("animations", "toggleAll");
-    this.hasSetCurrentTime = yield target.actorHasMethod("animationplayer",
-                                                         "setCurrentTime");
-    this.hasMutationEvents = yield target.actorHasMethod("animations",
-                                                         "stopAnimationPlayerUpdates");
-    this.hasSetPlaybackRate = yield target.actorHasMethod("animationplayer",
-                                                          "setPlaybackRate");
-    this.hasTargetNode = yield target.actorHasMethod("domwalker",
-                                                     "getNodeFromActor");
-    this.isNewUI = Services.prefs.getBoolPref("devtools.inspector.animationInspectorV3");
+    this.traits = yield getServerTraits(target);
 
     if (this.destroyed) {
       console.warn("Could not fully initialize the AnimationsController");
       return;
     }
 
     this.startListeners();
     yield this.onNewNodeFront();
@@ -205,93 +228,116 @@ let AnimationsController = {
 
     done();
   }),
 
   /**
    * Toggle (pause/play) all animations in the current target.
    */
   toggleAll: function() {
-    if (!this.hasToggleAll) {
+    if (!this.traits.hasToggleAll) {
       return promise.resolve();
     }
 
-    return this.animationsFront.toggleAll().catch(Cu.reportError);
+    return this.animationsFront.toggleAll().catch(e => console.error(e));
   },
 
+  /**
+   * Set all known animations' currentTimes to the provided time.
+   * Note that depending on the server's capabilities, this might resolve in
+   * either one packet, or as many packets as there are animations. In the
+   * latter case, some time deltas might be introduced.
+   * @param {Number} time.
+   * @param {Boolean} shouldPause Should the animations be paused too.
+   * @return {Promise} Resolves when the current time has been set.
+   */
+  setCurrentTimeAll: Task.async(function*(time, shouldPause) {
+    if (this.traits.hasSetCurrentTimes) {
+      yield this.animationsFront.setCurrentTimes(this.animationPlayers, time,
+                                                 shouldPause);
+    } else {
+      for (let animation of this.animationPlayers) {
+        if (shouldPause) {
+          yield animation.pause();
+        }
+        yield animation.setCurrentTime(time);
+      }
+    }
+  }),
+
   // AnimationPlayerFront objects are managed by this controller. They are
   // retrieved when refreshAnimationPlayers is called, stored in the
   // animationPlayers array, and destroyed when refreshAnimationPlayers is
   // called again.
   animationPlayers: [],
 
   refreshAnimationPlayers: Task.async(function*(nodeFront) {
     yield this.destroyAnimationPlayers();
 
     this.animationPlayers = yield this.animationsFront.getAnimationPlayersForNode(nodeFront);
     this.startAllAutoRefresh();
 
     // Start listening for animation mutations only after the first method call
     // otherwise events won't be sent.
-    if (!this.isListeningToMutations && this.hasMutationEvents) {
+    if (!this.isListeningToMutations && this.traits.hasMutationEvents) {
       this.animationsFront.on("mutations", this.onAnimationMutations);
       this.isListeningToMutations = true;
     }
   }),
 
   onAnimationMutations: Task.async(function*(changes) {
     // Insert new players into this.animationPlayers when new animations are
     // added.
     for (let {type, player} of changes) {
       if (type === "added") {
         this.animationPlayers.push(player);
-        if (!this.isNewUI) {
+        if (!this.traits.isNewUI) {
           player.startAutoRefresh();
         }
       }
 
       if (type === "removed") {
-        if (!this.isNewUI) {
+        if (!this.traits.isNewUI) {
           player.stopAutoRefresh();
         }
         yield player.release();
         let index = this.animationPlayers.indexOf(player);
         this.animationPlayers.splice(index, 1);
       }
     }
 
     // Let the UI know the list has been updated.
     this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers);
   }),
 
   startAllAutoRefresh: function() {
-    if (this.isNewUI) {
+    if (this.traits.isNewUI) {
       return;
     }
 
     for (let front of this.animationPlayers) {
       front.startAutoRefresh();
     }
   },
 
   stopAllAutoRefresh: function() {
-    if (this.isNewUI) {
+    if (this.traits.isNewUI) {
       return;
     }
 
     for (let front of this.animationPlayers) {
       front.stopAutoRefresh();
     }
   },
 
   destroyAnimationPlayers: Task.async(function*() {
     // Let the server know that we're not interested in receiving updates about
     // players for the current node. We're either being destroyed or a new node
     // has been selected.
-    if (this.hasMutationEvents) {
+    if (this.traits.hasMutationEvents) {
       yield this.animationsFront.stopAnimationPlayerUpdates();
     }
     this.stopAllAutoRefresh();
     for (let front of this.animationPlayers) {
       yield front.release();
     }
     this.animationPlayers = [];
   })
--- a/browser/devtools/animationinspector/animation-panel.js
+++ b/browser/devtools/animationinspector/animation-panel.js
@@ -36,35 +36,36 @@ let AnimationsPanel = {
 
     this.playersEl = document.querySelector("#players");
     this.errorMessageEl = document.querySelector("#error-message");
     this.pickerButtonEl = document.querySelector("#element-picker");
     this.toggleAllButtonEl = document.querySelector("#toggle-all");
 
     // If the server doesn't support toggling all animations at once, hide the
     // whole bottom toolbar.
-    if (!AnimationsController.hasToggleAll) {
+    if (!AnimationsController.traits.hasToggleAll) {
       document.querySelector("#toolbar").style.display = "none";
     }
 
     let hUtils = gToolbox.highlighterUtils;
     this.togglePicker = hUtils.togglePicker.bind(hUtils);
     this.onPickerStarted = this.onPickerStarted.bind(this);
     this.onPickerStopped = this.onPickerStopped.bind(this);
     this.refreshAnimations = this.refreshAnimations.bind(this);
     this.toggleAll = this.toggleAll.bind(this);
     this.onTabNavigated = this.onTabNavigated.bind(this);
+    this.onTimelineTimeChanged = this.onTimelineTimeChanged.bind(this);
 
-    this.startListeners();
-
-    if (AnimationsController.isNewUI) {
+    if (AnimationsController.traits.isNewUI) {
       this.animationsTimelineComponent = new AnimationsTimeline(gInspector);
       this.animationsTimelineComponent.init(this.playersEl);
     }
 
+    this.startListeners();
+
     yield this.refreshAnimations();
 
     this.initialized.resolve();
 
     this.emit(this.PANEL_INITIALIZED);
   }),
 
   destroy: Task.async(function*() {
@@ -96,28 +97,38 @@ let AnimationsPanel = {
       this.refreshAnimations);
 
     this.pickerButtonEl.addEventListener("click", this.togglePicker, false);
     gToolbox.on("picker-started", this.onPickerStarted);
     gToolbox.on("picker-stopped", this.onPickerStopped);
 
     this.toggleAllButtonEl.addEventListener("click", this.toggleAll, false);
     gToolbox.target.on("navigate", this.onTabNavigated);
+
+    if (this.animationsTimelineComponent) {
+      this.animationsTimelineComponent.on("current-time-changed",
+        this.onTimelineTimeChanged);
+    }
   },
 
   stopListeners: function() {
     AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT,
       this.refreshAnimations);
 
     this.pickerButtonEl.removeEventListener("click", this.togglePicker, false);
     gToolbox.off("picker-started", this.onPickerStarted);
     gToolbox.off("picker-stopped", this.onPickerStopped);
 
     this.toggleAllButtonEl.removeEventListener("click", this.toggleAll, false);
     gToolbox.target.off("navigate", this.onTabNavigated);
+
+    if (this.animationsTimelineComponent) {
+      this.animationsTimelineComponent.off("current-time-changed",
+        this.onTimelineTimeChanged);
+    }
   },
 
   displayErrorMessage: function() {
     this.errorMessageEl.style.display = "block";
     this.playersEl.style.display = "none";
   },
 
   hideErrorMessage: function() {
@@ -131,38 +142,42 @@ let AnimationsPanel = {
 
   onPickerStopped: function() {
     this.pickerButtonEl.removeAttribute("checked");
   },
 
   toggleAll: Task.async(function*() {
     let btnClass = this.toggleAllButtonEl.classList;
 
-    if (!AnimationsController.isNewUI) {
+    if (!AnimationsController.traits.isNewUI) {
       // Toggling all animations is async and it may be some time before each of
       // the current players get their states updated, so toggle locally too, to
       // avoid the timelines from jumping back and forth.
       if (this.playerWidgets) {
         let currentWidgetStateChange = [];
         for (let widget of this.playerWidgets) {
           currentWidgetStateChange.push(btnClass.contains("paused")
             ? widget.play() : widget.pause());
         }
-        yield promise.all(currentWidgetStateChange).catch(Cu.reportError);
+        yield promise.all(currentWidgetStateChange).catch(e => console.error(e));
       }
     }
 
     btnClass.toggle("paused");
     yield AnimationsController.toggleAll();
   }),
 
   onTabNavigated: function() {
     this.toggleAllButtonEl.classList.remove("paused");
   },
 
+  onTimelineTimeChanged: function(e, time) {
+    AnimationsController.setCurrentTimeAll(time, true).catch(e => console.error(e));
+  },
+
   refreshAnimations: Task.async(function*() {
     let done = gInspector.updating("animationspanel");
 
     // Empty the whole panel first.
     this.hideErrorMessage();
     yield this.destroyPlayerWidgets();
 
     // Re-render the timeline component.
@@ -177,17 +192,17 @@ let AnimationsPanel = {
       this.displayErrorMessage();
       this.emit(this.UI_UPDATED_EVENT);
       done();
       return;
     }
 
     // Otherwise, create player widgets (only when isNewUI is false, the
     // timeline has already been re-rendered).
-    if (!AnimationsController.isNewUI) {
+    if (!AnimationsController.traits.isNewUI) {
       this.playerWidgets = [];
       let initPromises = [];
 
       for (let player of AnimationsController.animationPlayers) {
         let widget = new PlayerWidget(player, this.playersEl);
         initPromises.push(widget.initialize());
         this.playerWidgets.push(widget);
       }
@@ -225,20 +240,20 @@ function PlayerWidget(player, containerE
   this.onStateChanged = this.onStateChanged.bind(this);
   this.onPlayPauseBtnClick = this.onPlayPauseBtnClick.bind(this);
   this.onRewindBtnClick = this.onRewindBtnClick.bind(this);
   this.onFastForwardBtnClick = this.onFastForwardBtnClick.bind(this);
   this.onCurrentTimeChanged = this.onCurrentTimeChanged.bind(this);
   this.onPlaybackRateChanged = this.onPlaybackRateChanged.bind(this);
 
   this.metaDataComponent = new PlayerMetaDataHeader();
-  if (AnimationsController.hasSetPlaybackRate) {
+  if (AnimationsController.traits.hasSetPlaybackRate) {
     this.rateComponent = new PlaybackRateSelector();
   }
-  if (AnimationsController.hasTargetNode) {
+  if (AnimationsController.traits.hasTargetNode) {
     this.targetNodeComponent = new AnimationTargetNode(gInspector);
   }
 }
 
 PlayerWidget.prototype = {
   initialize: Task.async(function*() {
     if (this.initialized) {
       return;
@@ -269,30 +284,30 @@ PlayerWidget.prototype = {
     this.playPauseBtnEl = this.rewindBtnEl = this.fastForwardBtnEl = null;
     this.currentTimeEl = this.timeDisplayEl = null;
     this.containerEl = this.el = this.player = null;
   }),
 
   startListeners: function() {
     this.player.on(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
     this.playPauseBtnEl.addEventListener("click", this.onPlayPauseBtnClick);
-    if (AnimationsController.hasSetCurrentTime) {
+    if (AnimationsController.traits.hasSetCurrentTime) {
       this.rewindBtnEl.addEventListener("click", this.onRewindBtnClick);
       this.fastForwardBtnEl.addEventListener("click", this.onFastForwardBtnClick);
       this.currentTimeEl.addEventListener("input", this.onCurrentTimeChanged);
     }
     if (this.rateComponent) {
       this.rateComponent.on("rate-changed", this.onPlaybackRateChanged);
     }
   },
 
   stopListeners: function() {
     this.player.off(this.player.AUTO_REFRESH_EVENT, this.onStateChanged);
     this.playPauseBtnEl.removeEventListener("click", this.onPlayPauseBtnClick);
-    if (AnimationsController.hasSetCurrentTime) {
+    if (AnimationsController.traits.hasSetCurrentTime) {
       this.rewindBtnEl.removeEventListener("click", this.onRewindBtnClick);
       this.fastForwardBtnEl.removeEventListener("click", this.onFastForwardBtnClick);
       this.currentTimeEl.removeEventListener("input", this.onCurrentTimeChanged);
     }
     if (this.rateComponent) {
       this.rateComponent.off("rate-changed", this.onPlaybackRateChanged);
     }
   },
@@ -335,17 +350,17 @@ PlayerWidget.prototype = {
     this.playPauseBtnEl = createNode({
       parent: playbackControlsEl,
       nodeType: "button",
       attributes: {
         "class": "toggle devtools-button"
       }
     });
 
-    if (AnimationsController.hasSetCurrentTime) {
+    if (AnimationsController.traits.hasSetCurrentTime) {
       this.rewindBtnEl = createNode({
         parent: playbackControlsEl,
         nodeType: "button",
         attributes: {
           "class": "rw devtools-button"
         }
       });
 
@@ -388,17 +403,17 @@ PlayerWidget.prototype = {
         "class": "current-time",
         "min": "0",
         "max": max,
         "step": "10",
         "value": "0"
       }
     });
 
-    if (!AnimationsController.hasSetCurrentTime) {
+    if (!AnimationsController.traits.hasSetCurrentTime) {
       this.currentTimeEl.setAttribute("disabled", "true");
     }
 
     // Time display
     this.timeDisplayEl = createNode({
       parent: timelineEl,
       attributes: {
         "class": "time-display"
@@ -486,17 +501,17 @@ PlayerWidget.prototype = {
 
   /**
    * Set the current time of the animation.
    * @param {Number} time.
    * @param {Boolean} shouldPause Should the player be paused too.
    * @return {Promise} Resolves when the current time has been set.
    */
   setCurrentTime: Task.async(function*(time, shouldPause) {
-    if (!AnimationsController.hasSetCurrentTime) {
+    if (!AnimationsController.traits.hasSetCurrentTime) {
       throw new Error("This server version doesn't support setting " +
                       "animations' currentTime");
     }
 
     if (shouldPause) {
       this.stopTimelineAnimation();
       yield this.pause();
     }
@@ -513,17 +528,17 @@ PlayerWidget.prototype = {
   }),
 
   /**
    * Set the playback rate of the animation.
    * @param {Number} rate.
    * @return {Promise} Resolves when the rate has been set.
    */
   setPlaybackRate: function(rate) {
-    if (!AnimationsController.hasSetPlaybackRate) {
+    if (!AnimationsController.traits.hasSetPlaybackRate) {
       throw new Error("This server version doesn't support setting " +
                       "animations' playbackRate");
     }
 
     return this.player.setPlaybackRate(rate);
   },
 
   /**
--- a/browser/devtools/animationinspector/components.js
+++ b/browser/devtools/animationinspector/components.js
@@ -774,17 +774,18 @@ AnimationsTimeline.prototype = {
   moveScrubberTo: function(pageX) {
     let offset = pageX - this.scrubberEl.offsetWidth;
     if (offset < 0) {
       offset = 0;
     }
 
     this.scrubberEl.style.left = offset + "px";
 
-    let time = TimeScale.distanceToTime(offset, this.timeHeaderEl.offsetWidth);
+    let time = TimeScale.distanceToRelativeTime(offset,
+      this.timeHeaderEl.offsetWidth);
     this.emit("current-time-changed", time);
   },
 
   render: function(animations) {
     this.unrender();
 
     this.animations = animations;
     if (!this.animations.length) {
--- a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_have_control_buttons.js
+++ b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_have_control_buttons.js
@@ -24,47 +24,47 @@ add_task(function*() {
   ok(container.children[1].classList.contains("rw"),
     "The second button is the rewind button");
   ok(container.children[2].classList.contains("ff"),
     "The third button is the fast-forward button");
   ok(container.querySelector("select"),
     "The container contains the playback rate select");
 
   info("Faking an older server version by setting " +
-    "AnimationsController.hasSetCurrentTime to false");
+    "AnimationsController.traits.hasSetCurrentTime to false");
 
   // Selecting <div.still> to make sure no widgets are displayed in the panel.
   yield selectNode(".still", inspector);
-  controller.hasSetCurrentTime = false;
+  controller.traits.hasSetCurrentTime = false;
 
   info("Selecting the animated node again");
   yield selectNode(".animated", inspector);
 
   widget = panel.playerWidgets[0];
   container = widget.el.querySelector(".playback-controls");
 
   ok(container, "The control buttons container still exists");
   is(container.querySelectorAll("button").length, 1,
     "The container only contains 1 button");
   ok(container.children[0].classList.contains("toggle"),
     "The first button is the play/pause button");
 
   yield selectNode(".still", inspector);
-  controller.hasSetCurrentTime = true;
+  controller.traits.hasSetCurrentTime = true;
 
   info("Faking an older server version by setting " +
-    "AnimationsController.hasSetPlaybackRate to false");
+    "AnimationsController.traits.hasSetPlaybackRate to false");
 
-  controller.hasSetPlaybackRate = false;
+  controller.traits.hasSetPlaybackRate = false;
 
   info("Selecting the animated node again");
   yield selectNode(".animated", inspector);
 
   widget = panel.playerWidgets[0];
   container = widget.el.querySelector(".playback-controls");
 
   ok(container, "The control buttons container still exists");
   ok(!container.querySelector("select"),
     "The playback rate select does not exist");
 
   yield selectNode(".still", inspector);
-  controller.hasSetPlaybackRate = true;
+  controller.traits.hasSetPlaybackRate = true;
 });
--- a/browser/devtools/animationinspector/test/browser_animation_playerWidgets_scrubber_enabled.js
+++ b/browser/devtools/animationinspector/test/browser_animation_playerWidgets_scrubber_enabled.js
@@ -18,24 +18,24 @@ add_task(function*() {
   let widget = panel.playerWidgets[0];
   let timeline = widget.currentTimeEl;
 
   ok(!timeline.hasAttribute("disabled"), "The timeline input[range] is enabled");
   ok(widget.setCurrentTime, "The widget has the setCurrentTime method");
   ok(widget.player.setCurrentTime, "The associated player front has the setCurrentTime method");
 
   info("Faking an older server version by setting " +
-    "AnimationsController.hasSetCurrentTime to false");
+    "AnimationsController.traits.hasSetCurrentTime to false");
 
   yield selectNode("body", inspector);
-  controller.hasSetCurrentTime = false;
+  controller.traits.hasSetCurrentTime = false;
 
   yield selectNode(".animated", inspector);
 
   info("Get the player widget's timeline element");
   widget = panel.playerWidgets[0];
   timeline = widget.currentTimeEl;
 
   ok(timeline.hasAttribute("disabled"), "The timeline input[range] is disabled");
 
   yield selectNode("body", inspector);
-  controller.hasSetCurrentTime = true;
+  controller.traits.hasSetCurrentTime = true;
 });
--- a/browser/devtools/markupview/markup-view.js
+++ b/browser/devtools/markupview/markup-view.js
@@ -2198,19 +2198,16 @@ function MarkupElementContainer(markupVi
 
   if (node.nodeType === Ci.nsIDOMNode.ELEMENT_NODE) {
     this.editor = new ElementEditor(this, node);
   } else {
     throw "Invalid node for MarkupElementContainer";
   }
 
   this.tagLine.appendChild(this.editor.elt);
-
-  // Prepare the image preview tooltip data if any
-  this._prepareImagePreview();
 }
 
 MarkupElementContainer.prototype = Heritage.extend(MarkupContainer.prototype, {
 
   _buildEventTooltipContent: function(target, tooltip) {
     if (target.hasAttribute("data-event")) {
       tooltip.hide(target);
 
@@ -2226,69 +2223,86 @@ MarkupElementContainer.prototype = Herit
         });
         tooltip.show(target);
       });
       return true;
     }
   },
 
   /**
-   * If the node is an image or canvas (@see isPreviewable), then get the
-   * image data uri from the server so that it can then later be previewed in
-   * a tooltip if needed.
-   * Stores a promise in this.tooltipData.data that resolves when the data has
-   * been retrieved
+   * Generates the an image preview for this Element. The element must be an
+   * image or canvas (@see isPreviewable).
+   *
+   * @return A Promise that is resolved with an object of form
+   * { data, size: { naturalWidth, naturalHeight, resizeRatio } } where
+   *   - data is the data-uri for the image preview.
+   *   - size contains information about the original image size and if the
+   *     preview has been resized.
+   *
+   * If this element is not previewable or the preview cannot be generated for
+   * some reason, the Promise is rejected.
    */
-  _prepareImagePreview: function() {
-    if (this.isPreviewable()) {
-      // Get the image data for later so that when the user actually hovers over
-      // the element, the tooltip does contain the image
-      let def = promise.defer();
-
-      let hasSrc = this.editor.getAttributeElement("src");
-      this.tooltipData = {
-        target: hasSrc ? hasSrc.querySelector(".link") : this.editor.tag,
-        data: def.promise
-      };
-
-      let maxDim = Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize");
-      this.node.getImageData(maxDim).then(data => {
-        data.data.string().then(str => {
-          let res = {data: str, size: data.size};
-          // Resolving the data promise and, to always keep tooltipData.data
-          // as a promise, create a new one that resolves immediately
-          def.resolve(res);
-          this.tooltipData.data = promise.resolve(res);
-        });
-      }, () => {
-        this.tooltipData.data = promise.resolve({});
-      });
+  _getPreview: function() {
+    if (!this.isPreviewable()) {
+      return promise.reject("_getPreview called on a non-previewable element.");
+    }
+
+    if (this.tooltipDataPromise) {
+      // A preview request is already pending. Re-use that request.
+      return this.tooltipDataPromise;
     }
+
+    let maxDim =
+      Services.prefs.getIntPref("devtools.inspector.imagePreviewTooltipSize");
+
+    // Fetch the preview from the server.
+    this.tooltipDataPromise = Task.spawn(function*() {
+      let preview = yield this.node.getImageData(maxDim);
+      let data = yield preview.data.string();
+
+      // Clear the pending preview request. We can't reuse the results later as
+      // the preview contents might have changed.
+      this.tooltipDataPromise = null;
+
+      return { data, size: preview.size };
+    }.bind(this));
+
+    return this.tooltipDataPromise;
   },
 
   /**
    * Executed by MarkupView._isImagePreviewTarget which is itself called when the
    * mouse hovers over a target in the markup-view.
    * Checks if the target is indeed something we want to have an image tooltip
    * preview over and, if so, inserts content into the tooltip.
    * @return a promise that resolves when the content has been inserted or
    * rejects if no preview is required. This promise is then used by Tooltip.js
    * to decide if/when to show the tooltip
    */
   isImagePreviewTarget: function(target, tooltip) {
-    if (!this.tooltipData || this.tooltipData.target !== target) {
+    // Is this Element previewable.
+    if (!this.isPreviewable()) {
       return promise.reject(false);
     }
 
-    return this.tooltipData.data.then(({data, size}) => {
-      if (data && size) {
-        tooltip.setImageContent(data, size);
-      } else {
-        tooltip.setBrokenImageContent();
-      }
+    // If the Element has an src attribute, the tooltip is shown when hovering
+    // over the src url. If not, the tooltip is shown when hovering over the tag
+    // name.
+    let src = this.editor.getAttributeElement("src");
+    let expectedTarget = src ? src.querySelector(".link") : this.editor.tag;
+    if (target !== expectedTarget) {
+      return promise.reject(false);
+    }
+
+    return this._getPreview().then(({data, size}) => {
+      // The preview is ready.
+      tooltip.setImageContent(data, size);
+    }, () => {
+      // Indicate the failure but show the tooltip anyway.
+      tooltip.setBrokenImageContent();
     });
   },
 
   copyImageDataUri: function() {
     // We need to send again a request to gettooltipData even if one was sent for
     // the tooltip, because we want the full-size image
     this.node.getImageData().then(data => {
       data.data.string().then(str => {
--- a/browser/devtools/markupview/test/browser.ini
+++ b/browser/devtools/markupview/test/browser.ini
@@ -80,16 +80,17 @@ skip-if = e10s # Bug 1040751 - CodeMirro
 [browser_markupview_links_05.js]
 [browser_markupview_links_06.js]
 [browser_markupview_links_07.js]
 [browser_markupview_load_01.js]
 [browser_markupview_html_edit_01.js]
 [browser_markupview_html_edit_02.js]
 [browser_markupview_html_edit_03.js]
 [browser_markupview_image_tooltip.js]
+[browser_markupview_image_tooltip_mutations.js]
 [browser_markupview_keybindings_01.js]
 [browser_markupview_keybindings_02.js]
 [browser_markupview_keybindings_03.js]
 [browser_markupview_keybindings_04.js]
 [browser_markupview_mutation_01.js]
 [browser_markupview_mutation_02.js]
 [browser_markupview_navigation.js]
 [browser_markupview_node_not_displayed_01.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/markupview/test/browser_markupview_image_tooltip_mutations.js
@@ -0,0 +1,79 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that image preview tooltip shows updated content when the image src
+// changes.
+
+const INITIAL_SRC = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAADI5JREFUeNrsWwuQFNUVPf1m5z87szv7HWSWj8CigBFMEFZKiQsB1PgJwUAZg1HBpIQsKmokEhNjWUnFVPnDWBT+KolJYbRMoqUVq0yCClpqiX8sCchPWFwVlt2db7+X93pez7zu6Vn2NxsVWh8987p7pu+9555z7+tZjTGGY3kjOMa34w447oBjfKsY7i/UNM3Y8eFSAkD50Plgw03K5P9gvGv7U5ieeR3PszeREiPNX3/0DL4hjslzhm8THh+OITfXk3dhiv4GDtGPVzCaeJmPLYzuu5qJuWfuw2QTlcN1X9pwQU7LhdZ/ZAseD45cOh9hHvDkc/yAF/DNhdb5Mrr3PvBMaAYW8fMSIi2G497IMEK/YutGtAYr6+ej+nxu/NN8Ks3N7AR6HgcLz0Eg1Ljg1UcxZzi5qewIkMYLRweTr2Kzp+nmyXAd5pS3XQDd+N/4h4zgu9FI7brlXf90nMEnuwQxlvv+hosE3TuexmWeysmT4W+WxkMaLzf9Y8ATgjcUn7T9H1gqrpFq8eV1gMn6t16NhngjfoX6q4DUP032Rd4LJgpSLwJ1yzFqBG69eRkah0MVyo0Acfe+yy9AG4nMiYCkeM53KKFXncBLAXqEm+wCqZwaueq7WCmuLTcKSJmj737ol2hurA9eq9VdyiO8yWa3NNyog+SB5CZodSsQq/dfu34tJpYbBaTMzvVddDZu16q5smXf4G8zEvqm4cyaAmJPuTJk3oJWdS4WzcVtfMZbThSQckb/pYfRGgo3zNOqZnEHbJPGK4abaDCQIIsT8V/qTaBqHkLh6LzXH8XZQhbLhYKyyCC/WeHYcNdmvOgfe8skzbWL270/T3wf7tSx/lGCbTu8xlzzmCSWLc5iwmgikcCHi3Mga0Ry913vBFvQwg90l6M4ImWKfsWOp7DSWxmfpPlCFuPFfsNfKrCnPYpQKIRgqBK7D0SxYaNHwkEiJMtl0ReDp3Lc5D3PGoTo/sKngCl7a5chFqvBatKwjBd7WwqIlzB/78NcoUcp5VSgGxm+7b8eqQRGnHMO634epO4S1EZww09/iFg5UmGoESDuznP1xVhTUX1WWHPzjpd25wyH0hRxI3LGM75nxmuNEEUVpAN0XgxmPoKralakbQnWlIMQyVBD/w+3orkq4lvualjKyWwzt4MaxqspQHVhPOWG64bxYuhZXSFGWhipbSDVragOu5Y9eAsmDDUKyBA703vemVhHoueD6e9wAzJK1WfmN0Umk5GGM4kEMZcuIECqgjm0nldAqmbjwtm4VxZH5AvlADP6mx9Eqy9Q0+KqW8Ch+47FaMMYmnNGfY1iPMshoC6qFxme4wQ+0p+ARE6H3+9veWEDWgUhDhUKyFARn4jM5BNxT0XsMg7bfymGK1ov3wtjDfhL4w0HVGUVBEjDaaE+QNdrcNWch1PG4W6xrjBUXECGivg++Cva3JUT4iQUz3V2RsSVaKLwOuDT89A3HdBQoxhNC+fnVm74ual2EG893P6G+PuP4SfiO4cCBWQooL9qCWKNXPbcI37Aa/lnlZxXRt4RFONGwSDCPAHqOuqjWct1QiEMw5mChM5X4K47FyNqcd3aK9AwFH0CGYLoe1ctxk2eWi57rg5JfGp9rzC6ggCdFlAgHBDw5Yxlcg6G8SyHCjMlsgmDD9zhSeHlF+JnAgWDTQUy2NxfdwOao1UVV3pi3+bE97YSbWpLAbn6zefHNQkp1PMpIBwwvslKgIYTKM2nEpNzrGcH3FXTEal0L38kJ4uDQgEZbO4vnI173LXf5NHZaiUxtaCxyZuo/rK6LpUg54yg3zTWRAArvDcRIPZ6BqzrQ1REpmL+DNw32OKIDCb3X1qPVn8wNNMT4w2bvs+q4bAZrqBh2skaL3yyhhIIZ4i6oHkUK0RckcB8GigEyRIH4A6Mgc8fatl0/+BkkQxC9gIT4ljna1rIZW9rEdNbjJcNjsnoYj7LHWCUwpITzEgzRQKZ3XAFHbTzA3hrz8TEUUZxFBhoKpABQt/97p+w0hMZG68I8R6FtlsJT3FELndZntjM+VMnylKYq8GJI3UZaRMpquGSGFVOEfv0YZBMNzz+uvjbfzS6xQERIhlI9FcvQWNdFVb7x1zCb+QNK8vb9NsiifmI5hBgVoOCBC1sb0ab5RomqENxLO3eA1/0NDRU47q2RQNbRCUDIb7lF2CNL3ZGxEV4n08TVvZWYG4pZyV0zUdS45tyCBByOHWiyvZmxFXDCyRo1ge5+Sy0TA+8lWMiP/6O0S32exGV9Jf4fr8azdUR3zL/CZz4MtvzdX5uOYs6NDOmpkuj5Huh+7qUQSYl0ThHzw0YQzcGo6bhzEqoYq5rN3yRiYiG3Vfe2Ybm/qKA9NNZ3nNm4F7/yDkg9AN+U1mHiBcXP8zuDN76jj8hg1QyiWQigalj02BJPhK8I0zxijAjhp5zhlpLUDvS+BCy2HMAvvB4XDgL9/SXC0g/ou/5+6/xLX8w0uJrOIkXfPvyhY0F6gr7M8H0KWFYikcqAXakB+xwD9CdREBLoau7Gz3cAdSIdLFxFtJTCqRChSjnutvhDcREtzjz2Tswtz+yeNRFUeXZXtWux7C1fuoVcbd3J//ipDX3uZZDLGrwweS+UBLL5TDliVBnF8P7H+XI8aRRGsIBJg/Zlslt1+W+D1JWoSyi+kD9jfhs78t7mhZhSl+fLfY1Bdyv3I8V/qpY3B1McgN7ZFT5/vNO0I5DPLLdPBIJA8qc4h2I0QplYfDpJwHT+aj0246r5S8rToG8OjCle8wk4OLvvYGa+Ovr84uo2qBSwJS9G5egoZFLTfiEqWDtbwGfHgKOdPHcS+ai7XDzMPW/FJRLGGcxnBbK4YJC2K+h+T6Bdu5CqHqCWERd3bawb7JI+iJ735+LNaHaprBLLHBm08U3XxShEsdt+f3eTh3v7aC95Dct4RCWL5OZWh/oXBZThxAIxyOXLzBk8aiEWJID8rK3CpPOmeHaGpvCS+7EHv5FujVHUSJPLXvIFeHcNc+9xrB2gws9KZdxuLFax/WLM5gzzSm/lTXF/OdAcapyvjxPqxqHjr2v4ckX2bS2dRBrc5lSdpKjEJ9/9tdwX2WMd53ZQ2IVo3RES+UwVSpCPvYepNx4gmTGDUKIMQ4eduPnD7mx9xOn/KZKOlFbStjONxHTtR+BYAPmnoZ1Zp8wkBRwP/EL3u0F/C2hGl7vpz7vW37T3vP7if8wroKuoh8ribknX9BK5rcF+mo1qKaKyRPJTgTDjbzY8szcuLb3bpH00u35T47j7prRpwDJTxzyG0dHgxPp5bPG8VdkpfPbUg3SgoOo2mwVukb98D5EqpswZTTulCggTk4gpYhv0++wIhCJxr0+Hq1sondis0SE2oxQe3qWXwWyO4DSQg9gJ8Iiw1VFcGqXxet0N9xE4ygIxv/9W6wo9WyROEX/R+eiobYSq2vHTOR631Eiv2lRfh9dvxkumkXh92Qsx8XrAJ+7YGbWuhxOi/U+31NQmzyqNYG8N/3wfo6CRtRHcN01FzkvojohwLu0VVvDa56IS/xcj2b7nN+O+m0jqpE1wMPXZxAN9iCVThtDvH7gmiRGRpU8Lspv1Uhq4wIVdQoyuGSLNYPKUCS8+CzNURbzMmjK3i8u0U793lmuV0ef9nWQ5MGC/DiUqEUSaCtXna9RJEspZS1lrXINK/pcq+SpT50t98QKMq1FRmDfx3vxty102k0PM4ssEnvuz5+G26Ij4yDpz6z9fV8bkyIkqBFkhej0Ib+ZQ34XJK9AfozaiimqIoX3Jp3tiISrcfYpuN2+iFph/02P36PNC9fVcCnp6H9jYouKyfaWufz5Tp9tVxcUniw7IohZv4dZz81/ns67z3AYPrc2n0+Ix2q8k0PWjgBy88XaibnfK9A+5LdDY2Ivhy36fbT8Zv3Lb1U1qLqUxorXEEXIs0mjjrtxoTZWtdvigNs2sgPiujTv6DIZLld6b/V5742JZV3fUsUVFy5gdsNtKWFzUCEVbNepD1MkSMVbsb6SZm7jI3/zODtQKgUMsOw8wDZ63t5xcV1TnaEAxoc6wrqY+Fj+N4DsqOnhOIdicrQSm1MPYCPlIqHn5bbHg8/bj2D3QfZnCX3mpAICDZV8jH5kpbZqTD0W+DxaA74CWzLN2nd14OlL72J38Lf7+TjC7dadZFDoZJQPrtaIKL/G0L6ktptPZVJ8fMqHYPZOKYPMyQGadIJfDvdXwAFiZOTvDBPydf5vk4rWA+RfdhBlaF/yDDBRoMu9pfnSjv/p7DG+HXfAcQcc49v/BBgAcFAO4DmB2GQAAAAASUVORK5CYII=";
+const UPDATED_SRC = TEST_URL_ROOT + "doc_markup_tooltip.png";
+
+const INITIAL_SRC_SIZE = "64" + " \u00D7 " + "64";
+const UPDATED_SRC_SIZE = "22" + " \u00D7 " + "23";
+
+add_task(function*() {
+  yield addTab("data:text/html,<p>markup view tooltip test</p><img>");
+
+  let { inspector } = yield openInspector();
+
+  info("Retrieving NodeFront for the <img> element.");
+  let img = yield getNodeFront("img", inspector);
+
+  info("Selecting the <img> element");
+  yield selectNode(img, inspector);
+
+  info("Adding src attribute to the image.");
+  yield updateImageSrc(img, INITIAL_SRC, inspector);
+
+  let container = getContainerForNodeFront(img, inspector);
+  ok(container, "Found markup container for the image.");
+
+  let target = container.editor.getAttributeElement("src").querySelector(".link");
+  ok(target, "Found the src attribute in the markup view.");
+
+  info("Showing tooltip on the src link.");
+  yield inspector.markup.tooltip.isValidHoverTarget(target);
+
+  checkImageTooltip(INITIAL_SRC_SIZE, inspector);
+
+  info("Updating the image src.");
+  yield updateImageSrc(img, UPDATED_SRC, inspector);
+
+  target = container.editor.getAttributeElement("src").querySelector(".link");
+  ok(target, "Found the src attribute in the markup view after mutation.");
+
+  info("Showing tooltip on the src link.");
+  yield inspector.markup.tooltip.isValidHoverTarget(target);
+
+  info("Checking that the new image was shown.");
+  checkImageTooltip(UPDATED_SRC_SIZE, inspector);
+});
+
+/**
+ * Updates the src attribute of the image. Return a Promise.
+ */
+function updateImageSrc(img, newSrc, inspector) {
+  let onMutated = inspector.once("markupmutation");
+  let onModified = img.modifyAttributes([{
+    attributeName: "src",
+    newValue: newSrc
+  }]);
+
+  return Promise.all([onMutated, onModified]);
+}
+
+/**
+ * Checks that the markup view tooltip contains an image element with the given
+ * size.
+ */
+function checkImageTooltip(size, {markup}) {
+  let images = markup.tooltip.panel.getElementsByTagName("image");
+  is(images.length, 1, "Tooltip contains an image");
+
+  let label = markup.tooltip.panel.querySelector(".devtools-tooltip-caption");
+  is(label.textContent, size, "Tooltip label displays the right image size");
+
+  markup.tooltip.hide();
+}
--- a/browser/devtools/netmonitor/netmonitor-view.js
+++ b/browser/devtools/netmonitor/netmonitor-view.js
@@ -1159,18 +1159,20 @@ RequestsMenuView.prototype = Heritage.ex
            !this.isXHR(e) &&
            !this.isFont(e) &&
            !this.isImage(e) &&
            !this.isMedia(e) &&
            !this.isFlash(e);
   },
 
   isFreetextMatch: function({ attachment: { url } }, text) {
+    let lowerCaseUrl = url.toLowerCase();
+    let lowerCaseText = text.toLowerCase();
     //no text is a positive match
-    return !text || url.includes(text);
+    return !text || lowerCaseUrl.includes(lowerCaseText);
   },
 
   /**
    * Predicates used when sorting items.
    *
    * @param object aFirst
    *        The first item used in the comparison.
    * @param object aSecond
--- a/browser/devtools/netmonitor/test/browser_net_filter-01.js
+++ b/browser/devtools/netmonitor/test/browser_net_filter-01.js
@@ -1,16 +1,16 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Test if filtering items in the network table works correctly.
  */
 const BASIC_REQUESTS = [
-  { url: "sjs_content-type-test-server.sjs?fmt=html&res=undefined&text=sample" },
+  { url: "sjs_content-type-test-server.sjs?fmt=html&res=undefined&text=Sample" },
   { url: "sjs_content-type-test-server.sjs?fmt=css&text=sample" },
   { url: "sjs_content-type-test-server.sjs?fmt=js&text=sample" },
 ];
 
 const REQUESTS_WITH_MEDIA = BASIC_REQUESTS.concat([
   { url: "sjs_content-type-test-server.sjs?fmt=font" },
   { url: "sjs_content-type-test-server.sjs?fmt=image" },
   { url: "sjs_content-type-test-server.sjs?fmt=audio" },
@@ -112,16 +112,22 @@ function test() {
           return testContents([0, 0, 0, 0, 0, 0, 0, 0]);
         })
         .then(() => {
           // Text in filter box that matches should filter out everything else.
           EventUtils.sendMouseEvent({ type: "click" }, $("#requests-menu-filter-all-button"));
           setFreetextFilter("sample");
           return testContents([1, 1, 1, 0, 0, 0, 0, 0]);
         })
+        .then(() => {
+          // Text in filter box that matches should filter out everything else.
+          EventUtils.sendMouseEvent({ type: "click" }, $("#requests-menu-filter-all-button"));
+          setFreetextFilter("SAMPLE");
+          return testContents([1, 1, 1, 0, 0, 0, 0, 0]);
+        })
         // ...then combine multiple filters together.
         .then(() => {
           // Enable filtering for html and css; should show request of both type.
           setFreetextFilter("");
           EventUtils.sendMouseEvent({ type: "click" }, $("#requests-menu-filter-html-button"));
           EventUtils.sendMouseEvent({ type: "click" }, $("#requests-menu-filter-css-button"));
           testFilterButtonsCustom(aMonitor, [0, 1, 1, 0, 0, 0, 0, 0, 0, 0]);
           return testContents([1, 1, 0, 0, 0, 0, 0, 0]);
--- a/browser/devtools/performance/modules/global.js
+++ b/browser/devtools/performance/modules/global.js
@@ -19,16 +19,18 @@ const L10N = new ViewHelpers.MultiL10N([
  */
 const PREFS = new ViewHelpers.Prefs("devtools.performance", {
   "show-platform-data": ["Bool", "ui.show-platform-data"],
   "hidden-markers": ["Json", "timeline.hidden-markers"],
   "memory-sample-probability": ["Float", "memory.sample-probability"],
   "memory-max-log-length": ["Int", "memory.max-log-length"],
   "profiler-buffer-size": ["Int", "profiler.buffer-size"],
   "profiler-sample-frequency": ["Int", "profiler.sample-frequency-khz"],
+  // TODO re-enable once we flame charts via bug 1148663
+  "enable-memory-flame": ["Bool", "ui.enable-memory-flame"],
 }, {
   monitorChanges: true
 });
 
 /**
  * Details about each profile entry cateogry.
  * @see CATEGORY_MAPPINGS.
  */
--- a/browser/devtools/performance/modules/widgets/graphs.js
+++ b/browser/devtools/performance/modules/widgets/graphs.js
@@ -283,17 +283,17 @@ GraphsController.prototype = {
   }),
 
   /**
    * Enable or disable a subgraph controlled by GraphsController.
    * This determines what graphs are visible and get rendered.
    */
   enable: function (graphName, isEnabled) {
     let el = this.$(this._definition[graphName].selector);
-    el.hidden = !isEnabled;
+    el.classList[isEnabled ? "remove" : "add"]("hidden");
 
     // If no status change, just return
     if (this._enabled.has(graphName) === isEnabled) {
       return;
     }
     if (isEnabled) {
       this._enabled.add(graphName);
     } else {
@@ -305,17 +305,17 @@ GraphsController.prototype = {
   },
 
   /**
    * Disables all graphs controller by the GraphsController, and
    * also hides the root element. This is a one way switch, and used
    * when older platforms do not have any timeline data.
    */
   disableAll: function () {
-    this._root.hidden = true;
+    this._root.classList.add("hidden");
     // Hide all the subelements
     Object.keys(this._definition).forEach(graphName => this.enable(graphName, false));
   },
 
   /**
    * Sets a mapped selection on the graph that is the main controller
    * for keeping the graphs' selections in sync.
    */
@@ -377,16 +377,19 @@ GraphsController.prototype = {
     // Sync the graphs' animations and selections together
     if (def.primaryLink) {
       graph.on("selecting", this._onSelecting);
     } else {
       CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph);
       CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph);
     }
 
+    // Sets the container element's visibility based off of enabled status
+    el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden");
+
     this.setTheme();
     return graph;
   }),
 
   /**
    * Returns the main graph for this collection, that all graphs
    * are bound to for syncing and selection.
    */
--- a/browser/devtools/performance/test/browser_perf-legacy-front-01.js
+++ b/browser/devtools/performance/test/browser_perf-legacy-front-01.js
@@ -51,17 +51,17 @@ function *testMockMemory () {
 
   ok(markers.length > 0, "markers exist.");
   ok(ticks.length > 0, "ticks exist.");
   isEmptyArray(memory, "memory");
   isEmptyArray(allocations.sites, "allocations.sites");
   isEmptyArray(allocations.timestamps, "allocations.timestamps");
   isEmptyArray(allocations.frames, "allocations.frames");
 
-  is($("#overview-pane").hidden, false,
+  is(isVisible($("#overview-pane")), true,
     "overview pane not hidden when server not supporting memory actors, yet UI prefs request them.");
   is($("#select-waterfall-view").hidden, false,
     "waterfall view button not hidden when memory mocked, and UI prefs enable them");
   is($("#select-js-calltree-view").hidden, false,
     "jscalltree view button not hidden when memory mocked, and UI prefs enable them");
   is($("#select-js-flamegraph-view").hidden, false,
     "jsflamegraph view button not hidden when memory mocked, and UI prefs enable them");
   is($("#select-memory-calltree-view").hidden, true,
@@ -106,17 +106,17 @@ function *testMockMemoryAndTimeline() {
     "Recording configuration set by target's support, not by UI prefs [No Memory/Timeline Actor: withTicks]");
   isEmptyArray(markers, "markers");
   isEmptyArray(ticks, "ticks");
   isEmptyArray(memory, "memory");
   isEmptyArray(allocations.sites, "allocations.sites");
   isEmptyArray(allocations.timestamps, "allocations.timestamps");
   isEmptyArray(allocations.frames, "allocations.frames");
 
-  is($("#overview-pane").hidden, true,
+  is(isVisible($("#overview-pane")), false,
     "overview pane hidden when server not supporting memory/timeline actors, yet UI prefs request them.");
   is($("#select-waterfall-view").hidden, true,
     "waterfall view button hidden when memory/timeline mocked, and UI prefs enable them");
   is($("#select-js-calltree-view").hidden, false,
     "jscalltree view button not hidden when memory/timeline mocked, and UI prefs enable them");
   is($("#select-js-flamegraph-view").hidden, false,
     "jsflamegraph view button not hidden when memory/timeline mocked, and UI prefs enable them");
   is($("#select-memory-calltree-view").hidden, true,
--- a/browser/devtools/performance/test/browser_perf-legacy-front-02.js
+++ b/browser/devtools/performance/test/browser_perf-legacy-front-02.js
@@ -55,17 +55,17 @@ let test = Task.async(function*() {
         ok(false, "The sample " + stack.toSource() + " doesn't have a root node.");
       }
     }
   }
 
   ok(sampleCount > 0,
     "At least some samples have been iterated over, checking for root nodes.");
 
-  is($("#overview-pane").hidden, true,
+  is(isVisible($("#overview-pane")), false,
     "overview pane hidden when timeline mocked.");
 
   is($("#select-waterfall-view").hidden, true,
     "waterfall view button hidden when timeline mocked");
   is($("#select-js-calltree-view").hidden, false,
     "jscalltree view button not hidden when timeline/memory mocked");
   is($("#select-js-flamegraph-view").hidden, false,
     "jsflamegraph view button not hidden when timeline mocked");
--- a/browser/devtools/performance/test/browser_perf-options-enable-framerate.js
+++ b/browser/devtools/performance/test/browser_perf-options-enable-framerate.js
@@ -10,23 +10,23 @@ function* spawnTest() {
   let { EVENTS, PerformanceController, $ } = panel.panelWin;
   Services.prefs.setBoolPref(FRAMERATE_PREF, false);
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
   is(PerformanceController.getCurrentRecording().getConfiguration().withTicks, false,
     "PerformanceFront started without ticks recording.");
-  ok($("#time-framerate").hidden, "fps graph is hidden when ticks disabled");
+  ok(!isVisible($("#time-framerate")), "fps graph is hidden when ticks disabled");
 
   Services.prefs.setBoolPref(FRAMERATE_PREF, true);
-  ok($("#time-framerate").hidden, "fps graph is still hidden if recording does not contain ticks.");
+  ok(!isVisible($("#time-framerate")), "fps graph is still hidden if recording does not contain ticks.");
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
-  ok(!$("#time-framerate").hidden, "fps graph is not hidden when ticks enabled before recording");
+  ok(isVisible($("#time-framerate")), "fps graph is not hidden when ticks enabled before recording");
   is(PerformanceController.getCurrentRecording().getConfiguration().withTicks, true,
     "PerformanceFront started with ticks recording.");
 
   yield teardown(panel);
   finish();
 }
--- a/browser/devtools/performance/test/browser_perf-options-enable-memory-01.js
+++ b/browser/devtools/performance/test/browser_perf-options-enable-memory-01.js
@@ -13,26 +13,26 @@ function* spawnTest() {
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
   is(PerformanceController.getCurrentRecording().getConfiguration().withMemory, false,
     "PerformanceFront started without memory recording.");
   is(PerformanceController.getCurrentRecording().getConfiguration().withAllocations, false,
     "PerformanceFront started without allocations recording.");
-  ok($("#memory-overview").hidden, "memory graph is hidden when memory disabled");
+  ok(!isVisible($("#memory-overview")), "memory graph is hidden when memory disabled");
 
   Services.prefs.setBoolPref(MEMORY_PREF, true);
-  ok($("#memory-overview").hidden,
+  ok(!isVisible($("#memory-overview")),
     "memory graph is still hidden after enabling if recording did not start recording memory");
 
   yield startRecording(panel);
   yield stopRecording(panel);
 
-  ok(!$("#memory-overview").hidden, "memory graph is not hidden when memory enabled before recording");
+  ok(isVisible($("#memory-overview")), "memory graph is not hidden when memory enabled before recording");
   is(PerformanceController.getCurrentRecording().getConfiguration().withMemory, true,
     "PerformanceFront started with memory recording.");
   is(PerformanceController.getCurrentRecording().getConfiguration().withAllocations, false,
     "PerformanceFront did not record with allocations.");
 
   yield teardown(panel);
   finish();
 }
--- a/browser/devtools/performance/test/browser_perf-overview-render-04.js
+++ b/browser/devtools/performance/test/browser_perf-overview-render-04.js
@@ -12,41 +12,41 @@ function* spawnTest() {
   let updated = 0;
   OverviewView.on(EVENTS.OVERVIEW_RENDERED, () => updated++);
   OverviewView.OVERVIEW_UPDATE_INTERVAL = 1;
 
   // Set realtime rendering off.
   OverviewView.isRealtimeRenderingEnabled = () => false;
 
   yield startRecording(panel, { waitForOverview: false, waitForStateChange: true });
-  is($("#overview-pane").hidden, true, "overview graphs hidden");
+  is(isVisible($("#overview-pane")), false, "overview graphs hidden");
   is(updated, 0, "Overview graphs have still not been updated");
   yield waitUntil(() => PerformanceController.getCurrentRecording().getMarkers().length);
   yield waitUntil(() => PerformanceController.getCurrentRecording().getTicks().length);
   is(updated, 0, "Overview graphs have still not been updated");
 
   yield stopRecording(panel);
 
   let markers = OverviewView.graphs.get("timeline");
   let framerate = OverviewView.graphs.get("framerate");
 
   ok(markers.width > 0,
     "The overview's markers graph has a width.");
   ok(framerate.width > 0,
     "The overview's framerate graph has a width.");
 
   is(updated, 1, "Overview graphs rendered upon completion.");
-  is($("#overview-pane").hidden, false, "overview graphs no longer hidden");
+  is(isVisible($("#overview-pane")), true, "overview graphs no longer hidden");
 
   yield startRecording(panel, { waitForOverview: false, waitForStateChange: true });
-  is($("#overview-pane").hidden, true, "overview graphs hidden again when starting new recording");
+  is(isVisible($("#overview-pane")), false, "overview graphs hidden again when starting new recording");
 
   RecordingsView.selectedIndex = 0;
-  is($("#overview-pane").hidden, false, "overview graphs no longer hidden when switching back to complete recording.");
+  is(isVisible($("#overview-pane")), true, "overview graphs no longer hidden when switching back to complete recording.");
   RecordingsView.selectedIndex = 1;
-  is($("#overview-pane").hidden, true, "overview graphs hidden again when going back to inprogress recording.");
+  is(isVisible($("#overview-pane")), false, "overview graphs hidden again when going back to inprogress recording.");
 
   yield stopRecording(panel);
-  is($("#overview-pane").hidden, false, "overview graphs no longer hidden when recording finishes");
+  is(isVisible($("#overview-pane")), true, "overview graphs no longer hidden when recording finishes");
 
   yield teardown(panel);
   finish();
 }
--- a/browser/devtools/performance/test/browser_perf_recordings-io-04.js
+++ b/browser/devtools/performance/test/browser_perf_recordings-io-04.js
@@ -101,20 +101,20 @@ let test = Task.async(function*() {
   yield imported;
   ok(true, "The original profiler data appears to have been successfully imported.");
 
   yield calltreeRendered;
   yield fpsRendered;
   ok(true, "The imported data was re-rendered.");
 
   // Ensure that only framerate and js calltree/flamegraph view are available
-  is($("#overview-pane").hidden, false, "overview graph container still shown");
-  is($("#memory-overview").hidden, true, "memory graph hidden");
-  is($("#markers-overview").hidden, true, "markers overview graph hidden");
-  is($("#time-framerate").hidden, false, "fps graph shown");
+  is(isVisible($("#overview-pane")), true, "overview graph container still shown");
+  is(isVisible($("#memory-overview")), false, "memory graph hidden");
+  is(isVisible($("#markers-overview")), false, "markers overview graph hidden");
+  is(isVisible($("#time-framerate")), true, "fps graph shown");
   is($("#select-waterfall-view").hidden, true, "waterfall button hidden");
   is($("#select-js-calltree-view").hidden, false, "jscalltree button shown");
   is($("#select-js-flamegraph-view").hidden, false, "jsflamegraph button shown");
   is($("#select-memory-calltree-view").hidden, true, "memorycalltree button hidden");
   is($("#select-memory-flamegraph-view").hidden, true, "memoryflamegraph button hidden");
   ok(DetailsView.isViewSelected(JsCallTreeView), "jscalltree view selected as its the only option");
 
   // Verify imported recording.
--- a/browser/devtools/performance/test/head.js
+++ b/browser/devtools/performance/test/head.js
@@ -74,16 +74,20 @@ let DEFAULT_PREFS = [
 }, {});
 
 // Enable the new performance panel for all tests.
 Services.prefs.setBoolPref("devtools.performance.enabled", true);
 // Enable logging for all the tests. Both the debugger server and frontend will
 // be affected by this pref.
 Services.prefs.setBoolPref("devtools.debugger.log", false);
 
+// By default, enable memory flame graphs for tests for now
+// TODO remove when we have flame charts via bug 1148663
+Services.prefs.setBoolPref("devtools.performance.ui.enable-memory-flame", true);
+
 /**
  * Call manually in tests that use frame script utils after initializing
  * the tool. Must be called after initializing (once we have a tab).
  */
 function loadFrameScripts () {
   mm = gBrowser.selectedBrowser.messageManager;
   mm.loadFrameScript(FRAME_SCRIPT_UTILS_URL, false);
 }
@@ -536,8 +540,12 @@ function synthesizeProfileForTest(sample
   });
 
   let uniqueStacks = new RecordingUtils.UniqueStacks();
   return RecordingUtils.deflateThread({
     samples: samples,
     markers: []
   }, uniqueStacks);
 }
+
+function isVisible (element) {
+  return !element.classList.contains("hidden") && !element.hidden;
+}
--- a/browser/devtools/performance/views/details.js
+++ b/browser/devtools/performance/views/details.js
@@ -29,17 +29,18 @@ let DetailsView = {
     "memory-calltree": {
       id: "memory-calltree-view",
       view: MemoryCallTreeView,
       features: ["withAllocations"]
     },
     "memory-flamegraph": {
       id: "memory-flamegraph-view",
       view: MemoryFlameGraphView,
-      features: ["withAllocations"]
+      features: ["withAllocations"],
+      prefs: ["enable-memory-flame"],
     },
     "optimizations": {
       id: "optimizations-view",
       view: OptimizationsView,
       features: ["withJITOptimizations"],
     }
   },
 
@@ -121,24 +122,27 @@ let DetailsView = {
   /**
    * Takes a view name and determines if the current recording 
    * can support the view.
    *
    * @param {string} viewName
    * @return {boolean}
    */
   _isViewSupported: function (viewName) {
-    let { features } = this.components[viewName];
+    let { features, prefs } = this.components[viewName];
     let recording = PerformanceController.getCurrentRecording();
 
     if (!recording || !recording.isCompleted()) {
       return false;
     }
 
-    return PerformanceController.isFeatureSupported(features);
+    let prefSupported = (prefs && prefs.length) ?
+                        prefs.every(p => PerformanceController.getPref(p)) :
+                        true;
+    return PerformanceController.isFeatureSupported(features) && prefSupported;
   },
 
   /**
    * Select one of the DetailView's subviews to be rendered,
    * hiding the others.
    *
    * @param String viewName
    *        Name of the view to be shown.
--- a/browser/devtools/performance/views/overview.js
+++ b/browser/devtools/performance/views/overview.js
@@ -331,24 +331,24 @@ let OverviewView = {
    * Show the graphs overview panel when a recording is finished
    * when non-realtime graphs are enabled. Also set the graph visibility
    * so the performance graphs know which graphs to render.
    *
    * @param {RecordingModel} recording
    */
   _showGraphsPanel: function (recording) {
     this._setGraphVisibilityFromRecordingFeatures(recording);
-    $("#overview-pane").hidden = false;
+    $("#overview-pane").classList.remove("hidden");
   },
 
   /**
    * Hide the graphs container completely.
    */
   _hideGraphsPanel: function () {
-    $("#overview-pane").hidden = true;
+    $("#overview-pane").classList.add("hidden");
   },
 
   /**
    * Called when `devtools.theme` changes.
    */
   _onThemeChanged: function (_, theme) {
     this.graphs.setTheme({ theme, redraw: true });
   },
--- a/browser/devtools/projecteditor/lib/editors.js
+++ b/browser/devtools/projecteditor/lib/editors.js
@@ -175,16 +175,19 @@ var TextEditor = Class({
       this.emit("change", ...args);
     });
     this.editor.on("cursorActivity", (...args) => {
       this.emit("cursorActivity", ...args);
     });
     this.editor.on("focus", (...args) => {
       this.emit("focus", ...args);
     });
+    this.editor.on("saveRequested", (...args) => {
+      this.emit("saveRequested", ...args);
+    })
 
     this.appended = this.editor.appendTo(this.elt);
   },
 
   /**
    * Clean up the editor.  This can have different meanings
    * depending on the type of editor.
    */
--- a/browser/devtools/projecteditor/lib/plugins/save/save.js
+++ b/browser/devtools/projecteditor/lib/plugins/save/save.js
@@ -41,17 +41,17 @@ var SavePlugin = Class({
 
   isCommandEnabled: function(cmd) {
     let currentEditor = this.host.currentEditor;
     return currentEditor.isEditable;
   },
 
   onCommand: function(cmd) {
     if (cmd === "cmd-save") {
-      this.save();
+      this.onEditorSaveRequested();
     } else if (cmd === "cmd-saveas") {
       this.saveAs();
     }
   },
 
   saveAs: function() {
     let editor = this.host.currentEditor;
     let project = this.host.resourceFor(editor);
@@ -66,17 +66,17 @@ var SavePlugin = Class({
     }).then(res => {
       resource = res;
       return this.saveResource(editor, resource);
     }).then(() => {
       this.host.openResource(resource);
     }).then(null, console.error);
   },
 
-  save: function() {
+  onEditorSaveRequested: function() {
     let editor = this.host.currentEditor;
     let resource = this.host.resourceFor(editor);
     if (!resource) {
       return this.saveAs();
     }
 
     return this.saveResource(editor, resource);
   },
--- a/browser/devtools/projecteditor/lib/projecteditor.js
+++ b/browser/devtools/projecteditor/lib/projecteditor.js
@@ -549,16 +549,17 @@ var ProjectEditor = Class({
    * @param Editor editor
    *               The new editor instance.
    */
   _onEditorCreated: function(editor) {
     this.pluginDispatch("onEditorCreated", editor);
     this._editorListenAndDispatch(editor, "change", "onEditorChange");
     this._editorListenAndDispatch(editor, "cursorActivity", "onEditorCursorActivity");
     this._editorListenAndDispatch(editor, "load", "onEditorLoad");
+    this._editorListenAndDispatch(editor, "saveRequested", "onEditorSaveRequested");
     this._editorListenAndDispatch(editor, "save", "onEditorSave");
 
     editor.on("focus", () => {
       this.projectTree.selectResource(this.resourceFor(editor));
     });
   },
 
   /**
--- a/browser/devtools/scratchpad/scratchpad.js
+++ b/browser/devtools/scratchpad/scratchpad.js
@@ -1729,17 +1729,17 @@ var Scratchpad = {
       this.editor.on("cursorActivity", this.updateStatusBar);
       let okstring = this.strings.GetStringFromName("selfxss.okstring");
       let msg = this.strings.formatStringFromName("selfxss.msg", [okstring], 1);
       this._onPaste = WebConsoleUtils.pasteHandlerGen(this.editor.container.contentDocument.body,
                                                       document.querySelector('#scratchpad-notificationbox'),
                                                       msg, okstring);
       editorElement.addEventListener("paste", this._onPaste);
       editorElement.addEventListener("drop", this._onPaste);
-      this.editor.on("save", () => this.saveFile());
+      this.editor.on("saveRequested", () => this.saveFile());
       this.editor.focus();
       this.editor.setCursor({ line: lines.length, ch: lines.pop().length });
 
       if (state)
         this.dirty = !state.saved;
 
       this.initialized = true;
       this._triggerObservers("Ready");
new file mode 100644
--- /dev/null
+++ b/browser/devtools/shared/test/unit/test_VariablesView_filtering-without-controller.js
@@ -0,0 +1,34 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that VariablesView._doSearch() works even without an attached
+// VariablesViewController (bug 1196341).
+
+const Cu = Components.utils;
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const DOMParser = Cc["@mozilla.org/xmlextras/domparser;1"]
+                    .createInstance(Ci.nsIDOMParser);
+const { VariablesView } =
+  Cu.import("resource:///modules/devtools/VariablesView.jsm", {});
+
+function run_test() {
+  let doc = DOMParser.parseFromString("<div>", "text/html");
+  let container = doc.body.firstChild;
+  ok(container, "Got a container.");
+
+  let vv = new VariablesView(container, { searchEnabled: true });
+  let scope = vv.addScope("Test scope");
+  let item1 = scope.addItem("a", { value: "1" });
+  let item2 = scope.addItem("b", { value: "2" });
+
+  do_print("Performing a search without a controller.");
+  vv._doSearch("a");
+
+  equal(item1.target.hasAttribute("unmatched"), false,
+    "First item that matched the filter is visible.");
+  equal(item2.target.hasAttribute("unmatched"), true,
+    "The second item that did not match the filter is hidden.");
+}
--- a/browser/devtools/shared/test/unit/xpcshell.ini
+++ b/browser/devtools/shared/test/unit/xpcshell.ini
@@ -6,9 +6,10 @@ firefox-appdir = browser
 skip-if = toolkit == 'android' || toolkit == 'gonk'
 
 [test_advanceValidate.js]
 [test_attribute-parsing-01.js]
 [test_attribute-parsing-02.js]
 [test_bezierCanvas.js]
 [test_cubicBezier.js]
 [test_undoStack.js]
+[test_VariablesView_filtering-without-controller.js]
 [test_VariablesView_getString_promise.js]
--- a/browser/devtools/shared/widgets/VariablesView.jsm
+++ b/browser/devtools/shared/widgets/VariablesView.jsm
@@ -542,17 +542,17 @@ VariablesView.prototype = {
    * If aToken is falsy, then all the scopes are unhidden and expanded,
    * while the available variables and properties inside those scopes are
    * just unhidden.
    *
    * @param string aToken
    *        The variable or property to search for.
    */
   _doSearch: function(aToken) {
-    if (this.controller.supportsSearch()) {
+    if (this.controller && this.controller.supportsSearch()) {
       // Retrieve the main Scope in which we add attributes
       let scope = this._store[0]._store.get("");
       if (!aToken) {
         // Prune the view from old previous content
         // so that we delete the intermediate search results
         // we created in previous searches
         for (let property of scope._store.values()) {
           property.remove();
--- a/browser/devtools/sourceeditor/editor.js
+++ b/browser/devtools/sourceeditor/editor.js
@@ -280,17 +280,17 @@ Editor.prototype = {
       win.CodeMirror.defineMIME("text/css", cssSpec);
 
       let scssSpec = win.CodeMirror.resolveMode("text/x-scss");
       scssSpec.propertyKeywords = cssProperties;
       scssSpec.colorKeywords = cssColors;
       scssSpec.valueKeywords = cssValues;
       win.CodeMirror.defineMIME("text/x-scss", scssSpec);
 
-      win.CodeMirror.commands.save = () => this.emit("save");
+      win.CodeMirror.commands.save = () => this.emit("saveRequested");
 
       // Create a CodeMirror instance add support for context menus,
       // overwrite the default controller (otherwise items in the top and
       // context menus won't work).
 
       cm = win.CodeMirror(win.document.body, this.config);
       cm.getWrapperElement().addEventListener("contextmenu", (ev) => {
         ev.preventDefault();
--- a/browser/devtools/sourceeditor/test/cm_emacs_test.js
+++ b/browser/devtools/sourceeditor/test/cm_emacs_test.js
@@ -123,16 +123,16 @@
   sim("upExpr", "foo {\n  bar[];\n  baz(blah);\n}",
       Pos(2, 7), "Ctrl-Alt-U", at(2, 5), "Ctrl-Alt-U", at(0, 4));
   sim("transposeExpr", "do foo[bar] dah",
       Pos(0, 6), "Ctrl-Alt-T", txt("do [bar]foo dah"));
 
   sim("clearMark", "abcde", Pos(0, 2), "Ctrl-Space", "Ctrl-F", "Ctrl-F",
       "Ctrl-G", "Ctrl-W", txt("abcde"));
 
-  testCM("save", function(cm) {
+  testCM("saveRequested", function(cm) {
     var saved = false;
     CodeMirror.commands.save = function(cm) { saved = cm.getValue(); };
     cm.triggerOnKeyDown(fakeEvent("Ctrl-X"));
     cm.triggerOnKeyDown(fakeEvent("Ctrl-S"));
     is(saved, "hi");
   }, {value: "hi", keyMap: "emacs"});
 })();
--- a/browser/devtools/styleeditor/StyleSheetEditor.jsm
+++ b/browser/devtools/styleeditor/StyleSheetEditor.jsm
@@ -381,17 +381,17 @@ StyleSheetEditor.prototype = {
       autocomplete: Services.prefs.getBoolPref(AUTOCOMPLETION_PREF),
       autocompleteOpts: { walker: this.walker }
     };
     let sourceEditor = this._sourceEditor = new Editor(config);
 
     sourceEditor.on("dirty-change", this._onPropertyChange);
 
     return sourceEditor.appendTo(inputElement).then(() => {
-      sourceEditor.on("save", this.saveToFile);
+      sourceEditor.on("saveRequested", this.saveToFile);
 
       if (this.styleSheet.update) {
         sourceEditor.on("change", this.updateStyleSheet);
       }
 
       this.sourceEditor = sourceEditor;
 
       if (this._focusOnSourceEditorReady) {
@@ -710,17 +710,17 @@ StyleSheetEditor.prototype = {
   },
 
   /**
    * Clean up for this editor.
    */
   destroy: function() {
     if (this._sourceEditor) {
       this._sourceEditor.off("dirty-change", this._onPropertyChange);
-      this._sourceEditor.off("save", this.saveToFile);
+      this._sourceEditor.off("saveRequested", this.saveToFile);
       this._sourceEditor.off("change", this.updateStyleSheet);
       if (this.highlighter && this.walker && this._sourceEditor.container) {
         this._sourceEditor.container.removeEventListener("mousemove",
           this._onMouseMove);
       }
       this._sourceEditor.destroy();
     }
     this.cssSheet.off("property-change", this._onPropertyChange);
--- a/browser/devtools/webide/content/project-listing.js
+++ b/browser/devtools/webide/content/project-listing.js
@@ -1,32 +1,39 @@
 /* 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/. */
 
+/* eslint-env browser */
+
 const Cu = Components.utils;
 const {require} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 const ProjectList = require("devtools/webide/project-list");
 
 let projectList = new ProjectList(window, window.parent);
 
 window.addEventListener("load", function onLoad() {
   window.removeEventListener("load", onLoad, true);
   document.getElementById("new-app").onclick = CreateNewApp;
   document.getElementById("hosted-app").onclick = ImportHostedApp;
   document.getElementById("packaged-app").onclick = ImportPackagedApp;
+  document.getElementById("refresh-tabs").onclick = RefreshTabs;
   projectList.update();
   projectList.updateCommands();
 }, true);
 
 window.addEventListener("unload", function onUnload() {
   window.removeEventListener("unload", onUnload);
   projectList.destroy();
 });
 
+function RefreshTabs() {
+  projectList.refreshTabs();
+}
+
 function CreateNewApp() {
   projectList.newApp();
 }
 
 function ImportHostedApp() {
   projectList.importHostedApp();
 }
 
--- a/browser/devtools/webide/content/project-listing.xhtml
+++ b/browser/devtools/webide/content/project-listing.xhtml
@@ -20,14 +20,16 @@
       <div id="project-panel-box">
         <button class="panel-item project-panel-item-newapp" id="new-app">&projectMenu_newApp_label;</button>
         <button class="panel-item project-panel-item-openpackaged" id="packaged-app">&projectMenu_importPackagedApp_label;</button>
         <button class="panel-item project-panel-item-openhosted" id="hosted-app">&projectMenu_importHostedApp_label;</button>
         <label class="panel-header">&projectPanel_myProjects;</label>
         <div id="project-panel-projects"></div>
         <label class="panel-header" id="panel-header-runtimeapps" hidden="true">&projectPanel_runtimeApps;</label>
         <div id="project-panel-runtimeapps"/>
-        <label class="panel-header" id="panel-header-tabs" hidden="true">&projectPanel_tabs;</label>
+        <label class="panel-header" id="panel-header-tabs" hidden="true">&projectPanel_tabs;
+          <button class="panel-item project-panel-item-refreshtabs" id="refresh-tabs">&projectMenu_refreshTabs_label;</button>
+        </label>
         <div id="project-panel-tabs"/>
       </div>
     </div>
   </body>
 </html>
--- a/browser/devtools/webide/content/webide.js
+++ b/browser/devtools/webide/content/webide.js
@@ -1110,24 +1110,19 @@ let Cmds = {
 
   showProjectPanel: function() {
     if (projectList.sidebarsEnabled) {
       ProjectPanel.toggleSidebar();
     } else {
       ProjectPanel.showPopup();
     }
 
-    // There are currently no available events to listen for when an unselected
-    // tab navigates.  Since we show every tab's location in the project menu,
-    // we re-list all the tabs each time the menu is displayed.
-    // TODO: An event-based solution will be needed for the sidebar UI.
+    // TODO: Remove this check if/when we remove the dropdown view.
     if (!projectList.sidebarsEnabled && AppManager.connected) {
-      return AppManager.listTabs().then(() => {
-        projectList.updateTabs();
-      }).catch(console.error);
+      projectList.refreshTabs();
     }
 
     return promise.resolve();
   },
 
   showRuntimePanel: function() {
     RuntimeScanners.scan();
 
--- a/browser/devtools/webide/modules/project-list.js
+++ b/browser/devtools/webide/modules/project-list.js
@@ -143,16 +143,24 @@ ProjectList.prototype = {
       opts.panel.appendChild(icon);
       opts.panel.appendChild(span);
     } else {
       opts.panel.setAttribute("label", opts.name);
       opts.panel.setAttribute("image", opts.icon);
     }
   },
 
+  refreshTabs: function() {
+    if (AppManager.connected) {
+      return AppManager.listTabs().then(() => {
+        this.updateTabs();
+      }).catch(console.error);
+    }
+  },
+
   updateTabs: function() {
     let tabsHeaderNode = this._doc.querySelector("#panel-header-tabs");
     let tabsNode = this._doc.querySelector("#project-panel-tabs");
 
     while (tabsNode.hasChildNodes()) {
       tabsNode.firstChild.remove();
     }
 
@@ -178,37 +186,39 @@ ProjectList.prototype = {
       } catch (e) {
         // Don't try to handle invalid URLs, especially from Valence.
         continue;
       }
       // Wanted to use nsIFaviconService here, but it only works for visited
       // tabs, so that's no help for any remote tabs.  Maybe some favicon wizard
       // knows how to get high-res favicons easily, or we could offer actor
       // support for this (bug 1061654).
-      tab.favicon = url.origin + "/favicon.ico";
+      if (url.origin) {
+        tab.favicon = url.origin + "/favicon.ico";
+      }
       tab.name = tab.title || Strings.GetStringFromName("project_tab_loading");
       if (url.protocol.startsWith("http")) {
         tab.name = url.hostname + ": " + tab.name;
       }
       let panelItemNode = this._doc.createElement(this._panelNodeEl);
       panelItemNode.className = "panel-item";
       tabsNode.appendChild(panelItemNode);
       this._renderProjectItem({
         panel: panelItemNode,
         name: tab.name,
-        icon: tab.favicon
+        icon: tab.favicon || AppManager.DEFAULT_PROJECT_ICON
       });
       panelItemNode.addEventListener("click", () => {
         if (!this._sidebarsEnabled) {
           this._UI.hidePanels();
         }
         AppManager.selectedProject = {
           type: "tab",
           app: tab,
-          icon: tab.favicon,
+          icon: tab.favicon || AppManager.DEFAULT_PROJECT_ICON,
           location: tab.url,
           name: tab.name
         };
       }, true);
     }
 
     return promise.resolve();
   },
--- a/browser/devtools/webide/test/sidebars/browser_tabs.js
+++ b/browser/devtools/webide/test/sidebars/browser_tabs.js
@@ -41,16 +41,30 @@ function test() {
     // Ensure tab list changes are noticed
     let tabsNode = docProject.querySelector("#project-panel-tabs");
     is(tabsNode.querySelectorAll(".panel-item").length, 2, "2 tabs available");
     yield removeTab(tab);
     yield waitForUpdate(win, "project");
     yield waitForUpdate(win, "runtime-targets");
     is(tabsNode.querySelectorAll(".panel-item").length, 1, "1 tab available");
 
+    tab = yield addTab(TEST_URI);
+
+    is(tabsNode.querySelectorAll(".panel-item").length, 2, "2 tabs available");
+
+    yield removeTab(tab);
+
+    is(tabsNode.querySelectorAll(".panel-item").length, 2, "2 tabs available");
+
+    docProject.querySelector("#refresh-tabs").click();
+
+    yield waitForUpdate(win, "runtime-targets");
+
+    is(tabsNode.querySelectorAll(".panel-item").length, 1, "1 tab available");
+
     yield win.Cmds.disconnectRuntime();
     yield closeWebIDE(win);
 
     DebuggerServer.destroy();
   }).then(finish, handleError);
 }
 
 function connectToLocal(win, docRuntime) {
--- a/browser/devtools/webide/test/sidebars/test_duplicate_import.html
+++ b/browser/devtools/webide/test/sidebars/test_duplicate_import.html
@@ -49,18 +49,18 @@
           yield win.projectList.importHostedApp(hostedAppManifest);
           yield waitForUpdate(win, "project-validated");
           project = win.AppManager.selectedProject;
           is(project.location, hostedAppManifest, "Correctly reselected existing hosted app.");
           yield nextTick();
 
           let panelNode = docProject.querySelector("#project-panel");
           let items = panelNode.querySelectorAll(".panel-item");
-          // 3 controls, + 2 projects
-          is(items.length, 5, "5 projects in panel");
+          // 4 controls, + 2 projects
+          is(items.length, 6, "6 projects in panel");
           is(items[3].querySelector("span").textContent, "A name (in app directory)", "Panel text is correct");
           is(items[4].querySelector("span").textContent, "hosted manifest name property", "Panel text is correct");
 
           yield closeWebIDE(win);
 
           yield removeAllProjects();
 
           SimpleTest.finish();
--- a/browser/devtools/webide/test/sidebars/test_import.html
+++ b/browser/devtools/webide/test/sidebars/test_import.html
@@ -54,18 +54,18 @@
           yield win.projectList.importHostedApp(hostedAppManifest);
           yield waitForUpdate(win, "project-validated");
 
           project = win.AppManager.selectedProject;
           ok(project.location.endsWith('manifest.webapp'), "The manifest was found and the project was updated");
 
           let panelNode = docProject.querySelector("#project-panel");
           let items = panelNode.querySelectorAll(".panel-item");
-          // 3 controls, + 2 projects
-          is(items.length, 6, "6 projects in panel");
+          // 4 controls, + 2 projects
+          is(items.length, 7, "7 projects in panel");
           is(items[3].querySelector("span").textContent, "A name (in app directory)", "Panel text is correct");
           is(items[4].querySelector("span").textContent, "hosted manifest name property", "Panel text is correct");
 
           yield closeWebIDE(win);
 
           yield removeAllProjects();
 
           SimpleTest.finish();
--- a/browser/devtools/webide/themes/panel-listing.css
+++ b/browser/devtools/webide/themes/panel-listing.css
@@ -67,16 +67,25 @@ label,
 button.panel-item {
   background-position: 8px 8px;
   background-repeat: no-repeat;
   background-size: 14px 14px;
   padding-left: 25px;
   width: 100%;
 }
 
+button.project-panel-item-refreshtabs {
+  display: inline-block;
+  float: right;
+  padding: 3px;
+  text-transform: none;
+  width: auto;
+  margin: 0 4px 5px 5px;
+}
+
 .panel-item:disabled {
   background-color: #FFF;
   color: #5A5A5A;
   opacity: 0.5;
 }
 
 .panel-item:not(:disabled):hover {
   background-color: #CCF0FD;
--- a/browser/locales/en-US/chrome/browser/devtools/webide.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/webide.dtd
@@ -21,16 +21,18 @@
 <!ENTITY projectMenu_debug_label "Debug App">
 <!ENTITY projectMenu_debug_accesskey "D">
 <!ENTITY projectMenu_remove_label "Remove Project">
 <!ENTITY projectMenu_remove_accesskey "R">
 <!ENTITY projectMenu_showPrefs_label "Preferences">
 <!ENTITY projectMenu_showPrefs_accesskey "e">
 <!ENTITY projectMenu_manageComponents_label "Manage Extra Components">
 <!ENTITY projectMenu_manageComponents_accesskey "M">
+<!ENTITY projectMenu_refreshTabs_label "Refresh Tabs">
+<!ENTITY projectMenu_refreshTabs_accesskey "U">
 
 <!ENTITY runtimeMenu_label "Runtime">
 <!ENTITY runtimeMenu_accesskey "R">
 <!ENTITY runtimeMenu_disconnect_label "Disconnect">
 <!ENTITY runtimeMenu_disconnect_accesskey "D">
 <!ENTITY runtimeMenu_showPermissionTable_label "Permissions Table">
 <!ENTITY runtimeMenu_showPermissionTable_accesskey "P">
 <!ENTITY runtimeMenu_takeScreenshot_label "Screenshot">
--- a/browser/locales/en-US/chrome/browser/preferences/sync.dtd
+++ b/browser/locales/en-US/chrome/browser/preferences/sync.dtd
@@ -67,17 +67,17 @@ before and after the account email addre
 both, to better adapt this sentence to their language.
 -->
 <!ENTITY signedInLoginFailure.beforename.label "Please sign in to reconnect">
 <!ENTITY signedInLoginFailure.aftername.label "">
 
 <!ENTITY notSignedIn.label           "You are not signed in.">
 <!ENTITY signIn.label                "Sign in">
 <!ENTITY profilePicture.tooltip      "Change profile picture">
-<!ENTITY manage.label                "Manage">
+<!ENTITY manageAccount.label         "Manage Account">
 <!ENTITY disconnect.label            "Disconnect…">
 <!ENTITY verify.label                "Verify Email">
 <!ENTITY forget.label                "Forget this Email">
 
 <!ENTITY welcome.description "Access your tabs, bookmarks, passwords and more wherever you use &brandShortName;.">
 <!ENTITY welcome.signIn.label "Sign In">
 <!ENTITY welcome.createAccount.label "Create Account">
 
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -264,17 +264,16 @@ browser.jar:
 # NOTE: The following two files (tab-selected-end.svg, tab-selected-start.svg) get pre-processed in
 #       Makefile.in with a non-default marker of "%" and the result of that gets packaged.
   skin/classic/browser/tabbrowser/tab-selected-end.svg      (tab-selected-end.svg)
   skin/classic/browser/tabbrowser/tab-selected-start.svg    (tab-selected-start.svg)
 
   skin/classic/browser/tabbrowser/tab-stroke-end.png        (tabbrowser/tab-stroke-end.png)
   skin/classic/browser/tabbrowser/tab-stroke-start.png      (tabbrowser/tab-stroke-start.png)
   skin/classic/browser/tabbrowser/tabDragIndicator.png      (tabbrowser/tabDragIndicator.png)
-  skin/classic/browser/tabbrowser/tab-separator.png         (tabbrowser/tab-separator.png)
 
   skin/classic/browser/tabbrowser/pendingpaint.png          (../shared/tabbrowser/pendingpaint.png)
 
   skin/classic/browser/tabview/edit-light.png         (tabview/edit-light.png)
   skin/classic/browser/tabview/search.png             (tabview/search.png)
   skin/classic/browser/tabview/stack-expander.png     (tabview/stack-expander.png)
   skin/classic/browser/tabview/tabview.png            (tabview/tabview.png)
   skin/classic/browser/tabview/tabview.css            (tabview/tabview.css)
deleted file mode 100644
index 0b3c4e4b5f6957c906c13ca2e73d11c523649441..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -2530,21 +2530,16 @@ toolbarbutton.chevron > .toolbarbutton-m
 
   .tab-throbber[busy] {
     list-style-image: url("chrome://browser/skin/tabbrowser/connecting@2x.png");
   }
 
   .tab-throbber[progress] {
     list-style-image: url("chrome://browser/skin/tabbrowser/loading@2x.png");
   }
-
-  /* Background tab separators */
-  #TabsToolbar:not([brighttext]) {
-    --tab-separator-image: url(chrome://browser/skin/tabbrowser/tab-separator@2x.png);
-  }
 }
 
 .tabbrowser-tab:not(:hover) > .tab-stack > .tab-content > .tab-icon-image:not([visuallyselected="true"]) {
   opacity: .9;
 }
 
 /*
  * Force the overlay to create a new stacking context so it always appears on
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -365,18 +365,16 @@ browser.jar:
   skin/classic/browser/tabbrowser/tab-selected-start.svg                 (tab-selected-start.svg)
 
   skin/classic/browser/tabbrowser/tab-stroke-end.png                     (tabbrowser/tab-stroke-end.png)
   skin/classic/browser/tabbrowser/tab-stroke-end@2x.png                  (tabbrowser/tab-stroke-end@2x.png)
   skin/classic/browser/tabbrowser/tab-stroke-start.png                   (tabbrowser/tab-stroke-start.png)
   skin/classic/browser/tabbrowser/tab-stroke-start@2x.png                (tabbrowser/tab-stroke-start@2x.png)
   skin/classic/browser/tabbrowser/tabDragIndicator.png                   (tabbrowser/tabDragIndicator.png)
   skin/classic/browser/tabbrowser/tabDragIndicator@2x.png                (tabbrowser/tabDragIndicator@2x.png)
-  skin/classic/browser/tabbrowser/tab-separator.png                      (tabbrowser/tab-separator.png)
-  skin/classic/browser/tabbrowser/tab-separator@2x.png                   (tabbrowser/tab-separator@2x.png)
   skin/classic/browser/tabview/close.png                    (tabview/close.png)
   skin/classic/browser/tabview/edit-light.png               (tabview/edit-light.png)
   skin/classic/browser/tabview/search.png                   (tabview/search.png)
   skin/classic/browser/tabview/stack-expander.png           (tabview/stack-expander.png)
   skin/classic/browser/tabview/tabview.png                  (tabview/tabview.png)
   skin/classic/browser/tabview/tabview.css                  (tabview/tabview.css)
   skin/classic/browser/translating-16.png                   (../shared/translation/translating-16.png)
   skin/classic/browser/translating-16@2x.png                (../shared/translation/translating-16@2x.png)
deleted file mode 100644
index b81e691acd2c1d3027ec3675c5e189571f488ad4..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
deleted file mode 100644
index 2a6b04241cb3920098af122a413107d9ebab4a4e..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/browser/themes/shared/devedition.inc.css
+++ b/browser/themes/shared/devedition.inc.css
@@ -26,17 +26,16 @@
   --chrome-nav-bar-controls-border-color: #1D2328;
   --chrome-selection-color: #fff;
   --chrome-selection-background-color: #074D75;
 
   /* Tabs */
   --tabs-toolbar-color: #F5F7FA;
   --tab-background-color: #1C2126;
   --tab-hover-background-color: #07090a;
-  --tab-separator-color: #474C50;
   --tab-selection-color: #f5f7fa;
   --tab-selection-background-color: #1a4666;
   --tab-selection-box-shadow: 0 2px 0 #D7F1FF inset,
                               0 -2px 0 rgba(0,0,0,.05) inset,
                               0 -1px 0 rgba(0,0,0,.3) inset;
   --pinned-tab-glow: radial-gradient(22px at center calc(100% - 2px), rgba(76,158,217,0.9) 13%, rgba(0,0,0,0.4) 16%, transparent 70%);
 
   /* Toolbar buttons */
@@ -87,17 +86,16 @@
   --chrome-nav-buttons-background: #fcfcfc;
   --chrome-nav-buttons-hover-background: #DADBDB;
   --chrome-nav-bar-controls-border-color: #ccc;
   --chrome-selection-color: #f5f7fa;
   --chrome-selection-background-color: #4c9ed9;
 
   --tab-background-color: #E3E4E6;
   --tab-hover-background-color: #D7D8DA;
-  --tab-separator-color: #C6C6C7;
   --tab-selection-color: #f5f7fa;
   --tab-selection-background-color: #4c9ed9;
   --tab-selection-box-shadow: 0 2px 0 #9FDFFF inset,
                               0 -2px 0 rgba(0,0,0,.05) inset,
                               0 -1px 0 rgba(0,0,0,.2) inset;
   --pinned-tab-glow: radial-gradient(22px at center calc(100% - 2px), rgba(76,158,217,0.9) 13%, transparent 16%);
 
 
@@ -280,34 +278,20 @@ searchbar:not([oneoffui]) .search-go-but
   -moz-image-region: auto !important;
   list-style-image: var(--search-button-image);
 }
 
 .tab-background {
   visibility: hidden;
 }
 
-/* Make the tab splitter 1px wide with a solid background. */
-#tabbrowser-tabs[movingtab] > .tabbrowser-tab[beforeselected]:not([last-visible-tab])::after,
-.tabbrowser-tab:not([visuallyselected]):not([afterselected-visible]):not([afterhovered]):not([first-visible-tab]):not(:hover)::before,
-#tabbrowser-tabs:not([overflow]) > .tabbrowser-tab[last-visible-tab]:not([visuallyselected]):not([beforehovered]):not(:hover)::after {
-  background: var(--tab-separator-color);
-  opacity: 1;
-  width: 1px;
-  -moz-margin-start: 0;
-  -moz-margin-end: -1px;
-}
 
-/* For the last tab separator, use margin-start of -1px to prevent jittering
-   due to the ::after element causing the width of the tab to extend, which
-   causes an overflow and makes it disappear, which removes the overflow and
-   causes it to reappear, etc, etc. */
-#tabbrowser-tabs:not([overflow]) > .tabbrowser-tab[last-visible-tab]:not([visuallyselected]):not([beforehovered]):not(:hover)::after {
-  -moz-margin-start: -1px;
-  -moz-margin-end: 0;
+#TabsToolbar {
+  --tab-separator-margin: 0;
+  --tab-separator-opacity: 0.2 !important;
 }
 
 .tabbrowser-arrowscrollbox > .scrollbutton-down,
 .tabbrowser-arrowscrollbox > .scrollbutton-up {
   background-color: var(--tab-background-color);
   border-color: transparent;
 }
 
--- a/browser/themes/shared/devtools/performance.css
+++ b/browser/themes/shared/devtools/performance.css
@@ -15,16 +15,26 @@
 .theme-light {
   --cell-border-color: rgba(0,0,0,0.15);
   --cell-border-color-light: rgba(0,0,0,0.1);
   --focus-cell-border-color: rgba(0,0,0,0.3);
   --row-alt-background-color: rgba(76,158,217,0.1);
   --row-hover-background-color: rgba(76,158,217,0.2);
 }
 
+/**
+ * A generic class to hide elements, replacing the `element.hidden` attribute
+ * that we use to hide elements that can later be active
+ */
+.hidden {
+  display: none;
+  width: 0px;
+  height: 0px;
+}
+
 /* Toolbar */
 
 #performance-toolbar-control-other {
   -moz-padding-end: 5px;
 }
 
 #performance-toolbar-controls-detail-views .toolbarbutton-text {
   -moz-padding-start: 4px;
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -455,28 +455,32 @@ description > html|a {
 
 .fxaAccountBox {
   border: 1px solid #D1D2D3;
   border-radius: 5px;
   padding: 14px 20px 14px 14px;
 }
 
 #signedOutAccountBoxTitle {
-  margin-inline-start: 6px !important;
   font-weight: bold;
 }
 
 .fxaAccountBoxButtons {
   margin-bottom: 0 !important;
   margin-top: 11px;
 }
 
-.fxaAccountBoxButtons > button {
+.fxaAccountBoxButtons button {
   padding-left: 11px;
   padding-right: 11px;
+  margin: 0;
+}
+
+.fxaAccountBoxButtons button:first-child {
+  margin-right: 14px !important;
 }
 
 .fxaSyncIllustration {
   width: 231px;
   list-style-image: url(chrome://browser/skin/fxa/sync-illustration.png)
 }
 
 #fxaEmailAddress1,
--- a/browser/themes/shared/notification-icons.inc.css
+++ b/browser/themes/shared/notification-icons.inc.css
@@ -14,17 +14,18 @@
   list-style-image: url(chrome://browser/skin/Geolocation-64.png);
 }
 
 .popup-notification-icon[popupid="push"] {
   list-style-image: url(chrome://browser/skin/Push-64.png);
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
-.popup-notification-icon[popupid="addon-install-blocked"] {
+.popup-notification-icon[popupid="addon-install-blocked"],
+.popup-notification-icon[popupid="addon-install-origin-blocked"] {
   list-style-image: url(chrome://browser/skin/addons/addon-install-blocked.svg);
 }
 
 .popup-notification-icon[popupid="addon-progress"] {
   list-style-image: url(chrome://browser/skin/addons/addon-install-downloading.svg);
 }
 
 .popup-notification-icon[popupid="addon-install-failed"] {
--- a/browser/themes/shared/tabs.inc.css
+++ b/browser/themes/shared/tabs.inc.css
@@ -5,21 +5,23 @@
 %endif
 
 :root {
   --tab-toolbar-navbar-overlap: 1px;
   --navbar-tab-toolbar-highlight-overlap: 1px;
   --tab-min-height: 31px;
 }
 #TabsToolbar {
-  --tab-separator-image: url(chrome://browser/skin/tabbrowser/tab-separator.png);
-  --tab-separator-size: 3px 100%;
-  --tab-separator-opacity: 1;
+  --tab-separator-opacity: 0.2;
+  --tab-separator-margin: 4px;
   --tab-stroke-background-size: auto 100%;
 }
+#TabsToolbar[brighttext] {
+  --tab-separator-opacity: 0.4;
+}
 
 %define tabCurveWidth 30px
 %define tabCurveHalfWidth 15px
 
 /* image preloading hack */
 #tabbrowser-tabs::before {
   /* Because of bug 853415, we need to ordinal this to the first position: */
   -moz-box-ordinal-group: 0;
@@ -424,36 +426,28 @@
 
 .tabbrowser-tab[pinned][titlechanged]:not([visuallyselected="true"]) > .tab-stack > .tab-content {
   background-image: radial-gradient(farthest-corner at center bottom, rgb(255,255,255) 3%, rgba(186,221,251,0.75) 20%, rgba(127,179,255,0.25) 40%, transparent 70%);
   background-position: center bottom var(--tab-toolbar-navbar-overlap);
   background-repeat: no-repeat;
   background-size: 85% 100%;
 }
 
-/* Background tab separators (3px wide).
+/* Background tab separators.
    Also show separators beside the selected tab when dragging it. */
 #tabbrowser-tabs[movingtab] > .tabbrowser-tab[beforeselected]:not([last-visible-tab])::after,
 .tabbrowser-tab:not([visuallyselected]):not([afterselected-visible]):not([afterhovered]):not([first-visible-tab]):not(:hover)::before,
 #tabbrowser-tabs:not([overflow]) > .tabbrowser-tab[last-visible-tab]:not([visuallyselected]):not([beforehovered]):not(:hover)::after {
-  -moz-margin-start: -1.5px;
-  -moz-margin-end: -1.5px;
-  background-image: var(--tab-separator-image);
-  background-position: left bottom var(--tab-toolbar-navbar-overlap);
-  background-repeat: no-repeat;
-  background-size: var(--tab-separator-size);
+  width: 1px;
+  -moz-margin-start: -1px;
+  margin-top: calc(var(--tab-separator-margin) + 1px);
+  margin-bottom: var(--tab-separator-margin);
+  background-color: currentColor;
   opacity: var(--tab-separator-opacity);
   content: "";
   display: -moz-box;
-  width: 3px;
-}
-
-#TabsToolbar[brighttext] {
-  --tab-separator-image: linear-gradient(transparent 0%, transparent 15%, currentColor 15%, currentColor 90%, transparent 90%);
-  --tab-separator-size: 1px 100%;
-  --tab-separator-opacity: 0.4;
 }
 
 /* New tab button */
 
 .tabs-newtab-button {
   width: calc(36px + @tabCurveWidth@);
 }
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -2046,36 +2046,16 @@ richlistitem[type~="action"][actiontype=
         :root {
           --tab-toolbar-navbar-overlap: 0;
         }
       }
     }
   }
 }
 
-/* Use solid tab separators for Windows 8+ */
-@media not all and (-moz-os-version: windows-xp) {
-  @media not all and (-moz-os-version: windows-vista) {
-    @media not all and (-moz-os-version: windows-win7) {
-      #TabsToolbar:not([brighttext]) {
-        --tab-separator-image: linear-gradient(transparent 0%, transparent 15%, currentColor 15%, currentColor 90%, transparent 90%);
-        --tab-separator-size: 1px 100%;
-        --tab-separator-opacity: 0.2;
-      }
-    }
-  }
-}
-
-/* Use lighter colors of buttons and text in the titlebar on luna-blue */
-@media (-moz-windows-theme: luna-blue) {
-  #TabsToolbar:not([brighttext]) {
-    --tab-separator-image: url("chrome://browser/skin/tabbrowser/tab-separator-luna-blue.png");
-  }
-}
-
 /* Invert the unhovered close tab icons on bright-text tabs */
 @media not all and (min-resolution: 1.1dppx) {
   #TabsToolbar[brighttext] .tab-close-button:not([visuallyselected="true"]) {
     list-style-image: url("chrome://global/skin/icons/close-inverted.png");
   }
 }
 
 @media (min-resolution: 1.1dppx) {
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -386,19 +386,16 @@ browser.jar:
         skin/classic/browser/tabbrowser/tab-selected-end.svg         (tab-selected-end.svg)
         skin/classic/browser/tabbrowser/tab-selected-start.svg       (tab-selected-start.svg)
 
         skin/classic/browser/tabbrowser/tab-stroke-end.png           (tabbrowser/tab-stroke-end.png)
         skin/classic/browser/tabbrowser/tab-stroke-end@2x.png        (tabbrowser/tab-stroke-end@2x.png)
         skin/classic/browser/tabbrowser/tab-stroke-start.png         (tabbrowser/tab-stroke-start.png)
         skin/classic/browser/tabbrowser/tab-stroke-start@2x.png      (tabbrowser/tab-stroke-start@2x.png)
         skin/classic/browser/tabbrowser/tabDragIndicator.png         (tabbrowser/tabDragIndicator.png)
-        skin/classic/browser/tabbrowser/tab-separator.png            (tabbrowser/tab-separator.png)
-        skin/classic/browser/tabbrowser/tab-separator-XP.png         (tabbrowser/tab-separator-XP.png)
-        skin/classic/browser/tabbrowser/tab-separator-luna-blue.png  (tabbrowser/tab-separator-luna-blue.png)
         skin/classic/browser/tabview/close.png                      (tabview/close.png)
         skin/classic/browser/tabview/edit-light.png                 (tabview/edit-light.png)
         skin/classic/browser/tabview/grain.png                      (tabview/grain.png)
         skin/classic/browser/tabview/search.png                     (tabview/search.png)
         skin/classic/browser/tabview/stack-expander.png             (tabview/stack-expander.png)
         skin/classic/browser/tabview/tabview.png                    (tabview/tabview.png)
         skin/classic/browser/tabview/tabview-inverted.png           (tabview/tabview-inverted.png)
         skin/classic/browser/tabview/tabview.css                    (tabview/tabview.css)
@@ -667,17 +664,16 @@ browser.jar:
 % override chrome://browser/skin/places/history.png                   chrome://browser/skin/places/history-XP.png                       os=WINNT osversion<6
 % override chrome://browser/skin/places/allBookmarks.png              chrome://browser/skin/places/allBookmarks-XP.png                  os=WINNT osversion<6
 % override chrome://browser/skin/places/unsortedBookmarks.png         chrome://browser/skin/places/unsortedBookmarks-XP.png             os=WINNT osversion<6
 % override chrome://browser/skin/preferences/alwaysAsk.png            chrome://browser/skin/preferences/alwaysAsk-XP.png                os=WINNT osversion<6
 % override chrome://browser/skin/preferences/application.png          chrome://browser/skin/preferences/application-XP.png              os=WINNT osversion<6
 % override chrome://browser/skin/preferences/mail.png                 chrome://browser/skin/preferences/mail-XP.png                     os=WINNT osversion<6
 % override chrome://browser/skin/preferences/Options.png              chrome://browser/skin/preferences/Options-XP.png                  os=WINNT osversion<6
 % override chrome://browser/skin/preferences/saveFile.png             chrome://browser/skin/preferences/saveFile-XP.png                 os=WINNT osversion<6
-% override chrome://browser/skin/tabbrowser/tab-separator.png         chrome://browser/skin/tabbrowser/tab-separator-XP.png             os=WINNT osversion<6
 
 % override chrome://browser/skin/actionicon-tab.png                   chrome://browser/skin/actionicon-tab-XPVista7.png                 os=WINNT osversion<=6.1
 % override chrome://browser/skin/sync-horizontalbar.png               chrome://browser/skin/sync-horizontalbar-XPVista7.png             os=WINNT osversion<=6.1
 % override chrome://browser/skin/sync-horizontalbar@2x.png            chrome://browser/skin/sync-horizontalbar-XPVista7@2x.png          os=WINNT osversion<=6.1
 % override chrome://browser/skin/syncProgress-horizontalbar.png       chrome://browser/skin/syncProgress-horizontalbar-XPVista7.png     os=WINNT osversion<=6.1
 % override chrome://browser/skin/syncProgress-horizontalbar@2x.png    chrome://browser/skin/syncProgress-horizontalbar-XPVista7@2x.png  os=WINNT osversion<=6.1
 % override chrome://browser/skin/syncProgress-toolbar.png             chrome://browser/skin/syncProgress-toolbar-XPVista7.png           os=WINNT osversion<=6.1
 % override chrome://browser/skin/syncProgress-toolbar@2x.png          chrome://browser/skin/syncProgress-toolbar-XPVista7@2x.png        os=WINNT osversion<=6.1
deleted file mode 100644
index b4192f776e91b155a907592175310c33119343a2..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
deleted file mode 100644
index 3f62dda73e3021820742e084c0c6b5ce7bda4ea9..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
deleted file mode 100644
index 8f46ed201249696e03e6ebd408afca7faa2ec237..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/caps/nsIScriptSecurityManager.idl
+++ b/caps/nsIScriptSecurityManager.idl
@@ -186,26 +186,26 @@ interface nsIScriptSecurityManager : nsI
      * Legacy method for getting a principal with no origin attributes.
      *
      * @deprecated use createCodebasePrincipal instead.
      */
     [deprecated] nsIPrincipal getCodebasePrincipal(in nsIURI uri);
 
     /**
      * Returns a principal whose origin is composed of |uri| and |originAttributes|.
-     * See nsIPrincipal.h for a description of origin attributes, and
-     * SystemDictionaries.webidl for a list of origin attributes and their defaults.
+     * See nsIPrincipal.idl for a description of origin attributes, and
+     * ChromeUtils.webidl for a list of origin attributes and their defaults.
      */
     [implicit_jscontext]
     nsIPrincipal createCodebasePrincipal(in nsIURI uri, in jsval originAttributes);
 
     /**
      * Returns a unique nonce principal with |originAttributes|.
-     * See nsIPrincipal.h for a description of origin attributes, and
-     * SystemDictionaries.webidl for a list of origin attributes and their defaults.
+     * See nsIPrincipal.idl for a description of origin attributes, and
+     * ChromeUtils.webidl for a list of origin attributes and their defaults.
      */
     [implicit_jscontext]
     nsIPrincipal createNullPrincipal(in jsval originAttributes);
 
     /**
      * Creates an expanded principal whose capabilities are the union of the
      * given principals. An expanded principal has an asymmetric privilege
      * relationship with its sub-principals (that is to say, it subsumes the
--- a/mobile/android/base/ThumbnailHelper.java
+++ b/mobile/android/base/ThumbnailHelper.java
@@ -25,19 +25,29 @@ import java.util.concurrent.atomic.Atomi
  * completion of the current thumbnail the next one is automatically processed.
  * Changes to the thumbnail width are stashed in mPendingWidth and the change is
  * applied between thumbnail processing. This allows a single thumbnail buffer to
  * be used for all thumbnails.
  */
 public final class ThumbnailHelper {
     private static final String LOGTAG = "GeckoThumbnailHelper";
 
-    public static final float THUMBNAIL_ASPECT_RATIO = 0.571f;  // this is a 4:7 ratio (as per UX decision)
+    public static final float TABS_PANEL_THUMBNAIL_ASPECT_RATIO = 0.8333333f;
+    public static final float TOP_SITES_THUMBNAIL_ASPECT_RATIO = 0.571428571f;  // this is a 4:7 ratio (as per UX decision)
+    private static final float THUMBNAIL_ASPECT_RATIO;
 
-    public static enum CachePolicy {
+    static {
+      // As we only want to generate one thumbnail for each tab, we calculate the
+      // largest aspect ratio required and create the thumbnail based off that.
+      // Any views with a smaller aspect ratio will use a cropped version of the
+      // same image.
+      THUMBNAIL_ASPECT_RATIO = Math.max(TABS_PANEL_THUMBNAIL_ASPECT_RATIO, TOP_SITES_THUMBNAIL_ASPECT_RATIO);
+    }
+
+    public enum CachePolicy {
         STORE,
         NO_STORE
     }
 
     // static singleton stuff
 
     private static ThumbnailHelper sInstance;
 
@@ -50,24 +60,20 @@ public final class ThumbnailHelper {
 
     // instance stuff
 
     private final LinkedList<Tab> mPendingThumbnails;    // synchronized access only
     private AtomicInteger mPendingWidth;
     private int mWidth;
     private int mHeight;
     private ByteBuffer mBuffer;
-    private final float mThumbnailAspectRatio;
 
     private ThumbnailHelper() {
         final Resources res = GeckoAppShell.getContext().getResources();
 
-        final TypedValue outValue = new TypedValue();
-        res.getValue(R.dimen.thumbnail_aspect_ratio, outValue, true);
-        mThumbnailAspectRatio = outValue.getFloat();
 
         mPendingThumbnails = new LinkedList<Tab>();
         try {
             mPendingWidth = new AtomicInteger((int) res.getDimension(R.dimen.tab_thumbnail_width));
         } catch (Resources.NotFoundException nfe) { mPendingWidth = new AtomicInteger(0); }
         mWidth = -1;
         mHeight = -1;
     }
@@ -106,17 +112,17 @@ public final class ThumbnailHelper {
             // Bug 776906: on 16-bit screens we need to ensure an even width.
             mPendingWidth.set((width & 1) == 0 ? width : width + 1);
         }
     }
 
     private void updateThumbnailSize() {
         // Apply any pending width updates.
         mWidth = mPendingWidth.get();
-        mHeight = Math.round(mWidth * mThumbnailAspectRatio);
+        mHeight = Math.round(mWidth * THUMBNAIL_ASPECT_RATIO);
 
         int pixelSize = (GeckoAppShell.getScreenDepth() == 24) ? 4 : 2;
         int capacity = mWidth * mHeight * pixelSize;
         Log.d(LOGTAG, "Using new thumbnail size: " + capacity + " (width " + mWidth + " - height " + mHeight + ")");
         if (mBuffer == null || mBuffer.capacity() != capacity) {
             if (mBuffer != null) {
                 mBuffer = DirectBufferAllocator.free(mBuffer);
             }
--- a/mobile/android/base/home/TopSitesThumbnailView.java
+++ b/mobile/android/base/home/TopSitesThumbnailView.java
@@ -3,144 +3,68 @@
  * 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.home;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.ThumbnailHelper;
 import org.mozilla.gecko.util.ColorUtils;
-import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.widget.CropImageView;
 
 import android.content.Context;
 import android.content.res.Resources;
-import android.graphics.Bitmap;
 import android.graphics.Canvas;
-import android.graphics.Matrix;
 import android.graphics.Paint;
 import android.graphics.PorterDuff.Mode;
-import android.graphics.RectF;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
-import android.widget.ImageView;
 
 /**
- * A height constrained ImageView to show thumbnails of top and pinned sites.
+ * A width constrained ImageView to show thumbnails of top and pinned sites.
  */
-public class TopSitesThumbnailView extends ImageView {
+public class TopSitesThumbnailView extends CropImageView {
     private static final String LOGTAG = "GeckoTopSitesThumbnailView";
 
     // 27.34% opacity filter for the dominant color.
     private static final int COLOR_FILTER = 0x46FFFFFF;
 
-    // Cache variables used in onMeasure.
-    //
-    // Note: we have two matrices because we can't change it in place - see ImageView.getImageMatrix docs.
-    private final RectF mLayoutRect = new RectF();
-    private Matrix mLayoutCurrentMatrix = new Matrix();
-    private Matrix mLayoutNextMatrix = new Matrix();
-
     // Default filter color for "Add a bookmark" views.
     private final int mDefaultColor = ColorUtils.getColor(getContext(), R.color.top_site_default);
 
     // Stroke width for the border.
     private final float mStrokeWidth = getResources().getDisplayMetrics().density * 2;
 
     // Paint for drawing the border.
     private final Paint mBorderPaint;
 
-    private boolean mResize = false;
-    private int mWidth;
-    private int mHeight;
-
     public TopSitesThumbnailView(Context context) {
         this(context, null);
 
         // A border will be drawn if needed.
         setWillNotDraw(false);
-
     }
 
     public TopSitesThumbnailView(Context context, AttributeSet attrs) {
         this(context, attrs, R.attr.topSitesThumbnailViewStyle);
     }
 
     public TopSitesThumbnailView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
 
         // Initialize the border paint.
         final Resources res = getResources();
         mBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
         mBorderPaint.setColor(ColorUtils.getColor(context, R.color.top_site_border));
         mBorderPaint.setStyle(Paint.Style.STROKE);
     }
 
-    public void setImageBitmap(Bitmap bm, boolean resize) {
-        super.setImageBitmap(bm);
-        mResize = resize;
-        clearLayoutVars();
-
-        updateImageMatrix();
-    }
-
-    private void clearLayoutVars() {
-        mLayoutRect.setEmpty();
-    }
-
-    private void updateImageMatrix() {
-        if (!HardwareUtils.isTablet() || !mResize) {
-            return;
-        }
-
-        // No work to be done here - assumes the rect gets reset when a new bitmap is set.
-        if (mLayoutRect.right == mWidth && mLayoutRect.bottom == mHeight) {
-            return;
-        }
-
-        setScaleType(ScaleType.MATRIX);
-
-        mLayoutRect.set(0, 0, mWidth, mHeight);
-        mLayoutNextMatrix.setRectToRect(mLayoutRect, mLayoutRect, Matrix.ScaleToFit.CENTER);
-        setImageMatrix(mLayoutNextMatrix);
-
-        final Matrix swapReferenceMatrix = mLayoutCurrentMatrix;
-        mLayoutCurrentMatrix = mLayoutNextMatrix;
-        mLayoutNextMatrix = swapReferenceMatrix;
-    }
-
     @Override
-    public void setImageResource(int resId) {
-        super.setImageResource(resId);
-        mResize = false;
-    }
-
-    @Override
-    public void setImageDrawable(Drawable drawable) {
-        super.setImageDrawable(drawable);
-        mResize = false;
-    }
-
-    /**
-     * Measure the view to determine the measured width and height.
-     * The height is constrained by the measured width.
-     *
-     * @param widthMeasureSpec horizontal space requirements as imposed by the parent.
-     * @param heightMeasureSpec vertical space requirements as imposed by the parent, but ignored.
-     */
-    @Override
-    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
-        // Default measuring.
-        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
-
-        // Force the height based on the aspect ratio.
-        mWidth = getMeasuredWidth();
-        mHeight = (int) (mWidth * ThumbnailHelper.THUMBNAIL_ASPECT_RATIO);
-        setMeasuredDimension(mWidth, mHeight);
-
-        updateImageMatrix();
+    protected float getAspectRatio() {
+        return ThumbnailHelper.TOP_SITES_THUMBNAIL_ASPECT_RATIO;
     }
 
     /**
      * {@inheritDoc}
      */
     @Override
     public void onDraw(Canvas canvas) {
         super.onDraw(canvas);
--- a/mobile/android/base/menu/MenuItemActionBar.java
+++ b/mobile/android/base/menu/MenuItemActionBar.java
@@ -4,41 +4,33 @@
 
 package org.mozilla.gecko.menu;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.DrawableUtil;
 import org.mozilla.gecko.widget.ThemedImageButton;
 
 import android.content.Context;
-import android.content.res.ColorStateList;
-import android.content.res.TypedArray;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class MenuItemActionBar extends ThemedImageButton
                                implements GeckoMenuItem.Layout {
     private static final String LOGTAG = "GeckoMenuItemActionBar";
 
-    private final ColorStateList drawableColors;
-
     public MenuItemActionBar(Context context) {
         this(context, null);
     }
 
     public MenuItemActionBar(Context context, AttributeSet attrs) {
         this(context, attrs, R.attr.menuItemActionBarStyle);
     }
 
     public MenuItemActionBar(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-
-        final TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.MenuItemActionBar, defStyle, 0);
-        drawableColors = ta.getColorStateList(R.styleable.MenuItemActionBar_drawableTintList);
-        ta.recycle();
     }
 
     @Override
     public void initialize(GeckoMenuItem item) {
         if (item == null)
             return;
 
         setIcon(item.getIcon());
@@ -47,19 +39,17 @@ public class MenuItemActionBar extends T
         setId(item.getItemId());
     }
 
     void setIcon(Drawable icon) {
         if (icon == null) {
             setVisibility(GONE);
         } else {
             setVisibility(VISIBLE);
-            final Drawable tintedIcon =
-                    DrawableUtil.tintDrawableWithStateList(icon, drawableColors);
-            setImageDrawable(tintedIcon);
+            setImageDrawable(icon);
         }
     }
 
     void setIcon(int icon) {
         setIcon((icon == 0) ? null : getResources().getDrawable(icon));
     }
 
     void setTitle(CharSequence title) {
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -465,16 +465,17 @@ gbjar.sources += [
     'tabs/TabHistoryItemRow.java',
     'tabs/TabHistoryPage.java',
     'tabs/TabPanelBackButton.java',
     'tabs/TabsGridLayout.java',
     'tabs/TabsLayoutAdapter.java',
     'tabs/TabsLayoutItemView.java',
     'tabs/TabsListLayout.java',
     'tabs/TabsPanel.java',
+    'tabs/TabsPanelThumbnailView.java',
     'Telemetry.java',
     'TelemetryContract.java',
     'TextSelection.java',
     'TextSelectionHandle.java',
     'ThumbnailHelper.java',
     'tiles/Tile.java',
     'tiles/TilesRecorder.java',
     'toolbar/AutocompleteHandler.java',
@@ -517,16 +518,17 @@ gbjar.sources += [
     'widget/AllCapsTextView.java',
     'widget/AnchoredPopup.java',
     'widget/AnimatedHeightLayout.java',
     'widget/BasicColorPicker.java',
     'widget/ButtonToast.java',
     'widget/CheckableLinearLayout.java',
     'widget/ClickableWhenDisabledEditText.java',
     'widget/ContentSecurityDoorHanger.java',
+    'widget/CropImageView.java',
     'widget/DateTimePicker.java',
     'widget/DefaultDoorHanger.java',
     'widget/Divider.java',
     'widget/DoorHanger.java',
     'widget/DoorhangerConfig.java',
     'widget/EllipsisTextView.java',
     'widget/FadedMultiColorTextView.java',
     'widget/FadedSingleColorTextView.java',
--- a/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
+++ b/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
@@ -32,17 +32,17 @@
 	     to the right of the back button.
 
          (for layout_marginLeft) We left align with back,
          but only need to hide halfway underneath.
 
          (for paddingLeft) We use left padding to center the
          arrow in the visible area as opposed to the true width. -->
     <org.mozilla.gecko.toolbar.ForwardButton
-            style="@style/UrlBar.ImageButton"
+            style="@style/UrlBar.ImageButton.BrowserToolbarColors"
             android:id="@+id/forward"
             android:layout_alignLeft="@id/back"
             android:contentDescription="@string/forward"
             android:layout_height="match_parent"
             android:paddingTop="0dp"
             android:paddingBottom="0dp"
             android:layout_marginTop="11.5dp"
             android:layout_marginBottom="11.5dp"
@@ -51,17 +51,17 @@
             android:src="@drawable/ic_menu_forward"
             android:background="@drawable/url_bar_nav_button"
             android:alpha="0"
             android:layout_width="@dimen/tablet_nav_button_width_plus_half"
             android:layout_marginLeft="@dimen/tablet_nav_button_width_half"
             android:paddingLeft="18dp"/>
 
     <org.mozilla.gecko.toolbar.BackButton android:id="@id/back"
-                                          style="@style/UrlBar.ImageButton"
+                                          style="@style/UrlBar.ImageButton.BrowserToolbarColors"
                                           android:layout_width="@dimen/tablet_nav_button_width"
                                           android:layout_height="@dimen/tablet_nav_button_width"
                                           android:layout_centerVertical="true"
                                           android:layout_marginLeft="12dp"
                                           android:layout_alignParentLeft="true"
                                           android:src="@drawable/ic_menu_back"
                                           android:contentDescription="@string/back"
                                           android:background="@drawable/url_bar_nav_button"/>
@@ -115,17 +115,17 @@
             android:layout_alignParentRight="true"
             android:layout_marginRight="6dp"
             android:contentDescription="@string/menu"
             android:background="@drawable/browser_toolbar_action_bar_button"
             android:visibility="gone"/>
 
     <org.mozilla.gecko.widget.ThemedImageView
             android:id="@+id/menu_icon"
-            style="@style/UrlBar.ImageButton"
+            style="@style/UrlBar.ImageButton.BrowserToolbarColors"
             android:layout_alignLeft="@id/menu"
             android:layout_alignRight="@id/menu"
             android:src="@drawable/tablet_menu"
             android:visibility="gone"/>
 
     <!-- We draw after the menu items so when they are hidden, the cancel button,
          which is thus drawn on top, may be pressed. -->
     <org.mozilla.gecko.widget.ThemedImageView
--- a/mobile/android/base/resources/layout-v11/tablet_tabs_item_cell.xml
+++ b/mobile/android/base/resources/layout-v11/tablet_tabs_item_cell.xml
@@ -61,16 +61,16 @@
     <org.mozilla.gecko.widget.TabThumbnailWrapper
             android:id="@+id/wrapper"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:padding="@dimen/tablet_tab_highlight_stroke_width"
             android:background="@drawable/tab_thumbnail"
             android:duplicateParentState="true">
 
-        <org.mozilla.gecko.widget.ThumbnailView android:id="@+id/thumbnail"
-                                                android:layout_width="@dimen/tablet_tab_thumbnail_width"
-                                                android:layout_height="@dimen/tablet_tab_thumbnail_height"
+        <org.mozilla.gecko.tabs.TabsPanelThumbnailView android:id="@+id/thumbnail"
+                                                       android:layout_width="@dimen/tablet_tab_thumbnail_width"
+                                                       android:layout_height="@dimen/tablet_tab_thumbnail_height"
                                                 />
 
     </org.mozilla.gecko.widget.TabThumbnailWrapper>
 
 </org.mozilla.gecko.tabs.TabsLayoutItemView>
--- a/mobile/android/base/resources/layout/tabs_item_cell.xml
+++ b/mobile/android/base/resources/layout/tabs_item_cell.xml
@@ -20,19 +20,19 @@
             android:id="@+id/wrapper"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_margin="6dip"
             android:padding="4dip"
             android:background="@drawable/tab_thumbnail"
             android:duplicateParentState="true">
 
-        <org.mozilla.gecko.widget.ThumbnailView android:id="@+id/thumbnail"
-                                                android:layout_width="@dimen/tab_thumbnail_width"
-                                                android:layout_height="@dimen/tab_thumbnail_height"/>
+        <org.mozilla.gecko.tabs.TabsPanelThumbnailView android:id="@+id/thumbnail"
+                                                       android:layout_width="@dimen/tab_thumbnail_width"
+                                                       android:layout_height="@dimen/tab_thumbnail_height"/>
 
         <LinearLayout android:layout_width="@dimen/tab_thumbnail_width"
                       android:layout_height="wrap_content"
                       android:orientation="horizontal"
                       android:background="#EFFF"
                       android:layout_below="@id/thumbnail"
                       android:duplicateParentState="true">
 
--- a/mobile/android/base/resources/layout/tabs_item_row.xml
+++ b/mobile/android/base/resources/layout/tabs_item_row.xml
@@ -18,19 +18,19 @@
     <org.mozilla.gecko.widget.TabThumbnailWrapper
                   android:id="@+id/wrapper"
 	          android:layout_width="wrap_content"
                   android:layout_height="wrap_content"
                   android:padding="4dip"
                   android:background="@drawable/tab_thumbnail"
                   android:duplicateParentState="true">
 
-        <org.mozilla.gecko.widget.ThumbnailView android:id="@+id/thumbnail"
-                                                android:layout_width="@dimen/tab_thumbnail_width"
-                                                android:layout_height="@dimen/tab_thumbnail_height"/>
+        <org.mozilla.gecko.tabs.TabsPanelThumbnailView android:id="@+id/thumbnail"
+                                                       android:layout_width="@dimen/tab_thumbnail_width"
+                                                       android:layout_height="@dimen/tab_thumbnail_height"/>
 
     </org.mozilla.gecko.widget.TabThumbnailWrapper>
 
     <LinearLayout android:layout_width="0dip"
                   android:layout_height="match_parent"
                   android:orientation="vertical"
                   android:layout_weight="1.0"
                   android:paddingTop="4dip"
--- a/mobile/android/base/resources/values-large-v11/dimens.xml
+++ b/mobile/android/base/resources/values-large-v11/dimens.xml
@@ -30,14 +30,11 @@
     <dimen name="tabs_panel_button_width">60dp</dimen>
     <dimen name="panel_grid_view_column_width">200dp</dimen>
 
     <dimen name="reading_list_row_height">96dp</dimen>
     <dimen name="reading_list_row_padding_right">15dp</dimen>
 
     <dimen name="overlay_prompt_container_width">360dp</dimen>
 
-    <!-- Should be closer to 0.83 (140/168) but various roundings mean that 0.9 works better -->
-    <item name="thumbnail_aspect_ratio" format="float" type="dimen">0.9</item>
-
     <item name="tab_strip_content_start" type="dimen">72dp</item>
 
 </resources>
--- a/mobile/android/base/resources/values-large-v11/styles.xml
+++ b/mobile/android/base/resources/values-large-v11/styles.xml
@@ -4,16 +4,22 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <resources>
 
     <style name="UrlBar.ImageButton" parent="UrlBar.ImageButtonBase">
         <item name="android:layout_width">@dimen/tablet_browser_toolbar_menu_item_width</item>
     </style>
 
+    <!-- If this style wasn't actually shared outside the
+         url bar, this name could be improved (bug 1197424). -->
+    <style name="UrlBar.ImageButton.BrowserToolbarColors">
+        <item name="drawableTintList">@color/action_bar_menu_item_colors</item>
+    </style>
+
     <style name="UrlBar.ImageButton.TabCount">
         <item name="android:background">@drawable/tabs_count</item>
     </style>
 
     <style name="UrlBar.Button.Container">
         <item name="android:layout_marginTop">6dp</item>
         <item name="android:layout_marginBottom">6dp</item>
         <!-- Start with forward hidden -->
--- a/mobile/android/base/resources/values/attrs.xml
+++ b/mobile/android/base/resources/values/attrs.xml
@@ -188,16 +188,20 @@
     </declare-styleable>
 
     <declare-styleable name="OverlayDialogButton">
         <attr name="drawable" format="reference" />
         <attr name="enabledText" format="string" />
         <attr name="disabledText" format="string" />
     </declare-styleable>
 
-    <declare-styleable name="MenuItemActionBar">
-        <!-- We reimplement tint list XML attrs here because it is
-             only available in Android 21+. -->
+    <declare-styleable name="ThemedView">
+        <!-- A reimplementation of android:tintList which is
+             otherwise only available on API 21+.
+
+             Using this attribute is mutually exclusive with android:tint
+             and setting colorFilters in code. This is because on pre-Lollipop,
+             android:tint and DrawableCompat.tint* uses colorFilters under the hood. -->
         <attr name="drawableTintList" format="color" />
     </declare-styleable>
 
 </resources>
 
--- a/mobile/android/base/resources/values/colors.xml
+++ b/mobile/android/base/resources/values/colors.xml
@@ -126,17 +126,17 @@
   <!-- Swipe to refresh colors for dynamic panel -->
   <color name="swipe_refresh_orange">#FFFFC26C</color>
   <color name="swipe_refresh_white">#FFFFFFFF</color>
 
   <!-- Remote tabs setup -->
   <color name="remote_tabs_setup_button_background_hit">#D95300</color>
 
   <!-- Button toast colors. -->
-  <color name="toast_background">#DD363B40</color>
+  <color name="toast_background">#DD222222</color>
   <color name="toast_button_background">#00000000</color>
   <color name="toast_button_pressed">#DD2C3136</color>
   <color name="toast_button_text">#FFFFFFFF</color>
 
   <!-- Tab History colors. -->
   <color name="tab_history_timeline_separator">#D7D9DB</color>
   <color name="tab_history_favicon_border">#D7D9DB</color>
   <color name="tab_history_favicon_background">#FFFFFF</color>
--- a/mobile/android/base/resources/values/dimens.xml
+++ b/mobile/android/base/resources/values/dimens.xml
@@ -208,18 +208,15 @@
     <dimen name="find_in_page_control_margin_top">2dip</dimen>
 
     <!-- The share icon asset has no padding while the other action bar items do
          so we dynamically add padding to compensate. To be removed in bug 1122752. -->
     <dimen name="ab_share_padding">12dp</dimen>
 
     <dimen name="progress_bar_scroll_offset">1.5dp</dimen>
 
-    <!-- This is a 4:7 ratio (as per UX decision). -->
-    <item name="thumbnail_aspect_ratio" format="float" type="dimen">0.571</item>
-
     <!-- http://blog.danlew.net/2015/01/06/handling-android-resources-with-non-standard-formats/ -->
     <item name="match_parent" type="dimen">-1</item>
     <item name="wrap_content" type="dimen">-2</item>
 
     <item name="tab_strip_content_start" type="dimen">12dp</item>
 
 </resources>
--- a/mobile/android/base/tabs/TabsLayoutItemView.java
+++ b/mobile/android/base/tabs/TabsLayoutItemView.java
@@ -5,17 +5,16 @@
 package org.mozilla.gecko.tabs;
 
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.widget.TabThumbnailWrapper;
-import org.mozilla.gecko.widget.ThumbnailView;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import android.content.Context;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
@@ -32,17 +31,17 @@ import android.widget.TextView;
 public class TabsLayoutItemView extends LinearLayout
                                 implements Checkable {
     private static final String LOGTAG = "Gecko" + TabsLayoutItemView.class.getSimpleName();
     private static final int[] STATE_CHECKED = { android.R.attr.state_checked };
     private boolean mChecked;
 
     private int mTabId;
     private TextView mTitle;
-    private ThumbnailView mThumbnail;
+    private TabsPanelThumbnailView mThumbnail;
     private ImageView mCloseButton;
     private ImageView mAudioPlayingButton;
     private TabThumbnailWrapper mThumbnailWrapper;
 
     public TabsLayoutItemView(Context context, AttributeSet attrs) {
         super(context, attrs);
     }
 
@@ -93,17 +92,17 @@ public class TabsLayoutItemView extends 
     public void setCloseOnClickListener(OnClickListener mOnClickListener) {
         mCloseButton.setOnClickListener(mOnClickListener);
     }
 
     @Override
     protected void onFinishInflate() {
         super.onFinishInflate();
         mTitle = (TextView) findViewById(R.id.title);
-        mThumbnail = (ThumbnailView) findViewById(R.id.thumbnail);
+        mThumbnail = (TabsPanelThumbnailView) findViewById(R.id.thumbnail);
         mCloseButton = (ImageView) findViewById(R.id.close);
         mAudioPlayingButton = (ImageView) findViewById(R.id.audio_playing);
         mThumbnailWrapper = (TabThumbnailWrapper) findViewById(R.id.wrapper);
 
         if (HardwareUtils.isTablet()) {
             growCloseButtonHitArea();
         }
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/tabs/TabsPanelThumbnailView.java
@@ -0,0 +1,52 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.tabs;
+
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.ThumbnailHelper;
+import org.mozilla.gecko.widget.CropImageView;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+
+/**
+ *  A width constrained ImageView to show thumbnails of open tabs in the tabs panel.
+ */
+public class TabsPanelThumbnailView extends CropImageView {
+    public static final String LOGTAG = "Gecko" + TabsPanelThumbnailView.class.getSimpleName();
+
+
+    public TabsPanelThumbnailView(final Context context) {
+        this(context, null);
+    }
+
+    public TabsPanelThumbnailView(final Context context, final AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public TabsPanelThumbnailView(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    @Override
+    protected float getAspectRatio() {
+        return ThumbnailHelper.TABS_PANEL_THUMBNAIL_ASPECT_RATIO;
+    }
+
+    @Override
+    public void setImageDrawable(Drawable drawable) {
+        boolean resize = true;
+
+        if (drawable == null) {
+            drawable = getResources().getDrawable(R.drawable.tab_panel_tab_background);
+            resize = false;
+            setScaleType(ScaleType.FIT_XY);
+        }
+
+        super.setImageDrawable(drawable, resize);
+    }
+}
--- a/mobile/android/base/toolbar/BrowserToolbarTabletBase.java
+++ b/mobile/android/base/toolbar/BrowserToolbarTabletBase.java
@@ -125,29 +125,25 @@ abstract class BrowserToolbarTabletBase 
         backButton.setNextFocusDownId(nextId);
         forwardButton.setNextFocusDownId(nextId);
     }
 
     @Override
     public void setPrivateMode(final boolean isPrivate) {
         super.setPrivateMode(isPrivate);
 
-        // Better done with android:tint but it doesn't take a ColorStateList:
-        //   https://code.google.com/p/android/issues/detail?can=2&start=0&num=100&q=&colspec=ID%20Type%20Status%20Owner%20Summary%20Stars&groupby=&sort=&id=18220
-        // Nor can we use DrawableCompat because the drawables (as opposed
-        // to the Views) don't receive gecko:state_private.
+        // If we had backgroundTintList, we could remove the colorFilter
+        // code in favor of setPrivateMode (bug 1197432).
         final PorterDuffColorFilter colorFilter =
                 isPrivate ? privateBrowsingTabletMenuItemColorFilter : null;
-        backButton.setColorFilter(colorFilter);
-        forwardButton.setColorFilter(colorFilter);
         setTabsCounterPrivateMode(isPrivate, colorFilter);
-        menuIcon.setColorFilter(colorFilter);
 
         backButton.setPrivateMode(isPrivate);
         forwardButton.setPrivateMode(isPrivate);
+        menuIcon.setPrivateMode(isPrivate);
         for (int i = 0; i < actionItemBar.getChildCount(); ++i) {
             final MenuItemActionBar child = (MenuItemActionBar) actionItemBar.getChildAt(i);
             child.setPrivateMode(isPrivate);
         }
     }
 
     private void setTabsCounterPrivateMode(final boolean isPrivate, final PorterDuffColorFilter colorFilter) {
         // The TabsCounter is a TextSwitcher which cycles two views
@@ -172,16 +168,11 @@ abstract class BrowserToolbarTabletBase 
         return (tab.canDoBack() && !isEditing());
     }
 
     protected boolean canDoForward(final Tab tab) {
         return (tab.canDoForward() && !isEditing());
     }
 
     protected static void setButtonEnabled(final ImageButton button, final boolean enabled) {
-        final Drawable drawable = button.getDrawable();
-        if (drawable != null) {
-            drawable.setAlpha(enabled ? 255 : 61);
-        }
-
         button.setEnabled(enabled);
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/widget/CropImageView.java
@@ -0,0 +1,142 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.widget;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+import com.nineoldandroids.view.ViewHelper;
+
+/**
+ * An ImageView which will always display at the given width and calculated height (based on the width and
+ * the supplied aspect ratio), drawn starting from the top left hand corner.  A supplied drawable will be resized to fit
+ * the width of the view; if the resized drawable is too tall for the view then the drawable will be cropped at the
+ * bottom, however if the resized drawable is too short for the view to display whilst honouring it's given width and
+ * height then the drawable will be displayed at full height with the right hand side cropped.
+ */
+public abstract class CropImageView extends ThemedImageView {
+    public static final String LOGTAG = "Gecko" + CropImageView.class.getSimpleName();
+
+    private int viewWidth;
+    private int viewHeight;
+    private int drawableWidth;
+    private int drawableHeight;
+
+    private boolean resize = true;
+    private Matrix layoutCurrentMatrix = new Matrix();
+    private Matrix layoutNextMatrix = new Matrix();
+
+
+    public CropImageView(final Context context) {
+        this(context, null);
+    }
+
+    public CropImageView(final Context context, final AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public CropImageView(final Context context, final AttributeSet attrs, final int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+        init();
+    }
+
+    protected abstract float getAspectRatio();
+
+    protected void init() {
+        // Setting the pivots means that the image will be drawn from the top left hand corner.  There are
+        // issues in Android 4.1 (16) which mean setting these values to 0 may not work.
+        // http://stackoverflow.com/questions/26658124/setpivotx-doesnt-work-on-android-4-1-1-nineoldandroids
+        ViewHelper.setPivotX(this, 1);
+        ViewHelper.setPivotY(this, 1);
+    }
+
+    /**
+     * Measure the view to determine the measured width and height.
+     * The height is constrained by the measured width.
+     *
+     * @param widthMeasureSpec  horizontal space requirements as imposed by the parent.
+     * @param heightMeasureSpec vertical space requirements as imposed by the parent, but ignored.
+     */
+    @Override
+    protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+        // Default measuring.
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+        // Force the height based on the aspect ratio.
+        viewWidth = getMeasuredWidth();
+        viewHeight = (int) (viewWidth * getAspectRatio());
+
+        setMeasuredDimension(viewWidth, viewHeight);
+
+        updateImageMatrix();
+    }
+
+    protected void updateImageMatrix() {
+        if (!resize || getDrawable() == null) {
+            return;
+        }
+
+        setScaleType(ImageView.ScaleType.MATRIX);
+
+        getDrawable().setBounds(0, 0, viewWidth, viewHeight);
+
+        final float horizontalScaleValue = (float) viewWidth / (float) drawableWidth;
+        final float verticalScaleValue = (float) viewHeight / (float) drawableHeight;
+
+        final float scale = Math.max(verticalScaleValue, horizontalScaleValue);
+
+        layoutNextMatrix.setScale(scale, scale);
+        setImageMatrix(layoutNextMatrix);
+
+        // You can't modify the matrix in place and we want to avoid allocation, so let's keep two references to two
+        // different matrix objects that we can swap when the values need to change
+        final Matrix swapReferenceMatrix = layoutCurrentMatrix;
+        layoutCurrentMatrix = layoutNextMatrix;
+        layoutNextMatrix = swapReferenceMatrix;
+    }
+
+    public void setImageBitmap(final Bitmap bm, final boolean resize) {
+        super.setImageBitmap(bm);
+
+        this.resize = resize;
+        updateImageMatrix();
+    }
+
+    @Override
+    public void setImageResource(final int resId) {
+        super.setImageResource(resId);
+        setImageMatrix(null);
+        resize = false;
+    }
+
+    @Override
+    public void setImageDrawable(final Drawable drawable) {
+        this.setImageDrawable(drawable, false);
+    }
+
+    public void setImageDrawable(final Drawable drawable, final boolean resize) {
+        super.setImageDrawable(drawable);
+
+        if (drawable != null) {
+            // Reset the matrix to ensure that any previous changes aren't carried through.
+            setImageMatrix(null);
+
+            drawableWidth = drawable.getIntrinsicWidth();
+            drawableHeight = drawable.getIntrinsicHeight();
+        } else {
+            drawableWidth = -1;
+            drawableHeight = -1;
+        }
+
+        this.resize = resize;
+
+        updateImageMatrix();
+    }
+}
--- a/mobile/android/base/widget/ThemedEditText.java
+++ b/mobile/android/base/widget/ThemedEditText.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedEditText extends android.widget.EditText
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,27 +31,29 @@ public class ThemedEditText extends andr
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedEditText(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedEditText(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedFrameLayout.java
+++ b/mobile/android/base/widget/ThemedFrameLayout.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedFrameLayout extends android.widget.FrameLayout
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,27 +31,29 @@ public class ThemedFrameLayout extends a
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedFrameLayout(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedFrameLayout(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedImageButton.java
+++ b/mobile/android/base/widget/ThemedImageButton.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedImageButton extends android.widget.ImageButton
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,37 +31,47 @@ public class ThemedImageButton extends a
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedImageButton(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedImageButton(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
         mAutoUpdateTheme = mTheme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
         a.recycle();
+
+        final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+        mDrawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+        themedA.recycle();
+
+        // Apply the tint initially - the Drawable is
+        // initially set by XML via super's constructor.
+        setTintedImageDrawable(getDrawable());
     }
 
     @Override
     public void onAttachedToWindow() {
         super.onAttachedToWindow();
 
         if (mAutoUpdateTheme)
             mTheme.addListener(this);
@@ -152,16 +165,35 @@ public class ThemedImageButton extends a
 
             if (mAutoUpdateTheme)
                 mTheme.addListener(this);
             else
                 mTheme.removeListener(this);
         }
     }
 
+    @Override
+    public void setImageDrawable(final Drawable drawable) {
+        setTintedImageDrawable(drawable);
+    }
+
+    private void setTintedImageDrawable(final Drawable drawable) {
+        final Drawable tintedDrawable;
+        if (mDrawableColors == null) {
+            // If we tint a drawable with a null ColorStateList, it will override
+            // any existing colorFilters and tint... so don't!
+            tintedDrawable = drawable;
+        } else if (drawable == null) {
+            tintedDrawable = null;
+        } else {
+            tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, mDrawableColors);
+        }
+        super.setImageDrawable(tintedDrawable);
+    }
+
     public ColorDrawable getColorDrawable(int id) {
         return new ColorDrawable(ColorUtils.getColor(getContext(), id));
     }
 
     protected LightweightTheme getTheme() {
         return mTheme;
     }
 }
--- a/mobile/android/base/widget/ThemedImageView.java
+++ b/mobile/android/base/widget/ThemedImageView.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedImageView extends android.widget.ImageView
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,37 +31,47 @@ public class ThemedImageView extends and
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedImageView(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedImageView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
         mAutoUpdateTheme = mTheme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
         a.recycle();
+
+        final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+        mDrawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+        themedA.recycle();
+
+        // Apply the tint initially - the Drawable is
+        // initially set by XML via super's constructor.
+        setTintedImageDrawable(getDrawable());
     }
 
     @Override
     public void onAttachedToWindow() {
         super.onAttachedToWindow();
 
         if (mAutoUpdateTheme)
             mTheme.addListener(this);
@@ -152,16 +165,35 @@ public class ThemedImageView extends and
 
             if (mAutoUpdateTheme)
                 mTheme.addListener(this);
             else
                 mTheme.removeListener(this);
         }
     }
 
+    @Override
+    public void setImageDrawable(final Drawable drawable) {
+        setTintedImageDrawable(drawable);
+    }
+
+    private void setTintedImageDrawable(final Drawable drawable) {
+        final Drawable tintedDrawable;
+        if (mDrawableColors == null) {
+            // If we tint a drawable with a null ColorStateList, it will override
+            // any existing colorFilters and tint... so don't!
+            tintedDrawable = drawable;
+        } else if (drawable == null) {
+            tintedDrawable = null;
+        } else {
+            tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, mDrawableColors);
+        }
+        super.setImageDrawable(tintedDrawable);
+    }
+
     public ColorDrawable getColorDrawable(int id) {
         return new ColorDrawable(ColorUtils.getColor(getContext(), id));
     }
 
     protected LightweightTheme getTheme() {
         return mTheme;
     }
 }
--- a/mobile/android/base/widget/ThemedLinearLayout.java
+++ b/mobile/android/base/widget/ThemedLinearLayout.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedLinearLayout extends android.widget.LinearLayout
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,22 +31,24 @@ public class ThemedLinearLayout extends 
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedLinearLayout(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedRelativeLayout.java
+++ b/mobile/android/base/widget/ThemedRelativeLayout.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedRelativeLayout extends android.widget.RelativeLayout
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,27 +31,29 @@ public class ThemedRelativeLayout extend
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedRelativeLayout(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedTextSwitcher.java
+++ b/mobile/android/base/widget/ThemedTextSwitcher.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedTextSwitcher extends android.widget.TextSwitcher
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,22 +31,24 @@ public class ThemedTextSwitcher extends 
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedTextSwitcher(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedTextView.java
+++ b/mobile/android/base/widget/ThemedTextView.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedTextView extends android.widget.TextView
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,27 +31,29 @@ public class ThemedTextView extends andr
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedTextView(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedTextView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedView.java
+++ b/mobile/android/base/widget/ThemedView.java
@@ -5,20 +5,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class ThemedView extends android.view.View
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -28,27 +31,29 @@ public class ThemedView extends android.
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public ThemedView(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
     public ThemedView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
--- a/mobile/android/base/widget/ThemedView.java.frag
+++ b/mobile/android/base/widget/ThemedView.java.frag
@@ -6,20 +6,23 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.widget;
 
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ColorUtils;
+import org.mozilla.gecko.util.DrawableUtil;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.content.res.TypedArray;
 import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 
 public class Themed@VIEW_NAME_SUFFIX@ extends @BASE_TYPE@
                                      implements LightweightTheme.OnChangeListener {
     private LightweightTheme mTheme;
 
     private static final int[] STATE_PRIVATE_MODE = { R.attr.state_private };
     private static final int[] STATE_LIGHT = { R.attr.state_light };
@@ -29,39 +32,51 @@ public class Themed@VIEW_NAME_SUFFIX@ ex
     protected static final int[] PRIVATE_FOCUSED_STATE_SET = { R.attr.state_private, android.R.attr.state_focused };
     protected static final int[] PRIVATE_STATE_SET = { R.attr.state_private };
 
     private boolean mIsPrivate;
     private boolean mIsLight;
     private boolean mIsDark;
     private boolean mAutoUpdateTheme;        // always false if there's no theme.
 
+    private ColorStateList mDrawableColors;
+
     public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs) {
         super(context, attrs);
-        initialize(context, attrs);
+        initialize(context, attrs, 0);
     }
 
 //#ifdef STYLE_CONSTRUCTOR
     public Themed@VIEW_NAME_SUFFIX@(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
-        initialize(context, attrs);
+        initialize(context, attrs, defStyle);
     }
 
 //#endif
-    private void initialize(final Context context, final AttributeSet attrs) {
+    private void initialize(final Context context, final AttributeSet attrs, final int defStyle) {
         // The theme can be null, particularly for webapps: Bug 1089266.  Or we
         // might be instantiating this View in an IDE, with no ambient GeckoApplication.
         final Context applicationContext = context.getApplicationContext();
         if (applicationContext instanceof GeckoApplication) {
             mTheme = ((GeckoApplication) applicationContext).getLightweightTheme();
         }
 
         final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LightweightTheme);
         mAutoUpdateTheme = mTheme != null && a.getBoolean(R.styleable.LightweightTheme_autoUpdateTheme, true);
         a.recycle();
+//#if TINT_FOREGROUND_DRAWABLE
+
+        final TypedArray themedA = context.obtainStyledAttributes(attrs, R.styleable.ThemedView, defStyle, 0);
+        mDrawableColors = themedA.getColorStateList(R.styleable.ThemedView_drawableTintList);
+        themedA.recycle();
+
+        // Apply the tint initially - the Drawable is
+        // initially set by XML via super's constructor.
+        setTintedImageDrawable(getDrawable());
+//#endif
     }
 
     @Override
     public void onAttachedToWindow() {
         super.onAttachedToWindow();
 
         if (mAutoUpdateTheme)
             mTheme.addListener(this);
@@ -155,16 +170,37 @@ public class Themed@VIEW_NAME_SUFFIX@ ex
 
             if (mAutoUpdateTheme)
                 mTheme.addListener(this);
             else
                 mTheme.removeListener(this);
         }
     }
 
+//#ifdef TINT_FOREGROUND_DRAWABLE
+    @Override
+    public void setImageDrawable(final Drawable drawable) {
+        setTintedImageDrawable(drawable);
+    }
+
+    private void setTintedImageDrawable(final Drawable drawable) {
+        final Drawable tintedDrawable;
+        if (mDrawableColors == null) {
+            // If we tint a drawable with a null ColorStateList, it will override
+            // any existing colorFilters and tint... so don't!
+            tintedDrawable = drawable;
+        } else if (drawable == null) {
+            tintedDrawable = null;
+        } else {
+            tintedDrawable = DrawableUtil.tintDrawableWithStateList(drawable, mDrawableColors);
+        }
+        super.setImageDrawable(tintedDrawable);
+    }
+
+//#endif
     public ColorDrawable getColorDrawable(int id) {
         return new ColorDrawable(ColorUtils.getColor(getContext(), id));
     }
 
     protected LightweightTheme getTheme() {
         return mTheme;
     }
 }
--- a/mobile/android/base/widget/generate_themed_views.py
+++ b/mobile/android/base/widget/generate_themed_views.py
@@ -35,20 +35,22 @@ views = [
     dict(VIEW_NAME_SUFFIX='EditText',
          BASE_TYPE='android.widget.EditText',
          STYLE_CONSTRUCTOR=1),
     dict(VIEW_NAME_SUFFIX='FrameLayout',
          BASE_TYPE='android.widget.FrameLayout',
          STYLE_CONSTRUCTOR=1),
     dict(VIEW_NAME_SUFFIX='ImageButton',
          BASE_TYPE='android.widget.ImageButton',
-         STYLE_CONSTRUCTOR=1),
+         STYLE_CONSTRUCTOR=1,
+         TINT_FOREGROUND_DRAWABLE=1),
     dict(VIEW_NAME_SUFFIX='ImageView',
          BASE_TYPE='android.widget.ImageView',
-         STYLE_CONSTRUCTOR=1),
+         STYLE_CONSTRUCTOR=1,
+         TINT_FOREGROUND_DRAWABLE=1),
     dict(VIEW_NAME_SUFFIX='LinearLayout',
          BASE_TYPE='android.widget.LinearLayout'),
     dict(VIEW_NAME_SUFFIX='RelativeLayout',
          BASE_TYPE='android.widget.RelativeLayout',
          STYLE_CONSTRUCTOR=1),
     dict(VIEW_NAME_SUFFIX='TextSwitcher',
          BASE_TYPE='android.widget.TextSwitcher'),
     dict(VIEW_NAME_SUFFIX='TextView',
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -5113,16 +5113,29 @@
   },
   "MISBEHAVING_ADDONS_JANK_LEVEL": {
     "expires_in_version": "never",
     "kind": "enumerated",
     "n_values": 10,
     "keyed": true,
     "description": "Longest blocking operation performed by the add-on (log2(duration in ms), keyed by add-on, updated every 15s by default)"
   },
+  "SLOW_ADDON_WARNING_STATES": {
+    "expires_in_version": "never",
+    "kind": "enumerated",
+    "n_values": 20,
+    "description": "The states the Slow Add-on Warning goes through. 0: Displayed the warning. 1: User clicked on 'Disable add-on'. 2: User clicked 'Ignore add-on for now'. 3: User clicked 'Ignore add-on permanently'. 4: User closed notification. Other values are reserved for future uses."
+  },
+  "SLOW_ADDON_WARNING_RESPONSE_TIME": {
+    "expires_in_version": "never",
+    "kind": "exponential",
+    "high": "86400000",
+    "n_buckets": 30,
+    "description": "Time elapsed between before responding to Slow Add-on Warning UI (ms). Not updated if the user doesn't respond at all."
+  },
   "SEARCH_COUNTS": {
     "expires_in_version": "never",
     "kind": "count",
     "keyed": true,
     "releaseChannelCollection": "opt-out",
     "description": "Record the search counts for search engines"
   },
   "SEARCH_SERVICE_INIT_MS": {
@@ -8760,16 +8773,80 @@
   "GRAPHICS_SANITY_TEST_REASON": {
     "alert_emails": ["danderson@mozilla.com"],
     "expires_in_version": "43",
     "kind": "enumerated",
     "n_values": 20,
     "releaseChannelCollection": "opt-out",
     "description": "Reports why a graphics sanity test was run. 0=First Run, 1=App Updated, 2=Device Change, 3=Driver Change."
   },
+  "TRANSLATION_OPPORTUNITIES": {
+    "expires_in_version": "default",
+    "kind": "boolean",
+    "description": "A number of successful and failed attempts to translate a document"
+  },
+  "TRANSLATION_OPPORTUNITIES_BY_LANGUAGE": {
+    "expires_in_version": "default",
+    "kind": "boolean",
+    "keyed": true,
+    "description": "A number of successful and failed attempts to translate a document grouped by language"
+  },
+  "TRANSLATED_PAGES": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "description": "A number of sucessfully translated pages"
+  },
+  "TRANSLATED_PAGES_BY_LANGUAGE": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "keyed": true,
+    "description": "A number of sucessfully translated pages by language"
+  },
+  "TRANSLATED_CHARACTERS": {
+    "expires_in_version": "default",
+    "kind": "exponential",
+    "high": "10 * 1024",
+    "n_buckets": 50,
+    "description": "A number of sucessfully translated characters"
+  },
+  "DENIED_TRANSLATION_OFFERS": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "description": "A number of tranlation offers the user denied"
+  },
+  "AUTO_REJECTED_TRANSLATION_OFFERS": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "description": "A number of auto-rejected tranlation offers"
+  },
+  "REQUESTS_OF_ORIGINAL_CONTENT": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "description": "A number of times the user requested to see the original content of a translated page"
+  },
+  "CHANGES_OF_TARGET_LANGUAGE": {
+    "expires_in_version": "default",
+    "kind": "count",
+    "description": "A number of times when the target language was changed by the user"
+  },
+  "CHANGES_OF_DETECTED_LANGUAGE": {
+    "expires_in_version": "default",
+    "kind": "boolean",
+    "description": "A number of changes of detected language before (true) or after (false) translating a page for the first time."
+  },
+  "SHOULD_TRANSLATION_UI_APPEAR": {
+    "expires_in_version": "default",
+    "kind": "flag",
+    "description": "Tracks situations when the user opts for displaying translation UI"
+  },
+  "SHOULD_AUTO_DETECT_LANGUAGE": {
+    "expires_in_version": "default",
+    "kind": "flag",
+    "description": "Tracks situations when the user opts for auto-detecting the language of a page"
+  },
   "PERMISSIONS_REMIGRATION_COMPARISON": {
     "alert_emails": ["michael@thelayzells.com"],
     "expires_in_version": "44",
     "kind": "enumerated",
     "n_values": 10,
     "description": "Reports a comparison between row count of original and re-migration of the v7 permissions DB. 0=New == 0, 1=New < Old, 2=New == Old, 3=New > Old"
   },
   "PERMISSIONS_MIGRATION_7_ERROR": {
--- a/toolkit/components/telemetry/TelemetryEnvironment.jsm
+++ b/toolkit/components/telemetry/TelemetryEnvironment.jsm
@@ -67,84 +67,85 @@ this.TelemetryEnvironment = {
 
   unregisterChangeListener: function(name) {
     return getGlobal().unregisterChangeListener(name);
   },
 
   // Policy to use when saving preferences. Exported for using them in tests.
   RECORD_PREF_STATE: 1, // Don't record the preference value
   RECORD_PREF_VALUE: 2, // We only record user-set prefs.
-  RECORD_PREF_NOTIFY_ONLY: 3, // Record nothing, just notify of changes.
 
   // Testing method
   _watchPreferences: function(prefMap) {
     return getGlobal()._watchPreferences(prefMap);
   },
 };
 
+const RECORD_PREF_STATE = TelemetryEnvironment.RECORD_PREF_STATE;
+const RECORD_PREF_VALUE = TelemetryEnvironment.RECORD_PREF_VALUE;
 const DEFAULT_ENVIRONMENT_PREFS = new Map([
-  ["app.feedback.baseURL", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.support.baseURL", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["accessibility.browsewithcaret", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["accessibility.force_disabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.auto", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.interval", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.service.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.silent", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["app.update.url", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.cache.disk.enable", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.cache.disk.capacity", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.cache.memory.enable", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.cache.offline.enable", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.formfill.enable", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.newtab.url", TelemetryEnvironment.RECORD_PREF_STATE],
-  ["browser.newtabpage.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.newtabpage.enhanced", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.polaris.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.shell.checkDefaultBrowser", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["browser.startup.homepage", TelemetryEnvironment.RECORD_PREF_STATE],
-  ["browser.startup.page", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["devtools.chrome.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["devtools.debugger.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["devtools.debugger.remote-enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["dom.ipc.plugins.asyncInit", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["dom.ipc.plugins.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["dom.ipc.processCount", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["experiments.manifest.uri", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.blocklist.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.blocklist.url", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.strictCompatibility", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.update.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.update.url", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["extensions.update.background.url", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["general.smoothScroll", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["gfx.direct2d.disabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["gfx.direct2d.force-enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["gfx.direct2d.use1_1", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.acceleration.disabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.acceleration.force-enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.async-pan-zoom.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.async-video-oop.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.async-video.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.componentalpha.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.d3d11.disable-warp", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.d3d11.force-warp", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.offmainthreadcomposition.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.prefer-d3d9", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layers.prefer-opengl", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["layout.css.devPixelsPerPx", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["network.proxy.autoconfig_url", TelemetryEnvironment.RECORD_PREF_STATE],
-  ["network.proxy.http", TelemetryEnvironment.RECORD_PREF_STATE],
-  ["network.proxy.ssl", TelemetryEnvironment.RECORD_PREF_STATE],
-  ["pdfjs.disabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["places.history.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["privacy.trackingprotection.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["privacy.donottrackheader.enabled", TelemetryEnvironment.RECORD_PREF_VALUE],
-  ["services.sync.serverURL", TelemetryEnvironment.RECORD_PREF_STATE],
+  ["app.feedback.baseURL", {what: RECORD_PREF_VALUE}],
+  ["app.support.baseURL", {what: RECORD_PREF_VALUE}],
+  ["accessibility.browsewithcaret", {what: RECORD_PREF_VALUE}],
+  ["accessibility.force_disabled", {what:  RECORD_PREF_VALUE}],
+  ["app.update.auto", {what: RECORD_PREF_VALUE}],
+  ["app.update.enabled", {what: RECORD_PREF_VALUE}],
+  ["app.update.interval", {what: RECORD_PREF_VALUE}],
+  ["app.update.service.enabled", {what: RECORD_PREF_VALUE}],
+  ["app.update.silent", {what: RECORD_PREF_VALUE}],
+  ["app.update.url", {what: RECORD_PREF_VALUE}],
+  ["browser.cache.disk.enable", {what: RECORD_PREF_VALUE}],
+  ["browser.cache.disk.capacity", {what: RECORD_PREF_VALUE}],
+  ["browser.cache.memory.enable", {what: RECORD_PREF_VALUE}],
+  ["browser.cache.offline.enable", {what: RECORD_PREF_VALUE}],
+  ["browser.formfill.enable", {what: RECORD_PREF_VALUE}],
+  ["browser.newtab.url", {what: RECORD_PREF_STATE}],
+  ["browser.newtabpage.enabled", {what: RECORD_PREF_VALUE}],
+  ["browser.newtabpage.enhanced", {what: RECORD_PREF_VALUE}],
+  ["browser.polaris.enabled", {what: RECORD_PREF_VALUE}],
+  ["browser.shell.checkDefaultBrowser", {what: RECORD_PREF_VALUE}],
+  ["browser.startup.homepage", {what: RECORD_PREF_STATE}],
+  ["browser.startup.page", {what: RECORD_PREF_VALUE}],
+  ["devtools.chrome.enabled", {what: RECORD_PREF_VALUE}],
+  ["devtools.debugger.enabled", {what: RECORD_PREF_VALUE}],
+  ["devtools.debugger.remote-enabled", {what: RECORD_PREF_VALUE}],
+  ["dom.ipc.plugins.asyncInit", {what: RECORD_PREF_VALUE}],
+  ["dom.ipc.plugins.enabled", {what: RECORD_PREF_VALUE}],
+  ["dom.ipc.processCount", {what: RECORD_PREF_VALUE, requiresRestart: true}],
+  ["experiments.manifest.uri", {what: RECORD_PREF_VALUE}],
+  ["extensions.blocklist.enabled", {what: RECORD_PREF_VALUE}],
+  ["extensions.blocklist.url", {what: RECORD_PREF_VALUE}],
+  ["extensions.strictCompatibility", {what: RECORD_PREF_VALUE}],
+  ["extensions.update.enabled", {what: RECORD_PREF_VALUE}],
+  ["extensions.update.url", {what: RECORD_PREF_VALUE}],
+  ["extensions.update.background.url", {what: RECORD_PREF_VALUE}],
+  ["general.smoothScroll", {what: RECORD_PREF_VALUE}],
+  ["gfx.direct2d.disabled", {what: RECORD_PREF_VALUE}],
+  ["gfx.direct2d.force-enabled", {what: RECORD_PREF_VALUE}],
+  ["gfx.direct2d.use1_1", {what: RECORD_PREF_VALUE}],
+  ["layers.acceleration.disabled", {what: RECORD_PREF_VALUE}],
+  ["layers.acceleration.force-enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.async-pan-zoom.enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.async-video-oop.enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.async-video.enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.componentalpha.enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.d3d11.disable-warp", {what: RECORD_PREF_VALUE}],
+  ["layers.d3d11.force-warp", {what: RECORD_PREF_VALUE}],
+  ["layers.offmainthreadcomposition.enabled", {what: RECORD_PREF_VALUE}],
+  ["layers.prefer-d3d9", {what: RECORD_PREF_VALUE}],
+  ["layers.prefer-opengl", {what: RECORD_PREF_VALUE}],
+  ["layout.css.devPixelsPerPx", {what: RECORD_PREF_VALUE}],
+  ["network.proxy.autoconfig_url", {what: RECORD_PREF_STATE}],
+  ["network.proxy.http", {what: RECORD_PREF_STATE}],
+  ["network.proxy.ssl", {what: RECORD_PREF_STATE}],
+  ["pdfjs.disabled", {what: RECORD_PREF_VALUE}],
+  ["places.history.enabled", {what: RECORD_PREF_VALUE}],
+  ["privacy.trackingprotection.enabled", {what: RECORD_PREF_VALUE}],
+  ["privacy.donottrackheader.enabled", {what: RECORD_PREF_VALUE}],
+  ["services.sync.serverURL", {what: RECORD_PREF_STATE}],
 ]);
 
 const LOGGER_NAME = "Toolkit.Telemetry";
 
 const PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
 const PREF_DISTRIBUTION_ID = "distribution.id";
 const PREF_DISTRIBUTION_VERSION = "distribution.version";
 const PREF_DISTRIBUTOR = "app.distributor";
@@ -757,61 +758,64 @@ EnvironmentCache.prototype = {
    * Get an object containing the values for the watched preferences. Depending on the
    * policy, the value for a preference or whether it was changed by user is reported.
    *
    * @return An object containing the preferences values.
    */
   _getPrefData: function () {
     let prefData = {};
     for (let [pref, policy] of this._watchedPrefs.entries()) {
-      // Only record preferences if they are non-default and policy allows recording.
-      if (!Preferences.isSet(pref) ||
-          policy == TelemetryEnvironment.RECORD_PREF_NOTIFY_ONLY) {
+      // Only record preferences if they are non-default
+      if (!Preferences.isSet(pref)) {
         continue;
       }
-
+      
       // Check the policy for the preference and decide if we need to store its value
       // or whether it changed from the default value.
       let prefValue = undefined;
-      if (policy == TelemetryEnvironment.RECORD_PREF_STATE) {
+      if (policy.what == TelemetryEnvironment.RECORD_PREF_STATE) {
         prefValue = "<user-set>";
       } else {
         prefValue = Preferences.get(pref, null);
       }
       prefData[pref] = prefValue;
     }
     return prefData;
   },
 
   /**
    * Start watching the preferences.
    */
   _startWatchingPrefs: function () {
     this._log.trace("_startWatchingPrefs - " + this._watchedPrefs);
 
-    for (let pref of this._watchedPrefs.keys()) {
-      Preferences.observe(pref, this._onPrefChanged, this);
+    for (let [pref, options] of this._watchedPrefs) {
+      if(!("requiresRestart" in options) || !options.requiresRestart) {
+        Preferences.observe(pref, this._onPrefChanged, this);
+      }
     }
   },
 
   _onPrefChanged: function() {
     this._log.trace("_onPrefChanged");
     let oldEnvironment = Cu.cloneInto(this._currentEnvironment, myScope);
     this._updateSettings();
     this._onEnvironmentChange("pref-changed", oldEnvironment);
   },
 
   /**
    * Do not receive any more change notifications for the preferences.
    */
   _stopWatchingPrefs: function () {
     this._log.trace("_stopWatchingPrefs");
 
-    for (let pref of this._watchedPrefs.keys()) {
-      Preferences.ignore(pref, this._onPrefChanged, this);
+    for (let [pref, options] of this._watchedPrefs) {
+      if(!("requiresRestart" in options) || !options.requiresRestart) {
+        Preferences.ignore(pref, this._onPrefChanged, this);
+      }
     }
   },
 
   _addObservers: function () {
     // Watch the search engine change and service topics.
     Services.obs.addObserver(this, SEARCH_ENGINE_MODIFIED_TOPIC, false);
     Services.obs.addObserver(this, SEARCH_SERVICE_TOPIC, false);
     Services.obs.addObserver(this, COMPOSITOR_CREATED_TOPIC, false);
--- a/toolkit/components/telemetry/tests/unit/test_SubsessionChaining.js
+++ b/toolkit/components/telemetry/tests/unit/test_SubsessionChaining.js
@@ -100,17 +100,17 @@ function run_test() {
 add_task(function* test_subsessionsChaining() {
   if (gIsAndroid) {
     // We don't support subsessions yet on Android, so skip the next checks.
     return;
   }
 
   const PREF_TEST = PREF_BRANCH + "test.pref1";
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_VALUE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
   ]);
   Preferences.reset(PREF_TEST);
 
   // Fake the clock data to manually trigger an aborted-session ping and a daily ping.
   // This is also helpful to make sure we get the archived pings in an expected order.
   let now = fakeNow(2009, 9, 18, 0, 0, 0);
 
   let moveClockForward = (minutes) => {
--- a/toolkit/components/telemetry/tests/unit/test_TelemetryEnvironment.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetryEnvironment.js
@@ -635,66 +635,74 @@ add_task(function* test_checkEnvironment
   checkEnvironmentData(environmentData);
 });
 
 add_task(function* test_prefWatchPolicies() {
   const PREF_TEST_1 = "toolkit.telemetry.test.pref_new";
   const PREF_TEST_2 = "toolkit.telemetry.test.pref1";
   const PREF_TEST_3 = "toolkit.telemetry.test.pref2";
   const PREF_TEST_4 = "toolkit.telemetry.test.pref_old";
+  const PREF_TEST_5 = "toolkit.telemetry.test.requiresRestart";
 
   const expectedValue = "some-test-value";
+  const unexpectedValue = "unexpected-test-value";
   gNow = futureDate(gNow, 10 * MILLISECONDS_PER_MINUTE);
   fakeNow(gNow);
 
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST_1, TelemetryEnvironment.RECORD_PREF_VALUE],
-    [PREF_TEST_2, TelemetryEnvironment.RECORD_PREF_STATE],
-    [PREF_TEST_3, TelemetryEnvironment.RECORD_PREF_STATE],
-    [PREF_TEST_4, TelemetryEnvironment.RECORD_PREF_VALUE],
+    [PREF_TEST_1, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
+    [PREF_TEST_2, {what: TelemetryEnvironment.RECORD_PREF_STATE}],
+    [PREF_TEST_3, {what: TelemetryEnvironment.RECORD_PREF_STATE}],
+    [PREF_TEST_4, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
+    [PREF_TEST_5, {what: TelemetryEnvironment.RECORD_PREF_VALUE, requiresRestart: true}],
   ]);
 
   Preferences.set(PREF_TEST_4, expectedValue);
+  Preferences.set(PREF_TEST_5, expectedValue);
 
   // Set the Environment preferences to watch.
   TelemetryEnvironment._watchPreferences(PREFS_TO_WATCH);
   let deferred = PromiseUtils.defer();
 
   // Check that the pref values are missing or present as expected
   Assert.strictEqual(TelemetryEnvironment.currentEnvironment.settings.userPrefs[PREF_TEST_1], undefined);
   Assert.strictEqual(TelemetryEnvironment.currentEnvironment.settings.userPrefs[PREF_TEST_4], expectedValue);
+  Assert.strictEqual(TelemetryEnvironment.currentEnvironment.settings.userPrefs[PREF_TEST_5], expectedValue);
 
   TelemetryEnvironment.registerChangeListener("testWatchPrefs",
     (reason, data) => deferred.resolve(data));
   let oldEnvironmentData = TelemetryEnvironment.currentEnvironment;
 
   // Trigger a change in the watched preferences.
   Preferences.set(PREF_TEST_1, expectedValue);
   Preferences.set(PREF_TEST_2, false);
+  Preferences.set(PREF_TEST_5, unexpectedValue);
   let eventEnvironmentData = yield deferred.promise;
 
   // Unregister the listener.
   TelemetryEnvironment.unregisterChangeListener("testWatchPrefs");
 
   // Check environment contains the correct data.
   Assert.deepEqual(oldEnvironmentData, eventEnvironmentData);
   let userPrefs = TelemetryEnvironment.currentEnvironment.settings.userPrefs;
 
   Assert.equal(userPrefs[PREF_TEST_1], expectedValue,
                "Environment contains the correct preference value.");
   Assert.equal(userPrefs[PREF_TEST_2], "<user-set>",
                "Report that the pref was user set but the value is not shown.");
   Assert.ok(!(PREF_TEST_3 in userPrefs),
             "Do not report if preference not user set.");
+  Assert.equal(userPrefs[PREF_TEST_5], expectedValue,
+	      "The pref value in the environment data should still be the same");
 });
 
 add_task(function* test_prefWatch_prefReset() {
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_STATE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_STATE}],
   ]);
 
   // Set the preference to a non-default value.
   Preferences.set(PREF_TEST, false);
 
   gNow = futureDate(gNow, 10 * MILLISECONDS_PER_MINUTE);
   fakeNow(gNow);
 
@@ -968,17 +976,17 @@ add_task(function* test_signedAddon() {
   for (let f in EXPECTED_ADDON_DATA) {
     Assert.equal(targetAddon[f], EXPECTED_ADDON_DATA[f], f + " must have the correct value.");
   }
 });
 
 add_task(function* test_changeThrottling() {
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_STATE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_STATE}],
   ]);
   Preferences.reset(PREF_TEST);
 
   gNow = futureDate(gNow, 10 * MILLISECONDS_PER_MINUTE);
   fakeNow(gNow);
 
   // Set the Environment preferences to watch.
   TelemetryEnvironment._watchPreferences(PREFS_TO_WATCH);
@@ -1077,17 +1085,17 @@ add_task(function* test_defaultSearchEng
     loadPath: "[profile]/searchplugins/telemetrydefault.xml"
   };
   Assert.deepEqual(data.settings.defaultSearchEngineData, EXPECTED_SEARCH_ENGINE_DATA);
   TelemetryEnvironment.unregisterChangeListener("testWatch_SearchDefault");
 
   // Define and reset the test preference.
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_STATE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_STATE}],
   ]);
   Preferences.reset(PREF_TEST);
 
   // Set the clock in the future so our changes don't get throttled.
   gNow = fakeNow(futureDate(gNow, 10 * MILLISECONDS_PER_MINUTE));
   // Watch the test preference.
   TelemetryEnvironment._watchPreferences(PREFS_TO_WATCH);
   deferred = PromiseUtils.defer();
--- a/toolkit/components/telemetry/tests/unit/test_TelemetrySendOldPings.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetrySendOldPings.js
@@ -33,16 +33,18 @@ const OVERDUE_PING_FILE_AGE = TelemetryS
 const PING_SAVE_FOLDER = "saved-telemetry-pings";
 const PING_TIMEOUT_LENGTH = 5000;
 const OVERDUE_PINGS = 6;
 const OLD_FORMAT_PINGS = 4;
 const RECENT_PINGS = 4;
 
 const TOTAL_EXPECTED_PINGS = OVERDUE_PINGS + RECENT_PINGS + OLD_FORMAT_PINGS;
 
+const PREF_FHR_UPLOAD = "datareporting.healthreport.uploadEnabled";
+
 let gCreatedPings = 0;
 let gSeenPings = 0;
 
 /**
  * Creates some Telemetry pings for the and saves them to disk. Each ping gets a
  * unique ID based on an incrementor.
  *
  * @param {Array} aPingInfos An array of ping type objects. Each entry must be an
@@ -54,17 +56,17 @@ let gSeenPings = 0;
  * @resolve an Array with the created pings ids.
  */
 let createSavedPings = Task.async(function* (aPingInfos) {
   let pingIds = [];
   let now = Date.now();
 
   for (let type in aPingInfos) {
     let num = aPingInfos[type].num;
-    let age = now - aPingInfos[type].age;
+    let age = now - (aPingInfos[type].age || 0);
     for (let i = 0; i < num; ++i) {
       let pingId = yield TelemetryController.addPendingPing("test-ping", {}, { overwrite: true });
       if (aPingInfos[type].age) {
         // savePing writes to the file synchronously, so we're good to
         // modify the lastModifedTime now.
         let filePath = getSavePathForPingId(pingId);
         yield File.setDates(filePath, null, age);
       }
@@ -174,16 +176,19 @@ function run_test() {
   run_next_test();
 }
 
 /**
  * Setup the tests by making sure the ping storage directory is available, otherwise
  * |TelemetryController.testSaveDirectoryToFile| could fail.
  */
 add_task(function* setupEnvironment() {
+  // The following tests assume this pref to be true by default.
+  Services.prefs.setBoolPref(PREF_FHR_UPLOAD, true);
+
   yield TelemetryController.setup();
 
   let directory = TelemetryStorage.pingDirectoryPath;
   yield File.makeDir(directory, { ignoreExisting: true, unixMode: OS.Constants.S_IRWXU });
 
   yield clearPendingPings();
 });
 
@@ -395,17 +400,16 @@ add_task(function* test_overdue_old_form
   Assert.equal(receivedPings, 1, "We must receive a ping in the old format.");
 
   yield clearPendingPings();
   PingServer.resetPingHandler();
 });
 
 add_task(function* test_pendingPingsQuota() {
   const PING_TYPE = "foo";
-  const PREF_FHR_UPLOAD = "datareporting.healthreport.uploadEnabled";
 
   // Disable upload so pings don't get sent and removed from the pending pings directory.
   Services.prefs.setBoolPref(PREF_FHR_UPLOAD, false);
 
   // Remove all the pending pings then startup and wait for the cleanup task to complete.
   // There should be nothing to remove.
   yield clearPendingPings();
   yield TelemetryController.reset();
--- a/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js
+++ b/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js
@@ -1043,17 +1043,17 @@ add_task(function* test_environmentChang
   PingServer.clearRequests();
 
   fakeNow(now);
 
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   Preferences.reset(PREF_TEST);
 
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_VALUE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
   ]);
 
   // Setup.
   yield TelemetrySession.setup();
   TelemetrySend.setServer("http://localhost:" + PingServer.port);
   TelemetryEnvironment._watchPreferences(PREFS_TO_WATCH);
 
   // Set histograms to expected state.
@@ -1148,17 +1148,17 @@ add_task(function* test_savedSessionData
     subsessionId: null,
     profileSubsessionCounter: 3785,
   };
   yield CommonUtils.writeJSON(sessionState, dataFilePath);
 
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   Preferences.reset(PREF_TEST);
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_VALUE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
   ]);
 
   // We expect one new subsession when starting TelemetrySession and one after triggering
   // an environment change.
   const expectedSubsessions = sessionState.profileSubsessionCounter + 2;
   const expectedSessionUUID = "ff602e52-47a1-b7e8-4c1a-ffffffffc87a";
   const expectedSubsessionUUID = "009fd1ad-b85e-4817-b3e5-000000003785";
   fakeGenerateUUID(() => expectedSessionUUID, () => expectedSubsessionUUID);
@@ -1503,17 +1503,17 @@ add_task(function* test_schedulerEnviron
     // We don't have the aborted session or the daily ping here.
     return;
   }
 
   // Reset the test preference.
   const PREF_TEST = "toolkit.telemetry.test.pref1";
   Preferences.reset(PREF_TEST);
   const PREFS_TO_WATCH = new Map([
-    [PREF_TEST, TelemetryEnvironment.RECORD_PREF_VALUE],
+    [PREF_TEST, {what: TelemetryEnvironment.RECORD_PREF_VALUE}],
   ]);
 
   yield clearPendingPings();
   PingServer.clearRequests();
 
   // Set a fake current date and start Telemetry.
   let nowDate = new Date(2060, 10, 18, 0, 0, 0);
   fakeNow(nowDate);
--- a/toolkit/content/widgets/remote-browser.xml
+++ b/toolkit/content/widgets/remote-browser.xml
@@ -401,17 +401,21 @@
                                                    aDisabledLength, aDisabledCommands);
           }
         </body>
       </method>
 
       <method name="purgeSessionHistory">
         <body>
           <![CDATA[
-            this.messageManager.sendAsyncMessage("Browser:PurgeSessionHistory");
+            try {
+              this.messageManager.sendAsyncMessage("Browser:PurgeSessionHistory");
+            } catch (ex if ex.result == Components.results.NS_ERROR_NOT_INITIALIZED) {
+              // This can throw if the browser has started to go away.
+            }
             this.webNavigation.canGoBack = false;
             this.webNavigation.canGoForward = false;
           ]]>
         </body>
       </method>
     </implementation>
 
   </binding>
--- a/toolkit/devtools/server/actors/animation.js
+++ b/toolkit/devtools/server/actors/animation.js
@@ -630,21 +630,23 @@ let AnimationsActor = exports.Animations
         // are finished and don't have forwards animation-fill-mode.
         // In the latter case, we don't send an event, because the corresponding
         // animation can still be seeked/resumed, so we want the client to keep
         // its reference to the AnimationPlayerActor.
         if (player.playState !== "idle") {
           continue;
         }
         let index = this.actors.findIndex(a => a.player === player);
-        eventData.push({
-          type: "removed",
-          player: this.actors[index]
-        });
-        this.actors.splice(index, 1);
+        if (index !== -1) {
+          eventData.push({
+            type: "removed",
+            player: this.actors[index]
+          });
+          this.actors.splice(index, 1);
+        }
       }
 
       for (let player of addedAnimations) {
         // If the added player already exists, it means we previously filtered
         // it out when it was reported as removed. So filter it out here too.
         if (this.actors.find(a => a.player === player)) {
           continue;
         }
@@ -781,16 +783,36 @@ let AnimationsActor = exports.Animations
   toggleAll: method(function() {
     if (this.allAnimationsPaused) {
       return this.playAll();
     }
     return this.pauseAll();
   }, {
     request: {},
     response: {}
+  }),
+
+  /**
+   * Set the current time of several animations at the same time.
+   * @param {Array} players A list of AnimationPlayerActor.
+   * @param {Number} time The new currentTime.
+   * @param {Boolean} shouldPause Should the players be paused too.
+   */
+  setCurrentTimes: method(function(players, time, shouldPause) {
+    return promise.all(players.map(player => {
+      let pause = shouldPause ? player.pause() : promise.resolve();
+      return pause.then(() => player.setCurrentTime(time));
+    }));
+  }, {
+    request: {
+      players: Arg(0, "array:animationplayer"),
+      time: Arg(1, "number"),
+      shouldPause: Arg(2, "boolean")
+    },
+    response: {}
   })
 });
 
 let AnimationsFront = exports.AnimationsFront = FrontClass(AnimationsActor, {
   initialize: function(client, {animationsActor}) {
     Front.prototype.initialize.call(this, client, {actor: animationsActor});
     this.manage(this);
   },
--- a/toolkit/devtools/server/actors/performance-recording.js
+++ b/toolkit/devtools/server/actors/performance-recording.js
@@ -231,16 +231,24 @@ let PerformanceRecordingFront = exports.
     this._localStartTime = form.localStartTime;
     this._recording = form.recording;
     this._completed = form.completed;
     this._duration = form.duration;
 
     if (form.profile) {
       this._profile = form.profile;
     }
+
+    // Sort again on the client side if we're using realtime markers and the recording
+    // just finished. This is because GC/Compositing markers can come into the array out of order with
+    // the other markers, leading to strange collapsing in waterfall view.
+    if (this._completed && !this._markersSorted) {
+      this._markers = this._markers.sort((a, b) => (a.start > b.start));
+      this._markersSorted = true;
+    }
   },
 
   initialize: function (client, form, config) {
     protocol.Front.prototype.initialize.call(this, client, form);
     this._markers = [];
     this._frames = [];
     this._memory = [];
     this._ticks = [];
--- a/toolkit/devtools/server/tests/browser/browser_animation_actors_11.js
+++ b/toolkit/devtools/server/tests/browser/browser_animation_actors_11.js
@@ -1,29 +1,38 @@
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-// Check that a player's currentTime can be changed.
+// Check that a player's currentTime can be changed and that the AnimationsActor
+// allows changing many players' currentTimes at once.
 
 const {AnimationsFront} = require("devtools/server/actors/animation");
 const {InspectorFront} = require("devtools/server/actors/inspector");
 
 add_task(function*() {
-  let doc = yield addTab(MAIN_DOMAIN + "animation.html");
+  yield addTab(MAIN_DOMAIN + "animation.html");
 
   initDebuggerServer();
   let client = new DebuggerClient(DebuggerServer.connectPipe());
   let form = yield connectDebuggerClient(client);
   let inspector = InspectorFront(client, form);
   let walker = yield inspector.getWalker();
   let animations = AnimationsFront(client, form);
 
+  yield testSetCurrentTime(walker, animations);
+  yield testSetCurrentTimes(walker, animations);
+
+  yield closeDebuggerClient(client);
+  gBrowser.removeCurrentTab();
+});
+
+function* testSetCurrentTime(walker, animations) {
   info("Retrieve an animated node");
   let node = yield walker.querySelector(walker.rootNode, ".simple-animation");
 
   info("Retrieve the animation player for the node");
   let [player] = yield animations.getAnimationPlayersForNode(node);
 
   ok(player.setCurrentTime, "Player has the setCurrentTime method");
 
@@ -44,12 +53,30 @@ add_task(function*() {
   is(Math.round(updatedState1.currentTime - pausedState.currentTime), 5000,
     "The currentTime was updated to +5s");
 
   info("Set the current time to currentTime - 2s");
   yield player.setCurrentTime(updatedState1.currentTime - 2000);
   let updatedState2 = yield player.getCurrentState();
   is(Math.round(updatedState2.currentTime - updatedState1.currentTime), -2000,
     "The currentTime was updated to -2s");
+}
 
-  yield closeDebuggerClient(client);
-  gBrowser.removeCurrentTab();
-});
+function* testSetCurrentTimes(walker, animations) {
+  ok(animations.setCurrentTimes, "The AnimationsActor has the right method");
+
+  info("Retrieve multiple animated nodes and their animation players");
+  let node1 = yield walker.querySelector(walker.rootNode, ".simple-animation");
+  let player1 = (yield animations.getAnimationPlayersForNode(node1))[0];
+  let node2 = yield walker.querySelector(walker.rootNode, ".delayed-animation");
+  let player2 = (yield animations.getAnimationPlayersForNode(node2))[0];
+
+  info("Try to set multiple current times at once");
+  yield animations.setCurrentTimes([player1, player2], 500, true);
+
+  info("Get the states of both players and verify their correctness");
+  let state1 = yield player1.getCurrentState();
+  let state2 = yield player2.getCurrentState();
+  is(state1.playState, "paused", "Player 1 is paused");
+  is(state2.playState, "paused", "Player 2 is paused");
+  is(state1.currentTime, 500, "Player 1 has the right currentTime");
+  is(state2.currentTime, 500, "Player 2 has the right currentTime");
+}
--- a/toolkit/devtools/server/tests/browser/browser_markers-gc.js
+++ b/toolkit/devtools/server/tests/browser/browser_markers-gc.js
@@ -20,11 +20,28 @@ add_task(function*() {
 
   let markers = yield waitForMarkerType(front, MARKER_NAME);
   yield front.stopRecording(rec);
 
   ok(markers.some(m => m.name === MARKER_NAME), `got some ${MARKER_NAME} markers`);
   ok(markers.every(({causeName}) => typeof causeName === "string"),
     "All markers have a causeName.");
 
+  markers = rec.getMarkers();
+
+  // Bug 1197646
+  let ordered = true;
+  markers.reduce((previousStart, current, i) => {
+    if (i === 0) {
+      return current.start;
+    }
+    if (current.start < previousStart) {
+      ok(false, `markers must be in order. ${current.name} marker has later start time (${current.start}) thanprevious: ${previousStart}`);
+      ordered = false;
+    }
+    return current.start;
+  });
+
+  is(ordered, true, "All GC and non-GC markers are in order by start time.");
+
   yield closeDebuggerClient(client);
   gBrowser.removeCurrentTab();
 });
--- a/toolkit/devtools/server/tests/browser/doc_force_gc.html
+++ b/toolkit/devtools/server/tests/browser/doc_force_gc.html
@@ -5,17 +5,23 @@
 <html>
   <head>
     <meta charset="utf-8"/>
     <title>Performance tool + garbage collection test page</title>
   </head>
 
   <body>
     <script type="text/javascript">
+    var x = 1;
     window.test = function () {
       SpecialPowers.Cu.forceGC();
+      document.body.style.borderTop = x + "px solid red";
+      x = 1^x;
+      document.body.innerHeight; // flush pending reflows
+
+      // Prevent this script from being garbage collected.
       setTimeout(window.test, 100);
     };
     test();
     </script>
   </body>
 
 </html>
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -299,16 +299,47 @@ function getLocale() {
     return Services.prefs.getCharPref(PREF_SELECTED_LOCALE);
   }
   catch (e) { }
 
   return "en-US";
 }
 
 /**
+ * Previously the APIs for installing add-ons from webpages accepted nsIURI
+ * arguments for the installing page. They now take an nsIPrincipal but for now
+ * maintain backwards compatibility by converting an nsIURI to an nsIPrincipal.
+ *
+ * @param  aPrincipalOrURI
+ *         The argument passed to the API function. Can be null, an nsIURI or
+ *         an nsIPrincipal.
+ * @return an nsIPrincipal.
+ */
+function ensurePrincipal(principalOrURI) {
+  if (principalOrURI instanceof Ci.nsIPrincipal)
+    return principalOrURI;
+
+  logger.warn("Deprecated API call, please pass a non-null nsIPrincipal instead of an nsIURI");
+
+  // Previously a null installing URI meant allowing the install regardless.
+  if (!principalOrURI) {
+    return Services.scriptSecurityManager.getSystemPrincipal();
+  }
+
+  if (principalOrURI instanceof Ci.nsIURI) {
+    return Services.scriptSecurityManager.createCodebasePrincipal(principalOrURI, {
+      inBrowser: true
+    });
+  }
+
+  // Just return whatever we have, the API method will log an error about it.
+  return principalOrURI;
+}
+
+/**
  * A helper class to repeatedly call a listener with each object in an array
  * optionally checking whether the object has a method in it.
  *
  * @param  aObjects
  *         The array of objects to iterate through
  * @param  aMethod
  *         An optional method name, if not null any objects without this method
  *         will not be passed to the listener
@@ -345,16 +376,110 @@ AsyncObjectCaller.prototype = {
     if (!this.method || this.method in object)
       this.listener.nextObject(this, object);
     else
       this.callNext();
   }
 };
 
 /**
+ * Listens for a browser changing origin and cancels the installs that were
+ * started by it.
+ */
+function BrowserListener(aBrowser, aInstallingPrincipal, aInstalls) {
+  this.browser = aBrowser;
+  this.principal = aInstallingPrincipal;
+  this.installs = aInstalls;
+  this.installCount = aInstalls.length;
+
+  aBrowser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+  Services.obs.addObserver(this, "message-manager-close", true);
+
+  for (let install of this.installs)
+    install.addListener(this);
+
+  this.registered = true;
+}
+
+BrowserListener.prototype = {
+  browser: null,
+  installs: null,
+  installCount: null,
+  registered: false,
+
+  unregister: function() {
+    if (!this.registered)
+      return;
+    this.registered = false;
+
+    Services.obs.removeObserver(this, "message-manager-close");
+    // The browser may have already been detached
+    if (this.browser.removeProgressListener)
+      this.browser.removeProgressListener(this);
+
+    for (let install of this.installs)
+      install.removeListener(this);
+    this.installs = null;
+  },
+
+  cancelInstalls: function() {
+    for (let install of this.installs) {
+      try {
+        install.cancel();
+      }
+      catch (e) {
+        // Some installs may have already failed or been cancelled, ignore these
+      }
+    }
+  },
+
+  observe: function(subject, topic, data) {
+    if (subject != this.browser.messageManager)
+      return;
+
+    // The browser's message manager has closed and so the browser is
+    // going away, cancel all installs
+    this.cancelInstalls();
+  },
+
+  onLocationChange: function(webProgress, request, location) {
+    if (this.browser.contentPrincipal && this.principal.subsumes(this.browser.contentPrincipal))
+      return;
+
+    // The browser has navigated to a new origin so cancel all installs
+    this.cancelInstalls();
+  },
+
+  onDownloadCancelled: function(install) {
+    // Don't need to hear more events from this install
+    install.removeListener(this);
+
+    // Once all installs have ended unregister everything
+    if (--this.installCount == 0)
+      this.unregister();
+  },
+
+  onDownloadFailed: function(install) {
+    this.onDownloadCancelled(install);
+  },
+
+  onInstallFailed: function(install) {
+    this.onDownloadCancelled(install);
+  },
+
+  onInstallEnded: function(install) {
+    this.onDownloadCancelled(install);
+  },
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupportsWeakReference,
+                                         Ci.nsIWebProgressListener,
+                                         Ci.nsIObserver])
+};
+
+/**
  * This represents an author of an add-on (e.g. creator or developer)
  *
  * @param  aName
  *         The name of the author
  * @param  aURL
  *         The URL of the author's profile page
  */
 function AddonAuthor(aName, aURL) {
@@ -1983,73 +2108,73 @@ var AddonManagerInternal = {
   },
 
   /**
    * Checks whether a particular source is allowed to install add-ons of a
    * given mimetype.
    *
    * @param  aMimetype
    *         The mimetype of the add-on
-   * @param  aURI
-   *         The optional nsIURI of the source
+   * @param  aInstallingPrincipal
+   *         The nsIPrincipal that initiated the install
    * @return true if the source is allowed to install this mimetype
    */
-  isInstallAllowed: function AMI_isInstallAllowed(aMimetype, aURI) {
+  isInstallAllowed: function AMI_isInstallAllowed(aMimetype, aInstallingPrincipal) {
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
     if (!aMimetype || typeof aMimetype != "string")
       throw Components.Exception("aMimetype must be a non-empty string",
                                  Cr.NS_ERROR_INVALID_ARG);
 
-    if (aURI && !(aURI instanceof Ci.nsIURI))
-      throw Components.Exception("aURI must be a nsIURI or null",
+    if (!aInstallingPrincipal || !(aInstallingPrincipal instanceof Ci.nsIPrincipal))
+      throw Components.Exception("aInstallingPrincipal must be a nsIPrincipal",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     let providers = [...this.providers];
     for (let provider of providers) {
       if (callProvider(provider, "supportsMimetype", false, aMimetype) &&
-          callProvider(provider, "isInstallAllowed", null, aURI))
+          callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal))
         return true;
     }
     return false;
   },
 
   /**
    * Starts installation of an array of AddonInstalls notifying the registered
    * web install listener of blocked or started installs.
    *
    * @param  aMimetype
    *         The mimetype of add-ons being installed
    * @param  aBrowser
    *         The optional browser element that started the installs
-   * @param  aURI
-   *         The optional nsIURI that started the installs
+   * @param  aInstallingPrincipal
+   *         The nsIPrincipal that initiated the install
    * @param  aInstalls
    *         The array of AddonInstalls to be installed
    */
   installAddonsFromWebpage: function AMI_installAddonsFromWebpage(aMimetype,
                                                                   aBrowser,
-                                                                  aURI,
+                                                                  aInstallingPrincipal,
                                                                   aInstalls) {
     if (!gStarted)
       throw Components.Exception("AddonManager is not initialized",
                                  Cr.NS_ERROR_NOT_INITIALIZED);
 
     if (!aMimetype || typeof aMimetype != "string")
       throw Components.Exception("aMimetype must be a non-empty string",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     if (aBrowser && !(aBrowser instanceof Ci.nsIDOMElement))
       throw Components.Exception("aSource must be a nsIDOMElement, or null",
                                  Cr.NS_ERROR_INVALID_ARG);
 
-    if (aURI && !(aURI instanceof Ci.nsIURI))
-      throw Components.Exception("aURI must be a nsIURI or null",
+    if (!aInstallingPrincipal || !(aInstallingPrincipal instanceof Ci.nsIPrincipal))
+      throw Components.Exception("aInstallingPrincipal must be a nsIPrincipal",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     if (!Array.isArray(aInstalls))
       throw Components.Exception("aInstalls must be an array",
                                  Cr.NS_ERROR_INVALID_ARG);
 
     if (!("@mozilla.org/addons/web-install-listener;1" in Cc)) {
       logger.warn("No web installer available, cancelling all installs");
@@ -2058,30 +2183,50 @@ var AddonManagerInternal = {
       });
       return;
     }
 
     try {
       let weblistener = Cc["@mozilla.org/addons/web-install-listener;1"].
                         getService(Ci.amIWebInstallListener);
 
-      if (!this.isInstallEnabled(aMimetype, aURI)) {
-        weblistener.onWebInstallDisabled(aBrowser, aURI, aInstalls,
-                                         aInstalls.length);
+      if (!this.isInstallEnabled(aMimetype)) {
+        for (let install of aInstalls)
+          install.cancel();
+
+        weblistener.onWebInstallDisabled(aBrowser, aInstallingPrincipal.URI,
+                                         aInstalls, aInstalls.length);
+        return;
       }
-      else if (!this.isInstallAllowed(aMimetype, aURI)) {
-        if (weblistener.onWebInstallBlocked(aBrowser, aURI, aInstalls,
-                                            aInstalls.length)) {
+      else if (!aBrowser.contentPrincipal || !aInstallingPrincipal.subsumes(aBrowser.contentPrincipal)) {
+        for (let install of aInstalls)
+          install.cancel();
+
+        if (weblistener instanceof Ci.amIWebInstallListener2) {
+          weblistener.onWebInstallOriginBlocked(aBrowser, aInstallingPrincipal.URI,
+                                                aInstalls, aInstalls.length);
+        }
+        return;
+      }
+
+      // The installs may start now depending on the web install listener,
+      // listen for the browser navigating to a new origin and cancel the
+      // installs in that case.
+      new BrowserListener(aBrowser, aInstallingPrincipal, aInstalls);
+
+      if (!this.isInstallAllowed(aMimetype, aInstallingPrincipal)) {
+        if (weblistener.onWebInstallBlocked(aBrowser, aInstallingPrincipal.URI,
+                                            aInstalls, aInstalls.length)) {
           aInstalls.forEach(function(aInstall) {
             aInstall.install();
           });
         }
       }
-      else if (weblistener.onWebInstallRequested(aBrowser, aURI, aInstalls,
-                                                   aInstalls.length)) {
+      else if (weblistener.onWebInstallRequested(aBrowser, aInstallingPrincipal.URI,
+                                                 aInstalls, aInstalls.length)) {
         aInstalls.forEach(function(aInstall) {
           aInstall.install();
         });
       }
     }
     catch (e) {
       // In the event that the weblistener throws during instantiation or when
       // calling onWebInstallBlocked or onWebInstallRequested all of the
@@ -2926,23 +3071,26 @@ this.AddonManager = {
   mapURIToAddonID: function AM_mapURIToAddonID(aURI) {
     return AddonManagerInternal.mapURIToAddonID(aURI);
   },
 
   isInstallEnabled: function AM_isInstallEnabled(aType) {
     return AddonManagerInternal.isInstallEnabled(aType);
   },
 
-  isInstallAllowed: function AM_isInstallAllowed(aType, aUri) {
-    return AddonManagerInternal.isInstallAllowed(aType, aUri);
+  isInstallAllowed: function AM_isInstallAllowed(aType, aInstallingPrincipal) {
+    return AddonManagerInternal.isInstallAllowed(aType, ensurePrincipal(aInstallingPrincipal));
   },
 
   installAddonsFromWebpage: function AM_installAddonsFromWebpage(aType, aBrowser,
-                                                                 aUri, aInstalls) {
-    AddonManagerInternal.installAddonsFromWebpage(aType, aBrowser, aUri, aInstalls);
+                                                                 aInstallingPrincipal,
+                                                                 aInstalls) {
+    AddonManagerInternal.installAddonsFromWebpage(aType, aBrowser,
+                                                  ensurePrincipal(aInstallingPrincipal),
+                                                  aInstalls);
   },
 
   addManagerListener: function AM_addManagerListener(aListener) {
     AddonManagerInternal.addManagerListener(aListener);
   },
 
   removeManagerListener: function AM_removeManagerListener(aListener) {
     AddonManagerInternal.removeManagerListener(aListener);
--- a/toolkit/mozapps/extensions/addonManager.js
+++ b/toolkit/mozapps/extensions/addonManager.js
@@ -70,32 +70,33 @@ amManager.prototype = {
     return AddonManager.isInstallEnabled(aMimetype);
   },
 
   /**
    * @see amIWebInstaller.idl
    */
   installAddonsFromWebpage: function AMC_installAddonsFromWebpage(aMimetype,
                                                                   aBrowser,
-                                                                  aReferer, aUris,
-                                                                  aHashes, aNames,
-                                                                  aIcons, aCallback) {
+                                                                  aInstallingPrincipal,
+                                                                  aUris, aHashes,
+                                                                  aNames, aIcons,
+                                                                  aCallback) {
     if (aUris.length == 0)
       return false;
 
     let retval = true;
-    if (!AddonManager.isInstallAllowed(aMimetype, aReferer)) {
+    if (!AddonManager.isInstallAllowed(aMimetype, aInstallingPrincipal)) {
       aCallback = null;
       retval = false;
     }
 
     let installs = [];
     function buildNextInstall() {
       if (aUris.length == 0) {
-        AddonManager.installAddonsFromWebpage(aMimetype, aBrowser, aReferer, installs);
+        AddonManager.installAddonsFromWebpage(aMimetype, aBrowser, aInstallingPrincipal, installs);
         return;
       }
       let uri = aUris.shift();
       AddonManager.getInstallForURL(uri, function buildNextInstall_getInstallForURL(aInstall) {
         function callCallback(aUri, aStatus) {
           try {
             aCallback.onInstallEnded(aUri, aStatus);
           }
@@ -147,40 +148,38 @@ amManager.prototype = {
   /**
    * messageManager callback function.
    *
    * Listens to requests from child processes for InstallTrigger
    * activity, and sends back callbacks.
    */
   receiveMessage: function AMC_receiveMessage(aMessage) {
     let payload = aMessage.data;
-    let referer = payload.referer ? Services.io.newURI(payload.referer, null, null)
-                                  : null;
 
     switch (aMessage.name) {
       case MSG_INSTALL_ENABLED:
-        return this.isInstallEnabled(payload.mimetype, referer);
+        return AddonManager.isInstallEnabled(payload.mimetype);
 
       case MSG_INSTALL_ADDONS: {
         let callback = null;
         if (payload.callbackID != -1) {
           callback = {
             onInstallEnded: function ITP_callback(url, status) {
               gParentMM.broadcastAsyncMessage(MSG_INSTALL_CALLBACK, {
                 callbackID: payload.callbackID,
                 url: url,
                 status: status
               });
             },
           };
         }
 
         return this.installAddonsFromWebpage(payload.mimetype,
-          aMessage.target, referer, payload.uris, payload.hashes,
-          payload.names, payload.icons, callback);
+          aMessage.target, payload.triggeringPrincipal, payload.uris,
+          payload.hashes, payload.names, payload.icons, callback);
       }
     }
   },
 
   classID: Components.ID("{4399533d-08d1-458c-a87a-235f74451cfa}"),
   _xpcom_factory: {
     createInstance: function AMC_createInstance(aOuter, aIid) {
       if (aOuter != null)
--- a/toolkit/mozapps/extensions/amContentHandler.js
+++ b/toolkit/mozapps/extensions/amContentHandler.js
@@ -38,39 +38,30 @@ amContentHandler.prototype = {
 
     let window = null;
     let callbacks = aRequest.notificationCallbacks ?
                     aRequest.notificationCallbacks :
                     aRequest.loadGroup.notificationCallbacks;
     if (callbacks)
       window = callbacks.getInterface(Ci.nsIDOMWindow);
 
-    let referer = null;
-    if (aRequest instanceof Ci.nsIPropertyBag2) {
-      referer = aRequest.getPropertyAsInterface("docshell.internalReferrer",
-                                                Ci.nsIURI);
-    }
-
-    if (!referer && aRequest instanceof Ci.nsIHttpChannel)
-      referer = aRequest.referrer;
-
     aRequest.cancel(Cr.NS_BINDING_ABORTED);
 
     let messageManager = window.QueryInterface(Ci.nsIInterfaceRequestor)
                                .getInterface(Ci.nsIDocShell)
                                .QueryInterface(Ci.nsIInterfaceRequestor)
                                .getInterface(Ci.nsIContentFrameMessageManager);
 
     messageManager.sendAsyncMessage(MSG_INSTALL_ADDONS, {
       uris: [uri.spec],
       hashes: [null],
       names: [null],
       icons: [null],
       mimetype: XPI_CONTENT_TYPE,
-      referer: referer ? referer.spec : null,
+      triggeringPrincipal: aRequest.loadInfo.triggeringPrincipal,
       callbackID: -1
     });
   },
 
   classID: Components.ID("{7beb3ba8-6ec3-41b4-b67c-da89b8518922}"),
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentHandler]),
 
   log : function XCH_log(aMsg) {
--- a/toolkit/mozapps/extensions/amIWebInstallListener.idl
+++ b/toolkit/mozapps/extensions/amIWebInstallListener.idl
@@ -82,16 +82,37 @@ interface amIWebInstallListener : nsISup
    *         The number of AddonInstalls
    * @return true if the caller should start the installs
    */
   boolean onWebInstallRequested(in nsIDOMElement aBrowser, in nsIURI aUri,
                                 [array, size_is(aCount)] in nsIVariant aInstalls,
                                 [optional] in uint32_t aCount);
 };
 
+[scriptable, uuid(a80b89ad-bb1a-4c43-9cb7-3ae656556f78)]
+interface amIWebInstallListener2 : nsISupports
+{
+  /**
+   * Called when a non-same-origin resource attempted to initiate an install.
+   * Installs will have already been cancelled and cannot be restarted.
+   *
+   * @param  aBrowser
+   *         The browser that triggered the installs
+   * @param  aUri
+   *         The URI of the site that triggered the installs
+   * @param  aInstalls
+   *         The AddonInstalls that were blocked
+   * @param  aCount
+   *         The number of AddonInstalls
+   */
+  boolean onWebInstallOriginBlocked(in nsIDOMElement aBrowser, in nsIURI aUri,
+                                    [array, size_is(aCount)] in nsIVariant aInstalls,
+                                    [optional] in uint32_t aCount);
+};
+
 /**
  * amIWebInstallPrompt is used, if available, by the default implementation of 
  * amIWebInstallInfo to display a confirmation UI to the user before running
  * installs.
  */
 [scriptable, uuid(386906f1-4d18-45bf-bc81-5dcd68e42c3b)]
 interface amIWebInstallPrompt : nsISupports
 {
--- a/toolkit/mozapps/extensions/amInstallTrigger.js
+++ b/toolkit/mozapps/extensions/amInstallTrigger.js
@@ -56,33 +56,32 @@ RemoteMediator.prototype = {
       if (callbackHandler) {
         callbackHandler.callCallback(payload.url, payload.status);
       }
     }
   },
 
   enabled: function(url) {
     let params = {
-      referer: url,
       mimetype: XPINSTALL_MIMETYPE
     };
     return this.mm.sendSyncMessage(MSG_INSTALL_ENABLED, params)[0];
   },
 
-  install: function(installs, referer, callback, window) {
+  install: function(installs, principal, callback, window) {
     let messageManager = window.QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIWebNavigation)
                          .QueryInterface(Ci.nsIDocShell)
                          .QueryInterface(Ci.nsIInterfaceRequestor)
                          .getInterface(Ci.nsIContentFrameMessageManager);
 
     let callbackID = this._addCallback(callback, installs.uris);
 
     installs.mimetype = XPINSTALL_MIMETYPE;
-    installs.referer = referer;
+    installs.triggeringPrincipal = principal;
     installs.callbackID = callbackID;
 
     return messageManager.sendSyncMessage(MSG_INSTALL_ADDONS, installs)[0];
   },
 
   _addCallback: function(callback, urls) {
     if (!callback || typeof callback != "function")
       return -1;
@@ -162,17 +161,17 @@ InstallTrigger.prototype = {
       }
 
       installData.uris.push(url.spec);
       installData.hashes.push(item.Hash || null);
       installData.names.push(name);
       installData.icons.push(iconUrl ? iconUrl.spec : null);
     }
 
-    return this._mediator.install(installData, this._url.spec, callback, this._window);
+    return this._mediator.install(installData, this._principal, callback, this._window);
   },
 
   startSoftwareUpdate: function(url, flags) {
     let filename = Services.io.newURI(url, null, null)
                               .QueryInterface(Ci.nsIURL)
                               .filename;
     let args = {};
     args[filename] = { "URL": url };
--- a/toolkit/mozapps/extensions/amWebInstallListener.js
+++ b/toolkit/mozapps/extensions/amWebInstallListener.js
@@ -287,16 +287,35 @@ extWebInstallListener.prototype = {
       QueryInterface: XPCOMUtils.generateQI([Ci.amIWebInstallInfo])
     };
     Services.obs.notifyObservers(info, "addon-install-disabled", null);
   },
 
   /**
    * @see amIWebInstallListener.idl
    */
+  onWebInstallOriginBlocked: function extWebInstallListener_onWebInstallOriginBlocked(aBrowser, aUri, aInstalls) {
+    let info = {
+      browser: aBrowser,
+      originatingURI: aUri,
+      installs: aInstalls,
+
+      install: function onWebInstallBlocked_install() {
+      },
+
+      QueryInterface: XPCOMUtils.generateQI([Ci.amIWebInstallInfo])
+    };
+    Services.obs.notifyObservers(info, "addon-install-origin-blocked", null);
+
+    return false;
+  },
+
+  /**
+   * @see amIWebInstallListener.idl
+   */
   onWebInstallBlocked: function extWebInstallListener_onWebInstallBlocked(aBrowser, aUri, aInstalls) {
     let info = {
       browser: aBrowser,
       originatingURI: aUri,
       installs: aInstalls,
 
       install: function onWebInstallBlocked_install() {
         new Installer(this.browser, this.originatingURI, this.installs);
@@ -317,12 +336,13 @@ extWebInstallListener.prototype = {
 
     // We start the installs ourself
     return false;
   },
 
   classDescription: "XPI Install Handler",
   contractID: "@mozilla.org/addons/web-install-listener;1",
   classID: Components.ID("{0f38e086-89a3-40a5-8ffc-9b694de1d04a}"),
-  QueryInterface: XPCOMUtils.generateQI([Ci.amIWebInstallListener])
+  QueryInterface: XPCOMUtils.generateQI([Ci.amIWebInstallListener,
+                                         Ci.amIWebInstallListener2])
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([extWebInstallListener]);
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -1245,18 +1245,21 @@ var gViewController = {
 
         var files = fp.files;
         var installs = [];
 
         function buildNextInstall() {
           if (!files.hasMoreElements()) {
             if (installs.length > 0) {
               // Display the normal install confirmation for the installs
-              AddonManager.installAddonsFromWebpage("application/x-xpinstall",
-                                                    getBrowserElement(), null, installs);
+              let webInstaller = Cc["@mozilla.org/addons/web-install-listener;1"].
+                                 getService(Ci.amIWebInstallListener);
+              webInstaller.onWebInstallRequested(getBrowserElement(),
+                                                 document.documentURIObject,
+                                                 installs);
             }
             return;
           }
 
           var file = files.getNext();
           AddonManager.getInstallForFile(file, function cmd_installFromFile_getInstallForFile(aInstall) {
             installs.push(aInstall);
             buildNextInstall();
@@ -3740,18 +3743,21 @@ var gDragDrop = {
 
     var pos = 0;
     var installs = [];
 
     function buildNextInstall() {
       if (pos == urls.length) {
         if (installs.length > 0) {
           // Display the normal install confirmation for the installs
-          AddonManager.installAddonsFromWebpage("application/x-xpinstall",
-                                                getBrowserElement(), null, installs);
+          let webInstaller = Cc["@mozilla.org/addons/web-install-listener;1"].
+                             getService(Ci.amIWebInstallListener);
+          webInstaller.onWebInstallRequested(getBrowserElement(),
+                                             document.documentURIObject,
+                                             installs);
         }
         return;
       }
 
       AddonManager.getInstallForURL(urls[pos++], function onDrop_getInstallForURL(aInstall) {
         installs.push(aInstall);
         buildNextInstall();
       }, "application/x-xpinstall");
--- a/toolkit/mozapps/extensions/content/xpinstallConfirm.js
+++ b/toolkit/mozapps/extensions/content/xpinstallConfirm.js
@@ -3,37 +3,55 @@
 /* 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/. */
 
 var XPInstallConfirm = {};
 
 XPInstallConfirm.init = function XPInstallConfirm_init()
 {
+  Components.utils.import("resource://gre/modules/AddonManager.jsm");
+
   var _installCountdown;
   var _installCountdownInterval;
   var _focused;
   var _timeout;
 
   // Default to cancelling the install when the window unloads
   XPInstallConfirm._installOK = false;
 
   var bundle = document.getElementById("xpinstallConfirmStrings");
 
   let args = window.arguments[0].wrappedJSObject;
 
+  // If all installs have already been cancelled in some way then just close
+  // the window
+  if (args.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)) {
+    window.close();
+    return;
+  }
+
   var _installCountdownLength = 5;
   try {
     var prefs = Components.classes["@mozilla.org/preferences-service;1"]
                           .getService(Components.interfaces.nsIPrefBranch);
     var delay_in_milliseconds = prefs.getIntPref("security.dialog_enable_delay");
     _installCountdownLength = Math.round(delay_in_milliseconds / 500);
   } catch (ex) { }
   
   var itemList = document.getElementById("itemList");
+
+  let installMap = new WeakMap();
+  let installListener = {
+    onDownloadCancelled: function(install) {
+      itemList.removeChild(installMap.get(install));
+      if (--numItemsToInstall == 0)
+        window.close();
+    }
+  };
   
   var numItemsToInstall = args.installs.length;
   for (let install of args.installs) {
     var installItem = document.createElement("installitem");
     itemList.appendChild(installItem);
 
     installItem.name = install.addon.name;
     installItem.url = install.sourceURI.spec;
@@ -45,16 +63,19 @@ XPInstallConfirm.init = function XPInsta
       installItem.type = type;
     if (install.certName) {
       installItem.cert = bundle.getFormattedString("signed", [install.certName]);
     }
     else {
       installItem.cert = bundle.getString("unverified");
     }
     installItem.signed = install.certName ? "true" : "false";
+
+    installMap.set(install, installItem);
+    install.addListener(installListener);
   }
   
   var introString = bundle.getString("itemWarnIntroSingle");
   if (numItemsToInstall > 4)
     introString = bundle.getFormattedString("itemWarnIntroMultiple", [numItemsToInstall]);
   var textNode = document.createTextNode(introString);
   var introNode = document.getElementById("itemWarningIntro");
   while (introNode.hasChildNodes())
@@ -121,25 +142,30 @@ XPInstallConfirm.init = function XPInsta
 
   function myUnload() {
     if (_installCountdownLength > 0) {
       document.removeEventListener("focus", myfocus, true);
       document.removeEventListener("blur", myblur, true);
     }
     window.removeEventListener("unload", myUnload, false);
 
+    for (let install of args.installs)
+      install.removeListener(installListener);
+
     // Now perform the desired action - either install the
     // addons or cancel the installations
     if (XPInstallConfirm._installOK) {
       for (let install of args.installs)
         install.install();
     }
     else {
-      for (let install of args.installs)
-        install.cancel();
+      for (let install of args.installs) {
+        if (install.state != AddonManager.STATE_CANCELLED)
+          install.cancel();
+      }
     }
   }
 
   window.addEventListener("unload", myUnload, false);
 
   if (_installCountdownLength > 0) {
     document.addEventListener("focus", myfocus, true);
     document.addEventListener("blur", myblur, true);
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -204,16 +204,21 @@ const TYPES = {
 };
 
 // Some add-on types that we track internally are presented as other types
 // externally
 const TYPE_ALIASES = {
   "webextension": "extension",
 };
 
+const CHROME_TYPES = new Set([
+  "extension",
+  "locale",
+]);
+
 const RESTARTLESS_TYPES = new Set([
   "webextension",
   "dictionary",
   "experiment",
   "locale",
 ]);
 
 const SIGNED_TYPES = new Set([
@@ -4019,46 +4024,48 @@ this.XPIProvider = {
   isFileRequestWhitelisted: function XPI_isFileRequestWhitelisted() {
     // Default to whitelisted if the preference does not exist.
     return Preferences.get(PREF_XPI_FILE_WHITELISTED, true);
   },
 
   /**
    * Called to test whether installing XPI add-ons from a URI is allowed.
    *
-   * @param  aUri
-   *         The URI being installed from
+   * @param  aInstallingPrincipal
+   *         The nsIPrincipal that initiated the install
    * @return true if installing is allowed
    */
-  isInstallAllowed: function XPI_isInstallAllowed(aUri) {
+  isInstallAllowed: function XPI_isInstallAllowed(aInstallingPrincipal) {
     if (!this.isInstallEnabled())
       return false;
 
+    let uri = aInstallingPrincipal.URI;
+
     // Direct requests without a referrer are either whitelisted or blocked.
-    if (!aUri)
+    if (!uri)
       return this.isDirectRequestWhitelisted();
 
     // Local referrers can be whitelisted.
     if (this.isFileRequestWhitelisted() &&
-        (aUri.schemeIs("chrome") || aUri.schemeIs("file")))
+        (uri.schemeIs("chrome") || uri.schemeIs("file")))
       return true;
 
     this.importPermissions();
 
-    let permission = Services.perms.testPermission(aUri, XPI_PERMISSION);
+    let permission = Services.perms.testPermissionFromPrincipal(aInstallingPrincipal, XPI_PERMISSION);
     if (permission == Ci.nsIPermissionManager.DENY_ACTION)
       return false;
 
     let requireWhitelist = Preferences.get(PREF_XPI_WHITELIST_REQUIRED, true);
     if (requireWhitelist && (permission != Ci.nsIPermissionManager.ALLOW_ACTION))
       return false;
 
     let requireSecureOrigin = Preferences.get(PREF_INSTALL_REQUIRESECUREORIGIN, true);
     let safeSchemes = ["https", "chrome", "file"];
-    if (requireSecureOrigin && safeSchemes.indexOf(aUri.scheme) == -1)
+    if (requireSecureOrigin && safeSchemes.indexOf(uri.scheme) == -1)
       return false;
 
     return true;
   },
 
   /**
    * Called to get an AddonInstall to download and install an add-on from a URL.
    *
@@ -4728,17 +4735,17 @@ this.XPIProvider = {
       return;
 
     if (!aAddon.id || !aAddon.version || !aAddon.type) {
       logger.error(new Error("aAddon must include an id, version, and type"));
       return;
     }
 
     let timeStart = new Date();
-    if (aMethod == "startup") {
+    if (CHROME_TYPES.has(aAddon.type) && aMethod == "startup") {
       logger.debug("Registering manifest for " + aFile.path);
       Components.manager.addBootstrappedManifestLocation(aFile);
     }
 
     try {
       // Load the scope if it hasn't already been loaded
       if (!(aAddon.id in this.bootstrapScopes))
         this.loadBootstrapScope(aAddon.id, aFile, aAddon.version, aAddon.type,
@@ -4771,17 +4778,17 @@ this.XPIProvider = {
       try {
         this.bootstrapScopes[aAddon.id][aMethod](params, aReason);
       }
       catch (e) {
         logger.warn("Exception running bootstrap method " + aMethod + " on " + aAddon.id, e);
       }
     }
     finally {
-      if (aMethod == "shutdown" && aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+      if (CHROME_TYPES.has(aAddon.type) && aMethod == "shutdown" && aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
         logger.debug("Removing manifest for " + aFile.path);
         Components.manager.removeBootstrappedManifestLocation(aFile);
 
         let manifest = getURIForResourceInFile(aFile, "chrome.manifest");
         for (let line of ChromeManifestParser.parseSync(manifest)) {
           if (line.type == "resource") {
             ResProtocolHandler.setSubstitution(line.args[0], null);
           }
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/test_dictionary/chrome.manifest
@@ -0,0 +1,1 @@
+content dict ./
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/addons/webextension_1/chrome.manifest
@@ -0,0 +1,1 @@
+content webex ./
--- a/toolkit/mozapps/extensions/test/xpcshell/test_blocklistchange.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_blocklistchange.js
@@ -463,56 +463,35 @@ function Pmanual_update(aVersion) {
   for (let name of ["soft1", "soft2", "soft3", "soft4", "soft5", "hard1", "regexp1"]) {
     Pinstalls.push(new Promise((resolve, reject) => {
       AddonManager.getInstallForURL("http://localhost:" + gPort + "/addons/blocklist_"
                                        + name + "_" + aVersion + ".xpi",
                                     resolve, "application/x-xpinstall");
     }));
   }
 
-  return Promise.all(Pinstalls)
-    .then(installs => {
-      return new Promise((resolve, reject) => {
-        Services.obs.addObserver(function(aSubject, aTopic, aData) {
-          Services.obs.removeObserver(arguments.callee, "addon-install-blocked");
-
-          aSubject.QueryInterface(Ci.amIWebInstallInfo);
-
-          var installCount = aSubject.installs.length;
-
-          var listener = {
-            installComplete: function() {
-              installCount--;
-              if (installCount)
-                return;
-
-              resolve();
-            },
+  return Promise.all(Pinstalls).then(installs => {
+    let completePromises = [];
+    for (let install of installs) {
+      completePromises.push(new Promise(resolve => {
+        install.addListener({
+          onDownloadCancelled: resolve,
+          onInstallEnded: resolve
+        })
+      }));
+    }
 
-            onDownloadCancelled: function(aInstall) {
-              this.installComplete();
-            },
-
-            onInstallEnded: function(aInstall) {
-              this.installComplete();
-            }
-          };
+    // Use the default web installer to cancel/allow installs based on whether
+    // the add-on is valid or not.
+    let webInstaller = Cc["@mozilla.org/addons/web-install-listener;1"]
+                       .getService(Ci.amIWebInstallListener);
+    webInstaller.onWebInstallRequested(null, null, installs);
 
-          aSubject.installs.forEach(function(aInstall) {
-            aInstall.addListener(listener);
-          });
-
-          aSubject.install();
-        }, "addon-install-blocked", false);
-
-        AddonManager.installAddonsFromWebpage("application/x-xpinstall", null,
-                                              NetUtil.newURI("http://localhost:" + gPort + "/"),
-                                              installs);
-      })
-    });
+    return Promise.all(completePromises);
+  });
 }
 
 // Checks that an add-ons properties match expected values
 function check_addon(aAddon, aExpectedVersion, aExpectedUserDisabled,
                      aExpectedSoftDisabled, aExpectedState) {
   do_check_neq(aAddon, null);
   do_print("Testing " + aAddon.id + " version " + aAddon.version + " user "
            + aAddon.userDisabled + " soft " + aAddon.softDisabled
--- a/toolkit/mozapps/extensions/test/xpcshell/test_dictionary.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary.js
@@ -170,16 +170,26 @@ function check_test_1() {
       do_check_true(b1.isActive);
       do_check_true(HunspellEngine.isDictionaryEnabled("ab-CD.dic"));
       do_check_true(b1.hasResource("install.rdf"));
       do_check_false(b1.hasResource("bootstrap.js"));
       do_check_in_crash_annotation("ab-CD@dictionaries.addons.mozilla.org", "1.0");
 
       let dir = do_get_addon_root_uri(profileDir, "ab-CD@dictionaries.addons.mozilla.org");
 
+      let chromeReg = AM_Cc["@mozilla.org/chrome/chrome-registry;1"].
+                      getService(AM_Ci.nsIChromeRegistry);
+      try {
+        chromeReg.convertChromeURL(NetUtil.newURI("chrome://dict/content/dict.xul"));
+        do_throw("Chrome manifest should not have been registered");
+      }
+      catch (e) {
+        // Expected the chrome url to not be registered
+      }
+
       AddonManager.getAddonsWithOperationsByTypes(null, function(list) {
         do_check_eq(list.length, 0);
 
         run_test_2();
       });
     });
   });
 }
--- a/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
@@ -4,79 +4,83 @@
 
 Components.utils.import("resource://gre/modules/NetUtil.jsm");
 
 // Checks that permissions set in preferences are correctly imported but can
 // be removed by the user.
 
 const XPI_MIMETYPE = "application/x-xpinstall";
 
+function newPrincipal(uri) {
+  return Services.scriptSecurityManager.createCodebasePrincipal(NetUtil.newURI(uri), {});
+}
+
 function run_test() {
   createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
 
   Services.prefs.setCharPref("xpinstall.whitelist.add", "https://test1.com,https://test2.com");
   Services.prefs.setCharPref("xpinstall.whitelist.add.36", "https://test3.com,https://www.test4.com");
   Services.prefs.setCharPref("xpinstall.whitelist.add.test5", "https://test5.com");
 
   Services.perms.add(NetUtil.newURI("https://www.test9.com"), "install",
                      AM_Ci.nsIPermissionManager.ALLOW_ACTION);
 
   startupManager();
 
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("http://test1.com")));
+                                               newPrincipal("http://test1.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://test1.com")));
+                                              newPrincipal("https://test1.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://www.test2.com")));
+                                              newPrincipal("https://www.test2.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://test3.com")));
+                                              newPrincipal("https://test3.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test4.com")));
+                                               newPrincipal("https://test4.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://www.test4.com")));
+                                              newPrincipal("https://www.test4.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("http://www.test5.com")));
+                                               newPrincipal("http://www.test5.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://www.test5.com")));
+                                              newPrincipal("https://www.test5.com")));
 
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("http://www.test6.com")));
+                                               newPrincipal("http://www.test6.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test6.com")));
+                                               newPrincipal("https://www.test6.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test7.com")));
+                                               newPrincipal("https://test7.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test8.com")));
+                                               newPrincipal("https://www.test8.com")));
 
   // This should remain unaffected
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("http://www.test9.com")));
+                                               newPrincipal("http://www.test9.com")));
   do_check_true(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                              NetUtil.newURI("https://www.test9.com")));
+                                              newPrincipal("https://www.test9.com")));
 
   Services.perms.removeAll();
 
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test1.com")));
+                                               newPrincipal("https://test1.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test2.com")));
+                                               newPrincipal("https://www.test2.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test3.com")));
+                                               newPrincipal("https://test3.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test4.com")));
+                                               newPrincipal("https://www.test4.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test5.com")));
+                                               newPrincipal("https://www.test5.com")));
 
   // Upgrade the application and verify that the permissions are still not there
   restartManager("2");
 
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test1.com")));
+                                               newPrincipal("https://test1.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test2.com")));
+                                               newPrincipal("https://www.test2.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://test3.com")));
+                                               newPrincipal("https://test3.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test4.com")));
+                                               newPrincipal("https://www.test4.com")));
   do_check_false(AddonManager.isInstallAllowed(XPI_MIMETYPE,
-                                               NetUtil.newURI("https://www.test5.com")));
+                                               newPrincipal("https://www.test5.com")));
 }
--- a/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
@@ -3,16 +3,20 @@
  */
 
 // Tests that xpinstall.[whitelist|blacklist].add preferences are emptied when
 // converted into permissions.
 
 const PREF_XPI_WHITELIST_PERMISSIONS  = "xpinstall.whitelist.add";
 const PREF_XPI_BLACKLIST_PERMISSIONS  = "xpinstall.blacklist.add";
 
+function newPrincipal(uri) {
+  return Services.scriptSecurityManager.createCodebasePrincipal(NetUtil.newURI(uri), {});
+}
+
 function do_check_permission_prefs(preferences) {
   // Check preferences were emptied
   for (let pref of preferences) {
     try {
       do_check_eq(Services.prefs.getCharPref(pref), "");
     }
     catch (e) {
       // Successfully emptied
@@ -38,18 +42,17 @@ function run_test() {
   var whitelistPreferences = Services.prefs.getChildList(PREF_XPI_WHITELIST_PERMISSIONS, {});
   var blacklistPreferences = Services.prefs.getChildList(PREF_XPI_BLACKLIST_PERMISSIONS, {});
   var preferences = whitelistPreferences.concat(blacklistPreferences);
 
   startupManager();
 
   // Permissions are imported lazily - act as thought we're checking an install,
   // to trigger on-deman importing of the permissions.
-  let url = Services.io.newURI("http://example.com/file.xpi", null, null);
-  AddonManager.isInstallAllowed("application/x-xpinstall", url);
+  AddonManager.isInstallAllowed("application/x-xpinstall", newPrincipal("http://example.com/file.xpi"));
   do_check_permission_prefs(preferences);
 
 
   // Import can also be triggerred by an observer notification by any other area
   // of code, such as a permissions management UI.
 
   // First, request to flush all permissions
   clear_imported_preferences_cache();
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -30,16 +30,26 @@ add_task(function*() {
   yield Promise.all([
     promiseInstallAllFiles([do_get_addon("webextension_1")], true),
     promiseAddonStartup()
   ]);
 
   do_check_eq(GlobalManager.count, 1);
   do_check_true(GlobalManager.extensionMap.has(ID));
 
+  let chromeReg = AM_Cc["@mozilla.org/chrome/chrome-registry;1"].
+                  getService(AM_Ci.nsIChromeRegistry);
+  try {
+    chromeReg.convertChromeURL(NetUtil.newURI("chrome://webex/content/webex.xul"));
+    do_throw("Chrome manifest should not have been registered");
+  }
+  catch (e) {
+    // Expected the chrome url to not be registered
+  }
+
   let addon = yield promiseAddonByID(ID);
   do_check_neq(addon, null);
   do_check_eq(addon.version, "1.0");
   do_check_eq(addon.name, "Web Extension Name");
   do_check_true(addon.isCompatible);
   do_check_false(addon.appDisabled);
   do_check_true(addon.isActive);
   do_check_eq(addon.type, "extension");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser.ini
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser.ini
@@ -11,16 +11,17 @@ support-files =
   enabled.html
   hashRedirect.sjs
   head.js
   incompatible.xpi
   installchrome.html
   installtrigger.html
   installtrigger_frame.html
   multipackage.xpi
+  navigate.html
   redirect.sjs
   restartless.xpi
   signed-no-cn.xpi
   signed-no-o.xpi
   signed-tampered.xpi
   signed-untrusted.xpi
   signed.xpi
   signed2.xpi
@@ -48,16 +49,17 @@ skip-if = true # disabled due to a leak.
 [browser_cancel.js]
 [browser_concurrent_installs.js]
 [browser_cookies.js]
 [browser_cookies2.js]
 [browser_cookies3.js]
 [browser_cookies4.js]
 skip-if = true # Bug 1084646
 [browser_corrupt.js]
+[browser_datauri.js]
 [browser_empty.js]
 [browser_enabled.js]
 [browser_enabled2.js]
 [browser_enabled3.js]
 [browser_hash.js]
 [browser_httphash.js]
 [browser_httphash2.js]
 [browser_httphash3.js]
@@ -67,30 +69,34 @@ skip-if = true # Bug 1084646
 [browser_installchrome.js]
 [browser_localfile.js]
 [browser_localfile2.js]
 [browser_localfile3.js]
 [browser_localfile4.js]
 [browser_multipackage.js]
 [browser_navigateaway.js]
 [browser_navigateaway2.js]
+[browser_navigateaway3.js]
+[browser_navigateaway4.js]
 [browser_offline.js]
 [browser_relative.js]
 [browser_signed_multiple.js]
 [browser_signed_naming.js]
 [browser_signed_tampered.js]
 [browser_signed_trigger.js]
 [browser_signed_untrusted.js]
 [browser_signed_url.js]
 [browser_softwareupdate.js]
 [browser_switchtab.js]
 [browser_trigger_redirect.js]
 [browser_unsigned_trigger.js]
 [browser_unsigned_trigger_iframe.js]
 skip-if = buildapp == "mulet"
+[browser_unsigned_trigger_xorigin.js]
+skip-if = buildapp == "mulet"
 [browser_unsigned_url.js]
 [browser_whitelist.js]
 [browser_whitelist2.js]
 [browser_whitelist3.js]
 [browser_whitelist4.js]
 [browser_whitelist5.js]
 [browser_whitelist6.js]
 [browser_whitelist7.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js
@@ -0,0 +1,37 @@
+// ----------------------------------------------------------------------------
+// Checks that a chained redirect through a data URI and javascript is blocked
+
+function setup_redirect(aSettings) {
+  var url = TESTROOT + "redirect.sjs?mode=setup";
+  for (var name in aSettings) {
+    url += "&" + name + "=" + encodeURIComponent(aSettings[name]);
+  }
+
+  var req = new XMLHttpRequest();
+  req.open("GET", url, false);
+  req.send(null);
+}
+
+function test() {
+  Harness.installOriginBlockedCallback = install_blocked;
+  Harness.installsCompletedCallback = finish_test;
+  Harness.setup();
+
+  setup_redirect({
+    "Location": "data:text/html,<script>window.location.href='" + TESTROOT + "unsigned.xpi'</script>"
+  });
+
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.loadURI(TESTROOT + "redirect.sjs?mode=redirect");
+}
+
+function install_blocked(installInfo) {
+}
+
+function finish_test(count) {
+  is(count, 0, "No add-ons should have been installed");
+  Services.perms.remove(makeURI("http://example.com"), "install");
+
+  gBrowser.removeCurrentTab();
+  Harness.finish();
+}
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
@@ -60,17 +60,17 @@ function finish_failed_download() {
   });
 
   // The harness expects onNewInstall events for all installs that are about to start
   Harness.onNewInstall(gInstall);
 
   // Restart the install as a regular webpage install so the harness tracks it
   AddonManager.installAddonsFromWebpage("application/x-xpinstall",
                                         gBrowser.selectedBrowser,
-                                        gBrowser.currentURI, [gInstall]);
+                                        gBrowser.contentPrincipal, [gInstall]);
 }
 
 function install_ended(install, addon) {
   install.cancel();
 }
 
 function finish_test(count) {
   is(count, 1, "1 Add-on should have been successfully installed");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
@@ -9,18 +9,21 @@ function test() {
                      .getService(Components.interfaces.nsIChromeRegistry);
 
   var chromeroot = extractChromeRoot(gTestPath);
   try {
     var xpipath = cr.convertChromeURL(makeURI(chromeroot + "unsigned.xpi")).spec;
   } catch (ex) {
     var xpipath = chromeroot + "unsigned.xpi"; //scenario where we are running from a .jar and already extracted
   }
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(xpipath);
+
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(xpipath);
+  });
 }
 
 function install_ended(install, addon) {
   install.cancel();
 }
 
 function finish_test(count) {
   is(count, 1, "1 Add-on should have been successfully installed");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
@@ -13,18 +13,21 @@ function test() {
                      .getService(Components.interfaces.nsIChromeRegistry);
 
   var chromeroot = extractChromeRoot(gTestPath);
   try {
     var xpipath = cr.convertChromeURL(makeURI(chromeroot + "unsigned.xpi")).spec;
   } catch (ex) {
     var xpipath = chromeroot + "unsigned.xpi"; //scenario where we are running from a .jar and already extracted
   }
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(xpipath);
+
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(xpipath);
+  });
 }
 
 function allow_blocked(installInfo) {
   ok(true, "Seen blocked");
   return false;
 }
 
 function finish_test(count) {
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_multipackage.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_multipackage.js
@@ -1,18 +1,20 @@
 // ----------------------------------------------------------------------------
 // Tests installing an signed add-on by navigating directly to the url
 function test() {
   Harness.installConfirmCallback = confirm_install;
   Harness.installEndedCallback = install_ended;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "multipackage.xpi");
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "multipackage.xpi");
+  });
 }
 
 function get_item(items, name) {
   for (let item of items) {
     if (item.name == name)
       return item;
   }
   ok(false, "Item for " + name + " was not listed");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway.js
@@ -14,17 +14,17 @@ function test() {
   var triggers = encodeURIComponent(JSON.stringify({
     "Unsigned XPI": TESTROOT + "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 }
 
 function download_progress(addon, value, maxValue) {
-  gBrowser.loadURI("about:blank");
+  gBrowser.loadURI(TESTROOT + "enabled.html");
 }
 
 function install_ended(install, addon) {
   install.cancel();
 }
 
 function finish_test(count) {
   is(count, 1, "1 Add-on should have been successfully installed");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway2.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway2.js
@@ -1,12 +1,11 @@
 // ----------------------------------------------------------------------------
-// Tests that closing the initiating page during the install doesn't break the
-// install.
-// This verifies bugs 473060 and 475347
+// Tests that closing the initiating page during the install cancels the install
+// to avoid spoofing the user.
 function test() {
   Harness.downloadProgressCallback = download_progress;
   Harness.installEndedCallback = install_ended;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
@@ -18,18 +17,18 @@ function test() {
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 }
 
 function download_progress(addon, value, maxValue) {
   gBrowser.removeCurrentTab();
 }
 
 function install_ended(install, addon) {
-  install.cancel();
+  ok(false, "Should not have seen installs complete");
 }
 
 function finish_test(count) {
-  is(count, 1, "1 Add-on should have been successfully installed");
+  is(count, 0, "No add-ons should have been successfully installed");
 
   Services.perms.remove(makeURI("http://example.com"), "install");
 
   Harness.finish();
 }
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway3.js
@@ -0,0 +1,38 @@
+// ----------------------------------------------------------------------------
+// Tests that navigating to a new origin cancels ongoing installs.
+
+// Block the modal install UI from showing.
+Services.prefs.setBoolPref(PREF_CUSTOM_CONFIRMATION_UI, true);
+
+function test() {
+  Harness.downloadProgressCallback = download_progress;
+  Harness.installEndedCallback = install_ended;
+  Harness.installsCompletedCallback = finish_test;
+  Harness.setup();
+
+  var pm = Services.perms;
+  pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
+
+  var triggers = encodeURIComponent(JSON.stringify({
+    "Unsigned XPI": TESTROOT + "unsigned.xpi"
+  }));
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
+}
+
+function download_progress(addon, value, maxValue) {
+  gBrowser.loadURI(TESTROOT2 + "enabled.html");
+}
+
+function install_ended(install, addon) {
+  ok(false, "Should not have seen installs complete");
+}
+
+function finish_test(count) {
+  is(count, 0, "No add-ons should have been successfully installed");
+
+  Services.perms.remove(makeURI("http://example.com"), "install");
+
+  gBrowser.removeCurrentTab();
+  Harness.finish();
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_navigateaway4.js
@@ -0,0 +1,44 @@
+// ----------------------------------------------------------------------------
+// Tests that navigating to a new origin cancels ongoing installs and closes
+// the install UI.
+let sawUnload = null;
+
+function test() {
+  Harness.installConfirmCallback = confirm_install;
+  Harness.installEndedCallback = install_ended;
+  Harness.installsCompletedCallback = finish_test;
+  Harness.setup();
+
+  var pm = Services.perms;
+  pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
+
+  var triggers = encodeURIComponent(JSON.stringify({
+    "Unsigned XPI": TESTROOT + "unsigned.xpi"
+  }));
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
+}
+
+function confirm_install(window) {
+  sawUnload = BrowserTestUtils.waitForEvent(window, "unload");
+
+  gBrowser.loadURI(TESTROOT2 + "enabled.html");
+
+  return Harness.leaveOpen;
+}
+
+function install_ended(install, addon) {
+  ok(false, "Should not have seen installs complete");
+}
+
+function finish_test(count) {
+  is(count, 0, "No add-ons should have been successfully installed");
+
+  Services.perms.remove(makeURI("http://example.com"), "install");
+
+  sawUnload.then(() => {
+    ok(true, "The install UI should have closed itself.");
+    gBrowser.removeCurrentTab();
+    Harness.finish();
+  });
+}
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
@@ -1,18 +1,20 @@
 // ----------------------------------------------------------------------------
 // Tests installing an signed add-on by navigating directly to the url
 function test() {
   Harness.installConfirmCallback = confirm_install;
   Harness.installEndedCallback = install_ended;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "signed.xpi");
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "signed.xpi");
+  });
 }
 
 function confirm_install(window) {
   let items = window.document.getElementById("itemList").childNodes;
   is(items.length, 1, "Should only be 1 item listed in the confirmation dialog");
   is(items[0].name, "Signed XPI Test", "Should have had the name");
   is(items[0].url, TESTROOT + "signed.xpi", "Should have listed the correct url for the item");
   is(items[0].cert, "(Object Signer)", "Should have seen the signer");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
@@ -7,25 +7,25 @@ function test() {
   Harness.installEndedCallback = install_ended;
   Harness.installsCompletedCallback = finish_test;
   Harness.finalContentEvent = "InstallComplete";
   Harness.setup();
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
-  var triggers = encodeURIComponent(JSON.stringify({
+  var inner_url = encodeURIComponent(TESTROOT + "installtrigger.html?" + encodeURIComponent(JSON.stringify({
     "Unsigned XPI": {
       URL: TESTROOT + "unsigned.xpi",
       IconURL: TESTROOT + "icon.png",
       toString: function() { return this.URL; }
     }
-  }));
+  })));
   gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "installtrigger_frame.html?" + triggers);
+  gBrowser.loadURI(TESTROOT + "installtrigger_frame.html?" + inner_url);
 }
 
 function confirm_install(window) {
   var items = window.document.getElementById("itemList").childNodes;
   is(items.length, 1, "Should only be 1 item listed in the confirmation dialog");
   is(items[0].name, "XPI Test", "Should have seen the name");
   is(items[0].url, TESTROOT + "unsigned.xpi", "Should have listed the correct url for the item");
   is(items[0].icon, TESTROOT + "icon.png", "Should have listed the correct icon for the item");
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js
@@ -0,0 +1,38 @@
+// ----------------------------------------------------------------------------
+// Ensure that an inner frame from a different origin can't initiate an install
+
+let wasOriginBlocked = false;
+
+function test() {
+  Harness.installOriginBlockedCallback = install_blocked;
+  Harness.installsCompletedCallback = finish_test;
+  Harness.finalContentEvent = "InstallComplete";
+  Harness.setup();
+
+  var pm = Services.perms;
+  pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
+
+  var inner_url = encodeURIComponent(TESTROOT + "installtrigger.html?" + encodeURIComponent(JSON.stringify({
+    "Unsigned XPI": {
+      URL: TESTROOT + "unsigned.xpi",
+      IconURL: TESTROOT + "icon.png",
+      toString: function() { return this.URL; }
+    }
+  })));
+  gBrowser.selectedTab = gBrowser.addTab();
+  gBrowser.loadURI(TESTROOT2 + "installtrigger_frame.html?" + inner_url);
+}
+
+function install_blocked(installInfo) {
+  wasOriginBlocked = true;
+}
+
+function finish_test(count) {
+  ok(wasOriginBlocked, "Should have been blocked due to the cross origin request.");
+
+  is(count, 0, "No add-ons should have been installed");
+  Services.perms.remove(makeURI("http://example.com"), "install");
+
+  gBrowser.removeCurrentTab();
+  Harness.finish();
+}
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
@@ -1,18 +1,20 @@
 // ----------------------------------------------------------------------------
 // Tests installing an unsigned add-on by navigating directly to the url
 function test() {
   Harness.installConfirmCallback = confirm_install;
   Harness.installEndedCallback = install_ended;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  });
 }
 
 function confirm_install(window) {
   let items = window.document.getElementById("itemList").childNodes;
   is(items.length, 1, "Should only be 1 item listed in the confirmation dialog");
   is(items[0].name, "XPI Test", "Should have had the filename for the item name");
   is(items[0].url, TESTROOT + "unsigned.xpi", "Should have listed the correct url for the item");
   is(items[0].icon, "", "Should have listed no icon for the item");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist3.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist3.js
@@ -1,24 +1,23 @@
 // ----------------------------------------------------------------------------
 // Tests installing an unsigned add-on through a navigation. Should not be
 // blocked since the referer is whitelisted.
+let URL = TESTROOT2 + "navigate.html?" + encodeURIComponent(TESTROOT + "unsigned.xpi");
+
 function test() {
   Harness.installConfirmCallback = confirm_install;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.org/"), "install", pm.ALLOW_ACTION);
 
-  var triggers = encodeURIComponent(JSON.stringify({
-    "Unsigned XPI": TESTROOT2 + "unsigned.xpi"
-  }));
   gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi", makeURI(TESTROOT2 + "test.html"));
+  gBrowser.loadURI(URL);
 }
 
 function confirm_install(window) {
   return false;
 }
 
 function finish_test(count) {
   is(count, 0, "No add-ons should have been installed");
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist4.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist4.js
@@ -1,29 +1,28 @@
 // ----------------------------------------------------------------------------
 // Tests installing an unsigned add-on through a navigation. Should be
 // blocked since the referer is not whitelisted even though the target is.
+let URL = TESTROOT2 + "navigate.html?" + encodeURIComponent(TESTROOT + "unsigned.xpi");
+
 function test() {
   Harness.installBlockedCallback = allow_blocked;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
-  var triggers = encodeURIComponent(JSON.stringify({
-    "Unsigned XPI": TESTROOT2 + "unsigned.xpi"
-  }));
   gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi", makeURI(TESTROOT2 + "test.html"));
+  gBrowser.loadURI(URL);
 }
 
 function allow_blocked(installInfo) {
   is(installInfo.browser, gBrowser.selectedBrowser, "Install should have been triggered by the right browser");
-  is(installInfo.originatingURI.spec, TESTROOT2 + "test.html", "Install should have been triggered by the right uri");
+  is(installInfo.originatingURI.spec, URL, "Install should have been triggered by the right uri");
   return false;
 }
 
 function finish_test(count) {
   is(count, 0, "No add-ons should have been installed");
   Services.perms.remove(makeURI("http://example.com"), "install");
 
   gBrowser.removeCurrentTab();
--- a/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist7.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_whitelist7.js
@@ -5,18 +5,20 @@
 function test() {
   Harness.installBlockedCallback = allow_blocked;
   Harness.installsCompletedCallback = finish_test;
   Harness.setup();
 
   // Disable direct request whitelisting, installing should be blocked.
   Services.prefs.setBoolPref("xpinstall.whitelist.directRequest", false);
 
-  gBrowser.selectedTab = gBrowser.addTab();
-  gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+    gBrowser.loadURI(TESTROOT + "unsigned.xpi");
+  });
 }
 
 function allow_blocked(installInfo) {
   ok(true, "Seen blocked");
   return false;
 }
 
 function finish_test(count) {
--- a/toolkit/mozapps/extensions/test/xpinstall/head.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/head.js
@@ -41,16 +41,18 @@ registerCleanupFunction(() => {
  */
 var Harness = {
   // If set then the callback is called when an install is attempted and
   // software installation is disabled.
   installDisabledCallback: null,
   // If set then the callback is called when an install is attempted and
   // then canceled.
   installCancelledCallback: null,
+  // If set then the callback will be called when an install's origin is blocked.
+  installOriginBlockedCallback: null,
   // If set then the callback will be called when an install is blocked by the
   // whitelist. The callback should return true to continue with the install
   // anyway.
   installBlockedCallback: null,
   // If set will be called in the event of authentication being needed to get
   // the xpi. Should return a 2 element array of username and password, or
   // null to not authenticate.
   authenticationCallback: null,
@@ -84,41 +86,47 @@ var Harness = {
 
   waitingForEvent: false,
   pendingCount: null,
   installCount: null,
   runningInstalls: null,
 
   waitingForFinish: false,
 
+  // A unique value to return from the installConfirmCallback to indicate that
+  // the install UI shouldn't be closed automatically
+  leaveOpen: {},
+
   // Setup and tear down functions
   setup: function() {
     if (!this.waitingForFinish) {
       waitForExplicitFinish();
       this.waitingForFinish = true;
 
       Services.prefs.setBoolPref(PREF_INSTALL_REQUIRESECUREORIGIN, false);
 
       Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
       Services.obs.addObserver(this, "addon-install-started", false);
       Services.obs.addObserver(this, "addon-install-disabled", false);
+      Services.obs.addObserver(this, "addon-install-origin-blocked", false);
       Services.obs.addObserver(this, "addon-install-blocked", false);
       Services.obs.addObserver(this, "addon-install-failed", false);
       Services.obs.addObserver(this, "addon-install-complete", false);
 
       AddonManager.addInstallListener(this);
 
       Services.wm.addListener(this);
 
       var self = this;
       registerCleanupFunction(function() {
         Services.prefs.clearUserPref(PREF_LOGGING_ENABLED);
         Services.prefs.clearUserPref(PREF_INSTALL_REQUIRESECUREORIGIN);
         Services.obs.removeObserver(self, "addon-install-started");
         Services.obs.removeObserver(self, "addon-install-disabled");
+        Services.obs.removeObserver(self, "addon-install-origin-blocked");
         Services.obs.removeObserver(self, "addon-install-blocked");
         Services.obs.removeObserver(self, "addon-install-failed");
         Services.obs.removeObserver(self, "addon-install-complete");
 
         AddonManager.removeInstallListener(self);
 
         Services.wm.removeListener(self);
 
@@ -145,16 +153,17 @@ var Harness = {
     let callback = this.installsCompletedCallback;
     let count = this.installCount;
 
     is(this.runningInstalls.length, 0, "Should be no running installs left");
     this.runningInstalls.forEach(function(aInstall) {
       info("Install for " + aInstall.sourceURI + " is in state " + aInstall.state);
     });
 
+    this.installOriginBlockedCallback = null;
     this.installBlockedCallback = null;
     this.authenticationCallback = null;
     this.installConfirmCallback = null;
     this.downloadStartedCallback = null;
     this.downloadProgressCallback = null;
     this.downloadCancelledCallback = null;
     this.downloadFailedCallback = null;
     this.downloadEndedCallback = null;
@@ -172,17 +181,24 @@ var Harness = {
   windowReady: function(window) {
     if (window.document.location.href == XPINSTALL_URL) {
       if (this.installBlockedCallback)
         ok(false, "Should have been blocked by the whitelist");
       this.pendingCount = window.document.getElementById("itemList").childNodes.length;
 
       // If there is a confirm callback then its return status determines whether
       // to install the items or not. If not the test is over.
-      if (this.installConfirmCallback && !this.installConfirmCallback(window)) {
+      let result = true;
+      if (this.installConfirmCallback) {
+        result = this.installConfirmCallback(window);
+        if (result === this.leaveOpen)
+          return;
+      }
+
+      if (!result) {
         window.document.documentElement.cancelDialog();
       }
       else {
         // Initially the accept button is disabled on a countdown timer
         var button = window.document.documentElement.getButton("accept");
         button.disabled = false;
         window.document.documentElement.acceptDialog();
       }
@@ -241,16 +257,23 @@ var Harness = {
       return;
 
     ok(!!this.installCancelledCallback, "Installation shouldn't have been cancelled");
     if (this.installCancelledCallback)
       this.installCancelledCallback(installInfo);
     this.endTest();
   },
 
+  installOriginBlocked: function(installInfo) {
+    ok(!!this.installOriginBlockedCallback, "Shouldn't have been blocked");
+    if (this.installOriginBlockedCallback)
+      this.installOriginBlockedCallback(installInfo);
+    this.endTest();
+  },
+
   installBlocked: function(installInfo) {
     ok(!!this.installBlockedCallback, "Shouldn't have been blocked by the whitelist");
     if (this.installBlockedCallback && this.installBlockedCallback(installInfo)) {
       this.installBlockedCallback = null;
       installInfo.install();
     }
     else {
       this.expectingCancelled = true;
@@ -366,16 +389,19 @@ var Harness = {
          "Should have seen the expected number of installs started");
       break;
     case "addon-install-disabled":
       this.installDisabled(installInfo);
       break;
     case "addon-install-cancelled":
       this.installCancelled(installInfo);
       break;
+    case "addon-install-origin-blocked":
+      this.installOriginBlocked(installInfo);
+      break;
     case "addon-install-blocked":
       this.installBlocked(installInfo);
       break;
     case "addon-install-failed":
       installInfo.installs.forEach(function(aInstall) {
         isnot(this.runningInstalls.indexOf(aInstall), -1,
               "Should only see failures for started installs");
 
--- a/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
+++ b/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
@@ -1,24 +1,24 @@
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
           "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
 
 <html>
 
-<!-- This page will accept some json as the uri query and pass it to
+<!-- This page will accept some url as the uri query and load it in
      an inner iframe, which will run InstallTrigger.install -->
 
 <head>
 <title>InstallTrigger frame tests</title>
 <script type="text/javascript">
 function prepChild() {
   // Pass our parameters over to the child
   var child = window.frames[0];
-  var params = document.location.search.substr(1);
-  child.location = "installtrigger.html?" + params;
+  var url = decodeURIComponent(document.location.search.substr(1));
+  child.location = url;
 }
 </script>
 </head>
 <body onload="prepChild()">
 
 <iframe src="about:blank">
 </iframe>
 
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/navigate.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+          "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some url as the uri query and navigate to it by
+     clicking a link -->
+
+<head>
+<title>Navigation tests</title>
+<script type="text/javascript">
+function navigate() {
+  // Pass our parameters over to the child
+  var child = window.frames[0];
+  var url = decodeURIComponent(document.location.search.substr(1));
+  var link = document.getElementById("link");
+  link.href = url;
+  link.click();
+}
+</script>
+</head>
+<body onload="navigate()">
+
+<p><a id="link">Test Link</a></p>
+</body>
+</html>
--- a/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
+++ b/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
@@ -4,17 +4,17 @@
 
 function handleRequest(request, response)
 {
   let parts = request.queryString.split("&");
   let settings = {};
 
   parts.forEach(function(aString) {
     let [k, v] = aString.split("=");
-    settings[k] = v;
+    settings[k] = decodeURIComponent(v);
   })
 
   if (settings.mode == "setup") {
     delete settings.mode;
 
     // Object states must be an nsISupports
     var state = {
       settings: settings,