Merge fx-team to m-c. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Wed, 25 Mar 2015 10:59:05 -0400
changeset 265862 97076df948c275036021a45da7347df3bae7acf5
parent 265809 eb3e4c2fa35edb81788b382ac8cea15a34b5f555 (current diff)
parent 265861 2894f27c8ca7bf2259f1b6c0b961b01f10376c40 (diff)
child 265863 e3d23172f0feca64ff091e8517f550e08827aa8d
push id830
push userraliiev@mozilla.com
push dateFri, 19 Jun 2015 19:24:37 +0000
treeherdermozilla-release@932614382a68 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone39.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge fx-team to m-c. a=merge
mobile/android/base/resources/color/overlay_btn_text.xml
mobile/android/base/resources/layout/overlay_share_send_tab_button.xml
--- a/addon-sdk/source/examples/debug-client/data/client.js
+++ b/addon-sdk/source/examples/debug-client/data/client.js
@@ -197,17 +197,17 @@ var Connection = Class({
   addPool: function(pool) {
     this.pools.add(pool);
   },
   removePool: function(pool) {
     this.pools.delete(pool);
   },
   poolFor: function(id) {
     for (let pool of this.pools.values()) {
-      if pool.has(id)
+      if (pool.has(id))
         return pool;
     }
   },
   get: function(id) {
     var pool = this.poolFor(id);
     return pool && pool.get(id);
   },
   disconnect: function() {
@@ -792,17 +792,17 @@ var Tab = Client.from({
     "canvasActor": "canvas",
     "webglActor": "webgl",
     "webaudioActor": "webaudio",
     "styleSheetsActor": "stylesheets",
     "styleEditorActor": "styleeditor",
     "storageActor": "storage",
     "gcliActor": "gcli",
     "memoryActor": "memory",
-    "eventLoopLag": "eventLoopLag"
+    "eventLoopLag": "eventLoopLag",
 
     "trace": "trace", // missing
   }
 });
 
 var tablist = Client.from({
   "category": "dict",
   "typeName": "tablist",
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -32,16 +32,18 @@ pref("extensions.strictCompatibility", f
 
 // Specifies a minimum maxVersion an addon needs to say it's compatible with
 // for it to be compatible by default.
 pref("extensions.minCompatibleAppVersion", "4.0");
 // Temporary preference to forcibly make themes more safe with Australis even if
 // extensions.checkCompatibility=false has been set.
 pref("extensions.checkCompatibility.temporaryThemeOverride_minAppVersion", "29.0a1");
 
+pref("xpinstall.customConfirmationUI", true);
+
 // Preferences for AMO integration
 pref("extensions.getAddons.cache.enabled", true);
 pref("extensions.getAddons.maxResults", 15);
 pref("extensions.getAddons.get.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/guid:%IDS%?src=firefox&appOS=%OS%&appVersion=%VERSION%");
 pref("extensions.getAddons.getWithPerformance.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/guid:%IDS%?src=firefox&appOS=%OS%&appVersion=%VERSION%&tMain=%TIME_MAIN%&tFirstPaint=%TIME_FIRST_PAINT%&tSessionRestored=%TIME_SESSION_RESTORED%");
 pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCALE%/firefox/search?q=%TERMS%&platform=%OS%&appver=%VERSION%");
 pref("extensions.getAddons.search.url", "https://services.addons.mozilla.org/%LOCALE%/firefox/api/%API_VERSION%/search/%TERMS%/all/%MAX_RESULTS%/%OS%/%VERSION%/%COMPATIBILITY_MODE%?src=firefox");
 pref("extensions.webservice.discoverURL", "https://services.addons.mozilla.org/%LOCALE%/firefox/discovery/pane/%VERSION%/%OS%/%COMPATIBILITY_MODE%");
@@ -1695,19 +1697,19 @@ pref("loop.ping.timeout", 10000);
 pref("loop.feedback.baseUrl", "https://input.mozilla.org/api/v1/feedback");
 pref("loop.feedback.product", "Loop");
 pref("loop.debug.loglevel", "Error");
 pref("loop.debug.dispatcher", false);
 pref("loop.debug.websocket", false);
 pref("loop.debug.sdk", false);
 pref("loop.debug.twoWayMediaTelemetry", false);
 #ifdef DEBUG
-pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome: http://localhost:*; img-src *; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net http://localhost:* ws://localhost:*; media-src blob:");
 #else
-pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src 'self' data: https://www.gravatar.com/ about: file: chrome:; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
+pref("loop.CSP", "default-src 'self' about: file: chrome:; img-src *; font-src 'none'; connect-src wss://*.tokbox.com https://*.opentok.com https://*.tokbox.com wss://*.mozilla.com https://*.mozilla.org wss://*.mozaws.net; media-src blob:");
 #endif
 pref("loop.oauth.google.redirect_uri", "urn:ietf:wg:oauth:2.0:oob:auto");
 pref("loop.oauth.google.scope", "https://www.google.com/m8/feeds");
 pref("loop.fxa_oauth.tokendata", "");
 pref("loop.fxa_oauth.profile", "");
 pref("loop.support_url", "https://support.mozilla.org/kb/group-conversations-firefox-hello-webrtc");
 pref("loop.contacts.gravatars.show", false);
 pref("loop.contacts.gravatars.promo", true);
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -43,19 +43,25 @@ const gXPInstallObserver = {
     var brandShortName = brandBundle.getString("brandShortName");
 
     var notificationID = aTopic;
     // Make notifications persist a minimum of 30 seconds
     var options = {
       timeout: Date.now() + 30000
     };
 
+    try {
+      options.originHost = installInfo.originatingURI.host;
+    } catch (e) {
+      // originatingURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
+    }
+
     switch (aTopic) {
-    case "addon-install-disabled":
-      notificationID = "xpinstall-disabled"
+    case "addon-install-disabled": {
+      notificationID = "xpinstall-disabled";
 
       if (gPrefService.prefIsLocked("xpinstall.enabled")) {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessageLocked");
         buttons = [];
       }
       else {
         messageString = gNavigatorBundle.getString("xpinstallDisabledMessage");
 
@@ -65,71 +71,79 @@ const gXPInstallObserver = {
           callback: function editPrefs() {
             gPrefService.setBoolPref("xpinstall.enabled", true);
           }
         };
       }
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
-      break;
-    case "addon-install-blocked":
-      let originatingHost;
-      try {
-        originatingHost = installInfo.originatingURI.host;
-      } catch (ex) {
+      break; }
+    case "addon-install-blocked": {
+      if (!options.originHost) {
         // Need to deal with missing originatingURI and with about:/data: URIs more gracefully,
         // see bug 1063418 - but for now, bail:
         return;
       }
-      messageString = gNavigatorBundle.getFormattedString("xpinstallPromptWarning",
-                        [brandShortName, originatingHost]);
+      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);
-      break;
-    case "addon-install-started":
-      var needsDownload = function needsDownload(aInstall) {
+      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))
         return;
       notificationID = "addon-progress";
-      messageString = gNavigatorBundle.getString("addonDownloading");
+      messageString = gNavigatorBundle.getString("addonDownloadingAndVerifying");
       messageString = PluralForm.get(installInfo.installs.length, messageString);
+      messageString = messageString.replace("#1", installInfo.installs.length);
       options.installs = installInfo.installs;
       options.contentWindow = browser.contentWindow;
       options.sourceURI = browser.currentURI;
-      options.eventCallback = function(aEvent) {
-        if (aEvent != "removed")
-          return;
-        options.contentWindow = null;
-        options.sourceURI = null;
+      options.eventCallback = (aEvent) => {
+        switch (aEvent) {
+          case "removed":
+            options.contentWindow = null;
+            options.sourceURI = null;
+            break;
+        }
       };
-      PopupNotifications.show(browser, notificationID, messageString, anchorID,
-                              null, null, options);
-      break;
-    case "addon-install-failed":
+      let notification = PopupNotifications.show(browser, notificationID, messageString,
+                                                 anchorID, null, null, options);
+      notification._startTime = Date.now();
+
+      let cancelButton = document.getElementById("addon-progress-cancel");
+      cancelButton.label = gNavigatorBundle.getString("addonInstall.cancelButton.label");
+      cancelButton.accessKey = gNavigatorBundle.getString("addonInstall.cancelButton.accesskey");
+
+      let acceptButton = document.getElementById("addon-progress-accept");
+      acceptButton.label = gNavigatorBundle.getString("addonInstall.acceptButton.label");
+      acceptButton.accessKey = gNavigatorBundle.getString("addonInstall.acceptButton.accesskey");
+      break; }
+    case "addon-install-failed": {
       // TODO This isn't terribly ideal for the multiple failure case
       for (let install of installInfo.installs) {
-        let host = (installInfo.originatingURI instanceof Ci.nsIStandardURL) &&
-                   installInfo.originatingURI.host;
+        let host = options.originHost;
         if (!host)
           host = (install.sourceURI instanceof Ci.nsIStandardURL) &&
                  install.sourceURI.host;
 
         let error = (host || install.error == 0) ? "addonError" : "addonLocalError";
         if (install.error != 0)
           error += install.error;
         else if (install.addon.blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED)
@@ -142,19 +156,110 @@ const gXPInstallObserver = {
         if (host)
           messageString = messageString.replace("#2", host);
         messageString = messageString.replace("#3", brandShortName);
         messageString = messageString.replace("#4", Services.appinfo.version);
 
         PopupNotifications.show(browser, notificationID, messageString, anchorID,
                                 action, null, options);
       }
-      break;
-    case "addon-install-complete":
-      var needsRestart = installInfo.installs.some(function(i) {
+      this._removeProgressNotification(browser);
+      break; }
+    case "addon-install-confirmation": {
+      options.eventCallback = (aEvent) => {
+        switch (aEvent) {
+          case "removed":
+            if (installInfo) {
+              for (let install of installInfo.installs)
+                install.cancel();
+            }
+            this.acceptInstallation = null;
+            break;
+          case "shown":
+            let addonList = document.getElementById("addon-install-confirmation-content");
+            while (addonList.firstChild)
+              addonList.firstChild.remove();
+
+            for (let install of installInfo.installs) {
+              let container = document.createElement("hbox");
+              let name = document.createElement("label");
+              let author = document.createElement("label");
+              name.setAttribute("value", install.addon.name);
+              author.setAttribute("value", !install.addon.creator ? "" :
+                gNavigatorBundle.getFormattedString("addonConfirmInstall.author", [install.addon.creator]));
+              name.setAttribute("class", "addon-install-confirmation-name");
+              author.setAttribute("class", "addon-install-confirmation-author");
+              container.appendChild(name);
+              container.appendChild(author);
+              addonList.appendChild(container);
+            }
+
+            this.acceptInstallation = () => {
+              for (let install of installInfo.installs)
+                install.install();
+              installInfo = null;
+
+              Services.telemetry
+                      .getHistogramById("SECURITY_UI")
+                      .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH);
+            };
+            break;
+        }
+      };
+
+      messageString = gNavigatorBundle.getString("addonConfirmInstall.message");
+      messageString = PluralForm.get(installInfo.installs.length, messageString);
+      messageString = messageString.replace("#1", brandShortName);
+      messageString = messageString.replace("#2", installInfo.installs.length);
+
+      let cancelButton = document.getElementById("addon-install-confirmation-cancel");
+      cancelButton.label = gNavigatorBundle.getString("addonInstall.cancelButton.label");
+      cancelButton.accessKey = gNavigatorBundle.getString("addonInstall.cancelButton.accesskey");
+
+      let acceptButton = document.getElementById("addon-install-confirmation-accept");
+      acceptButton.label = gNavigatorBundle.getString("addonInstall.acceptButton.label");
+      acceptButton.accessKey = gNavigatorBundle.getString("addonInstall.acceptButton.accesskey");
+
+      let showNotification = () => {
+        // The download may have been cancelled during the security delay
+        if (!PopupNotifications.getNotification("addon-progress", browser))
+          return;
+
+        let tab = gBrowser.getTabForBrowser(browser);
+        if (tab)
+          gBrowser.selectedTab = tab;
+
+        if (PopupNotifications.isPanelOpen) {
+          let rect = document.getElementById("addon-progress-notification").getBoundingClientRect();
+          let notification = document.getElementById("addon-install-confirmation-notification");
+          notification.style.minHeight = rect.height + "px";
+        }
+
+        PopupNotifications.show(browser, notificationID, messageString, anchorID,
+                                action, null, options);
+
+        this._removeProgressNotification(browser);
+
+        Services.telemetry
+                .getHistogramById("SECURITY_UI")
+                .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
+      };
+
+      let downloadDuration = 0;
+      let progressNotification = PopupNotifications.getNotification("addon-progress", browser);
+      if (progressNotification)
+        downloadDuration = Date.now() - progressNotification._startTime;
+      let securityDelay = Services.prefs.getIntPref("security.dialog_enable_delay") - downloadDuration;
+      if (securityDelay > 0)
+        setTimeout(showNotification, securityDelay);
+      else
+        showNotification();
+      break; }
+    case "addon-install-complete": {
+      let needsRestart = installInfo.installs.some(function(i) {
         return i.addon.pendingOperations != AddonManager.PENDING_NONE;
       });
 
       if (needsRestart) {
         messageString = gNavigatorBundle.getString("addonsInstalledNeedsRestart");
         action = {
           label: gNavigatorBundle.getString("addonInstallRestartButton"),
           accessKey: gNavigatorBundle.getString("addonInstallRestartButton.accesskey"),
@@ -175,18 +280,23 @@ const gXPInstallObserver = {
 
       // Remove notificaion on dismissal, since it's possible to cancel the
       // install through the addons manager UI, making the "restart" prompt
       // irrelevant.
       options.removeOnDismissal = true;
 
       PopupNotifications.show(browser, notificationID, messageString, anchorID,
                               action, null, options);
-      break;
+      break; }
     }
+  },
+  _removeProgressNotification(aBrowser) {
+    let notification = PopupNotifications.getNotification("addon-progress", aBrowser);
+    if (notification)
+      notification.remove();
   }
 };
 
 var LightWeightThemeWebInstaller = {
   handleEvent: function (event) {
     switch (event.type) {
       case "InstallBrowserTheme":
       case "PreviewBrowserTheme":
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1218,16 +1218,17 @@ var gBrowserInit = {
     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-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();
 #ifdef E10S_TESTING_ONLY
     gRemoteTabsUI.init();
@@ -1529,16 +1530,17 @@ var gBrowserInit = {
       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-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);
       } catch (ex) {
         Cu.reportError(ex);
@@ -2098,16 +2100,22 @@ function loadURI(uri, referrer, postData
 
 function getShortcutOrURIAndPostData(aURL, aCallback) {
   let mayInheritPrincipal = false;
   let postData = null;
   let shortcutURL = null;
   let keyword = aURL;
   let param = "";
 
+  // XXX Bug 1100294 will remove this little hack by using an async version of
+  // PlacesUtils.getURLAndPostDataForKeyword(). For now we simulate an async
+  // execution with at least a setTimeout(fn, 0).
+  let originalCallback = aCallback;
+  aCallback = data => setTimeout(() => originalCallback(data));
+
   let offset = aURL.indexOf(" ");
   if (offset > 0) {
     keyword = aURL.substr(0, offset);
     param = aURL.substr(offset + 1);
   }
 
   let engine = Services.search.getEngineByAlias(keyword);
   if (engine) {
@@ -4546,18 +4554,22 @@ var TabsProgressListener = {
       }, true);
     }
   },
 
   onLocationChange: function (aBrowser, aWebProgress, aRequest, aLocationURI,
                               aFlags) {
     // Filter out location changes caused by anchor navigation
     // or history.push/pop/replaceState.
-    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+    if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+      // Reader mode actually cares about these:
+      let mm = gBrowser.selectedBrowser.messageManager;
+      mm.sendAsyncMessage("Reader:PushState");
       return;
+    }
 
     // Filter out location changes in sub documents.
     if (!aWebProgress.isTopLevel)
       return;
 
     // Only need to call locationChange if the PopupNotifications object
     // for this window has already been initialized (i.e. its getter no
     // longer exists)
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -484,24 +484,29 @@ let AboutReaderListener = {
   _articlePromise: null,
 
   init: function() {
     addEventListener("AboutReaderContentLoaded", this, false, true);
     addEventListener("DOMContentLoaded", this, false);
     addEventListener("pageshow", this, false);
     addEventListener("pagehide", this, false);
     addMessageListener("Reader:ParseDocument", this);
+    addMessageListener("Reader:PushState", this);
   },
 
   receiveMessage: function(message) {
     switch (message.name) {
       case "Reader:ParseDocument":
         this._articlePromise = ReaderMode.parseDocument(content.document).catch(Cu.reportError);
         content.document.location = "about:reader?url=" + encodeURIComponent(message.data.url);
         break;
+
+      case "Reader:PushState":
+        this.updateReaderButton();
+        break;
     }
   },
 
   get isAboutReader() {
     return content.document.documentURI.startsWith("about:reader");
   },
 
   handleEvent: function(aEvent) {
@@ -514,39 +519,44 @@ let AboutReaderListener = {
         if (!this.isAboutReader) {
           return;
         }
 
         if (content.document.body) {
           // Update the toolbar icon to show the "reader active" icon.
           sendAsyncMessage("Reader:UpdateReaderButton");
           new AboutReader(global, content, this._articlePromise);
+          this._articlePromise = null;
         }
         break;
 
       case "pagehide":
         sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: false });
         break;
 
       case "pageshow":
         // If a page is loaded from the bfcache, we won't get a "DOMContentLoaded"
         // event, so we need to rely on "pageshow" in this case.
-        if (!aEvent.persisted) {
-          break;
+        if (aEvent.persisted) {
+          this.updateReaderButton();
         }
-        // Fall through.
+        break;
       case "DOMContentLoaded":
-        if (!ReaderMode.isEnabledForParseOnLoad || this.isAboutReader) {
-          return;
-        }
+        this.updateReaderButton();
+        break;
 
-        let isArticle = ReaderMode.isProbablyReaderable(content.document);
-        sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: isArticle });
     }
-  }
+  },
+  updateReaderButton: function() {
+    if (!ReaderMode.isEnabledForParseOnLoad || this.isAboutReader) {
+      return;
+    }
+    let isArticle = ReaderMode.isProbablyReaderable(content.document);
+    sendAsyncMessage("Reader:UpdateReaderButton", { isArticle: isArticle });
+  },
 };
 AboutReaderListener.init();
 
 // An event listener for custom "WebChannelMessageToChrome" events on pages
 addEventListener("WebChannelMessageToChrome", function (e) {
   // if target is window then we want the document principal, otherwise fallback to target itself.
   let principal = e.target.nodePrincipal ? e.target.nodePrincipal : e.target.document.nodePrincipal;
 
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -5,62 +5,56 @@
            footertype="promobox"
            position="after_start"
            hidden="true"
            orient="vertical"
            role="alert"/>
 
     <popupnotification id="webRTC-shareDevices-notification" hidden="true">
       <popupnotificationcontent id="webRTC-selectCamera" orient="vertical">
-        <separator class="thin"/>
         <label value="&getUserMedia.selectCamera.label;"
                accesskey="&getUserMedia.selectCamera.accesskey;"
                control="webRTC-selectCamera-menulist"/>
         <menulist id="webRTC-selectCamera-menulist">
           <menupopup id="webRTC-selectCamera-menupopup"/>
         </menulist>
       </popupnotificationcontent>
 
       <popupnotificationcontent id="webRTC-selectWindowOrScreen" orient="vertical">
-        <separator class="thin"/>
         <label id="webRTC-selectWindow-label"
                control="webRTC-selectWindow-menulist"/>
         <menulist id="webRTC-selectWindow-menulist"
                   oncommand="gWebRTCUI.updateMainActionLabel(this);">
           <menupopup id="webRTC-selectWindow-menupopup"/>
         </menulist>
         <description id="webRTC-all-windows-shared" hidden="true">&getUserMedia.allWindowsShared.message;</description>
       </popupnotificationcontent>
 
       <popupnotificationcontent id="webRTC-selectMicrophone" orient="vertical">
-        <separator class="thin"/>
         <label value="&getUserMedia.selectMicrophone.label;"
                accesskey="&getUserMedia.selectMicrophone.accesskey;"
                control="webRTC-selectMicrophone-menulist"/>
         <menulist id="webRTC-selectMicrophone-menulist">
           <menupopup id="webRTC-selectMicrophone-menupopup"/>
         </menulist>
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="webapps-install-progress-notification" hidden="true">
-      <popupnotificationcontent id="webapps-install-progress-content" orient="vertical" align="start">
-        <separator class="thin"/>
-      </popupnotificationcontent>
+      <popupnotificationcontent id="webapps-install-progress-content" orient="vertical" align="start"/>
     </popupnotification>
 
     <popupnotification id="servicesInstall-notification" hidden="true">
       <popupnotificationcontent orient="vertical" align="start">
         <!-- XXX bug 974146, tests are looking for this, can't remove yet. -->
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="pointerLock-notification" hidden="true">
       <popupnotificationcontent orient="vertical" align="start">
-        <separator class="thin"/>
         <label id="pointerLock-cancel">&pointerLock.notification.message;</label>
       </popupnotificationcontent>
     </popupnotification>
 
     <popupnotification id="password-notification" hidden="true">
       <popupnotificationcontent orient="vertical">
         <textbox id="password-notification-username"/>
         <textbox id="password-notification-password" type="password"
@@ -68,8 +62,23 @@
       </popupnotificationcontent>
     </popupnotification>
 
 #ifdef E10S_TESTING_ONLY
     <popupnotification id="enable-e10s-notification" hidden="true">
       <popupnotificationcontent orient="vertical"/>
     </popupnotification>
 #endif
+
+    <popupnotification id="addon-progress-notification" hidden="true">
+      <button id="addon-progress-cancel"
+              oncommand="this.parentNode.cancel();"/>
+      <button id="addon-progress-accept" disabled="true"/>
+    </popupnotification>
+
+    <popupnotification id="addon-install-confirmation-notification" hidden="true">
+      <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+      <button id="addon-install-confirmation-cancel"
+              oncommand="PopupNotifications.getNotification('addon-install-confirmation').remove();"/>
+      <button id="addon-install-confirmation-accept"
+              oncommand="gXPInstallObserver.acceptInstallation();
+                         PopupNotifications.getNotification('addon-install-confirmation').remove();"/>
+    </popupnotification>
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -447,17 +447,16 @@ skip-if = e10s # Bug 1093941 - Waits ind
 [browser_urlbarEnter.js]
 skip-if = e10s # Bug 1093941 - used to cause obscure non-windows child process crashes on try
 [browser_urlbarEnterAfterMouseOver.js]
 skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test unreliability on Linux/e10s
 [browser_urlbarRevert.js]
 skip-if = e10s # Bug 1093941 - ESC reverted the location bar value - Got foobar, expected example.com
 [browser_urlbarSearchSingleWordNotification.js]
 [browser_urlbarStop.js]
-skip-if = e10s # Bug 1093941 - test calls gBrowser.contentWindow.stop
 [browser_urlbarTrimURLs.js]
 [browser_urlbar_search_healthreport.js]
 [browser_utilityOverlay.js]
 [browser_visibleFindSelection.js]
 skip-if = e10s # Bug 921935 - focusmanager issues with e10s (test calls waitForFocus)
 [browser_visibleLabel.js]
 [browser_visibleTabs.js]
 [browser_visibleTabs_bookmarkAllPages.js]
--- a/browser/base/content/test/general/browser_bug1064280_changeUrlInPinnedTab.js
+++ b/browser/base/content/test/general/browser_bug1064280_changeUrlInPinnedTab.js
@@ -1,33 +1,35 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-add_task(function(){
+add_task(function* () {
   // Test that changing the URL in a pinned tab works correctly
 
   let TEST_LINK_INITIAL = "about:";
   let TEST_LINK_CHANGED = "about:support";
 
   let appTab = gBrowser.addTab(TEST_LINK_INITIAL);
+  let browser = appTab.linkedBrowser;
+  yield BrowserTestUtils.browserLoaded(browser);
+
   gBrowser.pinTab(appTab);
   is(appTab.pinned, true, "Tab was successfully pinned");
 
   let initialTabsNo = gBrowser.tabs.length;
 
   let goButton = document.getElementById("urlbar-go-button");
   gBrowser.selectedTab = appTab;
   gURLBar.focus();
   gURLBar.value = TEST_LINK_CHANGED;
 
-  let promisePageload = promiseTabLoadEvent(appTab);
   goButton.click();
-  yield promisePageload;
+  yield BrowserTestUtils.browserLoaded(browser);
 
   is(appTab.linkedBrowser.currentURI.spec, TEST_LINK_CHANGED,
      "New page loaded in the app tab");
   is(gBrowser.tabs.length, initialTabsNo, "No additional tabs were opened");
 });
 
 registerCleanupFunction(function () {
   gBrowser.removeTab(gBrowser.selectedTab);
--- a/browser/base/content/test/general/browser_bug553455.js
+++ b/browser/base/content/test/general/browser_bug553455.js
@@ -1,92 +1,96 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/
  */
 
 const TESTROOT = "http://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
 const TESTROOT2 = "http://example.org/browser/toolkit/mozapps/extensions/test/xpinstall/";
 const SECUREROOT = "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
-const XPINSTALL_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
 const PREF_INSTALL_REQUIREBUILTINCERTS = "extensions.install.requireBuiltInCerts";
-const PROGRESS_NOTIFICATION = "addon-progress-notification";
+const PROGRESS_NOTIFICATION = "addon-progress";
 
 var rootDir = getRootDirectory(gTestPath);
 var path = rootDir.split('/');
 var chromeName = path[0] + '//' + path[2];
 var croot = chromeName + "/content/browser/toolkit/mozapps/extensions/test/xpinstall/";
 var jar = getJar(croot);
 if (jar) {
   var tmpdir = extractJarToTmp(jar);
   croot = 'file://' + tmpdir.path + '/';
 }
 const CHROMEROOT = croot;
 
 var gApp = document.getElementById("bundle_brand").getString("brandShortName");
 var gVersion = Services.appinfo.version;
-var check_notification;
+
+function get_observer_topic(aNotificationId) {
+  let topic = aNotificationId;
+  if (topic == "xpinstall-disabled")
+    topic = "addon-install-disabled";
+  else if (topic == "addon-progress")
+    topic = "addon-install-started";
+  return topic;
+}
 
 function wait_for_progress_notification(aCallback) {
   wait_for_notification(PROGRESS_NOTIFICATION, aCallback, "popupshowing");
 }
 
 function wait_for_notification(aId, aCallback, aEvent = "popupshown") {
   info("Waiting for " + aId + " notification");
-  check_notification = function() {
+
+  let topic = get_observer_topic(aId);
+  function observer(aSubject, aTopic, aData) {
     // Ignore the progress notification unless that is the notification we want
-    if (aId != PROGRESS_NOTIFICATION && PopupNotifications.panel.childNodes[0].id == PROGRESS_NOTIFICATION)
+    if (aId != PROGRESS_NOTIFICATION &&
+        aTopic == get_observer_topic(PROGRESS_NOTIFICATION))
       return;
 
-    PopupNotifications.panel.removeEventListener(aEvent, check_notification, false);
+    Services.obs.removeObserver(observer, topic);
+
+    if (PopupNotifications.isPanelOpen)
+      executeSoon(verify);
+    else
+      PopupNotifications.panel.addEventListener(aEvent, event_listener);
+  }
+
+  function event_listener() {
+    // Ignore the progress notification unless that is the notification we want
+    if (aId != PROGRESS_NOTIFICATION &&
+        PopupNotifications.panel.childNodes[0].id == PROGRESS_NOTIFICATION + "-notification")
+      return;
+
+    PopupNotifications.panel.removeEventListener(aEvent, event_listener);
+
+    verify();
+  }
+
+  function verify() {
     info("Saw a notification");
+    ok(PopupNotifications.isPanelOpen, "Panel should be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-    if (PopupNotifications.panel.childNodes.length)
-      is(PopupNotifications.panel.childNodes[0].id, aId, "Should have seen the right notification");
+    if (PopupNotifications.panel.childNodes.length) {
+      is(PopupNotifications.panel.childNodes[0].id,
+         aId + "-notification", "Should have seen the right notification");
+    }
     aCallback(PopupNotifications.panel);
-  };
-  PopupNotifications.panel.addEventListener(aEvent, check_notification, false);
+  }
+
+  Services.obs.addObserver(observer, topic, false);
 }
 
 function wait_for_notification_close(aCallback) {
   info("Waiting for notification to close");
   PopupNotifications.panel.addEventListener("popuphidden", function() {
     PopupNotifications.panel.removeEventListener("popuphidden", arguments.callee, false);
     aCallback();
   }, false);
 }
 
-function wait_for_install_dialog(aCallback) {
-  info("Waiting for install dialog");
-  Services.wm.addListener({
-    onOpenWindow: function(aXULWindow) {
-      info("Install dialog opened, waiting for focus");
-      Services.wm.removeListener(this);
-
-      var domwindow = aXULWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                                .getInterface(Ci.nsIDOMWindow);
-      waitForFocus(function() {
-        info("Saw install dialog");
-        is(domwindow.document.location.href, XPINSTALL_URL, "Should have seen the right window open");
-
-        // Override the countdown timer on the accept button
-        var button = domwindow.document.documentElement.getButton("accept");
-        button.disabled = false;
-
-        aCallback(domwindow);
-      }, domwindow);
-    },
-
-    onCloseWindow: function(aXULWindow) {
-    },
-
-    onWindowTitleChange: function(aXULWindow, aNewTitle) {
-    }
-  });
-}
-
 function wait_for_single_notification(aCallback) {
   function inner_waiter() {
     info("Waiting for single notification");
     // Notification should never close while we wait
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     if (PopupNotifications.panel.childNodes.length == 2) {
       executeSoon(inner_waiter);
       return;
@@ -109,17 +113,17 @@ function setup_redirect(aSettings) {
   req.send(null);
 }
 
 var TESTS = [
 function test_disabled_install() {
   Services.prefs.setBoolPref("xpinstall.enabled", false);
 
   // Wait for the disabled notification
-  wait_for_notification("xpinstall-disabled-notification", function(aPanel) {
+  wait_for_notification("xpinstall-disabled", function(aPanel) {
     let notification = aPanel.childNodes[0];
     is(notification.button.label, "Enable", "Should have seen the right button");
     is(notification.getAttribute("label"),
        "Software installation is currently disabled. Click Enable and try again.");
 
     wait_for_notification_close(function() {
       try {
         ok(Services.prefs.getBoolPref("xpinstall.enabled"), "Installation should be enabled");
@@ -146,105 +150,112 @@ function test_disabled_install() {
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_blocked_install() {
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
     is(notification.button.label, "Allow", "Should have seen the right button");
+    is(notification.getAttribute("originhost"), "example.com",
+       "Should have seen the right origin host");
     is(notification.getAttribute("label"),
-       gApp + " prevented this site (example.com) from asking you to install " +
-       "software on your computer.",
+       gApp + " prevented this site from asking you to install software on your computer.",
        "Should have seen the right message");
 
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
         is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
 
     // Click on Allow
     EventUtils.synthesizeMouse(notification.button, 20, 10, {});
 
     // Notification should have changed to progress notification
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     notification = aPanel.childNodes[0];
     is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-
   });
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_whitelisted_install() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
+    gBrowser.selectedTab = originalTab;
+
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
+      is(gBrowser.selectedTab, tab,
+         "tab selected in response to the addon-install-confirmation notification");
+
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           Services.perms.remove("example.com", "install");
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
-  gBrowser.selectedTab = gBrowser.addTab();
+  let originalTab = gBrowser.selectedTab;
+  let tab = gBrowser.addTab();
+  gBrowser.selectedTab = tab;
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_failed_download() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "The add-on could not be downloaded because of a connection failure " +
          "on example.com.",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -261,17 +272,17 @@ function test_failed_download() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_corrupt_file() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "The add-on downloaded from example.com could not be installed " +
          "because it appears to be corrupt.",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -288,17 +299,17 @@ function test_corrupt_file() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_incompatible() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the failed notification
-    wait_for_notification("addon-install-failed-notification", function(aPanel) {
+    wait_for_notification("addon-install-failed", function(aPanel) {
       let notification = aPanel.childNodes[0];
       is(notification.getAttribute("label"),
          "XPI Test could not be installed because it is not compatible with " +
          gApp + " " + gVersion + ".",
          "Should have seen the right message");
 
       Services.perms.remove("example.com", "install");
       wait_for_notification_close(runNextTest);
@@ -315,19 +326,19 @@ function test_incompatible() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_restartless() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.getAttribute("label"),
            "XPI Test has been installed successfully.",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 0, "Should be no pending installs");
 
@@ -336,17 +347,17 @@ function test_restartless() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "restartless.xpi"
@@ -354,19 +365,19 @@ function test_restartless() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_multiple() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "2 add-ons will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
@@ -377,17 +388,17 @@ function test_multiple() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Unsigned XPI": "unsigned.xpi",
@@ -396,35 +407,35 @@ function test_multiple() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_url() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAllInstalls(function(aInstalls) {
           is(aInstalls.length, 1, "Should be one pending install");
           aInstalls[0].cancel();
 
           wait_for_notification_close(runNextTest);
           gBrowser.removeTab(gBrowser.selectedTab);
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "unsigned.xpi");
 },
 
 function test_localfile() {
@@ -462,17 +473,17 @@ function test_wronghost() {
     if (gBrowser.currentURI.spec != TESTROOT2 + "enabled.html")
       return;
 
     gBrowser.removeEventListener("load", arguments.callee, true);
 
     // Wait for the progress notification
     wait_for_progress_notification(function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-failed-notification", function(aPanel) {
+      wait_for_notification("addon-install-failed", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.getAttribute("label"),
            "The add-on downloaded from example.com could not be installed " +
            "because it appears to be corrupt.",
            "Should have seen the right message");
 
         wait_for_notification_close(runNextTest);
         gBrowser.removeTab(gBrowser.selectedTab);
@@ -483,19 +494,19 @@ function test_wronghost() {
   }, true);
   gBrowser.loadURI(TESTROOT2 + "enabled.html");
 },
 
 function test_reload() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "XPI Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         function test_fail() {
           ok(false, "Reloading should not have hidden the notification");
@@ -518,17 +529,17 @@ function test_reload() {
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         }, true);
         gBrowser.loadURI(TESTROOT2 + "enabled.html");
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Unsigned XPI": "unsigned.xpi"
@@ -536,19 +547,19 @@ function test_reload() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_theme() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         let notification = aPanel.childNodes[0];
         is(notification.button.label, "Restart Now", "Should have seen the right button");
         is(notification.getAttribute("label"),
            "Theme Test will be installed after you restart " + gApp + ".",
            "Should have seen the right message");
 
         AddonManager.getAddonByID("{972ce4c6-7e08-4474-a285-3208198ce6fd}", function(aAddon) {
           ok(aAddon.userDisabled, "Should be switching away from the default theme.");
@@ -561,39 +572,39 @@ function test_theme() {
 
             Services.perms.remove("example.com", "install");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
           });
         });
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "Theme XPI": "theme.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_renotify_blocked() {
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  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-notification", function(aPanel) {
+        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();
 
             info("Closing browser tab");
             wait_for_notification_close(runNextTest);
             gBrowser.removeTab(gBrowser.selectedTab);
@@ -614,135 +625,104 @@ function test_renotify_blocked() {
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
 function test_renotify_installed() {
   // Wait for the progress notification
   wait_for_progress_notification(function(aPanel) {
     // Wait for the install confirmation dialog
-    wait_for_install_dialog(function(aWindow) {
+    wait_for_notification("addon-install-confirmation", function(aPanel) {
       // Wait for the complete notification
-      wait_for_notification("addon-install-complete-notification", function(aPanel) {
+      wait_for_notification("addon-install-complete", function(aPanel) {
         // Dismiss the notification
         wait_for_notification_close(function () {
           // Install another
           executeSoon(function () {
             // Wait for the progress notification
             wait_for_progress_notification(function(aPanel) {
               // Wait for the install confirmation dialog
-              wait_for_install_dialog(function(aWindow) {
+              wait_for_notification("addon-install-confirmation", function(aPanel) {
                 info("Timeouts after this probably mean bug 589954 regressed");
 
                 // Wait for the complete notification
-                wait_for_notification("addon-install-complete-notification", function(aPanel) {
+                wait_for_notification("addon-install-complete", function(aPanel) {
                   AddonManager.getAllInstalls(function(aInstalls) {
                   is(aInstalls.length, 1, "Should be one pending installs");
                     aInstalls[0].cancel();
 
                     Services.perms.remove("example.com", "install");
                     wait_for_notification_close(runNextTest);
                     gBrowser.removeTab(gBrowser.selectedTab);
                   });
                 });
 
-                aWindow.document.documentElement.acceptDialog();
+                document.getElementById("addon-install-confirmation-accept").click();
               });
             });
 
             gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
           });
         });
 
         // hide the panel (this simulates the user dismissing it)
         aPanel.hidePopup();
       });
 
-      aWindow.document.documentElement.acceptDialog();
+      document.getElementById("addon-install-confirmation-accept").click();
     });
   });
 
   var pm = Services.perms;
   pm.add(makeURI("http://example.com/"), "install", pm.ALLOW_ACTION);
 
   var triggers = encodeURIComponent(JSON.stringify({
     "XPI": "unsigned.xpi"
   }));
   gBrowser.selectedTab = gBrowser.addTab();
   gBrowser.loadURI(TESTROOT + "installtrigger.html?" + triggers);
 },
 
-function test_cancel_restart() {
+function test_cancel() {
   function complete_install(callback) {
     let url = TESTROOT + "slowinstall.sjs?continue=true"
     NetUtil.asyncFetch(url, callback || (() => {}));
   }
 
   // Wait for the progress notification
   wait_for_notification(PROGRESS_NOTIFICATION, function(aPanel) {
     let notification = aPanel.childNodes[0];
     // Close the notification
     let anchor = document.getElementById("addons-notification-icon");
     anchor.click();
     // Reopen the notification
     anchor.click();
 
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-    isnot(notification, aPanel.childNodes[0], "Should have reconstructed the notification UI");
     notification = aPanel.childNodes[0];
     is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-    let button = document.getAnonymousElementByAttribute(notification, "anonid", "cancel");
+    let button = document.getElementById("addon-progress-cancel");
 
     // Wait for the install to fully cancel
     let install = notification.notification.options.installs[0];
     install.addListener({
       onDownloadCancelled: function() {
         install.removeListener(this);
 
         executeSoon(function() {
-          ok(PopupNotifications.isPanelOpen, "Notification should still be open");
-          is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-          isnot(notification, aPanel.childNodes[0], "Should have reconstructed the notification UI");
-          notification = aPanel.childNodes[0];
-          is(notification.id, "addon-install-cancelled-notification", "Should have seen the cancelled notification");
+          ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
 
-          // Wait for the install confirmation dialog
-          wait_for_install_dialog(function(aWindow) {
-            // Wait for the complete notification
-            wait_for_notification("addon-install-complete-notification", function(aPanel) {
-              let notification = aPanel.childNodes[0];
-              is(notification.button.label, "Restart Now", "Should have seen the right button");
-              is(notification.getAttribute("label"),
-                 "XPI Test will be installed after you restart " + gApp + ".",
-                 "Should have seen the right message");
-
-              AddonManager.getAllInstalls(function(aInstalls) {
-                is(aInstalls.length, 1, "Should be one pending install");
-                aInstalls[0].cancel();
+          AddonManager.getAllInstalls(function(aInstalls) {
+            is(aInstalls.length, 0, "Should be no pending install");
 
-                Services.perms.remove("example.com", "install");
-                wait_for_notification_close(runNextTest);
-                gBrowser.removeTab(gBrowser.selectedTab);
-              });
-            });
-
-            aWindow.document.documentElement.acceptDialog();
+            Services.perms.remove("example.com", "install");
+            gBrowser.removeTab(gBrowser.selectedTab);
+            runNextTest();
           });
-
-          // Restart the download
-          EventUtils.synthesizeMouseAtCenter(notification.button, {});
-
-          // Should be back to a progress notification
-          ok(PopupNotifications.isPanelOpen, "Notification should still be open");
-          is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
-          notification = aPanel.childNodes[0];
-          is(notification.id, "addon-progress-notification", "Should have seen the progress notification");
-
-          complete_install();
         });
       }
     });
 
     // Cancel the download
     EventUtils.synthesizeMouseAtCenter(button, {});
   });
 
@@ -759,17 +739,17 @@ function test_cancel_restart() {
 function test_failed_security() {
   Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
 
   setup_redirect({
     "Location": TESTROOT + "unsigned.xpi"
   });
 
   // Wait for the blocked notification
-  wait_for_notification("addon-install-blocked-notification", function(aPanel) {
+  wait_for_notification("addon-install-blocked", function(aPanel) {
     let notification = aPanel.childNodes[0];
 
     // Click on Allow
     EventUtils.synthesizeMouse(notification.button, 20, 10, {});
 
     // Notification should have changed to progress notification
     ok(PopupNotifications.isPanelOpen, "Notification should still be open");
     is(PopupNotifications.panel.childNodes.length, 1, "Should be only one notification");
@@ -836,36 +816,37 @@ var XPInstallObserver = {
 
 function test() {
   requestLongerTimeout(4);
   waitForExplicitFinish();
 
   Services.prefs.setBoolPref("extensions.logging.enabled", true);
   Services.prefs.setBoolPref("extensions.strictCompatibility", true);
   Services.prefs.setBoolPref("extensions.install.requireSecureOrigin", false);
+  Services.prefs.setIntPref("security.dialog_enable_delay", 0);
 
   Services.obs.addObserver(XPInstallObserver, "addon-install-started", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-blocked", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-failed", false);
   Services.obs.addObserver(XPInstallObserver, "addon-install-complete", false);
 
   registerCleanupFunction(function() {
     // Make sure no more test parts run in case we were timed out
     TESTS = [];
-    PopupNotifications.panel.removeEventListener("popupshown", check_notification, false);
 
     AddonManager.getAllInstalls(function(aInstalls) {
       aInstalls.forEach(function(aInstall) {
         aInstall.cancel();
       });
     });
 
     Services.prefs.clearUserPref("extensions.logging.enabled");
     Services.prefs.clearUserPref("extensions.strictCompatibility");
     Services.prefs.clearUserPref("extensions.install.requireSecureOrigin");
+    Services.prefs.clearUserPref("security.dialog_enable_delay");
 
     Services.obs.removeObserver(XPInstallObserver, "addon-install-started");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-blocked");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-failed");
     Services.obs.removeObserver(XPInstallObserver, "addon-install-complete");
   });
 
   runNextTest();
--- a/browser/base/content/test/general/browser_locationBarCommand.js
+++ b/browser/base/content/test/general/browser_locationBarCommand.js
@@ -1,212 +1,214 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 const TEST_VALUE = "example.com";
 const START_VALUE = "example.org";
 
-let gFocusManager = Services.focus;
-
-function test() {
-  waitForExplicitFinish();
-
-  registerCleanupFunction(function () {
-    Services.prefs.clearUserPref("browser.altClickSave");
-  });
+add_task(function* setup() {
   Services.prefs.setBoolPref("browser.altClickSave", true);
 
-  runAltLeftClickTest();
-}
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("browser.altClickSave");
+  });
+});
+
+add_task(function* alt_left_click_test() {
+  info("Running test: Alt left click");
 
-// Monkey patch saveURL to avoid dealing with file save code paths
-var oldSaveURL = saveURL;
-saveURL = function() {
+  // Monkey patch saveURL() to avoid dealing with file save code paths.
+  let oldSaveURL = saveURL;
+  let saveURLPromise = new Promise(resolve => {
+    saveURL = () => {
+      // Restore old saveURL() value.
+      saveURL = oldSaveURL;
+      resolve();
+    };
+  });
+
+  triggerCommand(true, {altKey: true});
+
+  yield saveURLPromise;
   ok(true, "SaveURL was called");
   is(gURLBar.value, "", "Urlbar reverted to original value");
-  saveURL = oldSaveURL;
-  runShiftLeftClickTest();
-}
-function runAltLeftClickTest() {
-  info("Running test: Alt left click");
-  triggerCommand(true, { altKey: true });
+});
+
+add_task(function* shift_left_click_test() {
+  info("Running test: Shift left click");
+
+  let newWindowPromise = promiseWaitForNewWindow();
+  triggerCommand(true, {shiftKey: true});
+  let win = yield newWindowPromise;
+
+  // Wait for the initial browser to load.
+  let browser = win.gBrowser.selectedBrowser;
+  yield BrowserTestUtils.browserLoaded(browser);
+
+  info("URL should be loaded in a new window");
+  is(gURLBar.value, "", "Urlbar reverted to original value");
+  is(Services.focus.focusedElement, null, "There should be no focused element");
+  is(Services.focus.focusedWindow, win.gBrowser.contentWindow, "Content window should be focused");
+  is(win.gURLBar.textValue, TEST_VALUE, "New URL is loaded in new window");
+
+  // Cleanup.
+  yield promiseWindowClosed(win);
+});
+
+add_task(function* right_click_test() {
+  info("Running test: Right click on go button");
+
+  // Add a new tab.
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+  triggerCommand(true, {button: 2});
+
+  // Right click should do nothing (context menu will be shown).
+  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+});
+
+add_task(function* shift_accel_left_click_test() {
+  info("Running test: Shift+Ctrl/Cmd left click on go button");
+
+  // Add a new tab.
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+  let loadStartedPromise = promiseLoadStarted();
+  triggerCommand(true, {accelKey: true, shiftKey: true});
+  yield loadStartedPromise;
+
+  // Check the load occurred in a new background tab.
+  info("URL should be loaded in a new background tab");
+  is(gURLBar.value, "", "Urlbar reverted to original value");
+  ok(!gURLBar.focused, "Urlbar is no longer focused after urlbar command");
+  is(gBrowser.selectedTab, tab, "Focus did not change to the new tab");
+
+  // Select the new background tab
+  gBrowser.selectedTab = gBrowser.selectedTab.nextSibling;
+  is(gURLBar.value, TEST_VALUE, "New URL is loaded in new tab");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+  gBrowser.removeCurrentTab();
+});
+
+add_task(function* load_in_current_tab_test() {
+  let tests = [
+    {desc: "Simple return keypress"},
+    {desc: "Left click on go button", click: true},
+    {desc: "Ctrl/Cmd+Return keypress", event: {accelKey: true}},
+    {desc: "Alt+Return keypress in a blank tab", event: {altKey: true}}
+  ];
+
+  for (let test of tests) {
+    info(`Running test: ${test.desc}`);
+
+    // Add a new tab.
+    let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+    // Trigger a load and check it occurs in the current tab.
+    let loadStartedPromise = promiseLoadStarted();
+    triggerCommand(test.click || false, test.event || {});
+    yield loadStartedPromise;
+
+    info("URL should be loaded in the current tab");
+    is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+    is(Services.focus.focusedElement, null, "There should be no focused element");
+    is(Services.focus.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
+    is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab");
+
+    // Cleanup.
+    gBrowser.removeCurrentTab();
+  }
+});
+
+add_task(function* load_in_new_tab_test() {
+  let tests = [
+    {desc: "Ctrl/Cmd left click on go button", click: true, event: {accelKey: true}},
+    {desc: "Alt+Return keypress in a dirty tab", event: {altKey: true}, url: START_VALUE}
+  ];
+
+  for (let test of tests) {
+    info(`Running test: ${test.desc}`);
+
+    // Add a new tab.
+    let tab = gBrowser.selectedTab = gBrowser.addTab(test.url || "about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+    // Trigger a load and check it occurs in the current tab.
+    let tabSelectedPromise = promiseNewTabSelected();
+    triggerCommand(test.click || false, test.event || {});
+    yield tabSelectedPromise;
+
+    // Check the load occurred in a new tab.
+    info("URL should be loaded in a new focused tab");
+    is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
+    is(Services.focus.focusedElement, null, "There should be no focused element");
+    is(Services.focus.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
+    isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab");
+
+    // Cleanup.
+    gBrowser.removeCurrentTab();
+    gBrowser.removeCurrentTab();
+  }
+});
+
+function triggerCommand(shouldClick, event) {
+  gURLBar.value = TEST_VALUE;
+  gURLBar.focus();
+
+  if (shouldClick) {
+    is(gURLBar.getAttribute("pageproxystate"), "invalid",
+       "page proxy state must be invalid for go button to be visible");
+
+    let goButton = document.getElementById("urlbar-go-button");
+    EventUtils.synthesizeMouseAtCenter(goButton, event);
+  } else {
+    EventUtils.synthesizeKey("VK_RETURN", event);
+  }
 }
 
-function runShiftLeftClickTest() {
-  let listener = new BrowserWindowListener(getBrowserURL(), function(aWindow) {
-    Services.wm.removeListener(listener);
-    addPageShowListener(aWindow.gBrowser.selectedBrowser, function() {
-      executeSoon(function () {
-        info("URL should be loaded in a new window");
-        is(gURLBar.value, "", "Urlbar reverted to original value");
-        is(gFocusManager.focusedElement, null, "There should be no focused element");
-        is(gFocusManager.focusedWindow, aWindow.gBrowser.contentWindow, "Content window should be focused");
-        is(aWindow.gURLBar.textValue, TEST_VALUE, "New URL is loaded in new window");
-
-        aWindow.close();
-
-        // Continue testing when the original window has focus again.
-        whenWindowActivated(window, runNextTest);
-      });
-    }, "http://example.com/");
-  });
-  Services.wm.addListener(listener);
-
-  info("Running test: Shift left click");
-  triggerCommand(true, { shiftKey: true });
-}
-
-function runNextTest() {
-  let test = gTests.shift();
-  if (!test) {
-    finish();
-    return;
-  }
-
-  info("Running test: " + test.desc);
-  // Tab will be blank if test.startValue is null
-  let tab = gBrowser.selectedTab = gBrowser.addTab(test.startValue);
-  addPageShowListener(gBrowser.selectedBrowser, function() {
-    triggerCommand(test.click, test.event);
-    test.check(tab);
-
-    // Clean up
-    while (gBrowser.tabs.length > 1)
-      gBrowser.removeTab(gBrowser.selectedTab)
-    runNextTest();
+function promiseLoadStarted() {
+  return new Promise(resolve => {
+    gBrowser.addTabsProgressListener({
+      onStateChange(browser, webProgress, req, flags, status) {
+        if (flags & Ci.nsIWebProgressListener.STATE_START) {
+          gBrowser.removeTabsProgressListener(this);
+          resolve();
+        }
+      }
+    });
   });
 }
 
-let gTests = [
-  { desc: "Right click on go button",
-    click: true,
-    event: { button: 2 },
-    check: function(aTab) {
-      // Right click should do nothing (context menu will be shown)
-      is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-    }
-  },
-
-  { desc: "Left click on go button",
-    click: true,
-    event: {},
-    check: checkCurrent
-  },
-
-  { desc: "Ctrl/Cmd left click on go button",
-    click: true,
-    event: { accelKey: true },
-    check: checkNewTab
-  },
-
-  { desc: "Shift+Ctrl/Cmd left click on go button",
-    click: true,
-    event: { accelKey: true, shiftKey: true },
-    check: function(aTab) {
-      info("URL should be loaded in a new background tab");
-      is(gURLBar.value, "", "Urlbar reverted to original value");
-      ok(!gURLBar.focused, "Urlbar is no longer focused after urlbar command");
-      is(gBrowser.selectedTab, aTab, "Focus did not change to the new tab");
-
-      // Select the new background tab
-      gBrowser.selectedTab = gBrowser.selectedTab.nextSibling;
-      is(gURLBar.value, TEST_VALUE, "New URL is loaded in new tab");
-    }
-  },
-
-  { desc: "Simple return keypress",
-    event: {},
-    check: checkCurrent
-  },
-
-  { desc: "Alt+Return keypress in a blank tab",
-    event: { altKey: true },
-    check: checkCurrent
-  },
-
-  { desc: "Alt+Return keypress in a dirty tab",
-    event: { altKey: true },
-    check: checkNewTab,
-    startValue: START_VALUE
-  },
-
-  { desc: "Ctrl/Cmd+Return keypress",
-    event: { accelKey: true },
-    check: checkCurrent
-  }
-]
-
-let gGoButton = document.getElementById("urlbar-go-button");
-function triggerCommand(aClick, aEvent) {
-  gURLBar.value = TEST_VALUE;
-  gURLBar.focus();
-
-  if (aClick) {
-    is(gURLBar.getAttribute("pageproxystate"), "invalid",
-       "page proxy state must be invalid for go button to be visible");
-    EventUtils.synthesizeMouseAtCenter(gGoButton, aEvent);
-  }
-  else
-    EventUtils.synthesizeKey("VK_RETURN", aEvent);
-}
-
-/* Checks that the URL was loaded in the current tab */
-function checkCurrent(aTab) {
-  info("URL should be loaded in the current tab");
-  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-  is(gFocusManager.focusedElement, null, "There should be no focused element");
-  is(gFocusManager.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
-  is(gBrowser.selectedTab, aTab, "New URL was loaded in the current tab");
-}
-
-/* Checks that the URL was loaded in a new focused tab */
-function checkNewTab(aTab) {
-  info("URL should be loaded in a new focused tab");
-  is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered");
-  is(gFocusManager.focusedElement, null, "There should be no focused element");
-  is(gFocusManager.focusedWindow, gBrowser.contentWindow, "Content window should be focused");
-  isnot(gBrowser.selectedTab, aTab, "New URL was loaded in a new tab");
-}
-
-function addPageShowListener(browser, cb, expectedURL) {
-  browser.addEventListener("pageshow", function pageShowListener() {
-    info("pageshow: " + browser.currentURI.spec);
-    if (expectedURL && browser.currentURI.spec != expectedURL)
-      return; // ignore pageshows for non-expected URLs
-    browser.removeEventListener("pageshow", pageShowListener, false);
-    cb();
+function promiseNewTabSelected() {
+  return new Promise(resolve => {
+    gBrowser.tabContainer.addEventListener("TabSelect", function onSelect() {
+      gBrowser.tabContainer.removeEventListener("TabSelect", onSelect);
+      resolve();
+    });
   });
 }
 
-function whenWindowActivated(win, cb) {
-  if (Services.focus.activeWindow == win) {
-    executeSoon(cb);
-    return;
-  }
+function promiseWaitForNewWindow() {
+  return new Promise(resolve => {
+    let listener = {
+      onOpenWindow(xulWindow) {
+        let win = xulWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDOMWindow);
 
-  win.addEventListener("activate", function onActivate() {
-    win.removeEventListener("activate", onActivate);
-    executeSoon(cb);
+        Services.wm.removeListener(listener);
+        whenDelayedStartupFinished(win, () => resolve(win));
+      },
+
+      onCloseWindow() {},
+      onWindowTitleChange() {}
+    };
+
+    Services.wm.addListener(listener);
   });
 }
-
-function BrowserWindowListener(aURL, aCallback) {
-  this.callback = aCallback;
-  this.url = aURL;
-}
-BrowserWindowListener.prototype = {
-  onOpenWindow: function(aXULWindow) {
-    let cb = () => this.callback(domwindow);
-    let domwindow = aXULWindow.QueryInterface(Ci.nsIInterfaceRequestor)
-                              .getInterface(Ci.nsIDOMWindow);
-
-    let numWait = 2;
-    function maybeRunCallback() {
-      if (--numWait == 0)
-        cb();
-    }
-
-    whenWindowActivated(domwindow, maybeRunCallback);
-    whenDelayedStartupFinished(domwindow, maybeRunCallback);
-  },
-  onCloseWindow: function(aXULWindow) {},
-  onWindowTitleChange: function(aXULWindow, aNewTitle) {}
-}
--- a/browser/base/content/test/general/browser_urlbarEnter.js
+++ b/browser/base/content/test/general/browser_urlbarEnter.js
@@ -1,69 +1,40 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
+"use strict";
+
 const TEST_VALUE = "example.com/\xF7?\xF7";
 const START_VALUE = "example.com/%C3%B7?%C3%B7";
 
-function test() {
-  waitForExplicitFinish();
-  runNextTest();
-}
+add_task(function* () {
+  info("Simple return keypress");
+  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
 
-function locationBarEnter(aEvent, aClosure) {
-  executeSoon(function() {
-    gURLBar.focus();
-    EventUtils.synthesizeKey("VK_RETURN", aEvent);
-    addPageShowListener(aClosure);
-  });
-}
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {});
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-function runNextTest() {
-  let test = gTests.shift();
-  if (!test) {
-    finish();
-    return;
-  }
-  
-  info("Running test: " + test.desc);
-  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
-  addPageShowListener(function() {
-    locationBarEnter(test.event, function() {
-      test.check(tab);
+  // Check url bar and selected tab.
+  is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
+  is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab");
+
+  // Cleanup.
+  gBrowser.removeCurrentTab();
+});
 
-      // Clean up
-      while (gBrowser.tabs.length > 1)
-        gBrowser.removeTab(gBrowser.selectedTab)
-      runNextTest();
-    });
-  });
-}
+add_task(function* () {
+  info("Alt+Return keypress");
+  let tab = gBrowser.selectedTab = gBrowser.addTab(START_VALUE);
 
-let gTests = [
-  { desc: "Simple return keypress",
-    event: {},
-    check: checkCurrent
-  },
+  gURLBar.focus();
+  EventUtils.synthesizeKey("VK_RETURN", {altKey: true});
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-  { desc: "Alt+Return keypress",
-    event: { altKey: true },
-    check: checkNewTab,
-  },
-]
-
-function checkCurrent(aTab) {
+  // Check url bar and selected tab.
   is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
-  is(gBrowser.selectedTab, aTab, "New URL was loaded in the current tab");
-}
+  isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab");
 
-function checkNewTab(aTab) {
-  is(gURLBar.textValue, TEST_VALUE, "Urlbar should preserve the value on return keypress");
-  isnot(gBrowser.selectedTab, aTab, "New URL was loaded in a new tab");
-}
-
-function addPageShowListener(aFunc) {
-  gBrowser.selectedBrowser.addEventListener("pageshow", function loadListener() {
-    gBrowser.selectedBrowser.removeEventListener("pageshow", loadListener, false);
-    aFunc();
-  });
-}
-
+  // Cleanup.
+  gBrowser.removeTab(tab);
+  gBrowser.removeCurrentTab();
+});
--- a/browser/base/content/test/general/browser_urlbarSearchSingleWordNotification.js
+++ b/browser/base/content/test/general/browser_urlbarSearchSingleWordNotification.js
@@ -55,23 +55,25 @@ function* runURLBarSearchTest(valueToOpe
 
   yield Promise.all([
     docLoadPromise,
     promiseNotificationForTab(aWindow.gBrowser, "keyword-uri-fixup", expectNotification)
   ]);
 }
 
 add_task(function* test_navigate_full_domain() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("www.mozilla.org", false, false);
   gBrowser.removeTab(tab);
 });
 
 add_task(function* test_navigate_numbers() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("1234", true, false);
   gBrowser.removeTab(tab);
 });
 
 function get_test_function_for_localhost_with_hostname(hostName, isPrivate) {
   return function* test_navigate_single_host() {
     const pref = "browser.fixup.domainwhitelist.localhost";
     let win;
@@ -79,17 +81,18 @@ function get_test_function_for_localhost
       win = yield promiseOpenAndLoadWindow({private: true}, true);
       let deferredOpenFocus = Promise.defer();
       waitForFocus(deferredOpenFocus.resolve, win);
       yield deferredOpenFocus.promise;
     } else {
       win = window;
     }
     let browser = win.gBrowser;
-    let tab = browser.selectedTab = browser.addTab();
+    let tab = browser.selectedTab = browser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
 
     Services.prefs.setBoolPref(pref, false);
     yield* runURLBarSearchTest(hostName, true, true, win);
 
     let notificationBox = browser.getNotificationBox(tab.linkedBrowser);
     let notification = notificationBox.getNotificationWithValue("keyword-uri-fixup");
     let docLoadPromise = waitForDocLoadAndStopIt("http://" + hostName + "/", tab.linkedBrowser);
     notification.querySelector(".notification-button-default").click();
@@ -97,17 +100,18 @@ function get_test_function_for_localhost
     // check pref value
     let prefValue = Services.prefs.getBoolPref(pref);
     is(prefValue, !isPrivate, "Pref should have the correct state.");
 
     yield docLoadPromise;
     browser.removeTab(tab);
 
     // Now try again with the pref set.
-    tab = browser.selectedTab = browser.addTab();
+    tab = browser.selectedTab = browser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
     // In a private window, the notification should appear again.
     yield* runURLBarSearchTest(hostName, isPrivate, isPrivate, win);
     browser.removeTab(tab);
     if (isPrivate) {
       info("Waiting for private window to close");
       yield promiseWindowClosed(win);
       let deferredFocus = Promise.defer();
       info("Waiting for focus");
@@ -117,12 +121,13 @@ function get_test_function_for_localhost
   }
 }
 
 add_task(get_test_function_for_localhost_with_hostname("localhost"));
 add_task(get_test_function_for_localhost_with_hostname("localhost."));
 add_task(get_test_function_for_localhost_with_hostname("localhost", true));
 
 add_task(function* test_navigate_invalid_url() {
-  let tab = gBrowser.selectedTab = gBrowser.addTab();
+  let tab = gBrowser.selectedTab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   yield* runURLBarSearchTest("mozilla is awesome", true, false);
   gBrowser.removeTab(tab);
 });
--- a/browser/base/content/test/general/browser_urlbarStop.js
+++ b/browser/base/content/test/general/browser_urlbarStop.js
@@ -1,40 +1,69 @@
+"use strict";
+
 const goodURL = "http://mochi.test:8888/";
 const badURL = "http://mochi.test:8888/whatever.html";
 
-function test() {
-  waitForExplicitFinish();
-
+add_task(function* () {
   gBrowser.selectedTab = gBrowser.addTab(goodURL);
-  gBrowser.selectedBrowser.addEventListener("load", onload, true);
-}
-
-function onload() {
-  gBrowser.selectedBrowser.removeEventListener("load", onload, true);
-
+  yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
   is(gURLBar.textValue, gURLBar.trimValue(goodURL), "location bar reflects loaded page");
 
-  typeAndSubmit(badURL);
-  is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects loading page");
-
-  gBrowser.contentWindow.stop();
+  yield typeAndSubmitAndStop(badURL);
   is(gURLBar.textValue, gURLBar.trimValue(goodURL), "location bar reflects loaded page after stop()");
   gBrowser.removeCurrentTab();
 
   gBrowser.selectedTab = gBrowser.addTab("about:blank");
   is(gURLBar.textValue, "", "location bar is empty");
 
-  typeAndSubmit(badURL);
-  is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects loading page");
-
-  gBrowser.contentWindow.stop();
+  yield typeAndSubmitAndStop(badURL);
   is(gURLBar.textValue, gURLBar.trimValue(badURL), "location bar reflects stopped page in an empty tab");
   gBrowser.removeCurrentTab();
+});
 
-  finish();
+function typeAndSubmitAndStop(url) {
+  gBrowser.userTypedValue = url;
+  URLBarSetURI();
+  is(gURLBar.textValue, gURLBar.trimValue(url), "location bar reflects loading page");
+
+  let promise = waitForDocLoadAndStopIt();
+  gURLBar.handleCommand();
+  return promise;
 }
 
-function typeAndSubmit(value) {
-  gBrowser.userTypedValue = value;
-  URLBarSetURI();
-  gURLBar.handleCommand();
+function waitForDocLoadAndStopIt() {
+  function content_script() {
+    const {interfaces: Ci, utils: Cu} = Components;
+    Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+    let progressListener = {
+      onStateChange(webProgress, req, flags, status) {
+        if (flags & Ci.nsIWebProgressListener.STATE_START) {
+          wp.removeProgressListener(progressListener);
+
+          /* Hammer time. */
+          content.stop();
+
+          /* Let the parent know we're done. */
+          sendAsyncMessage("{MSG}");
+        }
+      },
+
+      QueryInterface: XPCOMUtils.generateQI(["nsISupportsWeakReference"])
+    };
+
+    let wp = docShell.QueryInterface(Ci.nsIWebProgress);
+    wp.addProgressListener(progressListener, wp.NOTIFY_ALL);
+  }
+
+  return new Promise(resolve => {
+    const MSG = "test:waitForDocLoadAndStopIt";
+    const SCRIPT = content_script.toString().replace("{MSG}", MSG);
+
+    let mm = gBrowser.selectedBrowser.messageManager;
+    mm.loadFrameScript("data:,(" + SCRIPT + ")();", true);
+    mm.addMessageListener(MSG, function onComplete() {
+      mm.removeMessageListener(MSG, onComplete);
+      resolve();
+    });
+  });
 }
--- a/browser/base/content/test/general/browser_urlbar_search_healthreport.js
+++ b/browser/base/content/test/general/browser_urlbar_search_healthreport.js
@@ -34,17 +34,18 @@ add_task(function* test_healthreport_sea
 
   if (data.days.hasDay(now)) {
     let day = data.days.getDay(now);
     if (day.has(field)) {
       oldCount = day.get(field);
     }
   }
 
-  let tab = gBrowser.addTab();
+  let tab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
   gBrowser.selectedTab = tab;
 
   let searchStr = "firefox health report";
   let expectedURL = Services.search.currentEngine.
                     getSubmission(searchStr, "", "keyword").uri.spec;
 
   // Expect the search URL to load but stop it as soon as it starts.
   let docLoadPromise = waitForDocLoadAndStopIt(expectedURL);
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -353,28 +353,20 @@
             };
 
             // Focus the content area before triggering loads, since if the load
             // occurs in a new tab, we want focus to be restored to the content
             // area when the current tab is re-selected.
             gBrowser.selectedBrowser.focus();
 
             let isMouseEvent = aTriggeringEvent instanceof MouseEvent;
-            let altEnter = !isMouseEvent && aTriggeringEvent && aTriggeringEvent.altKey;
-
-            if (altEnter) {
-              // XXX This was added a long time ago, and I'm not sure why it is
-              // necessary. Alt+Enter's default action might cause a system beep,
-              // or something like that?
-              aTriggeringEvent.preventDefault();
-              aTriggeringEvent.stopPropagation();
-            }
 
             // If the current tab is empty, ignore Alt+Enter (just reuse this tab)
-            altEnter = altEnter && !isTabEmpty(gBrowser.selectedTab);
+            let altEnter = !isMouseEvent && aTriggeringEvent &&
+              aTriggeringEvent.altKey && !isTabEmpty(gBrowser.selectedTab);
 
             if (isMouseEvent || altEnter) {
               // Use the standard UI link behaviors for clicks or Alt+Enter
               let where = "tab";
               if (isMouseEvent)
                 where = whereToOpenLink(aTriggeringEvent, false, false);
 
               if (where == "current") {
@@ -1568,26 +1560,27 @@
     </implementation>
   </binding>
 
   <binding id="addon-progress-notification" extends="chrome://global/content/bindings/notification.xml#popup-notification">
     <content align="start">
       <xul:image class="popup-notification-icon"
                  xbl:inherits="popupid,src=icon"/>
       <xul:vbox flex="1">
-        <xul:description class="popup-notification-description addon-progress-description"
-                         xbl:inherits="xbl:text=label"/>
+        <xul:label class="popup-notification-originHost header"
+                   xbl:inherits="value=originhost"
+                   crop="end"/>
+        <xul:description class="popup-notification-description"
+                         xbl:inherits="xbl:text=label,popupid"/>
+        <xul:progressmeter anonid="progressmeter" flex="1" mode="undetermined" class="popup-progress-meter"/>
+        <xul:label anonid="progresstext" class="popup-progress-label" flex="1" crop="end"/>
         <xul:spacer flex="1"/>
-        <xul:hbox align="center">
-          <xul:progressmeter anonid="progressmeter" flex="1" mode="undetermined" class="popup-progress-meter"/>
-          <xul:button anonid="cancel" class="popup-progress-cancel" oncommand="document.getBindingParent(this).cancel()"/>
-        </xul:hbox>
-        <xul:label anonid="progresstext" class="popup-progress-label"/>
         <xul:hbox class="popup-notification-button-container"
                   pack="end" align="center">
+          <children includes="button"/>
           <xul:button anonid="button"
                       class="popup-notification-menubutton"
                       type="menu-button"
                       xbl:inherits="oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey">
             <xul:menupopup anonid="menupopup"
                            xbl:inherits="oncommand=menucommand">
               <children/>
               <xul:menuitem class="menuitem-iconic popup-notification-closeitem close-icon"
@@ -1601,17 +1594,18 @@
         <xul:toolbarbutton anonid="closebutton"
                            class="messageCloseButton close-icon popup-notification-closebutton tabbable"
                            xbl:inherits="oncommand=closebuttoncommand"
                            tooltiptext="&closeNotification.tooltip;"/>
       </xul:vbox>
     </content>
     <implementation>
       <constructor><![CDATA[
-        this.cancelbtn.setAttribute("tooltiptext", gNavigatorBundle.getString("addonDownloadCancelTooltip"));
+        if (!this.notification)
+          return;
 
         this.notification.options.installs.forEach(function(aInstall) {
           aInstall.addListener(this);
         }, this);
 
         // Calling updateProgress can sometimes cause this notification to be
         // removed in the middle of refreshing the notification panel which
         // makes the panel get refreshed again. Just initialise to the
@@ -1626,27 +1620,27 @@
       ]]></destructor>
 
       <field name="progressmeter" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "progressmeter");
       </field>
       <field name="progresstext" readonly="true">
         document.getAnonymousElementByAttribute(this, "anonid", "progresstext");
       </field>
-      <field name="cancelbtn" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid", "cancel");
-      </field>
       <field name="DownloadUtils" readonly="true">
         let utils = {};
         Components.utils.import("resource://gre/modules/DownloadUtils.jsm", utils);
         utils.DownloadUtils;
       </field>
 
       <method name="destroy">
         <body><![CDATA[
+          if (!this.notification)
+            return;
+
           this.notification.options.installs.forEach(function(aInstall) {
             aInstall.removeListener(this);
           }, this);
           clearTimeout(this._updateProgressTimeout);
         ]]></body>
       </method>
 
       <method name="setProgress">
@@ -1681,83 +1675,64 @@
             speed = speed * 0.9 + this.notification.speed * 0.1;
 
           this.notification.lastUpdate = now;
           this.notification.lastProgress = aProgress;
           this.notification.speed = speed;
 
           let status = null;
           [status, this.notification.last] = this.DownloadUtils.getDownloadStatus(aProgress, aMaxProgress, speed, this.notification.last);
-          this.progresstext.value = status;
+          this.progresstext.value = this.progresstext.tooltipText = status;
         ]]></body>
       </method>
 
       <method name="cancel">
         <body><![CDATA[
-          // Cache these as cancelling the installs will remove this
-          // notification which will drop these references
-          let browser = this.notification.browser;
-          let sourceURI = this.notification.options.sourceURI;
-
           let installs = this.notification.options.installs;
           installs.forEach(function(aInstall) {
             try {
               aInstall.cancel();
             }
             catch (e) {
               // Cancel will throw if the download has already failed
             }
           }, this);
 
-          let anchorID = "addons-notification-icon";
-          let notificationID = "addon-install-cancelled";
-          let messageString = gNavigatorBundle.getString("addonDownloadCancelled");
-          messageString = PluralForm.get(installs.length, messageString);
-          let buttonText = gNavigatorBundle.getString("addonDownloadRestart");
-          buttonText = PluralForm.get(installs.length, buttonText);
-
-          let action = {
-            label: buttonText,
-            accessKey: gNavigatorBundle.getString("addonDownloadRestart.accessKey"),
-            callback: function() {
-              let weblistener = Cc["@mozilla.org/addons/web-install-listener;1"].
-                                getService(Ci.amIWebInstallListener);
-              if (weblistener.onWebInstallRequested(browser, sourceURI,
-                                                    installs, installs.length)) {
-                installs.forEach(function(aInstall) {
-                  aInstall.install();
-                });
-              }
-            }
-          };
-
-          PopupNotifications.show(browser, notificationID, messageString,
-                                  anchorID, action);
+          PopupNotifications.remove(this.notification);
         ]]></body>
       </method>
 
       <method name="updateProgress">
         <body><![CDATA[
+          if (!this.notification)
+            return;
+
           let downloadingCount = 0;
           let progress = 0;
           let maxProgress = 0;
 
           this.notification.options.installs.forEach(function(aInstall) {
             if (aInstall.maxProgress == -1)
               maxProgress = -1;
             progress += aInstall.progress;
             if (maxProgress >= 0)
               maxProgress += aInstall.maxProgress;
             if (aInstall.state < AddonManager.STATE_DOWNLOADED)
               downloadingCount++;
           });
 
           if (downloadingCount == 0) {
             this.destroy();
-            PopupNotifications.remove(this.notification);
+            if (Preferences.get("xpinstall.customConfirmationUI", false)) {
+              this.progressmeter.mode = "undetermined";
+              this.progresstext.value = this.progresstext.tooltipText =
+                gNavigatorBundle.getString("addonDownloadVerifying");
+            } else {
+              PopupNotifications.remove(this.notification);
+            }
           }
           else {
             this.setProgress(progress, maxProgress);
           }
         ]]></body>
       </method>
 
       <method name="onDownloadProgress">
--- a/browser/components/loop/MozLoopAPI.jsm
+++ b/browser/components/loop/MozLoopAPI.jsm
@@ -16,16 +16,18 @@ Cu.import("resource:///modules/loop/Loop
 Cu.importGlobalProperties(["Blob"]);
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoopContacts",
                                         "resource:///modules/loop/LoopContacts.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
                                         "resource:///modules/loop/LoopStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "hookWindowCloseForPanelClose",
                                         "resource://gre/modules/MozSocialAPI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PageMetadata",
+                                        "resource://gre/modules/PageMetadata.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                         "resource://gre/modules/PluralForm.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "UITour",
                                         "resource:///modules/UITour.jsm");
 XPCOMUtils.defineLazyGetter(this, "appInfo", function() {
   return Cc["@mozilla.org/xre/app-info;1"]
            .getService(Ci.nsIXULAppInfo)
            .QueryInterface(Ci.nsIXULRuntime);
@@ -840,16 +842,34 @@ function injectLoopAPI(targetWindow) {
         let md5Email = [toHexString(hash.charCodeAt(i)) for (i in hash)].join("");
 
         // Compose the Gravatar URL.
         return "https://www.gravatar.com/avatar/" + md5Email + ".jpg?default=blank&s=" + size;
       }
     },
 
     /**
+     * Gets the metadata related to the currently selected tab in
+     * the most recent window.
+     *
+     * @param {Function} A callback that is passed the metadata.
+     */
+    getSelectedTabMetadata: {
+      value: function(callback) {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        win.messageManager.addMessageListener("PageMetadata:PageDataResult", function onPageDataResult(msg) {
+          win.messageManager.removeMessageListener("PageMetadata:PageDataResult", onPageDataResult);
+          let pageData = msg.json;
+          callback(cloneValueInto(pageData, targetWindow));
+        });
+        win.gBrowser.selectedBrowser.messageManager.sendAsyncMessage("PageMetadata:GetPageData");
+      }
+    },
+
+    /**
      * Associates a session-id and a call-id with a window for debugging.
      *
      * @param  {string}  windowId  The window id.
      * @param  {string}  sessionId OT session id.
      * @param  {string}  callId    The callId on the server.
      */
     addConversationContext: {
       enumerable: true,
--- a/browser/components/loop/content/css/panel.css
+++ b/browser/components/loop/content/css/panel.css
@@ -165,43 +165,91 @@ body {
 .content-area input:not(.pristine):invalid {
   border-color: #d74345;
   box-shadow: 0 0 4px #c43c3e;
 }
 
 /* Rooms */
 .rooms {
   min-height: 100px;
+  padding: 0 1rem;
 }
 
 .rooms > h1 {
   font-weight: bold;
   color: #999;
-  padding: .5rem 1rem;
+  padding: .5rem 0;
+}
+
+.rooms > div > .context {
+  margin: .5rem 0 0;
+  background-color: #DEEFF7;
+  border-radius: 3px 3px 0 0;
+  padding: .5rem;
+}
+
+.rooms > div > .context > .context-enabled {
+  margin-bottom: .5rem;
+  display: block;
+}
+
+.rooms > div > .context > .context-enabled > input {
+  -moz-margin-start: 0;
+}
+
+.rooms > div > .context > .context-preview {
+  float: right;
+  width: 100px;
+  max-height: 200px;
+  -moz-margin-start: 10px;
+  margin-bottom: 10px;
 }
 
-.rooms > p {
-  padding: .5rem 0;
-  margin: 0;
+body[dir=rtl] .rooms > div > .context > .context-preview {
+  float: left;
+}
+
+.rooms > div > .context > .context-preview[src=""] {
+  display: none;
+}
+
+.rooms > div > .context > .context-description {
+  display: block;
+  color: #707070;
 }
 
-.rooms > p > .btn {
+.rooms > div > .context > .context-url {
+  display: block;
+  color: #59A1D7;
+  clear: both;
+}
+
+.rooms > div > .btn {
   display: block;
   font-size: 1rem;
-  margin: 0 auto;
+  margin: 0 auto .5rem;
+  width: 100%;
   padding: .5rem 1rem;
+  border-radius: 0 0 3px 3px;
+}
+
+/* Remove when bug 1142671 is backed out. */
+.rooms > div > :not(.context) + .btn {
   border-radius: 3px;
+  margin-top: 0.5rem;
 }
 
 .room-list {
   max-height: 335px; /* XXX better computation needed */
   min-height: 7px;
   overflow: auto;
   border-top: 1px solid #ccc;
   border-bottom: 1px solid #ccc;
+  margin-left: -1rem;
+  margin-right: -1rem;
 }
 
 .room-list:empty {
   border-bottom-width: 0;
 }
 
 .room-list > .room-entry {
   padding: .5rem 1rem;
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -589,16 +589,17 @@ loop.panel = (function(_, mozL10n) {
 
   /**
    * Room list.
    */
   var RoomList = React.createClass({displayName: "RoomList",
     mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
       store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
       return this.props.store.getStoreState();
     },
@@ -661,29 +662,84 @@ loop.panel = (function(_, mozL10n) {
             this.state.rooms.map(function(room, i) {
               return React.createElement(RoomEntry, {
                 key: room.roomToken, 
                 dispatcher: this.props.dispatcher, 
                 room: room}
               );
             }, this)
           ), 
-          React.createElement("p", null, 
+          React.createElement("div", null, 
+            React.createElement(ContextInfo, {mozLoop: this.props.mozLoop}), 
             React.createElement("button", {className: "btn btn-info new-room-button", 
                     onClick: this.handleCreateButtonClick, 
                     disabled: this._hasPendingOperation()}, 
               mozL10n.get("rooms_new_room_button_label")
             )
           )
         )
       );
     }
   });
 
   /**
+   * Context info that is offered to be part of a Room.
+   */
+  var ContextInfo = React.createClass({displayName: "ContextInfo",
+    propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
+    },
+
+    mixins: [sharedMixins.DocumentVisibilityMixin],
+
+    getInitialState: function() {
+      return {
+        previewImage: "",
+        description: "",
+        url: ""
+      };
+    },
+
+    onDocumentVisible: function() {
+      this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
+        var previewImage = metadata.previews.length ? metadata.previews[0] : "";
+        var description = metadata.description || metadata.title;
+        var url = metadata.url;
+        this.setState({previewImage: previewImage,
+                       description: description,
+                       url: url});
+      }.bind(this));
+    },
+
+    onDocumentHidden: function() {
+      this.setState({previewImage: "",
+                     description: "",
+                     url: ""});
+    },
+
+    render: function() {
+      if (!this.props.mozLoop.getLoopPref("contextInConverations.enabled") ||
+          !this.state.url) {
+        return null;
+      }
+      return (
+        React.createElement("div", {className: "context"}, 
+          React.createElement("label", {className: "context-enabled"}, 
+            React.createElement("input", {type: "checkbox"}), 
+            mozL10n.get("context_offer_label")
+          ), 
+          React.createElement("img", {className: "context-preview", src: this.state.previewImage}), 
+          React.createElement("span", {className: "context-description"}, this.state.description), 
+          React.createElement("span", {className: "context-url"}, this.state.url)
+        )
+      );
+    }
+  });
+
+  /**
    * Panel view.
    */
   var PanelView = React.createClass({displayName: "PanelView",
     propTypes: {
       notifications: React.PropTypes.object.isRequired,
       // Mostly used for UI components showcase and unit tests
       userProfile: React.PropTypes.object,
       // Used only for unit tests.
@@ -814,17 +870,18 @@ loop.panel = (function(_, mozL10n) {
         React.createElement("div", null, 
           React.createElement(NotificationListView, {notifications: this.props.notifications, 
                                 clearOnDocumentHidden: true}), 
           React.createElement(TabView, {ref: "tabView", selectedTab: this.props.selectedTab, 
             buttonsHidden: hideButtons, mozLoop: this.props.mozLoop}, 
             React.createElement(Tab, {name: "rooms"}, 
               React.createElement(RoomList, {dispatcher: this.props.dispatcher, 
                         store: this.props.roomStore, 
-                        userDisplayName: this._getUserDisplayName()}), 
+                        userDisplayName: this._getUserDisplayName(), 
+                        mozLoop: this.props.mozLoop}), 
               React.createElement(ToSView, null)
             ), 
             React.createElement(Tab, {name: "contacts"}, 
               React.createElement(ContactsList, {selectTab: this.selectTab, 
                             startForm: this.startForm, 
                             notifications: this.props.notifications})
             ), 
             React.createElement(Tab, {name: "contacts_add", hidden: true}, 
@@ -885,16 +942,17 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
+    ContextInfo: ContextInfo,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -589,16 +589,17 @@ loop.panel = (function(_, mozL10n) {
 
   /**
    * Room list.
    */
   var RoomList = React.createClass({
     mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
       store: React.PropTypes.instanceOf(loop.store.RoomStore).isRequired,
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       userDisplayName: React.PropTypes.string.isRequired  // for room creation
     },
 
     getInitialState: function() {
       return this.props.store.getStoreState();
     },
@@ -661,23 +662,78 @@ loop.panel = (function(_, mozL10n) {
             this.state.rooms.map(function(room, i) {
               return <RoomEntry
                 key={room.roomToken}
                 dispatcher={this.props.dispatcher}
                 room={room}
               />;
             }, this)
           }</div>
-          <p>
+          <div>
+            <ContextInfo mozLoop={this.props.mozLoop} />
             <button className="btn btn-info new-room-button"
                     onClick={this.handleCreateButtonClick}
                     disabled={this._hasPendingOperation()}>
               {mozL10n.get("rooms_new_room_button_label")}
             </button>
-          </p>
+          </div>
+        </div>
+      );
+    }
+  });
+
+  /**
+   * Context info that is offered to be part of a Room.
+   */
+  var ContextInfo = React.createClass({
+    propTypes: {
+      mozLoop: React.PropTypes.object.isRequired,
+    },
+
+    mixins: [sharedMixins.DocumentVisibilityMixin],
+
+    getInitialState: function() {
+      return {
+        previewImage: "",
+        description: "",
+        url: ""
+      };
+    },
+
+    onDocumentVisible: function() {
+      this.props.mozLoop.getSelectedTabMetadata(function callback(metadata) {
+        var previewImage = metadata.previews.length ? metadata.previews[0] : "";
+        var description = metadata.description || metadata.title;
+        var url = metadata.url;
+        this.setState({previewImage: previewImage,
+                       description: description,
+                       url: url});
+      }.bind(this));
+    },
+
+    onDocumentHidden: function() {
+      this.setState({previewImage: "",
+                     description: "",
+                     url: ""});
+    },
+
+    render: function() {
+      if (!this.props.mozLoop.getLoopPref("contextInConverations.enabled") ||
+          !this.state.url) {
+        return null;
+      }
+      return (
+        <div className="context">
+          <label className="context-enabled">
+            <input type="checkbox"/>
+            {mozL10n.get("context_offer_label")}
+          </label>
+          <img className="context-preview" src={this.state.previewImage}/>
+          <span className="context-description">{this.state.description}</span>
+          <span className="context-url">{this.state.url}</span>
         </div>
       );
     }
   });
 
   /**
    * Panel view.
    */
@@ -814,17 +870,18 @@ loop.panel = (function(_, mozL10n) {
         <div>
           <NotificationListView notifications={this.props.notifications}
                                 clearOnDocumentHidden={true} />
           <TabView ref="tabView" selectedTab={this.props.selectedTab}
             buttonsHidden={hideButtons} mozLoop={this.props.mozLoop}>
             <Tab name="rooms">
               <RoomList dispatcher={this.props.dispatcher}
                         store={this.props.roomStore}
-                        userDisplayName={this._getUserDisplayName()}/>
+                        userDisplayName={this._getUserDisplayName()}
+                        mozLoop={this.props.mozLoop}/>
               <ToSView />
             </Tab>
             <Tab name="contacts">
               <ContactsList selectTab={this.selectTab}
                             startForm={this.startForm}
                             notifications={this.props.notifications} />
             </Tab>
             <Tab name="contacts_add" hidden={true}>
@@ -885,16 +942,17 @@ loop.panel = (function(_, mozL10n) {
     evtObject.initEvent('loopPanelInitialized', true, false);
     window.dispatchEvent(evtObject);
   }
 
   return {
     init: init,
     AuthLink: AuthLink,
     AvailabilityDropdown: AvailabilityDropdown,
+    ContextInfo: ContextInfo,
     GettingStartedView: GettingStartedView,
     PanelView: PanelView,
     RoomEntry: RoomEntry,
     RoomList: RoomList,
     SettingsDropdown: SettingsDropdown,
     ToSView: ToSView,
     UserIdentity: UserIdentity,
   };
--- a/browser/components/loop/content/shared/js/activeRoomStore.js
+++ b/browser/components/loop/content/shared/js/activeRoomStore.js
@@ -328,16 +328,20 @@ loop.store.ActiveRoomStore = (function()
       this.setStoreState({
         apiKey: actionData.apiKey,
         sessionToken: actionData.sessionToken,
         sessionId: actionData.sessionId,
         roomState: ROOM_STATES.JOINED
       });
 
       this._setRefreshTimeout(actionData.expires);
+
+      // Only send media telemetry on one side of the call: the desktop side.
+      actionData["sendTwoWayMediaTelemetry"] = this._isDesktop;
+
       this._sdkDriver.connectSession(actionData);
 
       this._mozLoop.addConversationContext(this._storeState.windowId,
                                            actionData.sessionId, "");
 
       // If we haven't got a room name yet, go and get one. We typically
       // need to do this in the case of the standalone window.
       // XXX When bug 1103331 lands this can be moved to earlier.
--- a/browser/components/loop/content/shared/js/conversationStore.js
+++ b/browser/components/loop/content/shared/js/conversationStore.js
@@ -265,17 +265,18 @@ loop.store = loop.store || {};
      * sdk, and setting the state appropriately.
      */
     _startCallConnection: function() {
       var state = this.getStoreState();
 
       this.sdkDriver.connectSession({
         apiKey: state.apiKey,
         sessionId: state.sessionId,
-        sessionToken: state.sessionToken
+        sessionToken: state.sessionToken,
+        sendTwoWayMediaTelemetry: state.outgoing // only one side of the call
       });
       this.mozLoop.addConversationContext(
         state.windowId,
         state.sessionId,
         state.callId);
       this.setStoreState({callState: CALL_STATES.ONGOING});
     },
 
--- a/browser/components/loop/content/shared/js/otSdkDriver.js
+++ b/browser/components/loop/content/shared/js/otSdkDriver.js
@@ -32,17 +32,16 @@ loop.OTSdkDriver = (function() {
       if (this._isDesktop) {
         if (!options.mozLoop) {
           throw new Error("Missing option mozLoop");
         }
         this.mozLoop = options.mozLoop;
       }
 
       this.connections = {};
-      this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED);
 
       this.dispatcher.register(this, [
         "setupStreamElements",
         "setMute"
       ]);
 
       // Set loop.debug.twoWayMediaTelemetry to true in the browser
       // by changing the hidden pref loop.debug.twoWayMediaTelemetry using
@@ -205,22 +204,30 @@ loop.OTSdkDriver = (function() {
 
     /**
      * Connects a session for the SDK, listening to the required events.
      *
      * sessionData items:
      * - sessionId: The OT session ID
      * - apiKey: The OT API key
      * - sessionToken: The token for the OT session
+     * - sendTwoWayMediaTelemetry: boolean should we send telemetry on length
+     *                             of media sessions.  Callers should ensure
+     *                             that this is only set for one side of the
+     *                             session so that things don't get
+     *                             double-counted.
      *
      * @param {Object} sessionData The session data for setting up the OT session.
      */
     connectSession: function(sessionData) {
       this.session = this.sdk.initSession(sessionData.sessionId);
 
+      this._sendTwoWayMediaTelemetry = !!sessionData.sendTwoWayMediaTelemetry;
+      this._setTwoWayMediaStartTime(this.CONNECTION_START_TIME_UNINITIALIZED);
+
       this.session.on("connectionCreated", this._onConnectionCreated.bind(this));
       this.session.on("streamCreated", this._onRemoteStreamCreated.bind(this));
       this.session.on("streamDestroyed", this._onRemoteStreamDestroyed.bind(this));
       this.session.on("connectionDestroyed",
         this._onConnectionDestroyed.bind(this));
       this.session.on("sessionDisconnected",
         this._onSessionDisconnected.bind(this));
       this.session.on("streamPropertyChanged", this._onStreamPropertyChanged.bind(this));
@@ -463,27 +470,27 @@ loop.OTSdkDriver = (function() {
 
     /**
      * Set and get the start time of the two-way media connection.  These
      * are done as wrapper functions so that we can log sets to make manual
      * verification of various telemetry scenarios possible.  The get API is
      * analogous in order to follow the principle of least surprise for
      * people consuming this code.
      *
-     * If this._isDesktop is not true, returns immediately without making
-     * any changes, since this data is not used, and it makes reading
-     * the logs confusing for manual verification of both ends of the call in
-     * the same browser, which is a case we care about.
+     * If this._sendTwoWayMediaTelemetry is not true, returns immediately
+     * without making any changes, since this data is not used, and it makes
+     * reading the logs confusing for manual verification of both ends of the
+     * call in the same browser, which is a case we care about.
      *
      * @param start  start time in milliseconds, as returned by
      *               performance.now()
      * @private
      */
     _setTwoWayMediaStartTime: function(start) {
-      if (!this._isDesktop) {
+      if (!this._sendTwoWayMediaTelemetry) {
         return;
       }
 
       this.__twoWayMediaStartTime = start;
       if (this._debugTwoWayMediaTelemetry) {
         console.log("Loop Telemetry: noted two-way connection start, " +
                     "start time in ms:", start);
       }
@@ -585,17 +592,17 @@ loop.OTSdkDriver = (function() {
     _maybePublishLocalStream: function() {
       if (this._sessionConnected && this._publisherReady) {
         // We are clear to publish the stream to the session.
         this.session.publish(this.publisher);
 
         // Now record the fact, and check if we've got all media yet.
         this._publishedLocalStream = true;
         if (this._checkAllStreamsConnected()) {
-          this._setTwoWayMediaStartTime(performance.now);
+          this._setTwoWayMediaStartTime(performance.now());
           this.dispatcher.dispatch(new sharedActions.MediaConnected());
         }
       }
     },
 
     /**
      * Used to check if both local and remote streams are available
      * and send an action if they are.
@@ -671,25 +678,24 @@ loop.OTSdkDriver = (function() {
         console.log('Loop Telemetry: noted two-way media connection ' +
           'in bucket: ', bucket);
       }
     },
 
     /**
      * Note connection length if it's valid (the startTime has been initialized
      * and is not later than endTime) and not yet already noted.  If
-     * this._isDesktop is not true, we're assumed to be running in the
-     * standalone client and return immediately.
+     * this._sendTwoWayMediaTelemetry is not true, we return immediately.
      *
      * @param {number} startTime  in milliseconds
      * @param {number} endTime  in milliseconds
      * @private
      */
     _noteConnectionLengthIfNeeded: function(startTime, endTime) {
-      if (!this._isDesktop) {
+      if (!this._sendTwoWayMediaTelemetry) {
         return;
       }
 
       if (startTime == this.CONNECTION_START_TIME_ALREADY_NOTED ||
           startTime == this.CONNECTION_START_TIME_UNINITIALIZED ||
           startTime > endTime) {
         if (this._debugTwoWayMediaTelemetry) {
           console.log("_noteConnectionLengthIfNeeded called with " +
--- a/browser/components/loop/run-all-loop-tests.sh
+++ b/browser/components/loop/run-all-loop-tests.sh
@@ -1,25 +1,38 @@
 #!/bin/sh
 # Run from topsrcdir, no args
 
+if [ "$1" == "--help" ]; then
+  echo "Usage: ./run-all-loop-tests.sh [options]"
+  echo "    --skip-e10s  Skips the e10s tests"
+  exit 0;
+fi
+
 set -e
 
 # Main tests
 ./mach xpcshell-test browser/components/loop/
 ./mach marionette-test browser/components/loop/manifest.ini
 
 # The browser_parsable_css.js can fail if we add some css that isn't parsable.
 #
 # The check to make sure that the media devices can be used in Loop without
 # prompting is in browser_devices_get_user_media_about_urls.js. It's possible
 # to mess this up with CSP handling, and probably other changes, too.
 
-./mach mochitest \
-  browser/components/loop/test/mochitest \
-  browser/modules/test/browser_UITour_loop.js \
+TESTS="
+  browser/components/loop/test/mochitest
+  browser/modules/test/browser_UITour_loop.js
   browser/base/content/test/general/browser_devices_get_user_media_about_urls.js
+"
+
+./mach mochitest $TESTS
+
+if [ "$1" != "--skip-e10s" ]; then
+  ./mach mochitest --e10s $TESTS
+fi
 
 # This is currently disabled because the test itself is busted.  Once bug
 # 1062821 is landed, we should see if things work again, and then re-enable it.
 # The re-enabling is tracked in bug 1113350.
 #
 #  browser/base/content/test/general/browser_parsable_css.js \
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -646,17 +646,18 @@ describe("loop.panel", function() {
       dispatch = sandbox.stub(dispatcher, "dispatch");
     });
 
     function createTestComponent() {
       return TestUtils.renderIntoDocument(
         React.createElement(loop.panel.RoomList, {
           store: roomStore,
           dispatcher: dispatcher,
-          userDisplayName: fakeEmail
+          userDisplayName: fakeEmail,
+          mozLoop: fakeMozLoop
         }));
     }
 
     it("should dispatch a GetAllRooms action on mount", function() {
       createTestComponent();
 
       sinon.assert.calledOnce(dispatch);
       sinon.assert.calledWithExactly(dispatch, new sharedActions.GetAllRooms());
@@ -703,16 +704,59 @@ describe("loop.panel", function() {
       function() {
         roomStore.setStoreState({pendingInitialRetrieval: true});
 
         var view = createTestComponent();
 
         var buttonNode = view.getDOMNode().querySelector("button[disabled]");
         expect(buttonNode).to.not.equal(null);
       });
+
+    it("should show context information when a URL is available",
+      function() {
+        navigator.mozLoop.getLoopPref = function() {
+          return true;
+        }
+
+        var view = TestUtils.renderIntoDocument(
+          React.createElement(loop.panel.ContextInfo, {
+            mozLoop: navigator.mozLoop
+          })
+        );
+        view.setState({
+          previews: [""],
+          description: "fake description",
+          url: "https://www.example.com"
+        });
+
+        var contextEnabledCheckbox = view.getDOMNode().querySelector(".context-enabled");
+        expect(contextEnabledCheckbox).to.not.equal(null);
+      });
+
+    it("should not show context information when a URL is unavailable",
+      function() {
+        navigator.mozLoop.getLoopPref = function() {
+          return true;
+        }
+
+        var view = TestUtils.renderIntoDocument(
+          React.createElement(loop.panel.ContextInfo, {
+            mozLoop: navigator.mozLoop
+          })
+        );
+        view.setState({
+          previews: [""],
+          description: "fake description",
+          url: ""
+        });
+
+        var contextInfo = view.getDOMNode();
+        expect(contextInfo).to.equal(null);
+      });
+
   });
 
   describe('loop.panel.ToSView', function() {
 
     it("should render when the value of loop.seenToS is not set", function() {
       navigator.mozLoop.getLoopPref = function(key) {
         return {
           "gettingStarted.seen": true,
--- a/browser/components/loop/test/shared/activeRoomStore_test.js
+++ b/browser/components/loop/test/shared/activeRoomStore_test.js
@@ -536,16 +536,38 @@ describe("loop.store.ActiveRoomStore", f
 
       store.joinedRoom(actionData);
 
       sinon.assert.calledOnce(fakeSdkDriver.connectSession);
       sinon.assert.calledWithExactly(fakeSdkDriver.connectSession,
         actionData);
     });
 
+    it("should pass 'sendTwoWayMediaTelemetry' as true to connectSession if " +
+       "store._isDesktop is true", function() {
+      store._isDesktop = true;
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithMatch(fakeSdkDriver.connectSession,
+        sinon.match.has("sendTwoWayMediaTelemetry", true));
+    });
+
+    it("should pass 'sendTwoWayTelemetry' as false to connectionSession if " +
+       "store._isDesktop is false", function() {
+      store._isDesktop = false;
+
+      store.joinedRoom(new sharedActions.JoinedRoom(fakeJoinedData));
+
+      sinon.assert.calledOnce(fakeSdkDriver.connectSession);
+      sinon.assert.calledWithMatch(fakeSdkDriver.connectSession,
+        sinon.match.has("sendTwoWayMediaTelemetry", false));
+    });
+
     it("should call mozLoop.addConversationContext", function() {
       var actionData = new sharedActions.JoinedRoom(fakeJoinedData);
 
       store.setupWindowData(new sharedActions.SetupWindowData({
         windowId: "42",
         type: "room",
       }));
 
--- a/browser/components/loop/test/shared/conversationStore_test.js
+++ b/browser/components/loop/test/shared/conversationStore_test.js
@@ -247,17 +247,18 @@ describe("loop.store.ConversationStore",
 
         store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
 
         sinon.assert.calledOnce(sdkDriver.connectSession);
         sinon.assert.calledWithExactly(sdkDriver.connectSession, {
           apiKey: "fakeKey",
           sessionId: "321456",
-          sessionToken: "341256"
+          sessionToken: "341256",
+          sendTwoWayMediaTelemetry: true
         });
       });
 
       it("should call mozLoop.addConversationContext", function() {
         store.setStoreState(fakeSessionData);
 
         store.connectionProgress(
           new sharedActions.ConnectionProgress({wsState: WS_STATES.CONNECTING}));
@@ -575,27 +576,28 @@ describe("loop.store.ConversationStore",
 
     it("should change the state to 'ongoing'", function() {
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
 
       expect(store.getStoreState("callState")).eql(CALL_STATES.ONGOING);
     });
 
-    it("should connect the session", function() {
+    it("should connect the session with sendTwoWayMediaTelemetry set as falsy", function() {
       store.setStoreState(fakeSessionData);
 
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
 
       sinon.assert.calledOnce(sdkDriver.connectSession);
       sinon.assert.calledWithExactly(sdkDriver.connectSession, {
         apiKey: "fakeKey",
         sessionId: "321456",
-        sessionToken: "341256"
+        sessionToken: "341256",
+        sendTwoWayMediaTelemetry: undefined
       });
     });
 
     it("should call mozLoop.addConversationContext", function() {
       store.setStoreState(fakeSessionData);
 
       store.acceptCall(
         new sharedActions.AcceptCall({callType: CALL_TYPES.AUDIO_ONLY}));
--- a/browser/components/loop/test/shared/otSdkDriver_test.js
+++ b/browser/components/loop/test/shared/otSdkDriver_test.js
@@ -99,24 +99,16 @@ describe("loop.OTSdkDriver", function ()
       }).to.Throw(/dispatcher/);
     });
 
     it("should throw an error if the sdk is missing", function() {
       expect(function() {
         new loop.OTSdkDriver({dispatcher: dispatcher});
       }).to.Throw(/sdk/);
     });
-
-    it("should set the two-way media start time to 'uninitialized'", function() {
-      var driver = new loop.OTSdkDriver(
-        {sdk: sdk, dispatcher: dispatcher, mozLoop: mozLoop, isDesktop: true});
-
-      expect(driver._getTwoWayMediaStartTime()).to.
-        eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
-    });
   });
 
   describe("#setupStreamElements", function() {
     it("should call initPublisher", function() {
       dispatcher.dispatch(new sharedActions.SetupStreamElements({
         getLocalElementFunc: function() {return fakeLocalElement;},
         getRemoteElementFunc: function() {return fakeRemoteElement;},
         publisherConfig: publisherConfig
@@ -347,16 +339,25 @@ describe("loop.OTSdkDriver", function ()
 
     it("should connect the session", function () {
       driver.connectSession(sessionData);
 
       sinon.assert.calledOnce(session.connect);
       sinon.assert.calledWith(session.connect, "1234567890", "1357924680");
     });
 
+    it("should set the two-way media start time to 'uninitialized' " +
+       "when sessionData.sendTwoWayMediaTelemetry is true'", function() {
+      driver.connectSession(_.extend(sessionData,
+                                     {sendTwoWayMediaTelemetry: true}));
+
+      expect(driver._getTwoWayMediaStartTime()).to.
+        eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
+    });
+
     describe("On connection complete", function() {
       it("should publish the stream if the publisher is ready", function() {
         driver._publisherReady = true;
         session.connect.callsArg(2);
 
         driver.connectSession(sessionData);
 
         sinon.assert.calledOnce(session.publish);
@@ -393,44 +394,47 @@ describe("loop.OTSdkDriver", function ()
 
       sinon.assert.calledOnce(publisher.destroy);
     });
 
     it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
       driver.session = session;
       var startTime = 1;
       var endTime = 3;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTime);
       sandbox.stub(performance, "now").returns(endTime);
       sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
       driver.disconnectSession();
 
       sinon.assert.calledWith(driver._noteConnectionLengthIfNeeded, startTime,
                               endTime);
     });
 
     it("should reset the two-way media connection start time", function() {
       driver.session = session;
       var startTime = 1;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTime);
       sandbox.stub(performance, "now");
       sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
       driver.disconnectSession();
 
       expect(driver._getTwoWayMediaStartTime()).to.
         eql(driver.CONNECTION_START_TIME_UNINITIALIZED);
     });
   });
 
   describe("#_noteConnectionLengthIfNeeded", function() {
     var startTimeMS;
     beforeEach(function() {
       startTimeMS = 1;
+      driver._sendTwoWayMediaTelemetry = true;
       driver._setTwoWayMediaStartTime(startTimeMS);
     });
 
     it("should set two-way media start time to CONNECTION_START_TIME_ALREADY_NOTED", function() {
       var endTimeMS = 3;
       driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
       expect(driver._getTwoWayMediaStartTime()).to.
@@ -478,21 +482,21 @@ describe("loop.OTSdkDriver", function ()
       driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
       sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue);
       sinon.assert.calledWith(mozLoop.telemetryAddKeyedValue,
         "LOOP_TWO_WAY_MEDIA_CONN_LENGTH",
         mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.MORE_THAN_5M);
     });
 
-    it("should not call mozLoop.noteConnectionLength if driver._isDesktop " +
-       "is false",
+    it("should not call mozLoop.noteConnectionLength if" +
+       " driver._sendTwoWayMediaTelemetry is false",
       function() {
         var endTimeMS = 10 * 60 * 1000;
-        driver._isDesktop = false;
+        driver._sendTwoWayMediaTelemetry = false;
 
         driver._noteConnectionLengthIfNeeded(startTimeMS, endTimeMS);
 
         sinon.assert.notCalled(mozLoop.telemetryAddKeyedValue);
       });
   });
 
   describe("#_noteSharingState", function() {
@@ -611,16 +615,17 @@ describe("loop.OTSdkDriver", function ()
             sinon.match.hasOwn("peerHungup", false));
       });
 
 
       it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
         driver.session = session;
         var startTime = 1;
         var endTime = 3;
+        driver._sendTwoWayMediaTelemetry = true;
         driver._setTwoWayMediaStartTime(startTime);
         sandbox.stub(performance, "now").returns(endTime);
         sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
         session.trigger("connectionDestroyed", {
           reason: "clientDisconnected"
         });
 
@@ -655,16 +660,17 @@ describe("loop.OTSdkDriver", function ()
           sinon.assert.calledWithMatch(dispatcher.dispatch,
             sinon.match.hasOwn("reason", FAILURE_DETAILS.EXPIRED_OR_INVALID));
         });
 
       it("should call _noteConnectionLengthIfNeeded with connection duration", function() {
         driver.session = session;
         var startTime = 1;
         var endTime = 3;
+        driver._sendTwoWayMediaTelemetry = true;
         driver._setTwoWayMediaStartTime(startTime);
         sandbox.stub(performance, "now").returns(endTime);
         sandbox.stub(driver, "_noteConnectionLengthIfNeeded");
 
         session.trigger("sessionDisconnected", {
           reason: "networkDisconnected"
         });
 
@@ -742,17 +748,18 @@ describe("loop.OTSdkDriver", function ()
 
         // Called twice due to the VideoDimensionsChanged above.
         sinon.assert.calledTwice(dispatcher.dispatch);
         sinon.assert.calledWithMatch(dispatcher.dispatch,
           sinon.match.hasOwn("name", "mediaConnected"));
       });
 
       it("should store the start time when both streams are up and" +
-      " driver._isDesktop is true", function() {
+      " driver._sendTwoWayMediaTelemetry is true", function() {
+        driver._sendTwoWayMediaTelemetry = true;
         driver._publishedLocalStream = true;
         var startTime = 1;
         sandbox.stub(performance, "now").returns(startTime);
 
         session.trigger("streamCreated", {stream: fakeStream});
 
         expect(driver._getTwoWayMediaStartTime()).to.eql(startTime);
       });
--- a/browser/components/loop/ui/fake-mozLoop.js
+++ b/browser/components/loop/ui/fake-mozLoop.js
@@ -110,28 +110,36 @@ var fakeContacts = [{
 navigator.mozLoop = {
   ensureRegistered: function() {},
   getAudioBlob: function(){},
   getLoopPref: function(pref) {
     switch(pref) {
       // Ensure we skip FTE completely.
       case "gettingStarted.seen":
       case "contacts.gravatars.promo":
+      case "contextInConverations.enabled":
         return true;
       case "contacts.gravatars.show":
         return false;
     }
   },
   setLoopPref: function(){},
   releaseCallData: function() {},
   copyString: function() {},
   getUserAvatar: function(emailAddress) {
     return "http://www.gravatar.com/avatar/" + (Math.ceil(Math.random() * 3) === 2 ?
       "0a996f0fe2727ef1668bdb11897e4459" : "foo") + ".jpg?default=blank&s=40";
   },
+  getSelectedTabMetadata: function(callback) {
+    callback({
+      previews: ["chrome://branding/content/about-logo.png"],
+      description: "sample webpage description",
+      url: "https://www.example.com"
+    });
+  },
   contacts: {
     getAll: function(callback) {
       callback(null, [].concat(fakeContacts));
     },
     on: function() {}
   },
   rooms: {
     getAll: function(version, callback) {
--- a/browser/components/loop/ui/ui-showcase.js
+++ b/browser/components/loop/ui/ui-showcase.js
@@ -51,16 +51,38 @@
   }
 
   function returnFalse() {
     return false;
   }
 
   function noop(){}
 
+  // We save the visibility change listeners so that we can fake an event
+  // to the panel once we've loaded all the views.
+  var visibilityListeners = [];
+  var rootObject = window;
+
+  rootObject.document.addEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      visibilityListeners.push(func);
+    }
+    window.addEventListener(eventName, func);
+  };
+
+  rootObject.document.removeEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      var index = visibilityListeners.indexOf(func);
+      visibilityListeners.splice(index, 1);
+    }
+    window.removeEventListener(eventName, func);
+  };
+
+  loop.shared.mixins.setRootObject(rootObject);
+
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
@@ -752,16 +774,20 @@
 
       };
     }
   }
 
   window.addEventListener("DOMContentLoaded", function() {
     try {
       React.renderComponent(React.createElement(App, null), document.getElementById("main"));
+
+      for (var listener of visibilityListeners) {
+        listener({target: {hidden: false}});
+      }
     } catch(err) {
       console.error(err);
       uncaughtError = err;
     }
 
     _renderComponentsInIframes();
 
     // Put the title back, in case views changed it.
--- a/browser/components/loop/ui/ui-showcase.jsx
+++ b/browser/components/loop/ui/ui-showcase.jsx
@@ -51,16 +51,38 @@
   }
 
   function returnFalse() {
     return false;
   }
 
   function noop(){}
 
+  // We save the visibility change listeners so that we can fake an event
+  // to the panel once we've loaded all the views.
+  var visibilityListeners = [];
+  var rootObject = window;
+
+  rootObject.document.addEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      visibilityListeners.push(func);
+    }
+    window.addEventListener(eventName, func);
+  };
+
+  rootObject.document.removeEventListener = function(eventName, func) {
+    if (eventName === "visibilitychange") {
+      var index = visibilityListeners.indexOf(func);
+      visibilityListeners.splice(index, 1);
+    }
+    window.removeEventListener(eventName, func);
+  };
+
+  loop.shared.mixins.setRootObject(rootObject);
+
   // Feedback API client configured to send data to the stage input server,
   // which is available at https://input.allizom.org
   var stageFeedbackApiClient = new loop.FeedbackAPIClient(
     "https://input.allizom.org/api/v1/feedback", {
       product: "Loop"
     }
   );
 
@@ -752,16 +774,20 @@
 
       };
     }
   }
 
   window.addEventListener("DOMContentLoaded", function() {
     try {
       React.renderComponent(<App />, document.getElementById("main"));
+
+      for (var listener of visibilityListeners) {
+        listener({target: {hidden: false}});
+      }
     } catch(err) {
       console.error(err);
       uncaughtError = err;
     }
 
     _renderComponentsInIframes();
 
     // Put the title back, in case views changed it.
--- a/browser/components/readinglist/ReadingList.jsm
+++ b/browser/components/readinglist/ReadingList.jsm
@@ -14,16 +14,22 @@ Cu.import("resource://gre/modules/Servic
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "SQLiteStore",
   "resource:///modules/readinglist/SQLiteStore.jsm");
 
+// We use Sync's "Utils" module for the device name, which is unfortunate,
+// but let's give it a better name here.
+XPCOMUtils.defineLazyGetter(this, "SyncUtils", function() {
+  const {Utils} = Cu.import("resource://services-sync/util.js", {});
+  return Utils;
+});
 
 { // Prevent the parent log setup from leaking into the global scope.
   let parentLog = Log.repository.getLogger("readinglist");
   parentLog.level = Preferences.get("browser.readinglist.logLevel", Log.Level.Warn);
   Preferences.observe("browser.readinglist.logLevel", value => {
     parentLog.level = value;
   });
   let formatter = new Log.BasicFormatter();
@@ -31,49 +37,86 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   parentLog.addAppender(new Log.DumpAppender(formatter));
 }
 let log = Log.repository.getLogger("readinglist.api");
 
 
 // Each ReadingListItem has a _record property, an object containing the raw
 // data from the server and local store.  These are the names of the properties
 // in that object.
+//
+// Not important, but FYI: The order that these are listed in follows the order
+// that the server doc lists the fields in the article data model, more or less:
+// http://readinglist.readthedocs.org/en/latest/model.html
 const ITEM_RECORD_PROPERTIES = `
   guid
-  lastModified
+  serverLastModified
   url
+  preview
   title
   resolvedURL
   resolvedTitle
   excerpt
-  preview
-  status
+  archived
+  deleted
   favorite
   isArticle
   wordCount
   unread
   addedBy
   addedOn
   storedOn
   markedReadBy
   markedReadOn
   readPosition
+  syncStatus
 `.trim().split(/\s+/);
 
 // Article objects that are passed to ReadingList.addItem may contain
 // some properties that are known but are not currently stored in the
 // ReadingList records. This is the list of properties that are knowingly
 // disregarded before the item is normalized.
 const ITEM_DISREGARDED_PROPERTIES = `
   byline
   dir
   content
   length
 `.trim().split(/\s+/);
 
+// Each local item has a syncStatus indicating the state of the item in relation
+// to the sync server.  See also Sync.jsm.
+const SYNC_STATUS_SYNCED = 0;
+const SYNC_STATUS_NEW = 1;
+const SYNC_STATUS_CHANGED_STATUS = 2;
+const SYNC_STATUS_CHANGED_MATERIAL = 3;
+const SYNC_STATUS_DELETED = 4;
+
+// These options are passed as the "control" options to store methods and filter
+// out all records in the store with syncStatus SYNC_STATUS_DELETED.
+const STORE_OPTIONS_IGNORE_DELETED = {
+  syncStatus: [
+    SYNC_STATUS_SYNCED,
+    SYNC_STATUS_NEW,
+    SYNC_STATUS_CHANGED_STATUS,
+    SYNC_STATUS_CHANGED_MATERIAL,
+  ],
+};
+
+// Changes to the following item properties are considered "status," or
+// "status-only," changes, in relation to the sync server.  Changes to other
+// properties are considered "material" changes.  See also Sync.jsm.
+const SYNC_STATUS_PROPERTIES_STATUS = `
+  favorite
+  markedReadBy
+  markedReadOn
+  readPosition
+  unread
+`.trim().split(/\s+/);
+
+
 /**
  * A reading list contains ReadingListItems.
  *
  * A list maintains only one copy of an item per URL.  So if for example you use
  * an iterator to get two references to items with the same URL, your references
  * actually refer to the same JS object.
  *
  * Options Objects
@@ -126,37 +169,49 @@ function ReadingListImpl(store) {
   this._iterators = new Set();
   this._listeners = new Set();
 }
 
 ReadingListImpl.prototype = {
 
   ItemRecordProperties: ITEM_RECORD_PROPERTIES,
 
+  SyncStatus: {
+    SYNCED: SYNC_STATUS_SYNCED,
+    NEW: SYNC_STATUS_NEW,
+    CHANGED_STATUS: SYNC_STATUS_CHANGED_STATUS,
+    CHANGED_MATERIAL: SYNC_STATUS_CHANGED_MATERIAL,
+    DELETED: SYNC_STATUS_DELETED,
+  },
+
+  SyncStatusProperties: {
+    STATUS: SYNC_STATUS_PROPERTIES_STATUS,
+  },
+
   /**
    * Yields the number of items in the list.
    *
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<number> The number of matching items in the list.  Rejected
    *         with an Error on error.
    */
   count: Task.async(function* (...optsList) {
-    return (yield this._store.count(...optsList));
+    return (yield this._store.count(optsList, STORE_OPTIONS_IGNORE_DELETED));
   }),
 
   /**
    * Checks whether a given URL is in the ReadingList already.
    *
    * @param {String/nsIURI} url - URL to check.
    * @returns {Promise} Promise that is fulfilled with a boolean indicating
    *                    whether the URL is in the list or not.
    */
   hasItemForURL: Task.async(function* (url) {
-    url = normalizeURI(url).spec;
+    url = normalizeURI(url);
 
     // This is used on every tab switch and page load of the current tab, so we
     // want it to be quick and avoid a DB query whenever possible.
 
     // First check if any cached items have a direct match.
     if (this._itemsByNormalizedURL.has(url)) {
       return true;
     }
@@ -184,29 +239,49 @@ ReadingListImpl.prototype = {
    *        is resolved.
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<null> Resolved when the enumeration completes *and* the
    *         last promise returned by the callback is resolved.  Rejected with
    *         an Error on error.
    */
   forEachItem: Task.async(function* (callback, ...optsList) {
+    yield this._forEachItem(callback, optsList, STORE_OPTIONS_IGNORE_DELETED);
+  }),
+
+  /**
+   * Like forEachItem, but enumerates only previously synced items that are
+   * marked as being locally deleted.
+   */
+  forEachSyncedDeletedItem: Task.async(function* (callback, ...optsList) {
+    yield this._forEachItem(callback, optsList, {
+      syncStatus: SYNC_STATUS_DELETED,
+    });
+  }),
+
+  /**
+   * See forEachItem.
+   *
+   * @param storeOptions An options object passed to the store as the "control"
+   *        options.
+   */
+  _forEachItem: Task.async(function* (callback, optsList, storeOptions) {
     let promiseChain = Promise.resolve();
     yield this._store.forEachItem(record => {
       promiseChain = promiseChain.then(() => {
         return new Promise((resolve, reject) => {
           let promise = callback(this._itemFromRecord(record));
           if (promise instanceof Promise) {
             return promise.then(resolve, reject);
           }
           resolve();
           return undefined;
         });
       });
-    }, ...optsList);
+    }, optsList, storeOptions);
     yield promiseChain;
   }),
 
   /**
    * Returns a new ReadingListItemIterator that can be used to enumerate items
    * in the list.
    *
    * @param optsList A variable number of options objects that control the
@@ -231,20 +306,33 @@ ReadingListImpl.prototype = {
    * returned promise is rejected in that case.
    *
    * @param record A simple object representing an item.
    * @return Promise<ReadingListItem> Resolved with the new item when the list
    *         is updated.  Rejected with an Error on error.
    */
   addItem: Task.async(function* (record) {
     record = normalizeRecord(record);
-    record.addedOn = Date.now();
-    if (Services.prefs.prefHasUserValue("services.sync.client.name")) {
-      record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
+    if (!record.url) {
+      throw new Error("The item must have a url");
+    }
+    if (!("addedOn" in record)) {
+      record.addedOn = Date.now();
     }
+    if (!("addedBy" in record)) {
+      try {
+        record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
+      } catch (ex) {
+        record.addedBy = SyncUtils.getDefaultDeviceName();
+      }
+    }
+    if (!("syncStatus" in record)) {
+      record.syncStatus = SYNC_STATUS_NEW;
+    }
+
     yield this._store.addItem(record);
     this._invalidateIterators();
     let item = this._itemFromRecord(record);
     this._callListeners("onItemAdded", item);
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Added", item);
     return item;
   }),
@@ -259,16 +347,19 @@ ReadingListImpl.prototype = {
    * It's an error to call this for an item that doesn't belong to the list.
    * The returned promise is rejected in that case.
    *
    * @param item The ReadingListItem to update.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   updateItem: Task.async(function* (item) {
+    if (!item._record.url) {
+      throw new Error("The item must have a url");
+    }
     this._ensureItemBelongsToList(item);
     yield this._store.updateItem(item._record);
     this._invalidateIterators();
     this._callListeners("onItemUpdated", item);
   }),
 
   /**
    * Deletes an item from the list.  The item must have a `url`.
@@ -277,17 +368,36 @@ ReadingListImpl.prototype = {
    * The returned promise is rejected in that case.
    *
    * @param item The ReadingListItem to delete.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   deleteItem: Task.async(function* (item) {
     this._ensureItemBelongsToList(item);
-    yield this._store.deleteItemByURL(item.url);
+
+    // If the item is new and therefore hasn't been synced yet, delete it from
+    // the store.  Otherwise mark it as deleted but don't actually delete it so
+    // that its status can be synced.
+    if (item._record.syncStatus == SYNC_STATUS_NEW) {
+      yield this._store.deleteItemByURL(item.url);
+    }
+    else {
+      // To prevent data leakage, only keep the record fields needed to sync
+      // the deleted status: guid and syncStatus.
+      let newRecord = {};
+      for (let prop of ITEM_RECORD_PROPERTIES) {
+        newRecord[prop] = null;
+      }
+      newRecord.guid = item._record.guid;
+      newRecord.syncStatus = SYNC_STATUS_DELETED;
+      item._record = newRecord;
+      yield this._store.updateItemByGUID(item._record);
+    }
+
     item.list = null;
     this._itemsByNormalizedURL.delete(item.url);
     this._invalidateIterators();
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Removed", item);
     this._callListeners("onItemDeleted", item);
   }),
 
@@ -304,17 +414,17 @@ ReadingListImpl.prototype = {
   /**
    * Find any item that matches a given URL - either the item's URL, or its
    * resolved URL.
    *
    * @param {String/nsIURI} uri - URI to match against. This will be normalized.
    * @return The first matching item, or null if there are no matching items.
    */
   itemForURL: Task.async(function* (uri) {
-    let url = normalizeURI(uri).spec;
+    let url = normalizeURI(uri);
     return (yield this.item({ url: url }, { resolvedURL: url }));
   }),
 
   /**
    * Add to the ReadingList the page that is loaded in a given browser.
    *
    * @param {<xul:browser>} browser - Browser element for the document,
    * used to get metadata about the article.
@@ -503,17 +613,17 @@ ReadingListItem.prototype = {
     return this._record.guid || undefined;
   },
 
   /**
    * The item's URL.
    * @type string
    */
   get url() {
-    return this._record.url;
+    return this._record.url || undefined;
   },
 
   /**
    * The item's URL as an nsIURI.
    * @type nsIURI
    */
   get uri() {
     if (!this._uri) {
@@ -524,17 +634,17 @@ ReadingListItem.prototype = {
     return this._uri;
   },
 
   /**
    * The item's resolved URL.
    * @type string
    */
   get resolvedURL() {
-    return this._record.resolvedURL;
+    return this._record.resolvedURL || undefined;
   },
   set resolvedURL(val) {
     this._updateRecord({ resolvedURL: val });
   },
 
   /**
    * The item's resolved URL as an nsIURI.  The setter takes an nsIURI or a
    * string spec.
@@ -549,53 +659,53 @@ ReadingListItem.prototype = {
     this._updateRecord({ resolvedURL: val });
   },
 
   /**
    * The item's title.
    * @type string
    */
   get title() {
-    return this._record.title;
+    return this._record.title || undefined;
   },
   set title(val) {
     this._updateRecord({ title: val });
   },
 
   /**
    * The item's resolved title.
    * @type string
    */
   get resolvedTitle() {
-    return this._record.resolvedTitle;
+    return this._record.resolvedTitle || undefined;
   },
   set resolvedTitle(val) {
     this._updateRecord({ resolvedTitle: val });
   },
 
   /**
    * The item's excerpt.
    * @type string
    */
   get excerpt() {
-    return this._record.excerpt;
+    return this._record.excerpt || undefined;
   },
   set excerpt(val) {
     this._updateRecord({ excerpt: val });
   },
 
   /**
-   * The item's status.
-   * @type integer
+   * The item's archived status.
+   * @type boolean
    */
-  get status() {
-    return this._record.status;
+  get archived() {
+    return !!this._record.archived;
   },
-  set status(val) {
-    this._updateRecord({ status: val });
+  set archived(val) {
+    this._updateRecord({ archived: !!val });
   },
 
   /**
    * Whether the item is a favorite.
    * @type boolean
    */
   get favorite() {
     return !!this._record.favorite;
@@ -615,17 +725,17 @@ ReadingListItem.prototype = {
     this._updateRecord({ isArticle: !!val });
   },
 
   /**
    * The item's word count.
    * @type integer
    */
   get wordCount() {
-    return this._record.wordCount;
+    return this._record.wordCount || undefined;
   },
   set wordCount(val) {
     this._updateRecord({ wordCount: val });
   },
 
   /**
    * Whether the item is unread.
    * @type boolean
@@ -663,17 +773,17 @@ ReadingListItem.prototype = {
     this._updateRecord({ storedOn: val.valueOf() });
   },
 
   /**
    * The GUID of the device that marked the item read.
    * @type string
    */
   get markedReadBy() {
-    return this._record.markedReadBy;
+    return this._record.markedReadBy || undefined;
   },
   set markedReadBy(val) {
     this._updateRecord({ markedReadBy: val });
   },
 
   /**
    * The date the item marked read.
    * @type Date
@@ -687,28 +797,28 @@ ReadingListItem.prototype = {
     this._updateRecord({ markedReadOn: val.valueOf() });
   },
 
   /**
    * The item's read position.
    * @param integer
    */
   get readPosition() {
-    return this._record.readPosition;
+    return this._record.readPosition || undefined;
   },
   set readPosition(val) {
     this._updateRecord({ readPosition: val });
   },
 
   /**
    * The URL to a preview image.
    * @type string
    */
    get preview() {
-     return this._record.preview;
+     return this._record.preview || undefined;
    },
 
   /**
    * Deletes the item from its list.
    *
    * @return Promise<null> Resolved when the list has been updated.
    */
   delete: Task.async(function* () {
@@ -725,32 +835,49 @@ ReadingListItem.prototype = {
    * Do not use this at all unless you know what you're doing.  Use the public
    * getters and setters, above, instead.
    *
    * A simple object that contains the item's normalized data in the same format
    * that the local store and server use.  Records passed in by the consumer are
    * not normalized, but everywhere else, records are always normalized unless
    * otherwise stated.  The setter normalizes the passed-in value, so it will
    * throw an error if the value is not a valid record.
+   *
+   * This object should reflect the item's representation in the local store, so
+   * when calling the setter, be careful that it doesn't drift away from the
+   * store's record.  If you set it, you should also call updateItem() around
+   * the same time.
    */
   get _record() {
     return this.__record;
   },
   set _record(val) {
     this.__record = normalizeRecord(val);
   },
 
   /**
    * Updates the item's record.  This calls the _record setter, so it will throw
    * an error if the partial record is not valid.
    *
    * @param partialRecord An object containing any of the record properties.
    */
   _updateRecord(partialRecord) {
     let record = this._record;
+
+    // The syncStatus flag can change from SYNCED to either CHANGED_STATUS or
+    // CHANGED_MATERIAL, or from CHANGED_STATUS to CHANGED_MATERIAL.
+    if (record.syncStatus == SYNC_STATUS_SYNCED ||
+        record.syncStatus == SYNC_STATUS_CHANGED_STATUS) {
+      let allStatusChanges = Object.keys(partialRecord).every(prop => {
+        return SYNC_STATUS_PROPERTIES_STATUS.indexOf(prop) >= 0;
+      });
+      record.syncStatus = allStatusChanges ? SYNC_STATUS_CHANGED_STATUS :
+                          SYNC_STATUS_CHANGED_MATERIAL;
+    }
+
     for (let prop in partialRecord) {
       record[prop] = partialRecord[prop];
     }
     this._record = record;
   },
 
   _ensureBelongsToList() {
     if (!this.list) {
@@ -859,53 +986,61 @@ ReadingListItemIterator.prototype = {
  * aren't in ITEM_RECORD_PROPERTIES.
  *
  * @param record A non-normalized record object.
  * @return The new normalized record.
  */
 function normalizeRecord(nonNormalizedRecord) {
   let record = {};
   for (let prop in nonNormalizedRecord) {
-    if (ITEM_DISREGARDED_PROPERTIES.includes(prop)) {
+    if (ITEM_DISREGARDED_PROPERTIES.indexOf(prop) >= 0) {
       continue;
     }
-    if (!ITEM_RECORD_PROPERTIES.includes(prop)) {
+    if (ITEM_RECORD_PROPERTIES.indexOf(prop) < 0) {
       throw new Error("Unrecognized item property: " + prop);
     }
     switch (prop) {
     case "url":
     case "resolvedURL":
       if (nonNormalizedRecord[prop]) {
-        record[prop] = normalizeURI(nonNormalizedRecord[prop]).spec;
+        record[prop] = normalizeURI(nonNormalizedRecord[prop]);
+      }
+      else {
+        record[prop] = nonNormalizedRecord[prop];
       }
       break;
     default:
       record[prop] = nonNormalizedRecord[prop];
       break;
     }
   }
   return record;
 }
 
 /**
  * Normalize a URI, stripping away extraneous parts we don't want to store
  * or compare against.
  *
  * @param {nsIURI/String} uri - URI to normalize.
- * @returns {nsIURI} Cloned and normalized version of the input URI.
+ * @returns {String} String spec of a cloned and normalized version of the
+ *          input URI.
  */
 function normalizeURI(uri) {
   if (typeof uri == "string") {
-    uri = Services.io.newURI(uri, "", null);
+    try {
+      uri = Services.io.newURI(uri, "", null);
+    } catch (ex) {
+      return uri;
+    }
   }
   uri = uri.cloneIgnoringRef();
   try {
     uri.userPass = "";
   } catch (ex) {} // Not all nsURI impls (eg, nsSimpleURI) support .userPass
-  return uri;
+  return uri.spec;
 };
 
 function hash(str) {
   let hasher = Cc["@mozilla.org/security/hash;1"].
                createInstance(Ci.nsICryptoHash);
   hasher.init(Ci.nsICryptoHash.MD5);
   let stream = Cc["@mozilla.org/io/string-input-stream;1"].
                createInstance(Ci.nsIStringInputStream);
@@ -939,14 +1074,14 @@ function getMetadataFromBrowser(browser)
     mm.addMessageListener("PageMetadata:PageDataResult", handleResult);
     mm.sendAsyncMessage("PageMetadata:GetPageData");
   });
 }
 
 Object.defineProperty(this, "ReadingList", {
   get() {
     if (!this._singleton) {
-      let store = new SQLiteStore("reading-list-temp2.sqlite");
+      let store = new SQLiteStore("reading-list.sqlite");
       this._singleton = new ReadingListImpl(store);
     }
     return this._singleton;
   },
 });
--- a/browser/components/readinglist/SQLiteStore.jsm
+++ b/browser/components/readinglist/SQLiteStore.jsm
@@ -30,43 +30,49 @@ this.SQLiteStore = function SQLiteStore(
   this._ensureConnection(pathRelativeToProfileDir);
 };
 
 this.SQLiteStore.prototype = {
 
   /**
    * Yields the number of items in the store that match the given options.
    *
-   * @param optsList A variable number of options objects that control the
+   * @param userOptsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @param controlOpts A single options object.  Use this to filter out items
+   *        that don't match it -- in other words, to override the user options.
+   *        See Options Objects in ReadingList.jsm.
    * @return Promise<number> The number of matching items in the store.
    *         Rejected with an Error on error.
    */
-  count: Task.async(function* (...optsList) {
-    let [sql, args] = sqlFromOptions(optsList);
+  count: Task.async(function* (userOptsList=[], controlOpts={}) {
+      let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
     let count = 0;
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       SELECT COUNT(*) AS count FROM items ${sql};
     `, args, row => count = row.getResultByName("count"));
     return count;
   }),
 
   /**
    * Enumerates the items in the store that match the given options.
    *
    * @param callback Called for each item in the enumeration.  It's passed a
    *        single object, an item.
-   * @param optsList A variable number of options objects that control the
+   * @param userOptsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects in ReadingList.jsm.
+   * @param controlOpts A single options object.  Use this to filter out items
+   *        that don't match it -- in other words, to override the user options.
+   *        See Options Objects in ReadingList.jsm.
    * @return Promise<null> Resolved when the enumeration completes.  Rejected
    *         with an Error on error.
    */
-  forEachItem: Task.async(function* (callback, ...optsList) {
-    let [sql, args] = sqlFromOptions(optsList);
+  forEachItem: Task.async(function* (callback, userOptsList=[], controlOpts={}) {
+    let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
     let colNames = ReadingList.ItemRecordProperties;
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       SELECT ${colNames} FROM items ${sql};
     `, args, row => callback(itemFromRow(row)));
   }),
 
   /**
@@ -94,41 +100,60 @@ this.SQLiteStore.prototype = {
    * Updates the properties of an item that's already present in the store.  See
    * ReadingList.prototype.updateItem.
    *
    * @param item The item to update.  It must have a `url`.
    * @return Promise<null> Resolved when the store is updated.  Rejected with an
    *         Error on error.
    */
   updateItem: Task.async(function* (item) {
-    let assignments = [];
-    for (let propName in item) {
-      assignments.push(`${propName} = :${propName}`);
-    }
-    let conn = yield this._connectionPromise;
-    yield conn.executeCached(`
-      UPDATE items SET ${assignments} WHERE url = :url;
-    `, item);
+    yield this._updateItem(item, "url");
   }),
 
   /**
-   * Deletes an item from the store.
+   * Same as updateItem, but the item is keyed off of its `guid` instead of its
+   * `url`.
+   *
+   * @param item The item to update.  It must have a `guid`.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  updateItemByGUID: Task.async(function* (item) {
+    yield this._updateItem(item, "guid");
+  }),
+
+  /**
+   * Deletes an item from the store by its URL.
    *
    * @param url The URL string of the item to delete.
    * @return Promise<null> Resolved when the store is updated.  Rejected with an
    *         Error on error.
    */
   deleteItemByURL: Task.async(function* (url) {
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       DELETE FROM items WHERE url = :url;
     `, { url: url });
   }),
 
   /**
+   * Deletes an item from the store by its GUID.
+   *
+   * @param guid The GUID string of the item to delete.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  deleteItemByGUID: Task.async(function* (guid) {
+    let conn = yield this._connectionPromise;
+    yield conn.executeCached(`
+      DELETE FROM items WHERE guid = :guid;
+    `, { guid: guid });
+  }),
+
+  /**
    * Call this when you're done with the store.  Don't use it afterward.
    */
   destroy() {
     if (!this._destroyPromise) {
       this._destroyPromise = Task.spawn(function* () {
         let conn = yield this._connectionPromise;
         yield conn.close();
         this._connectionPromise = Promise.reject("Store destroyed");
@@ -156,16 +181,40 @@ this.SQLiteStore.prototype = {
           PRAGMA locking_mode = EXCLUSIVE;
         `);
         yield this._checkSchema(conn);
         return conn;
       }.bind(this));
     }
   }),
 
+  /**
+   * Updates the properties of an item that's already present in the store.  See
+   * ReadingList.prototype.updateItem.
+   *
+   * @param item The item to update.  It must have the property named by
+   *        keyProp.
+   * @param keyProp The item is keyed off of this property.
+   * @return Promise<null> Resolved when the store is updated.  Rejected with an
+   *         Error on error.
+   */
+  _updateItem: Task.async(function* (item, keyProp) {
+    let assignments = [];
+    for (let propName in item) {
+      assignments.push(`${propName} = :${propName}`);
+    }
+    let conn = yield this._connectionPromise;
+    if (!item[keyProp]) {
+      throw new Error("Item must have " + keyProp);
+    }
+    yield conn.executeCached(`
+      UPDATE items SET ${assignments} WHERE ${keyProp} = :${keyProp};
+    `, item);
+  }),
+
   // Promise<Sqlite.OpenedConnection>
   _connectionPromise: null,
 
   // The current schema version.
   _schemaVersion: 1,
 
   _checkSchema: Task.async(function* (conn) {
     let version = parseInt(yield conn.getSchemaVersion());
@@ -179,38 +228,44 @@ this.SQLiteStore.prototype = {
   _migrateSchema0To1: Task.async(function* (conn) {
     yield conn.execute(`
       PRAGMA journal_mode = wal;
     `);
     // 524288 bytes = 512 KiB
     yield conn.execute(`
       PRAGMA journal_size_limit = 524288;
     `);
+    // Not important, but FYI: The order that these columns are listed in
+    // follows the order that the server doc lists the fields in the article
+    // data model, more or less:
+    // http://readinglist.readthedocs.org/en/latest/model.html
     yield conn.execute(`
       CREATE TABLE items (
         id INTEGER PRIMARY KEY AUTOINCREMENT,
         guid TEXT UNIQUE,
-        url TEXT NOT NULL UNIQUE,
+        serverLastModified INTEGER,
+        url TEXT UNIQUE,
+        preview TEXT,
+        title TEXT,
         resolvedURL TEXT UNIQUE,
-        lastModified INTEGER,
-        title TEXT,
         resolvedTitle TEXT,
         excerpt TEXT,
-        status INTEGER,
+        archived BOOLEAN,
+        deleted BOOLEAN,
         favorite BOOLEAN,
         isArticle BOOLEAN,
         wordCount INTEGER,
         unread BOOLEAN,
         addedBy TEXT,
         addedOn INTEGER,
         storedOn INTEGER,
         markedReadBy TEXT,
         markedReadOn INTEGER,
         readPosition INTEGER,
-        preview TEXT
+        syncStatus INTEGER
       );
     `);
     yield conn.execute(`
       CREATE INDEX items_addedOn ON items (addedOn);
     `);
     yield conn.execute(`
       CREATE INDEX items_unread ON items (unread);
     `);
@@ -231,30 +286,34 @@ function itemFromRow(row) {
   }
   return item;
 }
 
 /**
  * Returns the back part of a SELECT statement generated from the given list of
  * options.
  *
- * @param optsList See Options Objects in ReadingList.jsm.
+ * @param userOptsList A variable number of options objects that control the
+ *        items that are matched.  See Options Objects in ReadingList.jsm.
+ * @param controlOpts A single options object.  Use this to filter out items
+ *        that don't match it -- in other words, to override the user options.
+ *        See Options Objects in ReadingList.jsm.
  * @return An array [sql, args].  sql is a string of SQL.  args is an object
  *         that contains arguments for all the parameters in sql.
  */
-function sqlFromOptions(optsList) {
-  // We modify the options objects, which were passed in by the store client, so
-  // clone them first.
-  optsList = Cu.cloneInto(optsList, {}, { cloneFunctions: false });
+function sqlWhereFromOptions(userOptsList, controlOpts) {
+  // We modify the options objects in userOptsList, which were passed in by the
+  // store client, so clone them first.
+  userOptsList = Cu.cloneInto(userOptsList, {}, { cloneFunctions: false });
 
   let sort;
   let sortDir;
   let limit;
   let offset;
-  for (let opts of optsList) {
+  for (let opts of userOptsList) {
     if ("sort" in opts) {
       sort = opts.sort;
       delete opts.sort;
     }
     if ("descending" in opts) {
       if (opts.descending) {
         sortDir = "DESC";
       }
@@ -279,60 +338,98 @@ function sqlFromOptions(optsList) {
   if (limit) {
     fragments.push(`LIMIT ${limit}`);
     if (offset) {
       fragments.push(`OFFSET ${offset}`);
     }
   }
 
   let args = {};
+  let mainExprs = [];
 
-  function uniqueParamName(name) {
-    if (name in args) {
-      for (let i = 1; ; i++) {
-        let newName = `${name}_${i}`;
-        if (!(newName in args)) {
-          return newName;
-        }
-      }
-    }
-    return name;
+  let controlSQLExpr = sqlExpressionFromOptions([controlOpts], args);
+  if (controlSQLExpr) {
+    mainExprs.push(`(${controlSQLExpr})`);
+  }
+
+  let userSQLExpr = sqlExpressionFromOptions(userOptsList, args);
+  if (userSQLExpr) {
+    mainExprs.push(`(${userSQLExpr})`);
+  }
+
+  if (mainExprs.length) {
+    let conjunction = mainExprs.join(" AND ");
+    fragments.unshift(`WHERE ${conjunction}`);
   }
 
-  // Build a WHERE clause for the remaining properties.  Assume they all refer
-  // to columns.  (If they don't, the SQL query will fail.)
+  let sql = fragments.join(" ");
+  return [sql, args];
+}
+
+/**
+ * Returns a SQL expression generated from the given options list.  Each options
+ * object in the list generates a subexpression, and all the subexpressions are
+ * OR'ed together to produce the final top-level expression.  (e.g., an optsList
+ * with three options objects would generate an expression like "(guid = :guid
+ * OR (title = :title AND unread = :unread) OR resolvedURL = :resolvedURL)".)
+ *
+ * All the properties of the options objects are assumed to refer to columns in
+ * the database.  If they don't, your SQL query will fail.
+ *
+ * @param optsList See Options Objects in ReadingList.jsm.
+ * @param args An object that will hold the SQL parameters.  It will be
+ *        modified.
+ * @return A string of SQL.  Also, args will contain arguments for all the
+ *         parameters in the SQL.
+ */
+function sqlExpressionFromOptions(optsList, args) {
   let disjunctions = [];
   for (let opts of optsList) {
     let conjunctions = [];
     for (let key in opts) {
       if (Array.isArray(opts[key])) {
         // Convert arrays to IN expressions.  e.g., { guid: ['a', 'b', 'c'] }
         // becomes "guid IN (:guid, :guid_1, :guid_2)".  The guid_i arguments
         // are added to opts.
         let array = opts[key];
         let params = [];
         for (let i = 0; i < array.length; i++) {
-          let paramName = uniqueParamName(key);
+          let paramName = uniqueParamName(args, key);
           params.push(`:${paramName}`);
           args[paramName] = array[i];
         }
         conjunctions.push(`${key} IN (${params})`);
       }
       else {
-        let paramName = uniqueParamName(key);
+        let paramName = uniqueParamName(args, key);
         conjunctions.push(`${key} = :${paramName}`);
         args[paramName] = opts[key];
       }
     }
     let conjunction = conjunctions.join(" AND ");
     if (conjunction) {
       disjunctions.push(`(${conjunction})`);
     }
   }
   let disjunction = disjunctions.join(" OR ");
-  if (disjunction) {
-    let where = `WHERE ${disjunction}`;
-    fragments = [where].concat(fragments);
+  return disjunction;
+}
+
+/**
+ * Returns a version of the given name such that it doesn't conflict with the
+ * name of any property in args.  e.g., if name is "foo" but args already has
+ * properties named "foo", "foo1", and "foo2", then "foo3" is returned.
+ *
+ * @param args An object.
+ * @param name The name you want to use.
+ * @return A unique version of the given name.
+ */
+function uniqueParamName(args, name) {
+  if (name in args) {
+    for (let i = 1; ; i++) {
+      let newName = `${name}_${i}`;
+      if (!(newName in args)) {
+        return newName;
+      }
+    }
   }
-
-  let sql = fragments.join(" ");
-  return [sql, args];
+  return name;
 }
--- a/browser/components/readinglist/Scheduler.jsm
+++ b/browser/components/readinglist/Scheduler.jsm
@@ -3,87 +3,92 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict;"
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import('resource://gre/modules/Task.jsm');
 
 
 XPCOMUtils.defineLazyModuleGetter(this, 'LogManager',
   'resource://services-common/logmanager.js');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'Log',
   'resource://gre/modules/Log.jsm');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'Preferences',
   'resource://gre/modules/Preferences.jsm');
 
 XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
   'resource://gre/modules/Timer.jsm');
 XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout',
   'resource://gre/modules/Timer.jsm');
 
-Cu.import('resource://gre/modules/Task.jsm');
+// The main readinglist module.
+XPCOMUtils.defineLazyModuleGetter(this, 'ReadingList',
+  'resource:///modules/readinglist/ReadingList.jsm');
+
+// The "engine"
+XPCOMUtils.defineLazyModuleGetter(this, 'Sync',
+  'resource:///modules/readinglist/Sync.jsm');
+
 
 this.EXPORTED_SYMBOLS = ["ReadingListScheduler"];
 
 // A list of "external" observer topics that may cause us to change when we
 // sync.
 const OBSERVERS = [
   // We don't sync when offline and restart when online.
   "network:offline-status-changed",
   // FxA notifications also cause us to check if we should sync.
   "fxaccounts:onverified",
-  // When something notices a local change to an item.
-  "readinglist:item-changed",
   // some notifications the engine might send if we have been requested to backoff.
   "readinglist:backoff-requested",
   // request to sync now
   "readinglist:user-sync",
 
 ];
 
-///////// A temp object until we get our "engine"
-let engine = {
-  ERROR_AUTHENTICATION: "authentication error",
-  sync: Task.async(function* () {
-  }),
-}
-
 let prefs = new Preferences("readinglist.scheduler.");
 
 // A helper to manage our interval values.
 let intervals = {
   // Getters for our intervals.
   _fixupIntervalPref(prefName, def) {
     // All pref values are seconds, but we return ms.
     return prefs.get(prefName, def) * 1000;
   },
 
   // How long after startup do we do an initial sync?
-  get initial() this._fixupIntervalPref("initial", 20), // 20 seconds.
+  get initial() this._fixupIntervalPref("initial", 10), // 10 seconds.
   // Every interval after the first.
   get schedule() this._fixupIntervalPref("schedule", 2 * 60 * 60), // 2 hours
-  // After we've been told an item has changed
-  get dirty() this._fixupIntervalPref("dirty", 2 * 60), // 2 mins
   // After an error
   get retry() this._fixupIntervalPref("retry", 2 * 60), // 2 mins
 };
 
 // This is the implementation, but it's not exposed directly.
-function InternalScheduler() {
+function InternalScheduler(readingList = null) {
   // oh, I don't know what logs yet - let's guess!
-  let logs = ["readinglist", "FirefoxAccounts", "browserwindow.syncui"];
+  let logs = [
+    "browserwindow.syncui",
+    "FirefoxAccounts",
+    "readinglist.api",
+    "readinglist.serverclient",
+    "readinglist.sync",
+  ];
+
   this._logManager = new LogManager("readinglist.", logs, "readinglist");
   this.log = Log.repository.getLogger("readinglist.scheduler");
   this.log.info("readinglist scheduler created.")
   this.state = this.STATE_OK;
+  this.readingList = readingList || ReadingList; // hook point for tests.
 
   // don't this.init() here, but instead at the module level - tests want to
   // add hooks before it is called.
 }
 
 InternalScheduler.prototype = {
   // When the next scheduled sync should happen.  If we can sync, there will
   // be a timer set to fire then. If we can't sync there will not be a timer,
@@ -93,34 +98,55 @@ InternalScheduler.prototype = {
   // schedule a new timer before this.
   _backoffUntil: 0,
   // Our current timer.
   _timer: null,
   // Our timer fires a promise - _timerRunning is true until it resolves or
   // rejects.
   _timerRunning: false,
   // Our sync engine - XXX - maybe just a callback?
-  _engine: engine,
+  _engine: Sync,
 
   // Our state variable and constants.
   state: null,
   STATE_OK: "ok",
   STATE_ERROR_AUTHENTICATION: "authentication error",
   STATE_ERROR_OTHER: "other error",
 
   init() {
     this.log.info("scheduler initialzing");
+    this._setupRLListener();
     this._observe = this.observe.bind(this);
     for (let notification of OBSERVERS) {
       Services.obs.addObserver(this._observe, notification, false);
     }
     this._nextScheduledSync = Date.now() + intervals.initial;
     this._setupTimer();
   },
 
+  _setupRLListener() {
+    let maybeSync = () => {
+      if (this._timerRunning) {
+        // If a sync is currently running it is possible it will miss the change
+        // just made, so tell the timer the next sync should be 1 ms after
+        // it completes (we don't use zero as that has special meaning re backoffs)
+        this._maybeReschedule(1);
+      } else {
+        // Do the sync now.
+        this._syncNow();
+      }
+    };
+    let listener = {
+      onItemAdded: maybeSync,
+      onItemUpdated: maybeSync,
+      onItemDeleted: maybeSync,
+    }
+    this.readingList.addListener(listener);
+  },
+
   // Note: only called by tests.
   finalize() {
     this.log.info("scheduler finalizing");
     this._clearTimer();
     for (let notification of OBSERVERS) {
       Services.obs.removeObserver(this._observe, notification);
     }
     this._observe = null;
@@ -136,19 +162,16 @@ InternalScheduler.prototype = {
           this.log.warn("Backoff request had non-numeric value", data);
           return;
         }
         this.log.info("Received a request to backoff for ${} seconds", interval);
         this._backoffUntil = Date.now() + interval * 1000;
         this._maybeReschedule(0);
         break;
       }
-      case "readinglist:local:dirty":
-        this._maybeReschedule(intervals.dirty);
-        break;
       case "readinglist:user-sync":
         this._syncNow();
         break;
       case "fxaccounts:onverified":
         // If we were in an authentication error state, reset that now.
         if (this.state == this.STATE_ERROR_AUTHENTICATION) {
           this.state = this.STATE_OK;
         }
@@ -229,35 +252,35 @@ InternalScheduler.prototype = {
       return;
     }
     let now = Date.now();
     if (!this._nextScheduledSync) {
       this._nextScheduledSync = now + delay;
     }
     // If there is something currently scheduled before the requested delay,
     // keep the existing value (eg, if we have a timer firing in 1 second, and
-    // get a "dirty" notification that says we should sync in 2 seconds, we
-    // keep the 1 second value)
+    // get a notification that says we should sync in 2 seconds, we keep the 1
+    // second value)
     this._nextScheduledSync = Math.min(this._nextScheduledSync, now + delay);
     // But we still need to honor a backoff.
     this._nextScheduledSync = Math.max(this._nextScheduledSync, this._backoffUntil);
     // And always create a new timer next time _setupTimer is called.
     this._clearTimer();
   },
 
   // callback for when the timer fires.
   _doSync() {
     this.log.debug("starting sync");
     this._timer = null;
     this._timerRunning = true;
     // flag that there's no new schedule yet, so a request coming in while
     // we are running does the right thing.
     this._nextScheduledSync = 0;
     Services.obs.notifyObservers(null, "readinglist:sync:start", null);
-    this._engine.sync().then(() => {
+    this._engine.start().then(() => {
       this.log.info("Sync completed successfully");
       // Write a pref in the same format used to services/sync to indicate
       // the last success.
       prefs.set("lastSync", new Date().toString());
       this.state = this.STATE_OK;
       this._logManager.resetFileLog(this._logManager.REASON_SUCCESS);
       Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
       return intervals.schedule;
@@ -287,16 +310,21 @@ InternalScheduler.prototype = {
       this._timer = null;
     }
   },
 
   // A function to "sync now", but not allowing it to start if one is
   // already running, and rescheduling the timer.
   // To call this, just send a "readinglist:user-sync" notification.
   _syncNow() {
+    if (!prefs.get("enabled")) {
+      this.log.info("syncNow() but syncing is disabled - ignoring");
+      return;
+    }
+
     if (this._timerRunning) {
       this.log.info("syncNow() but a sync is already in progress - ignoring");
       return;
     }
     this._clearTimer();
     this._doSync();
   },
 
@@ -321,22 +349,22 @@ let ReadingListScheduler = {
   get STATE_ERROR_AUTHENTICATION() internalScheduler.STATE_ERROR_AUTHENTICATION,
   get STATE_ERROR_OTHER() internalScheduler.STATE_ERROR_OTHER,
 
   get state() internalScheduler.state,
 };
 
 // These functions are exposed purely for tests, which manage to grab them
 // via a BackstagePass.
-function createTestableScheduler() {
+function createTestableScheduler(readingList) {
   // kill the "real" scheduler as we don't want it listening to notifications etc.
   if (internalScheduler) {
     internalScheduler.finalize();
     internalScheduler = null;
   }
   // No .init() call - that's up to the tests after hooking.
-  return new InternalScheduler();
+  return new InternalScheduler(readingList);
 }
 
 // mochitests want the internal state of the real scheduler for various things.
 function getInternalScheduler() {
   return internalScheduler;
 }
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/Sync.jsm
@@ -0,0 +1,556 @@
+/* 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 = [
+  "Sync",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+  "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
+  "resource:///modules/readinglist/ReadingList.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ServerClient",
+  "resource:///modules/readinglist/ServerClient.jsm");
+
+// The Last-Modified header of server responses is stored here.
+const SERVER_LAST_MODIFIED_HEADER_PREF = "readinglist.sync.serverLastModified";
+
+// Maps local record properties to server record properties.
+const SERVER_PROPERTIES_BY_LOCAL_PROPERTIES = {
+  guid: "id",
+  serverLastModified: "last_modified",
+  url: "url",
+  preview: "preview",
+  title: "title",
+  resolvedURL: "resolved_url",
+  resolvedTitle: "resolved_title",
+  excerpt: "excerpt",
+  archived: "archived",
+  deleted: "deleted",
+  favorite: "favorite",
+  isArticle: "is_article",
+  wordCount: "word_count",
+  unread: "unread",
+  addedBy: "added_by",
+  addedOn: "added_on",
+  storedOn: "stored_on",
+  markedReadBy: "marked_read_by",
+  markedReadOn: "marked_read_on",
+  readPosition: "read_position",
+};
+
+// Local record properties that can be uploaded in new items.
+const NEW_RECORD_PROPERTIES = `
+  url
+  title
+  resolvedURL
+  resolvedTitle
+  excerpt
+  favorite
+  isArticle
+  wordCount
+  unread
+  addedBy
+  addedOn
+  markedReadBy
+  markedReadOn
+  readPosition
+  preview
+`.trim().split(/\s+/);
+
+// Local record properties that can be uploaded in changed items.
+const MUTABLE_RECORD_PROPERTIES = `
+  title
+  resolvedURL
+  resolvedTitle
+  excerpt
+  favorite
+  isArticle
+  wordCount
+  unread
+  markedReadBy
+  markedReadOn
+  readPosition
+  preview
+`.trim().split(/\s+/);
+
+let log = Log.repository.getLogger("readinglist.sync");
+
+
+/**
+ * An object that syncs reading list state with a server.  To sync, make a new
+ * SyncImpl object and then call start() on it.
+ *
+ * @param readingList The ReadingList to sync.
+ */
+function SyncImpl(readingList) {
+  this.list = readingList;
+  this._client = new ServerClient();
+}
+
+/**
+ * This implementation uses the sync algorithm described here:
+ * https://github.com/mozilla-services/readinglist/wiki/Client-phases
+ * The "phases" mentioned in the methods below refer to the phases in that
+ * document.
+ */
+SyncImpl.prototype = {
+
+  /**
+   * Starts sync, if it's not already started.
+   *
+   * @return Promise<null> this.promise, i.e., a promise that will be resolved
+   *         when sync completes, rejected on error.
+   */
+  start() {
+    if (!this.promise) {
+      this.promise = Task.spawn(function* () {
+        yield this._start();
+        delete this.promise;
+      }.bind(this));
+    }
+    return this.promise;
+  },
+
+  /**
+   * A Promise<null> that will be non-null when sync is ongoing.  Resolved when
+   * sync completes, rejected on error.
+   */
+  promise: null,
+
+  /**
+   * See the document linked above that describes the sync algorithm.
+   */
+  _start: Task.async(function* () {
+    log.info("Starting sync");
+    yield this._uploadStatusChanges();
+    yield this._uploadNewItems();
+    yield this._uploadDeletedItems();
+    yield this._downloadModifiedItems();
+
+    // TODO: "Repeat [this phase] until no conflicts occur," says the doc.
+    yield this._uploadMaterialChanges();
+
+    log.info("Sync done");
+  }),
+
+  /**
+   * Phase 1 part 1
+   *
+   * Uploads not-new items with status-only changes.  By design, status-only
+   * changes will never conflict with what's on the server.
+   */
+  _uploadStatusChanges: Task.async(function* () {
+    log.debug("Phase 1 part 1: Uploading status changes");
+    yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_STATUS,
+                              ReadingList.SyncStatusProperties.STATUS);
+  }),
+
+  /**
+   * There are two phases for uploading changed not-new items: one for items
+   * with status-only changes, one for items with material changes.  The two
+   * work similarly mechanically, and this method is a helper for both.
+   *
+   * @param syncStatus Local items matching this sync status will be uploaded.
+   * @param localProperties An array of local record property names.  The
+   *        uploaded item records will include only these properties.
+   */
+  _uploadChanges: Task.async(function* (syncStatus, localProperties) {
+    // Get local items that match the given syncStatus.
+    let requests = [];
+    yield this.list.forEachItem(localItem => {
+      requests.push({
+        path: "/articles/" + localItem.guid,
+        body: serverRecordFromLocalItem(localItem, localProperties),
+      });
+    }, { syncStatus: syncStatus });
+    if (!requests.length) {
+      log.debug("No local changes to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "PATCH",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading changes", batchResponse);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 404) {
+        // item deleted
+        yield this._deleteItemForGUID(response.body.id);
+        continue;
+      }
+      if (response.status == 412 || response.status == 409) {
+        // 412 Precondition failed: The item was modified since the last sync.
+        // 409 Conflict: A change violated a uniqueness constraint.
+        // In either case, mark the item as having material changes, and
+        // reconcile and upload it in the material-changes phase.
+        // TODO
+        continue;
+      }
+      if (response.status != 200) {
+        this._handleUnexpectedResponse("uploading a change", response);
+        continue;
+      }
+      let item = yield this._itemForGUID(response.body.id);
+      yield this._updateItemWithServerRecord(item, response.body);
+    }
+  }),
+
+  /**
+   * Phase 1 part 2
+   *
+   * Uploads new items.
+   */
+  _uploadNewItems: Task.async(function* () {
+    log.debug("Phase 1 part 2: Uploading new items");
+
+    // Get new local items.
+    let requests = [];
+    yield this.list.forEachItem(localItem => {
+      requests.push({
+        body: serverRecordFromLocalItem(localItem, NEW_RECORD_PROPERTIES),
+      });
+    }, { syncStatus: ReadingList.SyncStatus.NEW });
+    if (!requests.length) {
+      log.debug("No new local items to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "POST",
+          path: "/articles",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading new items", batchResponse);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 303) {
+        // "See Other": An item with the URL already exists.  Mark the item as
+        // having material changes, and reconcile and upload it in the
+        // material-changes phase.
+        // TODO
+        continue;
+      }
+      // Note that the server seems to return a 200 if an identical item already
+      // exists, but we shouldn't be uploading identical items in this phase in
+      // normal usage, so treat 200 as an unexpected response.
+      if (response.status != 201) {
+        this._handleUnexpectedResponse("uploading a new item", response);
+        continue;
+      }
+      let item = yield this.list.itemForURL(response.body.url);
+      yield this._updateItemWithServerRecord(item, response.body);
+    }
+  }),
+
+  /**
+   * Phase 1 part 3
+   *
+   * Uploads deleted synced items.
+   */
+  _uploadDeletedItems: Task.async(function* () {
+    log.debug("Phase 1 part 3: Uploading deleted items");
+
+    // Get deleted synced local items.
+    let requests = [];
+    yield this.list.forEachSyncedDeletedItem(localItem => {
+      requests.push({
+        path: "/articles/" + localItem.guid,
+      });
+    });
+    if (!requests.length) {
+      log.debug("No local deleted synced items to upload");
+      return;
+    }
+
+    // Send the request.
+    let request = {
+      method: "POST",
+      path: "/batch",
+      body: {
+        defaults: {
+          method: "DELETE",
+        },
+        requests: requests,
+      },
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Unmodified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let batchResponse = yield this._sendRequest(request);
+    if (batchResponse.status != 200) {
+      this._handleUnexpectedResponse("uploading deleted items", batchResponse);
+      return;
+    }
+
+    // Delete local items based on the response.
+    for (let response of batchResponse.body.responses) {
+      if (response.status == 412) {
+        // "Precondition failed": The item was modified since the last sync.
+        // Mark the item as having material changes, and reconcile and upload it
+        // in the material-changes phase.
+        // TODO
+        continue;
+      }
+      // A 404 means the item was already deleted on the server, which is OK.
+      // We still need to make sure it's deleted locally, though.
+      if (response.status != 200 && response.status != 404) {
+        this._handleUnexpectedResponse("uploading a deleted item", response);
+        continue;
+      }
+      yield this._deleteItemForGUID(response.body.id);
+    }
+  }),
+
+  /**
+   * Phase 2
+   *
+   * Downloads items that were modified since the last sync.
+   */
+  _downloadModifiedItems: Task.async(function* () {
+    log.debug("Phase 2: Downloading modified items");
+
+    // Get modified items from the server.
+    let path = "/articles";
+    if (this._serverLastModifiedHeader) {
+      path += "?_since=" + this._serverLastModifiedHeader;
+    }
+    let request = {
+      method: "GET",
+      path: path,
+      headers: {},
+    };
+    if (this._serverLastModifiedHeader) {
+      request.headers["If-Modified-Since"] = this._serverLastModifiedHeader;
+    }
+
+    let response = yield this._sendRequest(request);
+    if (response.status == 304) {
+      // not modified
+      log.debug("No server changes");
+      return;
+    }
+    if (response.status != 200) {
+      this._handleUnexpectedResponse("downloading modified items", response);
+      return;
+    }
+
+    // Update local items based on the response.
+    for (let serverRecord of response.body.items) {
+      let localItem = yield this._itemForGUID(serverRecord.id);
+      if (localItem) {
+        if (localItem.serverLastModified == serverRecord.last_modified) {
+          // We just uploaded this item in the new-items phase.
+          continue;
+        }
+        // The local item may have materially changed.  In that case, don't
+        // overwrite the local changes with the server record.  Instead, mark
+        // the item as having material changes and reconcile and upload it in
+        // the material-changes phase.
+        // TODO
+
+        if (serverRecord.deleted) {
+          yield this._deleteItemForGUID(serverRecord.id);
+          continue;
+        }
+        yield this._updateItemWithServerRecord(localItem, serverRecord);
+        continue;
+      }
+      // new item
+      yield this.list.addItem(localRecordFromServerRecord(serverRecord));
+    }
+  }),
+
+  /**
+   * Phase 3 (material changes)
+   *
+   * Uploads not-new items with material changes.
+   */
+  _uploadMaterialChanges: Task.async(function* () {
+    log.debug("Phase 3: Uploading material changes");
+    yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_MATERIAL,
+                              MUTABLE_RECORD_PROPERTIES);
+  }),
+
+  /**
+   * Gets the local ReadingListItem with the given GUID.
+   *
+   * @param guid The item's GUID.
+   * @return The matching ReadingListItem.
+   */
+  _itemForGUID: Task.async(function* (guid) {
+    return (yield this.list.item({ guid: guid }));
+  }),
+
+  /**
+   * Updates the given local ReadingListItem with the given server record.  The
+   * local item's sync status is updated to reflect the fact that the item has
+   * been synced and is up to date.
+   *
+   * @param item A local ReadingListItem.
+   * @param serverRecord A server record representing the item.
+   */
+  _updateItemWithServerRecord: Task.async(function* (localItem, serverRecord) {
+    if (!localItem) {
+      throw new Error("Item should exist");
+    }
+    localItem._record = localRecordFromServerRecord(serverRecord);
+    yield this.list.updateItem(localItem);
+  }),
+
+  /**
+   * Truly deletes the local ReadingListItem with the given GUID.
+   *
+   * @param guid The item's GUID.
+   */
+  _deleteItemForGUID: Task.async(function* (guid) {
+    let item = yield this._itemForGUID(guid);
+    if (item) {
+      // If item is non-null, then it hasn't been deleted locally.  Therefore
+      // it's important to delete it through its list so that the list and its
+      // consumers are notified properly.  Set the syncStatus to NEW so that the
+      // list truly deletes the item.
+      item._record.syncStatus = ReadingList.SyncStatus.NEW;
+      yield this.list.deleteItem(item);
+      return;
+    }
+    // If item is null, then it may not actually exist locally, or it may have
+    // been synced and then deleted so that it's marked as being deleted.  In
+    // that case, try to delete it directly from the store.  As far as the list
+    // is concerned, the item has already been deleted.
+    log.debug("Item not present in list, deleting it by GUID instead");
+    this.list._store.deleteItemByGUID(guid);
+  }),
+
+  /**
+   * Sends a request to the server.
+   *
+   * @param req The request object: { method, path, body, headers }.
+   * @return Promise<response> Resolved with the server's response object:
+   *         { status, body, headers }.
+   */
+  _sendRequest: Task.async(function* (req) {
+    log.debug("Sending request", req);
+    let response = yield this._client.request(req);
+    log.debug("Received response", response);
+    // Response header names are lowercase.
+    if (response.headers && "last-modified" in response.headers) {
+      this._serverLastModifiedHeader = response.headers["last-modified"];
+    }
+    return response;
+  }),
+
+  _handleUnexpectedResponse(contextMsgFragment, response) {
+    log.warn(`Unexpected response ${contextMsgFragment}`, response);
+  },
+
+  // TODO: Wipe this pref when user logs out.
+  get _serverLastModifiedHeader() {
+    if (!("__serverLastModifiedHeader" in this)) {
+      this.__serverLastModifiedHeader =
+        Preferences.get(SERVER_LAST_MODIFIED_HEADER_PREF, undefined);
+    }
+    return this.__serverLastModifiedHeader;
+  },
+  set _serverLastModifiedHeader(val) {
+    this.__serverLastModifiedHeader = val;
+    Preferences.set(SERVER_LAST_MODIFIED_HEADER_PREF, val);
+  },
+};
+
+
+/**
+ * Translates a local ReadingListItem into a server record.
+ *
+ * @param localItem The local ReadingListItem.
+ * @param localProperties An array of local item property names.  Only these
+ *        properties will be included in the server record.
+ * @return The server record.
+ */
+function serverRecordFromLocalItem(localItem, localProperties) {
+  let serverRecord = {};
+  for (let localProp of localProperties) {
+    let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
+    if (localProp in localItem._record) {
+      serverRecord[serverProp] = localItem._record[localProp];
+    }
+  }
+  return serverRecord;
+}
+
+/**
+ * Translates a server record into a local record.  The returned local record's
+ * syncStatus will reflect the fact that the local record is up-to-date synced.
+ *
+ * @param serverRecord The server record.
+ * @return The local record.
+ */
+function localRecordFromServerRecord(serverRecord) {
+  let localRecord = {
+    // Mark the record as being up-to-date synced.
+    syncStatus: ReadingList.SyncStatus.SYNCED,
+  };
+  for (let localProp in SERVER_PROPERTIES_BY_LOCAL_PROPERTIES) {
+    let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
+    if (serverProp in serverRecord) {
+      localRecord[localProp] = serverRecord[serverProp];
+    }
+  }
+  return localRecord;
+}
+
+Object.defineProperty(this, "Sync", {
+  get() {
+    if (!this._singleton) {
+      this._singleton = new SyncImpl(ReadingList);
+    }
+    return this._singleton;
+  },
+});
--- a/browser/components/readinglist/moz.build
+++ b/browser/components/readinglist/moz.build
@@ -4,16 +4,17 @@
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES.readinglist += [
     'ReadingList.jsm',
     'Scheduler.jsm',
     'ServerClient.jsm',
     'SQLiteStore.jsm',
+    'Sync.jsm',
 ]
 
 TESTING_JS_MODULES += [
     'test/ReadingListTestUtils.jsm',
 ]
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 
--- a/browser/components/readinglist/sidebar.js
+++ b/browser/components/readinglist/sidebar.js
@@ -296,16 +296,21 @@ let RLSidebar = {
     log.debug(`Opening page ${url}`);
 
     let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIWebNavigation)
                            .QueryInterface(Ci.nsIDocShellTreeItem)
                            .rootTreeItem
                            .QueryInterface(Ci.nsIInterfaceRequestor)
                            .getInterface(Ci.nsIDOMWindow);
+
+    let currentUrl = mainWindow.gBrowser.currentURI.spec;
+    if (currentUrl.startsWith("about:reader"))
+      url = "about:reader?url=" + encodeURIComponent(url);
+
     mainWindow.openUILink(url, event);
   },
 
   /**
    * Get the ID of the Item associated with a given list item element.
    * @param {element} node - List item element to get an ID for.
    * @return {string} Assocated Item ID.
    */
@@ -397,37 +402,44 @@ let RLSidebar = {
    * Handle a keydown event on the list box.
    * @param {Event} event - Triggering event.
    */
   onListKeyDown(event) {
     if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
       // TODO: Refactor this so we pass a direction to a generic method.
       // See autocomplete.xml's getNextIndex
       event.preventDefault();
+
+      if (!this.numItems) {
+        return;
+      }
       let index = this.selectedIndex + 1;
       if (index >= this.numItems) {
         index = 0;
       }
 
       this.selectedIndex = index;
       this.selectedItem.focus();
     } else if (event.keyCode == KeyEvent.DOM_VK_UP) {
       event.preventDefault();
 
+      if (!this.numItems) {
+        return;
+      }
       let index = this.selectedIndex - 1;
       if (index < 0) {
         index = this.numItems - 1;
       }
 
       this.selectedIndex = index;
       this.selectedItem.focus();
     } else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
       let selectedItem = this.selectedItem;
       if (selectedItem) {
-        this.activeItem = this.selectedItem;
+        this.activeItem = selectedItem;
         this.openActiveItem(event);
       }
     }
   },
 
   /**
    * Handle a message, typically sent from browser-readinglist.js
    * @param {Event} event - Triggering event.
--- a/browser/components/readinglist/test/xpcshell/test_ReadingList.js
+++ b/browser/components/readinglist/test/xpcshell/test_ReadingList.js
@@ -33,17 +33,16 @@ add_task(function* prepare() {
   for (let i = 0; i < 3; i++) {
     gItems.push({
       guid: `guid${i}`,
       url: `http://example.com/${i}`,
       resolvedURL: `http://example.com/resolved/${i}`,
       title: `title ${i}`,
       excerpt: `excerpt ${i}`,
       unread: 0,
-      lastModified: Date.now(),
       favorite: 0,
       isArticle: 1,
       storedOn: Date.now(),
     });
   }
 
   for (let item of gItems) {
     let addedItem = yield gList.addItem(item);
@@ -132,17 +131,36 @@ add_task(function* constraints() {
   delete item.url;
   err = null;
   try {
     yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
-  checkError(err);
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
+  Assert.equal(err.message, "The item must have a url");
+
+  // update an item with no url
+  item = (yield gList.item({ guid: gItems[0].guid }));
+  Assert.ok(item);
+  let oldURL = item._record.url;
+  item._record.url = null;
+  err = null;
+  try {
+    yield gList.updateItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  item._record.url = oldURL;
+  Assert.ok(err);
+  Assert.ok(err instanceof Cu.getGlobalForObject(ReadingList).Error, err);
+  Assert.equal(err.message, "The item must have a url");
 
   // add an item with a bogus property
   item = kindOfClone(gItems[0]);
   item.bogus = "gnarly";
   err = null;
   try {
     yield gList.addItem(item);
   }
@@ -264,16 +282,29 @@ add_task(function* forEachItem() {
     title: gItems[1].title,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
   });
   checkItems(items, [gItems[0], gItems[1]]);
 });
 
+add_task(function* forEachSyncedDeletedItem() {
+  let deletedItem = yield gList.addItem({
+    guid: "forEachSyncedDeletedItem",
+    url: "http://example.com/forEachSyncedDeletedItem",
+  });
+  deletedItem._record.syncStatus = gList.SyncStatus.SYNCED;
+  yield gList.deleteItem(deletedItem);
+  let items = [];
+  yield gList.forEachSyncedDeletedItem(item => items.push(item));
+  Assert.equal(items.length, 1);
+  Assert.equal(items[0].guid, deletedItem.guid);
+});
+
 add_task(function* forEachItem_promises() {
   // promises resolved immediately
   let items = [];
   yield gList.forEachItem(item => {
     items.push(item);
     return Promise.resolve();
   }, {
     sort: "guid",
@@ -535,46 +566,32 @@ add_task(function* updateItem() {
 add_task(function* item_setRecord() {
   // get an item
   let iter = gList.iterator({
     sort: "guid",
   });
   let item = (yield iter.items(1))[0];
   Assert.ok(item);
 
-  // Set item._record without an updateItem.  After fetching the item again, its
-  // title should be the old title.
-  let oldTitle = item.title;
+  // Set item._record followed by an updateItem.  After fetching the item again,
+  // its title should be the new title.
   let newTitle = "item_setRecord title 1";
-  Assert.notEqual(oldTitle, newTitle);
   item._record.title = newTitle;
+  yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   let sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
-  Assert.equal(sameItem.title, oldTitle);
-
-  // Set item._record followed by an updateItem.  After fetching the item again,
-  // its title should be the new title.
-  newTitle = "item_setRecord title 2";
-  item._record.title = newTitle;
-  yield gList.updateItem(item);
-  Assert.equal(item.title, newTitle);
-  iter = gList.iterator({
-    sort: "guid",
-  });
-  sameItem = (yield iter.items(1))[0];
-  Assert.ok(item === sameItem);
   Assert.equal(sameItem.title, newTitle);
 
   // Set item.title directly and call updateItem.  After fetching the item
   // again, its title should be the new title.
-  newTitle = "item_setRecord title 3";
+  newTitle = "item_setRecord title 2";
   item.title = newTitle;
   yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
@@ -673,21 +690,19 @@ add_task(function* deleteItem() {
     sort: "guid",
   });
   checkItems(items, gItems.slice(3));
 });
 
 function checkItems(actualItems, expectedItems) {
   Assert.equal(actualItems.length, expectedItems.length);
   for (let i = 0; i < expectedItems.length; i++) {
-    for (let prop in expectedItems[i]) {
-      if (prop != "list") {
-        Assert.ok(prop in actualItems[i]._record, prop);
-        Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
-      }
+    for (let prop in expectedItems[i]._record) {
+      Assert.ok(prop in actualItems[i]._record, prop);
+      Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
     }
   }
 }
 
 function checkError(err) {
   Assert.ok(err);
   Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error, err);
 }
--- a/browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
+++ b/browser/components/readinglist/test/xpcshell/test_SQLiteStore.js
@@ -156,152 +156,155 @@ add_task(function* constraints() {
   }
   Assert.ok(!err, err ? err.message : undefined);
   let url2 = item.url;
 
   // Delete both items since other tests assume the store contains only gItems.
   yield gStore.deleteItemByURL(url1);
   yield gStore.deleteItemByURL(url2);
   let items = [];
-  yield gStore.forEachItem(i => items.push(i), { url: [url1, url2] });
+  yield gStore.forEachItem(i => items.push(i), [{ url: [url1, url2] }]);
   Assert.equal(items.length, 0);
-
-  // add a new item with no url, which is not allowed
-  item = kindOfClone(gItems[0]);
-  delete item.url;
-  err = null;
-  try {
-    yield gStore.addItem(item);
-  }
-  catch (e) {
-    err = e;
-  }
-  checkError(err, "NOT NULL constraint failed: items.url");
 });
 
 add_task(function* count() {
   let count = yield gStore.count();
   Assert.equal(count, gItems.length);
 
-  count = yield gStore.count({
+  count = yield gStore.count([{
     guid: gItems[0].guid,
-  });
+  }]);
   Assert.equal(count, 1);
 });
 
 add_task(function* forEachItem() {
   // all items
   let items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems);
 
   // first item
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     limit: 1,
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(0, 1));
 
   // last item
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     limit: 1,
     sort: "guid",
     descending: true,
-  });
+  }]);
   checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
 
   // match on a scalar property
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, gItems.slice(0, 1));
 
   // match on an array
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems);
 
   // match on AND'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     title: gItems[0].title,
     sort: "guid",
-  });
+  }]);
   checkItems(items, [gItems[0]]);
 
   // match on OR'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems[1].guid,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, [gItems[0], gItems[1]]);
 
   // match on AND'ed and OR'ed properties
   items = [];
-  yield gStore.forEachItem(item => items.push(item), {
+  yield gStore.forEachItem(item => items.push(item), [{
     guid: gItems.map(i => i.guid),
     title: gItems[1].title,
     sort: "guid",
   }, {
     guid: gItems[0].guid,
-  });
+  }]);
   checkItems(items, [gItems[0], gItems[1]]);
 });
 
 add_task(function* updateItem() {
   let newTitle = "a new title";
   gItems[0].title = newTitle;
   yield gStore.updateItem(gItems[0]);
   let item;
-  yield gStore.forEachItem(i => item = i, {
+  yield gStore.forEachItem(i => item = i, [{
     guid: gItems[0].guid,
-  });
+  }]);
+  Assert.ok(item);
+  Assert.equal(item.title, gItems[0].title);
+});
+
+add_task(function* updateItemByGUID() {
+  let newTitle = "updateItemByGUID";
+  gItems[0].title = newTitle;
+  yield gStore.updateItemByGUID(gItems[0]);
+  let item;
+  yield gStore.forEachItem(i => item = i, [{
+    guid: gItems[0].guid,
+  }]);
   Assert.ok(item);
   Assert.equal(item.title, gItems[0].title);
 });
 
 // This test deletes items so it should probably run last.
 add_task(function* deleteItemByURL() {
   // delete first item
   yield gStore.deleteItemByURL(gItems[0].url);
   Assert.equal((yield gStore.count()), gItems.length - 1);
   let items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(1));
 
   // delete second item
   yield gStore.deleteItemByURL(gItems[1].url);
   Assert.equal((yield gStore.count()), gItems.length - 2);
   items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(2));
+});
 
+// This test deletes items so it should probably run last.
+add_task(function* deleteItemByGUID() {
   // delete third item
-  yield gStore.deleteItemByURL(gItems[2].url);
+  yield gStore.deleteItemByGUID(gItems[2].guid);
   Assert.equal((yield gStore.count()), gItems.length - 3);
-  items = [];
-  yield gStore.forEachItem(i => items.push(i), {
+  let items = [];
+  yield gStore.forEachItem(i => items.push(i), [{
     sort: "guid",
-  });
+  }]);
   checkItems(items, gItems.slice(3));
 });
 
 function checkItems(actualItems, expectedItems) {
   Assert.equal(actualItems.length, expectedItems.length);
   for (let i = 0; i < expectedItems.length; i++) {
     for (let prop in expectedItems[i]) {
       Assert.ok(prop in actualItems[i], prop);
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/xpcshell/test_Sync.js
@@ -0,0 +1,330 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gProfildDirFile = do_get_profile();
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource:///modules/readinglist/Sync.jsm");
+
+let { localRecordFromServerRecord } =
+  Cu.import("resource:///modules/readinglist/Sync.jsm", {});
+
+let gList;
+let gSync;
+let gClient;
+let gLocalItems = [];
+
+function run_test() {
+  run_next_test();
+}
+
+add_task(function* prepare() {
+  gSync = Sync;
+  gList = Sync.list;
+  let dbFile = gProfildDirFile.clone();
+  dbFile.append(gSync.list._store.pathRelativeToProfileDir);
+  do_register_cleanup(function* () {
+    // Wait for the list's store to close its connection to the database.
+    yield gList.destroy();
+    if (dbFile.exists()) {
+      dbFile.remove(true);
+    }
+  });
+
+  gClient = new MockClient();
+  gSync._client = gClient;
+
+  let dumpAppender = new Log.DumpAppender();
+  dumpAppender.level = Log.Level.All;
+  let logNames = [
+    "readinglist.sync",
+  ];
+  for (let name of logNames) {
+    let log = Log.repository.getLogger(name);
+    log.level = Log.Level.All;
+    log.addAppender(dumpAppender);
+  }
+});
+
+add_task(function* uploadNewItems() {
+  // Add some local items.
+  for (let i = 0; i < 3; i++) {
+    let record = {
+      url: `http://example.com/${i}`,
+      title: `title ${i}`,
+      addedBy: "device name",
+    };
+    gLocalItems.push(yield gList.addItem(record));
+  }
+
+  Assert.ok(!("resolvedURL" in gLocalItems[0]._record));
+  yield gSync.start();
+
+  // The syncer should update local items with the items in the server response.
+  // e.g., the item didn't have a resolvedURL before sync, but after sync it
+  // should.
+  Assert.ok("resolvedURL" in gLocalItems[0]._record);
+
+  checkItems(gClient.items, gLocalItems);
+});
+
+add_task(function* uploadStatusChanges() {
+  // Change an item's unread from true to false.
+  Assert.ok(gLocalItems[0].unread === true);
+
+  gLocalItems[0].unread = false;
+  yield gList.updateItem(gLocalItems[0]);
+  yield gSync.start();
+
+  Assert.ok(gLocalItems[0].unread === false);
+  checkItems(gClient.items, gLocalItems);
+});
+
+add_task(function* downloadChanges() {
+  // Change an item on the server.
+  let newTitle = "downloadChanges new title";
+  let response = yield gClient.request({
+    method: "PATCH",
+    path: "/articles/1",
+    body: {
+      title: newTitle,
+    },
+  });
+  Assert.equal(response.status, 200);
+
+  // Add a new item on the server.
+  let newRecord = {
+    url: "http://example.com/downloadChanges-new-item",
+    title: "downloadChanges 2",
+    added_by: "device name",
+  };
+  response = yield gClient.request({
+    method: "POST",
+    path: "/articles",
+    body: newRecord,
+  });
+  Assert.equal(response.status, 201);
+
+  // Delete an item on the server.
+  response = yield gClient.request({
+    method: "DELETE",
+    path: "/articles/2",
+  });
+  Assert.equal(response.status, 200);
+
+  yield gSync.start();
+
+  // Refresh the list of local items.  The changed item should be changed
+  // locally, the deleted item should be deleted locally, and the new item
+  // should appear in the list.
+  gLocalItems = (yield gList.iterator({ sort: "guid" }).
+                 items(gLocalItems.length));
+
+  Assert.equal(gLocalItems[1].title, newTitle);
+  Assert.equal(gLocalItems[2].url, newRecord.url);
+  checkItems(gClient.items, gLocalItems);
+});
+
+
+function MockClient() {
+  this._items = [];
+  this._nextItemID = 0;
+  this._nextLastModifiedToken = 0;
+}
+
+MockClient.prototype = {
+
+  request(req) {
+    let response = this._routeRequest(req);
+    return new Promise(resolve => {
+      // Resolve the promise asyncly, just as if this were a real server, so
+      // that we don't somehow end up depending on sync behavior.
+      setTimeout(() => {
+        resolve(response);
+      }, 0);
+    });
+  },
+
+  get items() {
+    return this._items.slice().sort((item1, item2) => {
+      return item2.id < item1.id;
+    });
+  },
+
+  itemByID(id) {
+    return this._items.find(item => item.id == id);
+  },
+
+  itemByURL(url) {
+    return this._items.find(item => item.url == url);
+  },
+
+  _items: null,
+  _nextItemID: null,
+  _nextLastModifiedToken: null,
+
+  _routeRequest(req) {
+    for (let prop in this) {
+      let match = (new RegExp("^" + prop + "$")).exec(req.path);
+      if (match) {
+        let handler = this[prop];
+        let method = req.method.toLowerCase();
+        if (!(method in handler)) {
+          throw new Error(`Handler ${prop} does not support method ${method}`);
+        }
+        let response = handler[method].call(this, req.body, match);
+        // Make sure the response really is JSON'able (1) as a kind of sanity
+        // check, (2) to convert any non-primitives (e.g., new String()) into
+        // primitives, and (3) because that's what the real server returns.
+        response = JSON.parse(JSON.stringify(response));
+        return response;
+      }
+    }
+    throw new Error(`Unrecognized path: ${req.path}`);
+  },
+
+  // route handlers
+
+  "/articles": {
+
+    get(body) {
+      return new MockResponse(200, {
+        // No URL params supported right now.
+        items: this.items,
+      });
+    },
+
+    post(body) {
+      let existingItem = this.itemByURL(body.url);
+      if (existingItem) {
+        // The real server seems to return a 200 if the items are identical.
+        if (areSameItems(existingItem, body)) {
+          return new MockResponse(200);
+        }
+        // 303 see other
+        return new MockResponse(303, {
+          id: existingItem.id,
+        });
+      }
+      body.id = new String(this._nextItemID++);
+      let defaultProps = {
+        last_modified: this._nextLastModifiedToken,
+        preview: "",
+        resolved_url: body.url,
+        resolved_title: body.title,
+        excerpt: "",
+        archived: 0,
+        deleted: 0,
+        favorite: false,
+        is_article: true,
+        word_count: null,
+        unread: true,
+        added_on: null,
+        stored_on: this._nextLastModifiedToken,
+        marked_read_by: null,
+        marked_read_on: null,
+        read_position: null,
+      };
+      for (let prop in defaultProps) {
+        if (!(prop in body) || body[prop] === null) {
+          body[prop] = defaultProps[prop];
+        }
+      }
+      this._nextLastModifiedToken++;
+      this._items.push(body);
+      // 201 created
+      return new MockResponse(201, body);
+    },
+  },
+
+  "/articles/([^/]+)": {
+
+    get(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      return new MockResponse(200, item);
+    },
+
+    patch(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      for (let prop in body) {
+        item[prop] = body[prop];
+      }
+      item.last_modified = this._nextLastModifiedToken++;
+      return new MockResponse(200, item);
+    },
+
+    delete(body, routeMatch) {
+      let id = routeMatch[1];
+      let item = this.itemByID(id);
+      if (!item) {
+        return new MockResponse(404);
+      }
+      item.deleted = true;
+      return new MockResponse(200);
+    },
+  },
+
+  "/batch": {
+
+    post(body) {
+      let responses = [];
+      let defaults = body.defaults || {};
+      for (let request of body.requests) {
+        for (let prop in defaults) {
+          if (!(prop in request)) {
+            request[prop] = defaults[prop];
+          }
+        }
+        responses.push(this._routeRequest(request));
+      }
+      return new MockResponse(200, {
+        defaults: defaults,
+        responses: responses,
+      });
+    },
+  },
+};
+
+function MockResponse(status, body, headers={}) {
+  this.status = status;
+  this.body = body;
+  this.headers = headers;
+}
+
+function areSameItems(item1, item2) {
+  for (let prop in item1) {
+    if (!(prop in item2) || item1[prop] != item2[prop]) {
+      return false;
+    }
+  }
+  for (let prop in item2) {
+    if (!(prop in item1) || item1[prop] != item2[prop]) {
+      return false;
+    }
+  }
+  return true;
+}
+
+function checkItems(serverRecords, localItems) {
+  serverRecords = serverRecords.map(r => localRecordFromServerRecord(r));
+  serverRecords = serverRecords.filter(r => !r.deleted);
+  Assert.equal(serverRecords.length, localItems.length);
+  for (let i = 0; i < serverRecords.length; i++) {
+    for (let prop in localItems[i]._record) {
+      Assert.ok(prop in serverRecords[i], prop);
+      Assert.equal(serverRecords[i][prop], localItems[i]._record[prop]);
+    }
+  }
+}
--- a/browser/components/readinglist/test/xpcshell/test_scheduler.js
+++ b/browser/components/readinglist/test/xpcshell/test_scheduler.js
@@ -21,28 +21,40 @@ function promiseObserver(topic) {
     let obs = (subject, topic, data) => {
       Services.obs.removeObserver(obs, topic);
       resolve(data);
     }
     Services.obs.addObserver(obs, topic, false);
   });
 }
 
+function ReadingListMock() {
+  this.listener = null;
+}
+
+ReadingListMock.prototype = {
+  addListener(listener) {
+    ok(!this.listener, "mock only expects 1 listener");
+    this.listener = listener;
+  },
+}
+
 function createScheduler(options) {
   // avoid typos in the test and other footguns in the options.
   let allowedOptions = ["expectedDelay", "expectNewTimer", "syncFunction"];
   for (let key of Object.keys(options)) {
     if (allowedOptions.indexOf(key) == -1) {
       throw new Error("Invalid option " + key);
     }
   }
-  let scheduler = createTestableScheduler();
+  let rlMock = new ReadingListMock();
+  let scheduler = createTestableScheduler(rlMock);
   // make our hooks
   let syncFunction = options.syncFunction || Promise.resolve;
-  scheduler._engine.sync = syncFunction;
+  scheduler._engine.start = syncFunction;
   // we expect _setTimeout to be called *twice* - first is the initial sync,
   // and there's no need to test the delay used for that. options.expectedDelay
   // is to check the *subsequent* timer.
   let numCalls = 0;
   scheduler._setTimeout = function(delay) {
     ++numCalls;
     print("Test scheduler _setTimeout call number " + numCalls + " with delay=" + delay);
     switch (numCalls) {
@@ -85,16 +97,37 @@ add_task(function* testSuccess() {
   ];
   // New delay should be "as regularly scheduled".
   prefs.set("schedule", 100);
   let scheduler = createScheduler({expectedDelay: 100});
   yield Promise.all(allNotifications);
   scheduler.finalize();
 });
 
+// Test that if we get a reading list notification while we are syncing we
+// immediately start a new one when it complets.
+add_task(function* testImmediateResyncWhenChangedDuringSync() {
+  // promises which resolve once we've got all the expected notifications.
+  let allNotifications = [
+    promiseObserver("readinglist:sync:start"),
+    promiseObserver("readinglist:sync:finish"),
+  ];
+  prefs.set("schedule", 100);
+  // New delay should be "immediate".
+  let scheduler = createScheduler({
+    expectedDelay: 0,
+    syncFunction: () => {
+      // we are now syncing - pretend the readinglist has an item change
+      scheduler.readingList.listener.onItemAdded();
+      return Promise.resolve();
+    }});
+  yield Promise.all(allNotifications);
+  scheduler.finalize();
+});
+
 add_task(function* testOffline() {
   let scheduler = createScheduler({expectNewTimer: false});
   Services.io.offline = true;
   ok(!scheduler._canSync(), "_canSync is false when offline.")
   ok(!scheduler._timer, "there is no current timer while offline.")
   Services.io.offline = false;
   ok(scheduler._canSync(), "_canSync is true when online.")
   ok(scheduler._timer, "there is a new timer when back online.")
--- a/browser/components/readinglist/test/xpcshell/xpcshell.ini
+++ b/browser/components/readinglist/test/xpcshell/xpcshell.ini
@@ -1,8 +1,9 @@
 [DEFAULT]
 head = head.js
 firefox-appdir = browser
 
 [test_ReadingList.js]
 [test_ServerClient.js]
 [test_scheduler.js]
 [test_SQLiteStore.js]
+[test_Sync.js]
--- a/browser/components/translation/BingTranslator.jsm
+++ b/browser/components/translation/BingTranslator.jsm
@@ -8,17 +8,17 @@ const {classes: Cc, interfaces: Ci, util
 
 this.EXPORTED_SYMBOLS = [ "BingTranslator" ];
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://services-common/utils.js");
-Cu.import("resource://services-common/rest.js");
+Cu.import("resource://gre/modules/Http.jsm");
 
 // The maximum amount of net data allowed per request on Bing's API.
 const MAX_REQUEST_DATA = 5000; // Documentation says 10000 but anywhere
                                // close to that is refused by the service.
 
 // The maximum number of chunks allowed to be translated in a single
 // request.
 const MAX_REQUEST_CHUNKS = 1000; // Documentation says 2000.
@@ -124,23 +124,23 @@ this.BingTranslator.prototype = {
   /**
    * Function called when a request sent to the server has failed.
    * This function handles deciding if the error is transient or means the
    * service is unavailable (zero balance on the key or request credentials are
    * not in an active state) and calling the function to resolve the promise
    * returned by the public `translate()` method when there's no pending.
    * request left.
    *
-   * @param   aError   [optional] The RESTRequest that failed.
+   * @param   aError   [optional] The XHR object of the request that failed.
    */
   _chunkFailed: function(aError) {
-    if (aError instanceof RESTRequest &&
-        [400, 401].indexOf(aError.response.status) != -1) {
-      let body = aError.response.body;
-      if (body.contains("TranslateApiException") &&
+    if (aError instanceof Ci.nsIXMLHttpRequest &&
+        [400, 401].indexOf(aError.status) != -1) {
+      let body = aError.responseText;
+      if (body && body.contains("TranslateApiException") &&
           (body.contains("balance") || body.contains("active state")))
         this._serviceUnavailable = true;
     }
 
     this._checkIfFinished();
   },
 
   /**
@@ -173,23 +173,19 @@ this.BingTranslator.prototype = {
    * particular interest, the only part of the response that matters
    * are the <TranslatedText> nodes, which contains the resulting
    * items that were sent to be translated.
    *
    * @param   request      The request sent to the server.
    * @returns boolean      True if parsing of this chunk was successful.
    */
   _parseChunkResult: function(bingRequest) {
-    let domParser = Cc["@mozilla.org/xmlextras/domparser;1"]
-                      .createInstance(Ci.nsIDOMParser);
-
     let results;
     try {
-      let doc = domParser.parseFromString(bingRequest.networkRequest
-                                                     .response.body, "text/xml");
+      let doc = bingRequest.networkRequest.responseXML;
       results = doc.querySelectorAll("TranslatedText");
     } catch (e) {
       return false;
     }
 
     let len = results.length;
     if (len != bingRequest.translationData.length) {
       // This should never happen, but if the service returns a different number
@@ -286,25 +282,28 @@ function BingRequest(translationData, so
 }
 
 BingRequest.prototype = {
   /**
    * Initiates the request
    */
   fireRequest: function() {
     return Task.spawn(function *(){
+      // Prepare authentication.
       let token = yield BingTokenManager.getToken();
       let auth = "Bearer " + token;
+
+      // Prepare URL.
       let url = getUrlParam("https://api.microsofttranslator.com/v2/Http.svc/TranslateArray",
-                            "browser.translation.bing.translateArrayURL",
-                            false);
-      let request = new RESTRequest(url);
-      request.setHeader("Content-type", "text/xml");
-      request.setHeader("Authorization", auth);
+                            "browser.translation.bing.translateArrayURL");
 
+      // Prepare request headers.
+      let headers = [["Content-type", "text/xml"], ["Authorization", auth]];
+
+      // Prepare the request body.
       let requestString =
         '<TranslateArrayRequest>' +
           '<AppId/>' +
           '<From>' + this.sourceLanguage + '</From>' +
           '<Options>' +
             '<ContentType xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2">text/html</ContentType>' +
             '<ReservedFlags xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2" />' +
           '</Options>' +
@@ -314,26 +313,34 @@ BingRequest.prototype = {
         requestString += '<s:string>' + text + '</s:string>';
         this.characterCount += text.length;
       }
 
       requestString += '</Texts>' +
           '<To>' + this.targetLanguage + '</To>' +
         '</TranslateArrayRequest>';
 
-      let utf8 = CommonUtils.encodeUTF8(requestString);
-
+      // Set up request options.
       let deferred = Promise.defer();
-      request.post(utf8, function(err) {
-        if (request.error || !request.response.success)
-          deferred.reject(request);
+      let options = {
+        onLoad: (function(responseText, xhr) {
+          deferred.resolve(this);
+        }).bind(this),
+        onError: function(e, responseText, xhr) {
+          deferred.reject(xhr);
+        },
+        postData: requestString,
+        headers: headers
+      };
 
-        deferred.resolve(this);
-      }.bind(this));
+      // Fire the request.
+      let request = httpRequest(url, options);
 
+      // Override the response MIME type.
+      request.overrideMimeType("text/xml");
       this.networkRequest = request;
       return deferred.promise;
     }.bind(this));
   }
 };
 
 /**
  * Authentication Token manager for the API
@@ -368,55 +375,56 @@ let BingTokenManager = {
   /**
    * Generates a new token from the server.
    *
    * @returns {Promise}  A promise that resolves with the token
    *                     string once it is obtained.
    */
   _getNewToken: function() {
     let url = getUrlParam("https://datamarket.accesscontrol.windows.net/v2/OAuth2-13",
-                          "browser.translation.bing.authURL",
-                          false);
-    let request = new RESTRequest(url);
-    request.setHeader("Content-type", "application/x-www-form-urlencoded");
+                          "browser.translation.bing.authURL");
     let params = [
-      "grant_type=client_credentials",
-      "scope=" + encodeURIComponent("http://api.microsofttranslator.com"),
-      "client_id=" +
-      getUrlParam("%BING_API_CLIENTID%", "browser.translation.bing.clientIdOverride"),
-      "client_secret=" +
-      getUrlParam("%BING_API_KEY%", "browser.translation.bing.apiKeyOverride")
+      ["grant_type", "client_credentials"],
+      ["scope", "http://api.microsofttranslator.com"],
+      ["client_id",
+      getUrlParam("%BING_API_CLIENTID%", "browser.translation.bing.clientIdOverride")],
+      ["client_secret",
+      getUrlParam("%BING_API_KEY%", "browser.translation.bing.apiKeyOverride")]
     ];
 
     let deferred = Promise.defer();
-    this._pendingRequest = deferred.promise;
-    request.post(params.join("&"), function(err) {
-      BingTokenManager._pendingRequest = null;
+    let options = {
+      onLoad: function(responseText, xhr) {
+        BingTokenManager._pendingRequest = null;
+        try {
+          let json = JSON.parse(responseText);
 
-      if (err) {
-        deferred.reject(err);
-      }
-
-      try {
-        let json = JSON.parse(this.response.body);
+          if (json.error) {
+            deferred.reject(json.error);
+            return;
+          }
 
-        if (json.error) {
-          deferred.reject(json.error);
-          return;
+          let token = json.access_token;
+          let expires_in = json.expires_in;
+          BingTokenManager._currentToken = token;
+          BingTokenManager._currentExpiryTime = new Date(Date.now() + expires_in * 1000);
+          deferred.resolve(token);
+        } catch (e) {
+          deferred.reject(e);
         }
+      },
+      onError: function(e, responseText, xhr) {
+        BingTokenManager._pendingRequest = null;
+        deferred.reject(e);
+      },
+      postData: params
+    };
 
-        let token = json.access_token;
-        let expires_in = json.expires_in;
-        BingTokenManager._currentToken = token;
-        BingTokenManager._currentExpiryTime = new Date(Date.now() + expires_in * 1000);
-        deferred.resolve(token);
-      } catch (e) {
-        deferred.reject(e);
-      }
-    });
+    this._pendingRequest = deferred.promise;
+    let request = httpRequest(url, options);
 
     return deferred.promise;
   }
 };
 
 /**
  * Escape a string to be valid XML content.
  */
@@ -428,15 +436,14 @@ function escapeXML(aStr) {
              .replace(/</g, "&lt;")
              .replace(/>/g, "&gt;");
 }
 
 /**
  * Fetch an auth token (clientID or client secret), which may be overridden by
  * a pref if it's set.
  */
-function getUrlParam(paramValue, prefName, encode = true) {
+function getUrlParam(paramValue, prefName) {
   if (Services.prefs.getPrefType(prefName))
     paramValue = Services.prefs.getCharPref(prefName);
   paramValue = Services.urlFormatter.formatURL(paramValue);
-
-  return encode ? encodeURIComponent(paramValue) : paramValue;
+  return paramValue;
 }
--- a/browser/components/translation/test/bing.sjs
+++ b/browser/components/translation/test/bing.sjs
@@ -146,17 +146,17 @@ function reallyHandleRequest(req, res) {
 
   let body = getRequestBody(req);
   log("body: " + body);
 
   // First, we'll see if we're dealing with an XML body:
   let contentType = req.hasHeader("Content-Type") ? req.getHeader("Content-Type") : null;
   log("contentType: " + contentType);
 
-  if (contentType == "text/xml") {
+  if (contentType.startsWith("text/xml")) {
     try {
       // For all these requests the client needs to supply the correct
       // authentication headers.
       checkAuth(req);
 
       let xml = parseXml(body);
       let method = xml.documentElement.localName;
       log("invoking method: " + method);
--- a/browser/devtools/markupview/markup-view.css
+++ b/browser/devtools/markupview/markup-view.css
@@ -161,17 +161,17 @@ ul.children + .tag-line::before {
   margin-right: -1em;
   padding: 1px 0;
 }
 
 .newattr:focus {
   margin-right: 0;
 }
 
-.tag-state.flash-out {
+.flash-out {
   transition: background .5s;
 }
 
 .tag-line {
   cursor: default;
 }
 
 .markupview-events {
--- a/browser/devtools/markupview/markup-view.js
+++ b/browser/devtools/markupview/markup-view.js
@@ -22,16 +22,17 @@ const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 1
 const {UndoStack} = require("devtools/shared/undo");
 const {editableField, InplaceEditor} = require("devtools/shared/inplace-editor");
 const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 const {HTMLEditor} = require("devtools/markupview/html-editor");
 const promise = require("resource://gre/modules/Promise.jsm").Promise;
 const {Tooltip} = require("devtools/shared/widgets/Tooltip");
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const Heritage = require("sdk/core/heritage");
+const {setTimeout, clearTimeout, setInterval, clearInterval} = require("sdk/timers");
 const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data;
 
 Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm");
 Cu.import("resource://gre/modules/devtools/Templater.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 loader.lazyGetter(this, "DOMParser", function() {
@@ -169,42 +170,42 @@ MarkupView.prototype = {
   _onMouseMove: function(event) {
     if (this.isDragging) {
       event.preventDefault();
       this._dragStartEl = event.target;
 
       let docEl = this.doc.documentElement;
 
       if (this._scrollInterval) {
-        this.win.clearInterval(this._scrollInterval);
+        clearInterval(this._scrollInterval);
       }
 
       // Auto-scroll when the mouse approaches top/bottom edge
       let distanceFromBottom = docEl.clientHeight - event.pageY + this.win.scrollY,
           distanceFromTop = event.pageY - this.win.scrollY;
 
       if (distanceFromBottom <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
         // Map our distance from 0-50 to 5-15 range so the speed is kept
         // in a range not too fast, not too slow
         let speed = map(distanceFromBottom, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
                         DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
         // Here, we use minus because the value of speed - 15 is always negative
         // and it makes the speed relative to the distance between mouse and edge
         // the closer to the edge, the faster
-        this._scrollInterval = this.win.setInterval(() => {
+        this._scrollInterval = setInterval(() => {
           docEl.scrollTop -= speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
         }, 0);
       }
 
       if (distanceFromTop <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) {
         // refer to bottom edge's comments for more info
         let speed = map(distanceFromTop, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE,
                         DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED);
 
-        this._scrollInterval = this.win.setInterval(() => {
+        this._scrollInterval = setInterval(() => {
           docEl.scrollTop += speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED;
         }, 0);
       }
 
       return;
     };
 
     let target = event.target;
@@ -251,17 +252,17 @@ MarkupView.prototype = {
   _onMouseUp: function() {
     if (this._lastDropTarget) {
       this.indicateDropTarget(null);
     }
     if (this._lastDragTarget) {
       this.indicateDragTarget(null);
     }
     if (this._scrollInterval) {
-      this.win.clearInterval(this._scrollInterval);
+      clearInterval(this._scrollInterval);
     }
   },
 
   _hoveredNode: null,
 
   /**
    * Show a NodeFront's container as being hovered
    * @param {NodeFront} nodeFront The node to show as hovered
@@ -276,17 +277,17 @@ MarkupView.prototype = {
     }
 
     this.getContainer(nodeFront).hovered = true;
     this._hoveredNode = nodeFront;
   },
 
   _onMouseLeave: function() {
     if (this._scrollInterval) {
-      this.win.clearInterval(this._scrollInterval);
+      clearInterval(this._scrollInterval);
     }
     if (this.isDragging) return;
 
     this._hideBoxModel(true);
     if (this._hoveredNode) {
       this.getContainer(this._hoveredNode).hovered = false;
     }
     this._hoveredNode = null;
@@ -315,23 +316,23 @@ MarkupView.prototype = {
     return this._inspector.toolbox.highlighterUtils.unhighlight(forceHide);
   },
 
   _briefBoxModelTimer: null,
   _brieflyShowBoxModel: function(nodeFront) {
     let win = this._frame.contentWindow;
 
     if (this._briefBoxModelTimer) {
-      win.clearTimeout(this._briefBoxModelTimer);
+      clearTimeout(this._briefBoxModelTimer);
       this._briefBoxModelTimer = null;
     }
 
     this._showBoxModel(nodeFront);
 
-    this._briefBoxModelTimer = this._frame.contentWindow.setTimeout(() => {
+    this._briefBoxModelTimer = setTimeout(() => {
       this._hideBoxModel();
     }, NEW_SELECTION_HIGHLIGHTER_TIMER);
   },
 
   template: function(aName, aDest, aOptions={stack: "markup-view.xhtml"}) {
     let node = this.doc.getElementById("template-" + aName).cloneNode(true);
     node.removeAttribute("id");
     template(node, aDest, aOptions);
@@ -775,21 +776,26 @@ MarkupView.prototype = {
   /**
    * Given a list of mutations returned by the mutation observer, flash the
    * corresponding containers to attract attention.
    */
   _flashMutatedNodes: function(aMutations) {
     let addedOrEditedContainers = new Set();
     let removedContainers = new Set();
 
-    for (let {type, target, added, removed} of aMutations) {
+    for (let {type, target, added, removed, newValue} of aMutations) {
       let container = this.getContainer(target);
 
       if (container) {
-        if (type === "attributes" || type === "characterData") {
+        if (type === "characterData") {
+          addedOrEditedContainers.add(container);
+        } else if (type === "attributes" && newValue === null) {
+          // Removed attributes should flash the entire node.
+          // New or changed attributes will flash the attribute itself
+          // in ElementEditor.flashAttribute.
           addedOrEditedContainers.add(container);
         } else if (type === "childList") {
           // If there has been removals, flash the parent
           if (removed.length) {
             removedContainers.add(container);
           }
 
           // If there has been additions, flash the nodes if their associated
@@ -1514,19 +1520,19 @@ MarkupView.prototype = {
    * Hide the preview while resizing, to avoid slowness.
    */
   _resizePreview: function() {
     if (!this._previewEnabled) {
       return;
     }
     let win = this._frame.contentWindow;
     this._previewBar.classList.add("hide");
-    win.clearTimeout(this._resizePreviewTimeout);
-
-    win.setTimeout(() => {
+    clearTimeout(this._resizePreviewTimeout);
+
+    setTimeout(() => {
       this._updatePreview();
       this._previewBar.classList.remove("hide");
     }, 1000);
   },
 
   /**
    * Takes an element as it's only argument and marks the element
    * as the drop target
@@ -1813,17 +1819,17 @@ MarkupContainer.prototype = {
     // target is the MarkupContainer itself.
     this._isMouseDown = true;
     this.hovered = false;
     this.markup.navigate(this);
     event.stopPropagation();
 
     // Start dragging the container after a delay.
     this.markup._dragStartEl = target;
-    this.win.setTimeout(() => {
+    setTimeout(() => {
       // Make sure the mouse is still down and on target.
       if (!this._isMouseDown || this.markup._dragStartEl !== target ||
           this.node.isPseudoElement || this.node.isAnonymous ||
           !this.win.getSelection().isCollapsed) {
         return;
       }
       this.isDragging = true;
 
@@ -1878,57 +1884,27 @@ MarkupContainer.prototype = {
 
   /**
    * Temporarily flash the container to attract attention.
    * Used for markup mutations.
    */
   flashMutation: function() {
     if (!this.selected) {
       let contentWin = this.win;
-      this.flashed = true;
+      flashElementOn(this.tagState, this.editor.elt);
       if (this._flashMutationTimer) {
-        contentWin.clearTimeout(this._flashMutationTimer);
+        clearTimeout(this._flashMutationTimer);
         this._flashMutationTimer = null;
       }
-      this._flashMutationTimer = contentWin.setTimeout(() => {
-        this.flashed = false;
+      this._flashMutationTimer = setTimeout(() => {
+        flashElementOff(this.tagState, this.editor.elt);
       }, this.markup.CONTAINER_FLASHING_DURATION);
     }
   },
 
-  set flashed(aValue) {
-    if (aValue) {
-      // Make sure the animation class is not here
-      this.tagState.classList.remove("flash-out");
-
-      // Change the background
-      this.tagState.classList.add("theme-bg-contrast");
-
-      // Change the text color
-      this.editor.elt.classList.add("theme-fg-contrast");
-      [].forEach.call(
-        this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
-        span => span.classList.add("theme-fg-contrast")
-      );
-    } else {
-      // Add the animation class to smoothly remove the background
-      this.tagState.classList.add("flash-out");
-
-      // Remove the background
-      this.tagState.classList.remove("theme-bg-contrast");
-
-      // Remove the text color
-      this.editor.elt.classList.remove("theme-fg-contrast");
-      [].forEach.call(
-        this.editor.elt.querySelectorAll("[class*=theme-fg-color]"),
-        span => span.classList.remove("theme-fg-contrast")
-      );
-    }
-  },
-
   _hovered: false,
 
   /**
    * Highlight the currently hovered tag + its closing tag if necessary
    * (that is if the tag is expanded)
    */
   set hovered(aValue) {
     this.tagState.classList.remove("flash-out");
@@ -2345,16 +2321,17 @@ TextEditor.prototype = {
 function ElementEditor(aContainer, aNode) {
   this.container = aContainer;
   this.node = aNode;
   this.markup = this.container.markup;
   this.template = this.markup.template.bind(this.markup);
   this.doc = this.markup.doc;
 
   this.attrs = {};
+  this.animationTimers = {};
 
   // The templates will fill the following properties
   this.elt = null;
   this.tag = null;
   this.closeTag = null;
   this.attrList = null;
   this.newAttr = null;
   this.closeElt = null;
@@ -2402,45 +2379,66 @@ function ElementEditor(aContainer, aNode
   });
 
   let tagName = this.node.nodeName.toLowerCase();
   this.tag.textContent = tagName;
   this.closeTag.textContent = tagName;
   this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none";
 
   this.update();
+  this.initialized = true;
 }
 
 ElementEditor.prototype = {
+
+  flashAttribute: function(attrName) {
+    if (this.animationTimers[attrName]) {
+      clearTimeout(this.animationTimers[attrName]);
+    }
+
+    flashElementOn(this.getAttributeElement(attrName));
+
+    this.animationTimers[attrName] = setTimeout(() => {
+      flashElementOff(this.getAttributeElement(attrName));
+    }, this.markup.CONTAINER_FLASHING_DURATION);
+  },
+
   /**
    * Update the state of the editor from the node.
    */
   update: function() {
     let attrs = this.node.attributes || [];
     let attrsToRemove = new Set(this.attrList.querySelectorAll(".attreditor"));
 
     // Only loop through the current attributes on the node, anything that's
     // been removed will be removed from this DOM because it will be part of
     // the attrsToRemove set.
     for (let attr of attrs) {
       let el = this.attrs[attr.name];
       let valueChanged = el && el.querySelector(".attr-value").innerHTML !== attr.value;
       let isEditing = el && el.querySelector(".editable").inplaceEditor;
-      let needToCreateAttributeEditor = el && (!valueChanged || isEditing);
-
-      if (needToCreateAttributeEditor) {
+      let canSimplyShowEditor = el && (!valueChanged || isEditing);
+
+      if (canSimplyShowEditor) {
         // Element already exists and doesn't need to be recreated.
         // Just show it (it's hidden by default due to the template).
         attrsToRemove.delete(el);
         el.style.removeProperty("display");
       } else {
         // Create a new editor, because the value of an existing attribute
         // has changed.
         let attribute = this._createAttribute(attr);
         attribute.style.removeProperty("display");
+
+        // Temporarily flash the attribute to highlight the change.
+        // But not if this is the first time the editor instance has
+        // been created.
+        if (this.initialized) {
+          this.flashAttribute(attr.name);
+        }
       }
     }
 
     for (let el of attrsToRemove) {
       el.remove();
     }
   },
 
@@ -2703,17 +2701,22 @@ ElementEditor.prototype = {
     // selected afterwards.
     this.markup.reselectOnRemoved(this.node, "edittagname");
     this.markup.walker.editTagName(this.node, newTagName).then(null, () => {
       // Failed to edit the tag name, cancel the reselection.
       this.markup.cancelReselectOnRemoved();
     });
   },
 
-  destroy: function() {}
+  destroy: function() {
+    for (let key in this.animationTimers) {
+      clearTimeout(this.animationTimers[key]);
+    }
+    this.animationTimers = null;
+  }
 };
 
 function nodeDocument(node) {
   return node.ownerDocument ||
     (node.nodeType == Ci.nsIDOMNode.DOCUMENT_NODE ? node : null);
 }
 
 function truncateString(str, maxLength) {
@@ -2758,16 +2761,72 @@ function parseAttributeValues(attr, doc)
     catch(e) { }
   }
 
   // Attributes return from DOMParser in reverse order from how they are entered.
   return attributes.reverse();
 }
 
 /**
+ * Apply a 'flashed' background and foreground color to elements.  Intended
+ * to be used with flashElementOff as a way of drawing attention to an element.
+ *
+ * @param  {Node} backgroundElt
+ *         The element to set the highlighted background color on.
+ * @param  {Node} foregroundElt
+ *         The element to set the matching foreground color on.
+ *         Optional.  This will equal backgroundElt if not set.
+ */
+function flashElementOn(backgroundElt, foregroundElt=backgroundElt) {
+  if (!backgroundElt || !foregroundElt) {
+    return;
+  }
+
+  // Make sure the animation class is not here
+  backgroundElt.classList.remove("flash-out");
+
+  // Change the background
+  backgroundElt.classList.add("theme-bg-contrast");
+
+  foregroundElt.classList.add("theme-fg-contrast");
+  [].forEach.call(
+    foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
+    span => span.classList.add("theme-fg-contrast")
+  );
+}
+
+/**
+ * Remove a 'flashed' background and foreground color to elements.
+ * See flashElementOn.
+ *
+ * @param  {Node} backgroundElt
+ *         The element to reomve the highlighted background color on.
+ * @param  {Node} foregroundElt
+ *         The element to remove the matching foreground color on.
+ *         Optional.  This will equal backgroundElt if not set.
+ */
+function flashElementOff(backgroundElt, foregroundElt=backgroundElt) {
+  if (!backgroundElt || !foregroundElt) {
+    return;
+  }
+
+  // Add the animation class to smoothly remove the background
+  backgroundElt.classList.add("flash-out");
+
+  // Remove the background
+  backgroundElt.classList.remove("theme-bg-contrast");
+
+  foregroundElt.classList.remove("theme-fg-contrast");
+  [].forEach.call(
+    foregroundElt.querySelectorAll("[class*=theme-fg-color]"),
+    span => span.classList.remove("theme-fg-contrast")
+  );
+}
+
+/**
  * Map a number from one range to another.
  */
 function map(value, oldMin, oldMax, newMin, newMax) {
   let ratio = oldMax - oldMin;
   if (ratio == 0) {
     return value;
   }
   return newMin + (newMax - newMin) * ((value - oldMin) / ratio);
--- a/browser/devtools/markupview/test/browser_markupview_mutation_02.js
+++ b/browser/devtools/markupview/test/browser_markupview_mutation_02.js
@@ -8,16 +8,18 @@
 // corresponding DOM nodes mutate
 
 const TEST_URL = TEST_URL_ROOT + "doc_markup_flashing.html";
 
 // The test data contains a list of mutations to test.
 // Each item is an object:
 // - desc: a description of the test step, for better logging
 // - mutate: a function that should make changes to the content DOM
+// - attribute: if set, the test will expect the corresponding attribute to flash
+//   instead of the whole node
 // - flashedNode: [optional] the css selector of the node that is expected to
 //   flash in the markup-view as a result of the mutation.
 //   If missing, the rootNode (".list") will be expected to flash
 const TEST_DATA = [{
   desc: "Adding a new node should flash the new node",
   mutate: (doc, rootNode) => {
     let newLi = doc.createElement("LI");
     newLi.textContent = "new list item";
@@ -31,26 +33,39 @@ const TEST_DATA = [{
   }
 }, {
   desc: "Re-appending an existing node should only flash this node",
   mutate: (doc, rootNode) => {
     rootNode.appendChild(rootNode.firstElementChild);
   },
   flashedNode: ".list .item:last-child"
 }, {
-  desc: "Adding an attribute should flash the node",
+  desc: "Adding an attribute should flash the attribute",
+  attribute: "test-name",
   mutate: (doc, rootNode) => {
-    rootNode.setAttribute("name-" + Date.now(), "value-" + Date.now());
+    rootNode.setAttribute("test-name", "value-" + Date.now());
   }
 }, {
-  desc: "Editing an attribute should flash the node",
+  desc: "Editing an attribute should flash the attribute",
+  attribute: "class",
   mutate: (doc, rootNode) => {
     rootNode.setAttribute("class", "list value-" + Date.now());
   }
 }, {
+  desc: "Multiple changes to an attribute should flash the attribute",
+  attribute: "class",
+  mutate: (doc, rootNode) => {
+    rootNode.removeAttribute("class");
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.removeAttribute("class");
+    rootNode.setAttribute("class", "list value-" + Date.now());
+    rootNode.setAttribute("class", "list value-" + Date.now());
+  }
+}, {
   desc: "Removing an attribute should flash the node",
   mutate: (doc, rootNode) => {
     rootNode.removeAttribute("class");
   }
 }];
 
 add_task(function*() {
   let {inspector} = yield addTab(TEST_URL).then(openInspector);
@@ -61,40 +76,59 @@ add_task(function*() {
 
   info("Getting the <ul.list> root node to test mutations on");
   let rootNode = getNode(".list");
   let rootNodeFront = yield getNodeFront(".list", inspector);
 
   info("Selecting the last element of the root node before starting");
   yield selectNode(".list .item:nth-child(2)", inspector);
 
-  for (let {mutate, flashedNode, desc} of TEST_DATA) {
+  for (let {mutate, flashedNode, desc, attribute} of TEST_DATA) {
     info("Starting test: " + desc);
 
     info("Mutating the DOM and listening for markupmutation event");
     let mutated = inspector.once("markupmutation");
     let updated = inspector.once("inspector-updated");
     mutate(content.document, rootNode);
     yield mutated;
 
     info("Asserting that the correct markup-container is flashing");
     let flashingNodeFront = rootNodeFront;
     if (flashedNode) {
       flashingNodeFront = yield getNodeFront(flashedNode, inspector);
     }
-    yield assertNodeFlashing(flashingNodeFront, inspector);
+
+    if (attribute) {
+      yield assertAttributeFlashing(flashingNodeFront, attribute, inspector);
+    } else {
+      yield assertNodeFlashing(flashingNodeFront, inspector);
+    }
 
     // Making sure the inspector has finished updating before moving on
     yield updated;
   }
 });
 
 function* assertNodeFlashing(nodeFront, inspector) {
   let container = getContainerForNodeFront(nodeFront, inspector);
   ok(container, "Markup container for node found");
   ok(container.tagState.classList.contains("theme-bg-contrast"),
     "Markup container for node is flashing");
 
   // Clear the mutation flashing timeout now that we checked the node was flashing
   let markup = inspector.markup;
-  markup._frame.contentWindow.clearTimeout(container._flashMutationTimer);
+  clearTimeout(container._flashMutationTimer);
   container._flashMutationTimer = null;
+  container.tagState.classList.remove("theme-bg-contrast");
 }
+
+function* assertAttributeFlashing(nodeFront, attribute, inspector) {
+  let container = getContainerForNodeFront(nodeFront, inspector);
+  ok(container, "Markup container for node found");
+  ok(container.editor.attrs[attribute], "Attribute exists on editor");
+
+  let attributeElement = container.editor.getAttributeElement(attribute);
+
+  ok(attributeElement.classList.contains("theme-bg-contrast"),
+    "Element for " + attribute + " attribute is flashing");
+
+  attributeElement.classList.remove("theme-bg-contrast");
+}
--- a/browser/devtools/markupview/test/browser_markupview_tag_edit_03.js
+++ b/browser/devtools/markupview/test/browser_markupview_tag_edit_03.js
@@ -9,31 +9,42 @@
 const TEST_URL = "data:text/html;charset=utf-8,<div id='retag-me'><div id='retag-me-2'></div></div>";
 
 add_task(function*() {
   let {toolbox, inspector} = yield addTab(TEST_URL).then(openInspector);
 
   yield inspector.markup.expandAll();
 
   info("Selecting the test node");
-  let node = content.document.querySelector("#retag-me");
-  let child = content.document.querySelector("#retag-me-2");
   yield selectNode("#retag-me", inspector);
 
+  info("Getting the markup-container for the test node");
   let container = yield getContainerForSelector("#retag-me", inspector);
-  is(node.tagName, "DIV", "We've got #retag-me element, it's a DIV");
-  ok(container.expanded, "It is expanded");
-  is(child.parentNode, node, "Child #retag-me-2 is inside #retag-me");
+  ok(container.expanded, "The container is expanded");
 
-  info("Changing the tagname");
+  let parentInfo = yield getNodeInfo("#retag-me");
+  is(parentInfo.tagName.toLowerCase(), "div",
+     "We've got #retag-me element, it's a DIV");
+  is(parentInfo.numChildren, 1, "#retag-me has one child");
+  let childInfo = yield getNodeInfo("#retag-me > *");
+  is(childInfo.attributes[0].value, "retag-me-2",
+     "#retag-me's only child is #retag-me-2");
+
+  info("Changing #retag-me's tagname in the markup-view");
   let mutated = inspector.once("markupmutation");
   let tagEditor = container.editor.tag;
   setEditableFieldValue(tagEditor, "p", inspector);
   yield mutated;
 
-  info("Checking that the tagname change was done");
-  node = content.document.querySelector("#retag-me");
+  info("Checking that the markup-container exists and is correct");
   container = yield getContainerForSelector("#retag-me", inspector);
-  is(node.tagName, "P", "We've got #retag-me, it should now be a P");
-  ok(container.expanded, "It is still expanded");
-  ok(container.selected, "It is still selected");
-  is(child.parentNode, node, "Child #retag-me-2 is still inside #retag-me");
+  ok(container.expanded, "The container is still expanded");
+  ok(container.selected, "The container is still selected");
+
+  info("Checking that the tagname change was done");
+  parentInfo = yield getNodeInfo("#retag-me");
+  is(parentInfo.tagName.toLowerCase(), "p",
+     "The #retag-me element is now a P");
+  is(parentInfo.numChildren, 1, "#retag-me still has one child");
+  childInfo = yield getNodeInfo("#retag-me > *");
+  is(childInfo.attributes[0].value, "retag-me-2",
+     "#retag-me's only child is #retag-me-2");
 });
--- a/browser/devtools/markupview/test/head.js
+++ b/browser/devtools/markupview/test/head.js
@@ -4,16 +4,17 @@
 
 const Cu = Components.utils;
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 let TargetFactory = devtools.TargetFactory;
 let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let promise = devtools.require("resource://gre/modules/Promise.jsm").Promise;
 let {getInplaceEditorForSpan: inplaceEditor} = devtools.require("devtools/shared/inplace-editor");
 let clipboard = devtools.require("sdk/clipboard");
+let {setTimeout, clearTimeout} = devtools.require("sdk/timers");
 
 // All test are asynchronous
 waitForExplicitFinish();
 
 // If a test times out we want to see the complete log and not just the last few
 // lines.
 SimpleTest.requestCompleteLog();
 
@@ -42,16 +43,17 @@ registerCleanupFunction(function*() {
 
   while (gBrowser.tabs.length > 1) {
     gBrowser.removeCurrentTab();
   }
 });
 
 const TEST_URL_ROOT = "http://mochi.test:8888/browser/browser/devtools/markupview/test/";
 const CHROME_BASE = "chrome://mochitests/content/browser/browser/devtools/markupview/test/";
+const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js";
 
 /**
  * Add a new test tab in the browser and load the given url.
  * @param {String} url The url to be loaded in the new tab
  * @return a promise that resolves to the tab object when the url is loaded
  */
 function addTab(url) {
   info("Adding a new tab with URL: '" + url + "'");
@@ -60,16 +62,19 @@ function addTab(url) {
   // Bug 921935 should bring waitForFocus() support to e10s, which would
   // probably cover the case of the test losing focus when the page is loading.
   // For now, we just make sure the window is focused.
   window.focus();
 
   let tab = window.gBrowser.selectedTab = window.gBrowser.addTab(url);
   let linkedBrowser = tab.linkedBrowser;
 
+  info("Loading the helper frame script " + COMMON_FRAME_SCRIPT_URL);
+  linkedBrowser.messageManager.loadFrameScript(COMMON_FRAME_SCRIPT_URL, false);
+
   linkedBrowser.addEventListener("load", function onload() {
     linkedBrowser.removeEventListener("load", onload, true);
     info("URL '" + url + "' loading complete");
     def.resolve(tab);
   }, true);
 
   return def.promise;
 }
@@ -119,16 +124,60 @@ function openInspector() {
       def.resolve({toolbox: toolbox, inspector: inspector});
     });
   }).then(null, console.error);
 
   return def.promise;
 }
 
 /**
+ * Wait for a content -> chrome message on the message manager (the window
+ * messagemanager is used).
+ * @param {String} name The message name
+ * @return {Promise} A promise that resolves to the response data when the
+ * message has been received
+ */
+function waitForContentMessage(name) {
+  info("Expecting message " + name + " from content");
+
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  let def = promise.defer();
+  mm.addMessageListener(name, function onMessage(msg) {
+    mm.removeMessageListener(name, onMessage);
+    def.resolve(msg.data);
+  });
+  return def.promise;
+}
+
+/**
+ * Send an async message to the frame script (chrome -> content) and wait for a
+ * response message with the same name (content -> chrome).
+ * @param {String} name The message name. Should be one of the messages defined
+ * in doc_frame_script.js
+ * @param {Object} data Optional data to send along
+ * @param {Object} objects Optional CPOW objects to send along
+ * @param {Boolean} expectResponse If set to false, don't wait for a response
+ * with the same name from the content script. Defaults to true.
+ * @return {Promise} Resolves to the response data if a response is expected,
+ * immediately resolves otherwise
+ */
+function executeInContent(name, data={}, objects={}, expectResponse=true) {
+  info("Sending message " + name + " to content");
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  mm.sendAsyncMessage(name, data, objects);
+  if (expectResponse) {
+    return waitForContentMessage(name);
+  } else {
+    return promise.resolve();
+  }
+}
+
+/**
  * Simple DOM node accesor function that takes either a node or a string css
  * selector as argument and returns the corresponding node
  * @param {String|DOMNode} nodeOrSelector
  * @return {DOMNode|CPOW} Note that in e10s mode a CPOW object is returned which
  * doesn't implement *all* of the DOMNode's properties
  */
 function getNode(nodeOrSelector) {
   info("Getting the node for '" + nodeOrSelector + "'");
@@ -147,16 +196,25 @@ function getNode(nodeOrSelector) {
 function getNodeFront(selector, {walker}) {
   if (selector._form) {
     return selector;
   }
   return walker.querySelector(walker.rootNode, selector);
 }
 
 /**
+ * Get information about a DOM element, identified by its selector.
+ * @param {String} selector.
+ * @return {Promise} a promise that resolves to the element's information.
+ */
+function getNodeInfo(selector) {
+  return executeInContent("devtools:test:getDomElementInfo", {selector});
+}
+
+/**
  * Highlight a node and set the inspector's current selection to the node or
  * the first match of the given css selector.
  * @param {String|DOMNode} nodeOrSelector
  * @param {InspectorPanel} inspector
  *        The instance of InspectorPanel currently loaded in the toolbox
  * @return a promise that resolves when the inspector is updated with the new
  * node
  */
--- a/browser/devtools/shared/frame-script-utils.js
+++ b/browser/devtools/shared/frame-script-utils.js
@@ -129,16 +129,46 @@ addMessageListener("devtools:test:setSty
   }
 
   node.style[propertyName] = propertyValue;
 
   sendAsyncMessage("devtools:test:setStyle");
 });
 
 /**
+ * Get information about a DOM element, identified by a selector.
+ * @param {Object} data
+ * - {String} selector The CSS selector to get the node (can be a "super"
+ *   selector).
+ * @return {Object} data Null if selector didn't match any node, otherwise:
+ * - {String} tagName.
+ * - {String} namespaceURI.
+ * - {Number} numChildren The number of children in the element.
+ * - {Array} attributes An array of {name, value, namespaceURI} objects.
+ */
+addMessageListener("devtools:test:getDomElementInfo", function(msg) {
+  let {selector} = msg.data;
+  let node = superQuerySelector(selector);
+
+  let info = null;
+  if (node) {
+    info = {
+      tagName: node.tagName,
+      namespaceURI: node.namespaceURI,
+      numChildren: node.children.length,
+      attributes: [...node.attributes].map(({name, value, namespaceURI}) => {
+        return {name, value, namespaceURI};
+      })
+    };
+  }
+
+  sendAsyncMessage("devtools:test:getDomElementInfo", info);
+});
+
+/**
  * Set a given attribute value on a node.
  * @param {Object} data
  * - {String} selector The CSS selector to get the node (can be a "super"
  *   selector).
  * - {String} attributeName The name of the attribute to set.
  * - {String} attributeValue The value for the attribute.
  */
 addMessageListener("devtools:test:setAttribute", function(msg) {
--- a/browser/devtools/webide/content/webide.xul
+++ b/browser/devtools/webide/content/webide.xul
@@ -188,17 +188,17 @@
           <toolbarbutton class="panel-item" id="runtime-disconnect"  command="cmd_disconnectRuntime"/>
         </vbox>
       </vbox>
     </panel>
 
   </popupset>
 
   <notificationbox flex="1" id="notificationbox">
-    <hbox flex="1">
+    <hbox flex="1" id="deck-panels">
       <vbox id="project-listing-panel" class="project-listing" flex="1">
         <div id="project-listing-wrapper">
           <iframe id="project-listing-panel-details" flex="1" src="project-listing.xhtml"/>
         </div>
       </vbox>
       <splitter class="devtools-side-splitter" id="project-listing-splitter"/>
       <deck flex="1" id="deck" selectedIndex="-1">
         <iframe id="deck-panel-details" flex="1" src="details.xhtml"/>
--- a/browser/devtools/webide/themes/webide.css
+++ b/browser/devtools/webide/themes/webide.css
@@ -319,18 +319,20 @@ panel > .panel-arrowcontainer > .panel-a
 
 .devtools-horizontal-splitter {
   position: relative;
   border-bottom: 1px solid #aaa;
 }
 
 /* Toolbox */
 
-#notificationbox[toolboxfullscreen] > .devtools-horizontal-splitter,
-#notificationbox[toolboxfullscreen] > #deck,
-#notificationbox[toolboxfullscreen] > #deck > iframe {
+#notificationbox[toolboxfullscreen] > .devtools-horizontal-splitter {
   min-height: 0;
   max-height: 0;
 }
 
+#notificationbox[toolboxfullscreen] > #deck-panels {
+  display: none;
+}
+
 #notificationbox[toolboxfullscreen] > #toolbox {
   -moz-box-flex: 1;
 }
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -14,36 +14,48 @@ droponhomemsg=Do you want this document 
 # %2$S is the selection string.
 contextMenuSearch=Search %1$S for "%2$S"
 contextMenuSearch.accesskey=S
 
 # bookmark dialog strings
 
 bookmarkAllTabsDefault=[Folder Name]
 
-xpinstallPromptWarning=%S prevented this site (%S) from asking you to install software on your computer.
+xpinstallPromptMessage=%S prevented this site from asking you to install software on your computer.
 xpinstallPromptAllowButton=Allow
 # Accessibility Note:
 # Be sure you do not choose an accesskey that is used elsewhere in the active context (e.g. main menu bar, submenu of the warning popup button)
 # See http://www.mozilla.org/access/keyboard/accesskey for details
 xpinstallPromptAllowButton.accesskey=A
 xpinstallDisabledMessageLocked=Software installation has been disabled by your system administrator.
 xpinstallDisabledMessage=Software installation is currently disabled. Click Enable and try again.
 xpinstallDisabledButton=Enable
 xpinstallDisabledButton.accesskey=n
 
-# LOCALIZATION NOTE (addonDownloading, addonDownloadCancelled, addonDownloadRestart):
+# LOCALIZATION NOTE (addonDownloadingAndVerifying):
 # Semicolon-separated list of plural forms. See:
 # http://developer.mozilla.org/en/docs/Localization_and_Plurals
 # Also see https://bugzilla.mozilla.org/show_bug.cgi?id=570012 for mockups
-addonDownloading=Add-on downloading;Add-ons downloading
-addonDownloadCancelled=Add-on download cancelled.;Add-on downloads cancelled.
-addonDownloadRestart=Restart Download;Restart Downloads
-addonDownloadRestart.accessKey=R
-addonDownloadCancelTooltip=Cancel
+addonDownloadingAndVerifying=Downloading and verifying add-on…;Downloading and verifying #1 add-ons…
+addonDownloadVerifying=Verifying
+
+addonInstall.cancelButton.label=Cancel
+addonInstall.cancelButton.accesskey=C
+addonInstall.acceptButton.label=Install
+addonInstall.acceptButton.accesskey=I
+
+# LOCALIZATION NOTE (addonConfirmInstallMessage):
+# Semicolon-separated list of plural forms. See:
+# http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is brandShortName
+# #2 is the number of add-ons being installed
+addonConfirmInstall.message=This site would like to install an add-on in #1:;This site would like to install #2 add-ons in #1:
+# LOCALIZATION NOTE (addonConfirmInstall.author):
+# %S is the add-on author's name
+addonConfirmInstall.author=by %S
 
 addonwatch.slow=%1$S might be making %2$S run slowly
 addonwatch.disable.label=Disable %S
 addonwatch.disable.accesskey=D
 addonwatch.ignoreSession.label=Ignore for now
 addonwatch.ignoreSession.accesskey=I
 addonwatch.ignorePerm.label=Ignore permanently
 addonwatch.ignorePerm.accesskey=p
--- a/browser/locales/en-US/chrome/browser/loop/loop.properties
+++ b/browser/locales/en-US/chrome/browser/loop/loop.properties
@@ -326,8 +326,11 @@ rooms_signout_alert=Open conversations w
 
 # Infobar strings
 
 infobar_screenshare_browser_message=Users in your conversation will now be able to see the contents of any tab you click on.
 infobar_button_gotit_label=Got it!
 infobar_button_gotit_accesskey=G
 infobar_menuitem_dontshowagain_label=Don't show this again
 infobar_menuitem_dontshowagain_accesskey=D
+
+# Context in conversation strings
+context_offer_label=Let's talk about this page
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -1127,73 +1127,59 @@ toolbarbutton[sdk-button="true"][cui-are
 }
 
 #identity-popup-button-container {
   background: linear-gradient(to bottom, rgba(0,0,0,0.04) 60%, transparent);
   padding: 10px;
   margin-top: 5px;
 }
 
-/* Notification popup */
-#notification-popup {
-  min-width: 280px;
-}
-
 .popup-notification-icon {
   width: 64px;
   height: 64px;
   -moz-margin-end: 10px;
 }
 
 .popup-notification-icon[popupid="geolocation"] {
   list-style-image: url(chrome://browser/skin/Geolocation-64.png);
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
+}
+
+.popup-progress-meter {
+  margin-top: .5em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+}
+
 .popup-notification-icon[popupid="click-to-play-plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="web-notifications"] {
   list-style-image: url(chrome://browser/skin/notification-64.png);
 }
 
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
-}
-
-.popup-progress-label,
-.popup-progress-meter {
-  -moz-margin-start: 0;
-  -moz-margin-end: 0;
-}
-
-.popup-progress-cancel {
-  -moz-appearance: none;
-  background: transparent;
-  border: none;
-  padding: 0;
-  margin: 0;
-  -moz-margin-start: 5px;
-  min-height: 0;
-  min-width: 0;
-  list-style-image: url("moz-icon://stock/gtk-cancel?size=menu");
-}
-
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
 .popup-notification-icon[popupid="password"] {
   list-style-image: url(chrome://mozapps/skin/passwordmgr/key-64.png);
--- a/browser/themes/linux/readinglist/sidebar.css
+++ b/browser/themes/linux/readinglist/sidebar.css
@@ -1,18 +1,30 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  border: 1px solid ThreeDShadow;
+  background-color: -moz-Field;
+  color: -moz-FieldText;
+  box-sizing: border-box;
+}
+
 .item {
   -moz-padding-end: 0;
 }
 
+.item.active {
+  background-color: -moz-cellhighlight;
+  color: -moz-cellhighlighttext;
+}
+
 .item-title {
   margin: 1px 0 0;
 }
 
 .item-title, .item-domain {
   -moz-margin-end: 6px;
 }
 
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -4145,58 +4145,49 @@ notification[value="loop-sharing-notific
 @media (min-resolution: 2dppx) {
   .popup-notification-icon[popupid="web-notifications"] {
     list-style-image: url(chrome://browser/skin/notification-64@2x.png);
   }
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
-.popup-notification-icon[popupid="click-to-play-plugins"] {
-  list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
-}
-
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
 }
 
 .popup-progress-label,
 .popup-progress-meter {
   -moz-margin-start: 0;
   -moz-margin-end: 0;
 }
 
-.popup-progress-cancel {
-  -moz-appearance: none;
-  min-height: 16px;
-  min-width: 16px;
-  max-height: 16px;
-  max-width: 16px;
-  padding: 0;
-  margin: 0 1px 0 1px;
-  list-style-image: url(chrome://mozapps/skin/downloads/buttons.png);
-  -moz-image-region: rect(0px, 16px, 16px, 0px);
-}
-
-.popup-progress-cancel:hover {
-  -moz-image-region: rect(0px, 32px, 16px, 16px);
-}
-
-.popup-progress-cancel:active {
-  -moz-image-region: rect(0px, 48px, 16px, 32px);
+.popup-progress-meter,
+#addon-install-confirmation-content {
+  margin-top: 1em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+  -moz-margin-start: 0 !important; /* override default label margin to match description margin */
+}
+
+.popup-notification-icon[popupid="click-to-play-plugins"] {
+  list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
--- a/browser/themes/osx/readinglist/sidebar.css
+++ b/browser/themes/osx/readinglist/sidebar.css
@@ -1,14 +1,18 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  border-top: 1px solid #bdbdbd;
+}
+
 .item-title {
   margin: 4px 0 0;
 }
 
 .remove-button {
   background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 16, 16, 0);
 }
 
--- a/browser/themes/shared/readinglist/sidebar.inc.css
+++ b/browser/themes/shared/readinglist/sidebar.inc.css
@@ -5,17 +5,16 @@
 :root, body {
   height: 100%;
   overflow-x: hidden;
 }
 
 body {
   margin: 0;
   font: message-box;
-  background: #F8F7F8;
   color: #333333;
   -moz-user-select: none;
   overflow: hidden;
 }
 
 #emptyListInfo {
   cursor: default;
   padding: 3em 1em;
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -2153,64 +2153,48 @@ toolbarbutton.bookmark-item[dragover="tr
 }
 
 .popup-notification-icon[popupid="geolocation"] {
   list-style-image: url(chrome://browser/skin/Geolocation-64.png);
 }
 
 .popup-notification-icon[popupid="xpinstall-disabled"],
 .popup-notification-icon[popupid="addon-progress"],
-.popup-notification-icon[popupid="addon-install-cancelled"],
 .popup-notification-icon[popupid="addon-install-blocked"],
 .popup-notification-icon[popupid="addon-install-failed"],
+.popup-notification-icon[popupid="addon-install-confirmation"],
 .popup-notification-icon[popupid="addon-install-complete"] {
   list-style-image: url(chrome://mozapps/skin/extensions/extensionGeneric.png);
   width: 32px;
   height: 32px;
 }
 
+.popup-notification-description[popupid="addon-progress"],
+.popup-notification-description[popupid="addon-install-confirmation"] {
+  width: 27em;
+  max-width: 27em;
+}
+
+.popup-progress-meter,
+#addon-install-confirmation-content {
+  margin-top: 1em;
+}
+
+.addon-install-confirmation-name {
+  font-weight: bold;
+}
+
 .popup-notification-icon[popupid="click-to-play-plugins"] {
   list-style-image: url(chrome://mozapps/skin/plugins/pluginBlocked-64.png);
 }
 
 .popup-notification-icon[popupid="web-notifications"] {
   list-style-image: url(chrome://browser/skin/notification-64.png);
 }
 
-.addon-progress-description {
-  width: 350px;
-  max-width: 350px;
-}
-
-.popup-progress-label,
-.popup-progress-meter {
-  -moz-margin-start: 0;
-  -moz-margin-end: 0;
-}
-
-.popup-progress-cancel {
-  -moz-appearance: none;
-  background: transparent;
-  border: none;
-  padding: 0;
-  margin: 0;
-  min-height: 0;
-  min-width: 0;
-  list-style-image: url(chrome://mozapps/skin/downloads/downloadButtons.png);
-  -moz-image-region: rect(0px, 32px, 16px, 16px);
-}
-
-.popup-progress-cancel:hover {
-  -moz-image-region: rect(16px, 32px, 32px, 16px);
-}
-
-.popup-progress-cancel:active {
-  -moz-image-region: rect(32px, 32px, 48px, 16px);
-}
-
 .popup-notification-icon[popupid="indexedDB-permissions-prompt"],
 .popup-notification-icon[popupid*="offline-app-requested"],
 .popup-notification-icon[popupid="offline-app-usage"] {
   list-style-image: url(chrome://global/skin/icons/question-64.png);
 }
 
 .popup-notification-icon[popupid="password"] {
   list-style-image: url(chrome://mozapps/skin/passwordmgr/key-64.png);
--- a/browser/themes/windows/readinglist/sidebar.css
+++ b/browser/themes/windows/readinglist/sidebar.css
@@ -1,14 +1,18 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 %include ../../shared/readinglist/sidebar.inc.css
 
+html {
+  background-color: #EEF3FA;
+}
+
 .item {
   -moz-padding-end: 0;
 }
 
 .item-title {
   margin: 1px 0 0;
 }
 
--- a/docshell/test/browser/browser.ini
+++ b/docshell/test/browser/browser.ini
@@ -89,17 +89,16 @@ skip-if = e10s # Bug ?????? - PlacesUtil
 skip-if = e10s # Bug 916974 - browser.sessionHistory is null
 [browser_bug673467.js]
 skip-if = e10s # Bug ?????? - test touches content (adds event listener to content document's iframe)
 [browser_bug852909.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_bug92473.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_uriFixupIntegration.js]
-skip-if = e10s
 [browser_loadDisallowInherit.js]
 skip-if = e10s
 [browser_loadURI.js]
 skip-if = e10s # Bug ?????? - event handler checks event.target is the content document and test e10s-utils doesn't do that.
 [browser_onbeforeunload_navigation.js]
 skip-if = e10s
 [browser_search_notification.js]
 [browser_timelineMarkers-01.js]
--- a/docshell/test/browser/browser_uriFixupIntegration.js
+++ b/docshell/test/browser/browser_uriFixupIntegration.js
@@ -1,80 +1,51 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
-const Cc = Components.classes;
-const Ci = Components.interfaces;
+"use strict";
 
 const kSearchEngineID = "browser_urifixup_search_engine";
 const kSearchEngineURL = "http://example.com/?search={searchTerms}";
-Services.search.addEngineWithDetails(kSearchEngineID, "", "", "", "get",
-                                     kSearchEngineURL);
+
+add_task(function* setup() {
+  // Add a new fake search engine.
+  Services.search.addEngineWithDetails(kSearchEngineID, "", "", "", "get",
+                                       kSearchEngineURL);
 
-let oldDefaultEngine = Services.search.defaultEngine;
-Services.search.defaultEngine = Services.search.getEngineByName(kSearchEngineID);
+  let oldDefaultEngine = Services.search.defaultEngine;
+  Services.search.defaultEngine = Services.search.getEngineByName(kSearchEngineID);
 
-let tab;
-let searchParams;
+  // Remove the fake engine when done.
+  registerCleanupFunction(() => {
+    if (oldDefaultEngine) {
+      Services.search.defaultEngine = oldDefaultEngine;
+    }
 
-function checkURL() {
-  let escapedParams = encodeURIComponent(searchParams).replace("%20", "+");
-  let expectedURL = kSearchEngineURL.replace("{searchTerms}", escapedParams);
-  is(tab.linkedBrowser.currentURI.spec, expectedURL,
-     "New tab should have loaded with expected url.");
-}
+    let engine = Services.search.getEngineByName(kSearchEngineID);
+    if (engine) {
+      Services.search.removeEngine(engine);
+    }
+  });
+});
 
-function addPageShowListener(aFunc) {
-  gBrowser.selectedBrowser.addEventListener("pageshow", function loadListener() {
-    gBrowser.selectedBrowser.removeEventListener("pageshow", loadListener, false);
-    aFunc();
-  });
-}
+add_task(function* test() {
+  for (let searchParams of ["foo bar", "brokenprotocol:somethingelse"]) {
+    // Add a new blank tab.
+    gBrowser.selectedTab = gBrowser.addTab("about:blank");
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-function locationBarEnter(aCallback) {
-  executeSoon(function() {
+    // Enter search terms and start a search.
+    gURLBar.value = searchParams;
     gURLBar.focus();
     EventUtils.synthesizeKey("VK_RETURN", {});
-    addPageShowListener(aCallback);
-  });
-}
-
-let urlbarInput = [
-  "foo bar",
-  "brokenprotocol:somethingelse"
-];
-function test() {
-  waitForExplicitFinish();
-
-  nextTest();
-}
-
-function nextTest() {
-  searchParams = urlbarInput.pop();
-  tab = gBrowser.selectedTab = gBrowser.addTab();
+    yield BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
 
-  gURLBar.value = searchParams;
-  locationBarEnter(function() {
-    checkURL();
-    gBrowser.removeTab(tab);
-    tab = null;
-    if (urlbarInput.length) {
-      nextTest();
-    } else {
-      finish();
-    }
-  });
-}
+    // Check that we arrived at the correct URL.
+    let escapedParams = encodeURIComponent(searchParams).replace("%20", "+");
+    let expectedURL = kSearchEngineURL.replace("{searchTerms}", escapedParams);
+    is(gBrowser.selectedBrowser.currentURI.spec, expectedURL,
+       "New tab should have loaded with expected url.");
 
-registerCleanupFunction(function () {
-  if (tab) {
-    gBrowser.removeTab(tab);
-  }
-
-  if (oldDefaultEngine) {
-    Services.search.defaultEngine = oldDefaultEngine;
-  }
-  let engine = Services.search.getEngineByName(kSearchEngineID);
-  if (engine) {
-    Services.search.removeEngine(engine);
+    // Cleanup.
+    gBrowser.removeCurrentTab();
   }
 });
-
--- a/mobile/android/base/overlays/service/OverlayActionService.java
+++ b/mobile/android/base/overlays/service/OverlayActionService.java
@@ -114,27 +114,17 @@ public class OverlayActionService extend
                 final ShareMethod.Result result = shareMethod.handle(shareData);
                 // Dispatch the share to the targeted ShareMethod.
                 switch (result) {
                     case SUCCESS:
                         // \o/
                         OverlayToastHelper.showSuccessToast(getApplicationContext(), shareMethod.getSuccessMessage());
                         break;
                     case TRANSIENT_FAILURE:
-                        // An OnClickListener to do this share again.
-                        View.OnClickListener retryListener = new View.OnClickListener() {
-                            @Override
-                            public void onClick(View view) {
-                                handleShare(intent);
-                            }
-                        };
-
-                        // Show a failure toast with a retry button.
-                        OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage(), retryListener);
-                        break;
+                        // Fall-through
                     case PERMANENT_FAILURE:
                         // Show a failure toast without a retry button.
                         OverlayToastHelper.showFailureToast(getApplicationContext(), shareMethod.getFailureMessage());
                         break;
                     default:
                         Assert.fail("Unknown share method result code: " + result);
                         break;
                 }
--- a/mobile/android/base/overlays/ui/OverlayDialogButton.java
+++ b/mobile/android/base/overlays/ui/OverlayDialogButton.java
@@ -25,18 +25,18 @@ import android.widget.TextView;
  *
  * When disabled, tapping results in a "pop" animation causing the icon to pulse. When enabled,
  * tapping calls the OnClickListener set by the consumer in the usual way.
  */
 public class OverlayDialogButton extends LinearLayout {
     private static final String LOGTAG = "GeckoOverlayDialogButton";
 
     // The views making up this button.
-    private ImageView icon;
-    private TextView label;
+    private final ImageView icon;
+    private final TextView label;
 
     // Label/icon used when enabled.
     private String enabledLabel;
     private Drawable enabledIcon;
 
     // Label/icon used when disabled.
     private String disabledLabel;
     private Drawable disabledIcon;
@@ -44,36 +44,26 @@ public class OverlayDialogButton extends
     // Click listeners used when enabled/disabled. Currently, disabledOnClickListener is set
     // internally to something that causes the icon to pulse.
     private OnClickListener enabledOnClickListener;
     private OnClickListener disabledOnClickListener;
 
     private boolean isEnabled = true;
 
     public OverlayDialogButton(Context context) {
-        super(context);
-        init(context);
+        this(context, null);
     }
 
     public OverlayDialogButton(Context context, AttributeSet attrs) {
         super(context, attrs);
-        init(context);
-    }
 
-    public OverlayDialogButton(Context context, AttributeSet attrs, int defStyle) {
-        super(context, attrs, defStyle);
-        init(context);
-    }
-
-    private void init(Context context) {
-        setOrientation(HORIZONTAL);
-        setPadding(0, 0, 0, 0);
-        setBackgroundResource(R.drawable.overlay_share_button_background);
+        setOrientation(LinearLayout.HORIZONTAL);
 
         LayoutInflater.from(context).inflate(R.layout.overlay_share_button, this);
+
         icon = (ImageView) findViewById(R.id.overlaybtn_icon);
         label = (TextView) findViewById(R.id.overlaybtn_label);
     }
 
     public void setEnabledLabelAndIcon(String s, Drawable d) {
         enabledLabel = s;
         enabledIcon = d;
 
--- a/mobile/android/base/overlays/ui/OverlayToastHelper.java
+++ b/mobile/android/base/overlays/ui/OverlayToastHelper.java
@@ -1,76 +1,59 @@
 /* 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.overlays.ui;
 
 import android.content.Context;
-import android.view.Gravity;
 import android.view.LayoutInflater;
 import android.view.View;
-import android.widget.Button;
 import android.widget.TextView;
 import android.widget.Toast;
 
 import org.mozilla.gecko.R;
 
 /**
  * Static helper class for generating toasts for share events.
  *
  * The overlay toasts come in a variety of flavours: success (rectangle with happy green tick,
  * failure (no tick, a retry button), and success-with-tutorial (as success, but with a pretty
  * picture of some description to educate the user on how to use the feature) TODO: Bug 1048645.
  */
 public class OverlayToastHelper {
+
     /**
      * Show a toast indicating a failure to share.
      * @param context Context in which to inflate the toast.
      * @param failureMessage String to display in the toast.
-     * @param isTransient Should a retry button be presented?
-     * @param retryListener Listener to fire when the retry button is pressed.
      */
-    public static void showFailureToast(Context context, String failureMessage, View.OnClickListener retryListener) {
-        showToast(context, failureMessage, false, retryListener);
-    }
     public static void showFailureToast(Context context, String failureMessage) {
-        showFailureToast(context, failureMessage, null);
+        showToast(context, failureMessage, false);
     }
 
     /**
      * Show a toast indicating a successful share.
      * @param successMessage Message to show in the toast.
      */
     public static void showSuccessToast(Context context, String successMessage) {
-        showToast(context, successMessage, true, null);
+        showToast(context, successMessage, true);
     }
 
-    private static void showToast(Context context, String message, boolean success, View.OnClickListener retryListener) {
+    private static void showToast(Context context, String message, boolean success) {
         LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 
         View layout = inflater.inflate(R.layout.overlay_share_toast, null);
 
         TextView text = (TextView) layout.findViewById(R.id.overlay_toast_message);
         text.setText(message);
 
-        if (retryListener == null) {
-            // Hide the retry button.
-            layout.findViewById(R.id.overlay_toast_separator).setVisibility(View.GONE);
-            layout.findViewById(R.id.overlay_toast_retry_btn).setVisibility(View.GONE);
-        } else {
-            // Set up the button to perform a retry.
-            Button retryBtn = (Button) layout.findViewById(R.id.overlay_toast_retry_btn);
-            retryBtn.setOnClickListener(retryListener);
-        }
-
         if (!success) {
             // Hide the happy green tick.
             text.setCompoundDrawables(null, null, null, null);
         }
 
         Toast toast = new Toast(context);
-        toast.setGravity(Gravity.CENTER_VERTICAL | Gravity.BOTTOM, 0, 0);
         toast.setDuration(Toast.LENGTH_SHORT);
         toast.setView(layout);
         toast.show();
     }
 }
--- a/mobile/android/base/overlays/ui/SendTabDeviceListArrayAdapter.java
+++ b/mobile/android/base/overlays/ui/SendTabDeviceListArrayAdapter.java
@@ -9,20 +9,22 @@ import java.util.Collection;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.Assert;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.overlays.service.sharemethods.ParcelableClientRecord;
 import org.mozilla.gecko.overlays.ui.SendTabList.State;
 
 import android.app.AlertDialog;
 import android.content.Context;
+import android.graphics.drawable.Drawable;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
+import android.widget.ImageView;
 import android.widget.TextView;
 
 public class SendTabDeviceListArrayAdapter extends ArrayAdapter<ParcelableClientRecord> {
     @SuppressWarnings("unused")
     private static final String LOGTAG = "GeckoSendTabAdapter";
 
     private State currentState;
 
@@ -34,17 +36,17 @@ public class SendTabDeviceListArrayAdapt
 
     private Collection<ParcelableClientRecord> records;
 
     // The AlertDialog to show in the event the record is pressed while in the SHOW_DEVICES state.
     // This will show the user a prompt to select a device from a longer list of devices.
     private AlertDialog dialog;
 
     public SendTabDeviceListArrayAdapter(Context context, SendTabTargetSelectedListener aListener) {
-        super(context, R.layout.overlay_share_send_tab_item);
+        super(context, R.layout.overlay_share_send_tab_item, R.id.overlaybtn_label);
 
         listener = aListener;
 
         // We do this manually and avoid multiple notifications when doing compound operations.
         setNotifyOnChange(false);
     }
 
     /**
@@ -83,25 +85,32 @@ public class SendTabDeviceListArrayAdapt
         notifyDataSetChanged();
     }
 
     @Override
     public View getView(final int position, View convertView, ViewGroup parent) {
         final Context context = getContext();
 
         // Reuse View objects if they exist.
-        TextView row = (TextView) convertView;
+        OverlayDialogButton row = (OverlayDialogButton) convertView;
         if (row == null) {
-            row = (TextView) View.inflate(context, R.layout.overlay_share_send_tab_item, null);
+            row = (OverlayDialogButton) View.inflate(context, R.layout.overlay_share_send_tab_item, null);
+        }
+
+        // The first view in the list has a unique style.
+        if (position == 0) {
+            row.setBackgroundResource(R.drawable.overlay_share_button_background_first);
+        } else {
+            row.setBackgroundResource(R.drawable.overlay_share_button_background);
         }
 
         if (currentState != State.LIST) {
             // If we're in a special "Button-like" state, use the override string and a generic icon.
-            row.setText(dummyRecordName);
-            row.setCompoundDrawablesWithIntrinsicBounds(R.drawable.overlay_send_tab_icon, 0, 0, 0);
+            final Drawable sendTabIcon = context.getResources().getDrawable(R.drawable.overlay_send_tab_icon);
+            row.setEnabledLabelAndIcon(dummyRecordName, sendTabIcon);
         }
 
         // If we're just a button to launch the dialog, set the listener and abort.
         if (currentState == State.SHOW_DEVICES) {
             row.setOnClickListener(new OnClickListener() {
                 @Override
                 public void onClick(View view) {
                     dialog.show();
@@ -109,18 +118,18 @@ public class SendTabDeviceListArrayAdapt
             });
 
             return row;
         }
 
         // The remaining states delegate to the SentTabTargetSelectedListener.
         final ParcelableClientRecord clientRecord = getItem(position);
         if (currentState == State.LIST) {
-            row.setText(clientRecord.name);
-            row.setCompoundDrawablesWithIntrinsicBounds(getImage(clientRecord), 0, 0, 0);
+            final Drawable clientIcon = context.getResources().getDrawable(getImage(clientRecord));
+            row.setEnabledLabelAndIcon(clientRecord.name, clientIcon);
 
             final String listenerGUID = clientRecord.guid;
 
             row.setOnClickListener(new OnClickListener() {
                 @Override
                 public void onClick(View view) {
                     listener.onSendTabTargetSelected(listenerGUID);
                 }
--- a/mobile/android/base/overlays/ui/ShareDialog.java
+++ b/mobile/android/base/overlays/ui/ShareDialog.java
@@ -66,16 +66,19 @@ public class ShareDialog extends Locales
     private static final int MAXIMUM_INLINE_DEVICES = 2;
 
     private State state;
 
     private SendTabList sendTabList;
     private OverlayDialogButton readingListButton;
     private OverlayDialogButton bookmarkButton;
 
+    // The reading list drawable set from XML - we need this to reset state.
+    private Drawable readingListButtonDrawable;
+
     private String url;
     private String title;
 
     // The override intent specified by SendTab (if any). See SendTab.java.
     private Intent sendTabOverrideIntent;
 
     // Flag set during animation to prevent animation multiple-start.
     private boolean isAnimating;
@@ -117,16 +120,26 @@ public class ShareDialog extends Locales
 
         sendTabList.setSyncClients(clientrecords);
 
         if (state == State.DEVICES_ONLY ||
                 clientrecords == null ||
                 clientrecords.length <= MAXIMUM_INLINE_DEVICES) {
             // Show the list of devices in-line.
             sendTabList.switchState(SendTabList.State.LIST);
+
+            // The first item in the list has a unique style. If there are no items
+            // in the list, the next button appears to be the first item in the list.
+            //
+            // Note: a more thorough implementation would add this
+            // (and other non-ListView buttons) into a custom ListView.
+            if (clientrecords == null || clientrecords.length == 0) {
+                readingListButton.setBackgroundResource(
+                        R.drawable.overlay_share_button_background_first);
+            }
             return;
         }
 
         // Just show a button to launch the list of devices to choose from.
         sendTabList.switchState(SendTabList.State.SHOW_DEVICES);
     }
 
     @Override
@@ -169,16 +182,18 @@ public class ShareDialog extends Locales
         // Register ourselves as both the listener and the context for the Adapter.
         final SendTabDeviceListArrayAdapter adapter = new SendTabDeviceListArrayAdapter(this, this);
         sendTabList.setAdapter(adapter);
         sendTabList.setSendTabTargetSelectedListener(this);
 
         bookmarkButton = (OverlayDialogButton) findViewById(R.id.overlay_share_bookmark_btn);
         readingListButton = (OverlayDialogButton) findViewById(R.id.overlay_share_reading_list_btn);
 
+        readingListButtonDrawable = readingListButton.getBackground();
+
         final Resources resources = getResources();
         final String bookmarkEnabledLabel = resources.getString(R.string.overlay_share_bookmark_btn_label);
         final Drawable bookmarkEnabledIcon = resources.getDrawable(R.drawable.overlay_bookmark_icon);
         bookmarkButton.setEnabledLabelAndIcon(bookmarkEnabledLabel, bookmarkEnabledIcon);
 
         final String bookmarkDisabledLabel = resources.getString(R.string.overlay_share_bookmark_btn_label_already);
         final Drawable bookmarkDisabledIcon = resources.getDrawable(R.drawable.overlay_bookmarked_already_icon);
         bookmarkButton.setDisabledLabelAndIcon(bookmarkDisabledLabel, bookmarkDisabledIcon);
@@ -213,16 +228,17 @@ public class ShareDialog extends Locales
         final Intent intent = getIntent();
 
         state = intent.getBooleanExtra(INTENT_EXTRA_DEVICES_ONLY, false) ?
                 State.DEVICES_ONLY : State.DEFAULT;
 
         // If the Activity is being reused, we need to reset the state. Ideally, we create a
         // new instance for each call, but Android L breaks this (bug 1137928).
         sendTabList.switchState(SendTabList.State.LOADING);
+        readingListButton.setBackgroundDrawable(readingListButtonDrawable);
 
         // The URL is usually hiding somewhere in the extra text. Extract it.
         final String extraText = ContextUtils.getStringExtra(intent, Intent.EXTRA_TEXT);
         if (TextUtils.isEmpty(extraText)) {
             abortDueToNoURL();
             return;
         }
 
@@ -258,41 +274,40 @@ public class ShareDialog extends Locales
         // be, since it's a URL).
         final TextView subtitleView = (TextView) findViewById(R.id.subtitle);
         subtitleView.setText(pageUrl);
         subtitleView.setEllipsize(TextUtils.TruncateAt.MARQUEE);
         subtitleView.setSingleLine(true);
         subtitleView.setMarqueeRepeatLimit(5);
         subtitleView.setSelected(true);
 
-        final ImageView foxIcon = (ImageView) findViewById(R.id.share_overlay_icon);
-        final LinearLayout topBar = (LinearLayout) findViewById(R.id.share_overlay_top_bar);
+        final View titleView = findViewById(R.id.title);
 
         if (state == State.DEVICES_ONLY) {
             bookmarkButton.setVisibility(View.GONE);
             readingListButton.setVisibility(View.GONE);
 
-            foxIcon.setOnClickListener(null);
-            topBar.setOnClickListener(null);
+            titleView.setOnClickListener(null);
+            subtitleView.setOnClickListener(null);
             return;
         }
 
         bookmarkButton.setVisibility(View.VISIBLE);
         readingListButton.setVisibility(View.VISIBLE);
 
         // Configure buttons.
         final View.OnClickListener launchBrowser = new View.OnClickListener() {
             @Override
             public void onClick(View view) {
                 ShareDialog.this.launchBrowser();
             }
         };
 
-        foxIcon.setOnClickListener(launchBrowser);
-        topBar.setOnClickListener(launchBrowser);
+        titleView.setOnClickListener(launchBrowser);
+        subtitleView.setOnClickListener(launchBrowser);
 
         final LocalBrowserDB browserDB = new LocalBrowserDB(getCurrentProfile());
         setButtonState(url, browserDB);
 
         // Start the slide-up animation.
         final Animation anim = AnimationUtils.loadAnimation(this, R.anim.overlay_slide_up);
         findViewById(R.id.sharedialog).startAnimation(anim);
     }
rename from mobile/android/base/resources/color/overlay_btn_text.xml
rename to mobile/android/base/resources/color/primary_text_selector.xml
--- a/mobile/android/base/resources/color/overlay_btn_text.xml
+++ b/mobile/android/base/resources/color/primary_text_selector.xml
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <selector xmlns:android="http://schemas.android.com/apk/res/android">
 
-    <item android:state_enabled="false" android:color="@color/text_color_overlaybtn_disabled" />
-    <item android:color="@color/text_color_overlaybtn" />
+    <item android:state_enabled="false" android:color="@color/disabled_grey" />
+    <item android:color="@color/text_and_tabs_tray_grey" />
 
 </selector>
index f3ef465227367e5b02db9728f545c9ad5f2dc195..30d032ea9793ee9210b59a1676d5ef82c60312d2
GIT binary patch
literal 1390
zc%17D@N?(olHy`uVBq!ia0vp^x**KK3=%mav;s&m76-XIF|0c$^OAvqNix7E#FYUC
zLPJ9x92|^|(?E=b1R&|_n-&`z3uO2uB}GR^hlZvFBqT&dMa9L%g(fCOMy7-&B*es|
zMSx^eA`%nh;(+Q?qLPy0<I`edlH=l1BNNi16Vv14Q==2o;^I=`l9OW+)8gY(q7zc%
z;xb|r)1nhn;*!#05>n!m(_<4;V-hle+_)qlBRxJT4J?u#3u33nC8ozDrvceOMto8R
zkc>@EjZXq{)8c__Fe5H81IUO6GJvKffw*x=Kx3iYcnBBBNKTIf8J-abW&pKAxDfHU
zqztfQ(&Ljs?AT-o18Q$9kPTv_$0cWg$utNXXcvf)lmX>}i~?!|*$8rN5=2Wn#PB$X
z2!ery3o!wv9;Q7RVLr&%<aCIoV5xMdU0^AQX%H^d5U}3(WT@leHiAt^hbTaDOolS&
z*$cpAv8g1;FPMRmiJ66!jf0bmn}?T=UqDb;R9r$*N=8;rQAt@vO+!mtSI^MM*woD2
z!p7Fl-oequ)y>_**Uvv7C?qT<HZDFfIVC+KGb=kMH!r`Su&B7Cw5+_MvZ}hKwxO}P
zrM0cItGlOf!jx&#XUv>EcmAR!OINI1wR+9EEnBy3-@9-BfrEz*A31vL_=%IJ&z-+;
z>B_b1x9{A&cmKh|$4{O<d;a3(tJiPdzI*@S)8{W=zkUDt>-V3(|NOK<_XCr-tEY=&
zh=gSC!FK1KLWbiH^N&u{Sh6M{MLShk%Sb6zWy+bHO`ejGo@s%eS}&)B?mQp=qWpB}
z``bI7UtDl1>a@tF7=>`fSr1tDxoexa?hw^hnJBxXR(gHFn}e?xI6cT<^lIqy=+$B0
zv3|KjqnpVq<q3;Q9-NlwO)>u9aLG7fx|_g1K98wen0`FD@WH5|y{MY+Nk&EKfufyj
zS)|;vJsyjt#IeYoT+Oy)+TMyiYZi&6&0UpI@^GTs`l}VD;nPy}yC#JPz1*uN&Up1x
zwPVBTotsPoPpL<g&-xQqDy$Q)`{T==U8OeFnQPc)ZPNLD<n49)51E0c$DZnH7D`3c
zyt-D@lXdnl^PbMDA0FLf-1~V6@81KzD|pkRY%k1LOuihyAaPfl*=Ejy)Kkjri!B|U
zXWnhv7;DEWG5>i+foHD7?=SI&ZogxA)qZNbb+TD>UbKua>z*T#%2mX7c&AI4_ijTy
zjfe=#W4w`fkM59C>+YC+KrnJDQ@K*gWVSWauevG3mfdcM+~mblcH$k+AD-z~SJ)SX
zm5F``D$8HsRrWPNZ+^>S<0r+kb|=zG<ljs^e#N`?%e?;S?t)*eQ%u<-G?PEgyuahG
zm96J7DTTnW?8XYsXA%5$cU6U#NFJz)eA}+qtNkiz!MVT5hpQemtkH9R`_61n%vQZU
zQRUYTibmS1EzpWQ8OmZaF;u$FbY+ouZD0Iuokbrjcl*`7zV}FYaf0#3j8k0U4i#a-
za-3XZm$^%xSc;sPc%AW!?-bXCn;#ZTd9#Sm)PnozXMXJs4gPM+s=;NYr>mdKI;Vst
E0GCimG5`Po
index 59d2e72e9a29e448fafec5572ef81004b337aeea..552ea85be4a5cc6d7d3a3e07527109510128aa70
GIT binary patch
literal 951
zc%17D@N?(olHy`uVBq!ia0vp^dO)np!3-pyeO|emfq}6$z$e6&0Sp=%8dj}ZwSWKq
znKNes8T04Q-@JMA&Ye4f?2Q{YZriqP{rdHL_Ur+QY~8vQ$OSTh+#Nf1Kp4As@7}j>
z->zM|fNUTG$lbelFOUHwfg%t#L<-IT+69*aviG6_xFAFeoB<aEGO&subAb%FAYAc2
z7}yU3SlMXm(KI3}!=oOLIS^gHxdb|Zp<h=L<QL4q$i&RT%ErOP&BMzlAS@;!BdesM
zp`~kVVrp(-W$onZ=I-GY6cQF46B`$wn39^2o0nfuSX5G3Rb5k8-`LdL(%RnH-P6}U
zVe+);GiNPcvSRJJ^_#bD+qHYozWoP}o;q{({M8#bZ{N9h|G}fjPo6$|@#fvf&tJZN
z|MBzppTGZRpE`UVm?W}2T^vIy7<(^q?&b<)V0*BhQ}~n~cdFv1j0v^<9^X_WH+f78
z2#kDWc0YfA`K)I%GtbRAS6P3!+atX0j`4!G94EUkr2bR+SuSfbcfn#N+td3me@xfi
zBJx7Z+5Hc9?EY&G&hzF8T}jfdYSYws`eHZFQnlJ<6_IboFPKE`8LimQRn)n*{e3~$
z*;C6DB6hyb_!)USZOOgUtEPG;YSpg&cXnCO`=e*yGWZ3@v%Z-0i)YWmed`ZQtG|-^
zo^`@+e_!>7C;C@Dm5G_=%d1+^6|$T6e&wn=?iWhWeZO#^N9%c|koyVdQ?~Jq29E_(
zrPN#`Psup7Bv0Ad#bK`TSwe7L&@*PGEgGMtm6Baneig`Baz~HjTi`nj7T3AG`*!nG
znWs6(HiduLd#|&kci(w|k{6lp8k60d=dmt~IJE5b<%1H-%Jx6MlGc**z~y=rTl|{?
z?tUqPo2pzk)n7ZZrefh?)!71NX&3fQX*pqYRq9?-y5RjN7V`<Y_2!`b=jrO_vd$@?
F2>{UY8?yia
index 761dad952a3c7351096b036132d42ba5d4b24006..7017da785d4d86ad8253752b22da6e3536fccb85
GIT binary patch
literal 282
zc%17D@N?(olHy`uVBq!ia0vp^YCx>Z!3-q7s=SN`Qmz3$A+8Kyuxiz+y?giW*|P^o
z?gxW?Ft8s9AajvrYcibufu@L+1o;IsFtSN2DTin0&0V{1|KaO5Z{Pp?<<GzFIfspa
zD)KyC978H@y*+0w)S$rQdXa&v>r?2a|4XwR8C@GLN$&hQbC1K!3*yi8bQ?ONbvDgO
z5L&Hr@Y}4nEZnBt0b6ECTi;D<6lS+l=-a??{KD3a_rrcv+{oN?Qth{<ckWc9;@oFO
l!MV$fl4turS#<w~w_McDtmMAQ_wRwc=;`X`vd$@?2>|4Lg5dxF
index 2d3d7c3a5992ec816ae634a45db917527bb51c1c..400cde2f5d1d5e0da99bae24db802e252e489eb9
GIT binary patch
literal 702
zc%17D@N?(olHy`uVBq!ia0vp^x<IVO!3-n?4k`%%sh<HpA+8Ky;Naj83Ib^X2??R0
z$&rz1p^1r+ktuO;8F6uGv9Ul#YGguMbYgmZd}>TmdQ?JcOk!Gmd`fgeN?cM}d~$kB
zLPl(2N=!m}d{SC$Vk(dklbjZx1XP?62h^3A9+#XNo0J|0LTNxQP)i(8Dha3y#E1oP
zfqKD;lQQCg%91nUKqdeg5S4Hes5b*b0u6yEh=<4~gLK71)j&i*R>UK;AoN=L9=-we
zc~D7^UoZnB3n#yTkcg<bw49=vhL*OzfswI=jh%y&tDC!rw_kKjVp2v?MP*GxQ*&!u
zN6&<bQ>M+BvtrxM-3JdJKYix>r7PEO+`N7N$%~JlzkUDl^Vi>hdz8-wGB7YIdAc};
zR4^XB_}kbjQ0Caj^!>G*CzrbDr8;|v=jiPW5ENQl_3yv^v2zz@+I|yO-@NR4zxDf>
zw&yJ+HWw{tDb|afq1@?z?cC9|&tB!^vmJOdDdfHJi(Ky8@0Nww2VU45FUg#6la>Eu
zlX^O%j2hE{W%K_AhL_j!`Q^WO`JZ{h<X0jk-M(hiA8st#H~Gf2nB+%!QF|PB9;>g3
zULHQxGh@jvZ&l^}dop8RP1%3tcxbBJVHdWooiDy}l>TaL{`J9QW^>A`IZO5gO?b5a
z^;TZhU&XrI%lFlXgdK|e`9|yY<Jh0+rGGE2f2P8qGH2Get$zGfJg>q7UK+1xXH!+5
zVlv_7r4V!GBcEM+jekTR*39};=&>nx(pw##qft3W6Lfw~I>I3)&{Qt8<%H6<S(A2v
OlA))opUXO@geCyF_$hw?
index 0358a0e32b63603312a1509647b810505f800ef5..ab12b9dfc3cb483a3154b5996258bc13495eddbe
GIT binary patch
literal 1084
zc%17D@N?(olHy`uVBq!ia0vp^>Oic^!3-p&o;4*hFfh&w@Ck8cfPv7^PzMJGW8*Xs
zBO$@pH!U_c7RZi{jt&h?3rI+aii!#iO^%C;i;PSQO-zi8Oo@p}i%3d}i_3^eOpJ?5
zi%7_bjZKM<Pm4-O2Qs1))8pb&Vv^FM5>jIl)8gY(q7zbqilY-!;*!$hlhb1oGC+)^
zwAjSdxTMs$#Eh8awAh67_@s=u#Prx?ph$WgP$Q5`PK{3jvNK|VWHN{mmy{k0)SH+A
zGzVx13}wUu6$7<{O#qn#(v=P(lYthcgA4(&GvdJ{SY>)Vhzk(`=>;2@0Ym9<E=U<z
zB}5BMWjaJ7)WBp2I|C*HQ2<s97KG@6nwbo>D;{bfSUeeSK8S3n763(IXGxG>FaskK
zGYcyl2NyRFAHRU0kg$lTxTKV{tek?PvZ|(*p1y&Rv8kDbjh%y|o4cn^Ku}nCL`-~Q
zQd)XOR!(j~VM%F4eM56=dv{Oogh`X9%$zlQ{(?ozR;^jTVdJLF+ji{SwfDfG<EKua
zId}fTr7Ksj-Mn-6-u;J9p1*wc`pw%9A3uNn_Wj4tU%&tS{kJTxqy(6Jj(EB_hE&|@
zy%-)I94Nx_z>|qX!>vn5Q%N_3yNS!g{L8)B8XX)F4pls|Ow)f}U$fcoU2J3VZJTrd
z;?i<=cwO3{{p}0;-3^;+cNNZGRPs=+XQEzA!qhzlWm6MB<($2^HE!~o%Q7*aR6NVp
zUAXu3k5Iy{iQm@!3b=EfNwE6*!p_fi-#FgJyfSgsYf_yx_w;Y=^`-YjZ|%75{^>?<
zRh0Gl=hBt4KihRi>~{Y=<>%Zk!Khv0v!`&{w@V~Gyf-i4*(RP}99JuMuXxV<MQ~O1
zpQ|r^H{9U3Dw-qtwMkrJ+0NCrrnh?ACn$$Jf4y{~hu#y8t4sASdTe1TYI)UF{&*ML
z381EWxo}>Fq?T7g?2l)=${*plnyPnPe0C<oglm;W_gTL`ng36>_w0l@SKGhNxm##z
zzn^F8e(tnN29>{xizS^*&D9mMKU{jX-?2A`u|dE*j<NBgc5!=MQLI&wqWp=u7kpw*
z@kL4$?~(ez{4>~GjW0B1|GSD<l~vO}tMNVNQGd0L-Dqj|s?YKte9z>nOXpfnxl&;E
zY*oVSof&K`vm0vX$!_QPZZFH186m!o&+lW6KF>$#Ig_u;TAgj|u6dKyJX^)&=R4Ew
XOhVpq*8<r=r2>PetDnm{r-UW|=A6p>
index c2a3b0bacdeee5af1b01cca98e9874b55715b1b0..42e3f794c9ce3bb5b02426a8bbff2a2f9ed2f1aa
GIT binary patch
literal 983
zc%17D@N?(olHy`uVBq!ia0vp^av;pX3?zBp#Z6#fV4M`-6XMDM2FAu|p<oaj8w(`;
zl9HmMqeDZ}0umBJLzCm;;vyr{LK71sBU3^X5+ah4;^H!*l9MA6GGb#>fG9dKJw84)
zE-ob|DLpD7H7+?hCNV8OJ|#9WEjl43E-5`GF*Q0qBPJmuE-5WKIXxyJJw7Q7NX90l
z$0b6^_@s>Z#Ppb?3?MrWs2xZqr^Y9y#U*CMC8x(Gr2!F;0Tho-1epUy8S%;KU=qXz
zqjV4(s5c2}CWw)o77yY=*cq`&U@b{tH4p?f1S$?;AQ=eOh|Erg>WT*&2r&v|S2EZa
zP;)Zi#>PXmCqqnt+XCYzBTNR8E0u*JfKk#~666=mz{JYN$<4#d$1flxEFvl{Atf!Z
zps1;(t*38bXk=_|VQFRU;OOk?=HcZZ6cQF09TT6Jl$n!XP+VSFU0dJK*woV6-qG3J
zJ7MCa$y4UeTd;7^l9j91ZP>VJ^Oo&9_8vHR=<v~F$4{I*edX%4`%j)ed;a3(tJiPd
zzW@07%hzw;fBgLQ`_JEhuNRB{1tyn#PZ!4!3CYrf-SOUu496eNcT$!O$qLr<*4p{Z
zXsOU4j=Q}v&!VQpgwOR0yPb3Y|Lxtu^3Q$K&o9nZl22D&^~TU9(6#+q;^Pfko=G+#
ztn4;%=O)^&DA@imLS02v>(Qi1_cn$tH~2dxDae{f`^Y_>%SM;hTsswZwdUWmFE#U$
zCij?W)KprPC`PF&n{AtretAmG`kNsU65W{-H^s}VvFTfVy84~R_pEO4wiPCKGS^<@
z``u^Xv_)$F!+9Yc>+aXsE19N7yuQHqyhz9NbVlJ#i`D;Q{x#p$y3g5vNHuq|#>1mq
zm)d&2oTbCOp)TtGoRc3z8)G9s-QA>8sd_qH?u6O7+DNyVQ*x)tMOOV(xy|LCdWDbw
z%83^TrY`Ms+PdZcH7%X{H<T?OXk0k9N2ytFpU?fgzfYzxg;!kM-}JCj!hYTU=c|~?
zKR$JnNmprqKaWYX=aEZuPuoW&H?KzX1D{uHbD7VWFY2+wxG?gOah_9ZJ>w0h>IN(I
R<msUN>FMg{vd$@?2>={6pUwaP
index 2d1fd9fe46afec0efaf1464f8b809234d70a032f..576745ad3b3fd0f9b49fa731766e51a21bce407e
GIT binary patch
literal 710
zc%17D@N?(olHy`uVBq!ia0vp^@<1%d!3-oH>zJ-%U|>`T@Ck8c0E7Me_s^U;bKACU
zt5&Vrym|A^ojZZ-_3PK~*s){Jo;_Q)Zr!zO*IqE#zI{7T1jqo202x4)aB|<ieL!)D
zERel>_wGGl01*U=07;-ldtqQ73_t|oY+MYi?EPSXCW6%jWc7GdqOncHxZVN-D83}f
zFPMRmg_WI?i<?hCNLWHjT3$g(SyfF#OGi)N$kfc-#@5x%$1fy2GB%^As;;@Et*d*|
zwCOWv&7L!N-uxv?SFT#KZqt^%`wtvGd-3wsoA;kSd-?j^`wySKeEt68=da&?{{Gun
z=X45a)i+NU#}En0+Jl_RhY}dr9$fc5aaQJ}@5x&E&Y~j|GOy*l{QLj^p;-@af2+0S
zciQz{PADU>-KIV2Kl{r*2Tk|$h9Om}zgRd2oVZlZuydaIuQw`7jCkHSE_*$-@s@&<
zm0Gyj=l=PBg0HVH&W=)_wPmw<dr;|LC03iwtebgNw*|1Z#ji;5VVtod;NTqB%Uc{Z
zwR`1@)l}cG{_%?LDV+W>|6<ya>sl_idn{Z}a4P2s6rJagRTOozcq{P5FpZ~4Bzc-b
zMEZeMMiUwny6w{I6*fjMelO6ZT_rVr@duUun5sNQjfpy096isaQzV;uzdy2JF=kcf
azHe@twdVK?TNejVa`bfdb6Mw<&;$UTcYqrJ
index 6c5b82a4650ef1e55608d10a3462ce229be9f16f..118c3718695f47ff820c32e57538ffb52f5f285a
GIT binary patch
literal 205
zc%17D@N?(olHy`uVBq!ia0vp^Qa~)n!3-o>S^vEPQd$8%A+A7r<Hn79_wL<?1ondg
zoDCtT8O6*38CMeI7tFwGRa@70@Y=1rAHV(exF22$lyLQQaSW-r_4c&2P=kU1%SFbg
z=kNUA6)|o0fh0E;{kK^hkG18mN`H~D=i95I>3FAgRgq$`(*IcQfYM85nn8m1Q<&Kg
aGV#7tmI?m0asF(efefCmelF{r5}E+jCsd#S
index f4c8d550daa6e64d573065dd397315ca9f5bd498..741f73e180d50ea03be9cc6b56489c70e0aae0d1
GIT binary patch
literal 510
zc%17D@N?(olHy`uVBq!ia0vp^azHG@!3-oF)&9>0QX2z&LR=ZZAT%`8!NCDY#>U1*
zM@NT-Cdb9aMMkDYN2f$2CPpPEM<irKCZxs3r$!~D#wMjl$7jSPrNt(uMklAoC8fqE
zrNtzt#V4i%rQ(y)<A8#R8L^3Ju}MHdpvv_4<g_@b6quWo0Yq_d#Xv5IgtCEZ;z3#<
zib2Xi3P2=KFoRG2dLYonRwY4x!3<2yENt9-Lc$_4vhoTV+6E@pHulagZUK?WX<0dW
z1%*W|9rKqiTfS!9maRK>9XxsY+O4<mK7RT3{m%yDU<IHlhdf;zLn>~~J)h3sWFXM+
zFxc?`<Ga1$Onh@*{QWP#?Bk_nUw!O<)MU;w-<kc;-!tb-$Na?>@m$@3q6f6IEBm{i
zFZiF4@w0Q)Z{FAjml$COmyLSyQgQmm0YyHMS!G|BpVawy$4>Bn`8&mxnwEYkdLo{D
zAx6)0Kg}%`Hj6Rz{W#e={n6%wf0Au>>n3eJQ~o*T{)AKUyU$jtyCk2Ma{uHNxixAb
zTabx|;tjc$<hJ~q9~E+#11htRY-MK>Z%cS&v7nMMnA@ex<ljpsP&9hF`njxgN@xNA
D*L}wQ
index 46018ec519f69b64ed10a8e6dffdb693b29996b3..b95bfe1e60a9252dd2e20f230a75f3ae118625e8
GIT binary patch
literal 789
zc%17D@N?(olHy`uVBq!ia0vp^Qa~)n!3-o>S^vFaU|@6%@Ck8cfB^>w2V>*3(9lqa
zgalvTwAk2KAUirbIy5vbAR!?%G&wXe5hxguloS`25f_&hk&qD^n-T@$rbZ?J)u%)!
zrbi{D#w4c2CZz+_M8{{uC8fnCrh>^75F;@oHX%JGIV~;;%t%U&1+o*<<AB<eQ)7Y3
zl7Ld_VD%aC$sh($B}g%doe>X0=^*t<>G3cWmz)7YKz1@z0aznQ7f2)-BpwGL!HQv8
z(m{er87L&!3b10BAyAXScEQ+i>ma1;f@Nob!IxDM<QL4q$i%|R#=*tS!^bZmBq}B)
zEvKNUq^6;%Wng4tW^H5d=;ZF>8xR;292y=Kn_W;;QC(Bt(Aw7C(?4O-^qF%OEM2y0
z&H7E7w;eii{N$Mn7q4EsdFSrqr_Y|heD(V6hmW7XeEt68=da&?{{9Q!yX+h=3MD;V
z978G?OV3{qIuyXr^3YYYYtbT?jpD4L+=~*9_if~eXmU+9oHn8MKYM|i(HYO8Pxs%}
zgmdqAs;;|K>?to<zl2}+|BNCo`3tK&R&MusCv7yVMv*_%E90ojdZRBvPdZX`7gl<%
zob*RV?}GDXD^Kmk5^|@mdK&Rh)yuM1^ps-{Dhd3*b?@nk8=ft?vf5Vuk;Vnb#yzR}
z$s%137xY~|7R3_MWm0il;`IR@*ICui>z6VV=mnf+J6ZMiQJ)V3NBaC(Ci}OUT)fW3
zQ)9^eK<DPP%Xg=TM=~C`e>Y;whi6L?raCit+V<Xf*6tx;Sf2CCkLkrdGleZ*Wmrzy
zym_SLy2a$gYylPDsSepZm%3tgoZ_Azsui)*+??@^oy)NBV5o@RQ&7_OboFyt=akR{
E06;c9vj6}9
index 4833d381c844be258b22e23e8826c41de060d67f..878c332251a0566e193e92f6efc7a54c29d2094b
GIT binary patch
literal 1662
zc${68dpJ~i7{{l`xa1PDn%qWmOQlS)EgAQQF=?8y%*;7+<{Wd*NShR*i;`Ts%ZLgQ
zL)zT3B}K)eC?>nnvPxo=O41gUv~vd4v*$VI_xrrx^Zvf?_mA^DC)tnTrLIa+B@hVe
zv~})G+3tux$_lt*YDJZ8v$iw6TnQzu2G0qE*(?0$EDyrW#b&dqRH~gFDgzpgMui|5
z`1+y%0LXv=!(1+x%|`toh{NHq*$4=Nfq^JnAdoc#1_rSqC;)~*5DkDt01(81VLl(_
z@emG#fuJxDLb(Er3!yw1<3K_XL3jcbfH6RTf-uGt2)TR_2%|hGh>yrbArHb!KoG=5
zFdl^QaTbEG5P&cqf`S4OD3Ak;1Aw4>SOg$K5ckJ{4`ZOr84&_<0AP_^5%F;m2iXe1
zC|{<?O59u|zhyk7Of!Hp#>WR?GlOJdM0hD@ImpeYQXwKd5`xJA@8r*bOqBOe`^%3^
zm){h*^HeDZ`JnF^nV!x^MBlk#{OMO;!+cdl|Ko3Arl!nfzz@#Em<D7f4Dv02Y)x2g
zpDFuXs>7L#jf7dV6%^+v5tUU`)#j>eXll*V*3q4>_k%vk$b@XV$ZWB>rS;O~wkuXr
z?0;PIlcUpGXR3><n}??t&D)2*-fsho?H|D50w5nof`lj*ylHdj*0Au1$Xzk}V)w_z
zCnP1O96WOLc<PBXar()Oth0IN3M7Ta7k<51T3&If^0&)Zs;^zIsl9RYR$YBVW7F+B
z%`JEDwYEvy|9sHV`B&H9J&ztg>3iBg@Xxd7FJ8VH9(gnR?)~_OkDn$!Pg)yjwh;&l
zt7z^nEd2#px03m~LIYy&Ya@e&r+dqlPo>Ta_hzM$l4zM3<jolzf2n{7CC0}Se@>E0
zl)RO_iz-tr7n7gGe5sCkyE-ScXTUIHAi6ALNK{E`VJ%pMhy&6OsiY*&wP{*w9TA}P
zWtFvk`?2aCN=;l^bg7gw>{T4u&OFRg-w^I)S+eu^scj_hEA1oZ%<V_|Y_`V2zsIrC
z?vJSYxzxW{=b`DL*r(lKY*aO7pw1;nO3Ms~vn1n&N^Q0c1l|%!?riV*>fOB^VJSZ2
zIXlw&?5kK4$6zA;n#i(=*^$-}^T3W)Uq&>4tgpV~6SqznlB=_`eE7wq?n?G8rf+vv
zZTd*d)iT|V)j3&k>W1YulOgnNg{4<7Yf-n@M?{ia!J+WMyc247!7U*pWJZ^Tg(r5E
zYJrMQ8;3YE$A)$;r(8e!i^|^nZw3{*P}_9{SI*Z?ggK|DT|N)*^>G#-e8?E6GA=2|
z$!Dri{DOTFeKHMMB=V`EGtK*!#4fY;5{&HjDX`O7=D01PdCbVN*5A5kmW|azuK~=M
z9$Z*-=1!fqzk5-5etqf(HSwg0o_J5TcPJsO!jxsYq%29Sz-pay++^3W=6C}Kj+494
zl1TICOIB8?YPi<3Jlz6JLJu`dNurB}xhu0Z4@cM+y^QmB65r@>yU?qrzF6AjTHkN(
zV0nFgxi;s6?%VZsG!xHJa|8ND#V7emd1@Ll1yLkMBy+b0aj9<T%{$~2r>$C&C}T$L
zxhOpiFwa5BVvyFjzf4?1;3mNYhJ*$t4rDH?sL@aFHv1AAr_~Ugkv*QA@bKsy+joBb
zE~N+Z<~3J`8c8X8DaOqwbql23Zg2G5pWU{3I`QebbrLD1MjDbIr5g2b+%Ed)SguWP
zbV}RVPR~&R+Bj*oiJtJsB`XFs^g#c3w@xKskpJrAFxRY<+Oi`jxv|(WyZ8c~G8osY
zWL_2cG;yTUJ@YJKO}9Sw5h6%##$G%0mK6S|Kqw&-!z!G!a|?{t7`Ib8&m@{f`);S~
zQOv4m!OoixG&UkDtP^uDWhT5gb+Fu2n5)Qn?NP|_DNDL{apQf3vd_e#p$?O-jD=13
OZ-eH+a4&I%_WuXWtHz`N
index a823b3485e34773182a2713b5bc932101f173f28..c3304f407acea7784e1809b4982d1438952b57af
GIT binary patch
literal 1199
zc%17D@N?(olHy`uVBq!ia0vp^wm@vd!3-oHJ#eaHU|_r$;1lA?00s>W4XakI+P{DQ
z%$YNRjBVSt&7VIX$k@DjGmzZ4aU)P<%a$#B_Ur+Q0NFct?%c6s$F5zw_U_#a;qKnO
z8_3?ieLIkZNC6cC#o^?>efuDS5S2g@A_(E`0RtcxP67pi3<wDUKsL~Ya1xV&tbRWj
zK)7&Sa4BSCu>!baI2&0z@m#n|kfq=vXc{r4h+(6dyjMe&4;Z7fN`m}?85o(ESy<WF
zIXJm^c=-f{MMT9UrKDwK<>VEVRaDh9v~={0O-wDUY;5fvoLpSpJ-mH<{R4x-qGIC{
zlT%XDGcvPt^9l-!ipwjis%z>STibj3CQh0%b=u51bLY)pv}D=x6|2{*UAJ-jj-9*r
z>^pGq@X_NZPM$e;{=%iJ*RJ2bbNAkZhmRgVd;a3(tJiPdzI*@S<EPJGzJB}h>(4)b
zo2(FE68!Ax;us<!Irm_vb9W*G+lTbcU8ccHVy3Q)QQUr~cSg|^Z4>u(E<C3V7a8<+
zN*%lQ`Fd3Lw%Ysmw!g2wf1m&J&Cd_B)l0T6^OS3odmf$k=Y!K}yR@$k-Yt>A-3t@N
zi_bY7<|tOvUYMi(dDjA+BYSp8cbRSSVCZU#P}wHnH7lb)#)~cU!%i8l8e@}J4NZ)*
zYIK@&voD!gv)pddF?`qL%d0ufipksCOm^=d3%7+X77`h&TLZ49)!(12zhw%?clKLZ
zfg7&XtBNe2<g(*`N;mU?ONm)sCQQy(zTN9o`Fi!C>Q>*~8)o0y#qcHQaxJ^juN6-d
zEz>O51v_U5KC^fqaI=H`|4zApi`n{IT5^FWGfca7&JQ{m!P{e_pAlW6dS;K{Es@pk
zD_@mwNxj5AaaQ3igS#vnLj$9J?PWCj>KS#DPcytLtY$*if~9*W-179kJHg7;+gzcv
zciKE>Ef??m3Z{<PaUK;JhpRkHF1ImH$XL$1cfyN>yyc38v(5H29?_oFYq$8OJo}Nl
zi!Ux8WW6D{@FW+9?sCUJF04i}Z<9@Kur1Wp`SdwPwdm9zVVxo#C)qQ<t2c7F-nkom
zW;e@1<1?P@uM-~G&QCd~%Q5%aoa$bIh%(coYi0{%JzMio%wvt*jKpJg9i?vj`L`v>
hy>8n0gH@gDALELW6!W@*urN@$<LT<>vd$@?2>^>RnA`vW
index 58ad26121960337c4870766a8b78375000fafb02..4a8b50b4d566339541c15667ebbf98611fda6a30
GIT binary patch
literal 325
zc%17D@N?(olHy`uVBq!ia0vp^CO~Y%!3-qjzGiL#QXv68A+A8Wp`l^bs#W{<@1H+^
z{?45{_wL=hZ{NN>d-m*uf&DN5XG0jsQpnsWe*McpbEQjy{DK*nm{~Y9?Cf1i%j%lj
z`<I=+bmi8a$In0i{`;@EXyHDfvQ|$Q$B>F!Z!cK#H5l-?1a5v`ZT8{sd(TD@0hgjl
zZO8W4U&?q8Y^YhYWEs;5uio_~t}}fes81AJ;pT4I=q4`3xn<`Cw)L07WF#&q&SJ6R
zS;s8dmm<<NOT5r!Hp6#s2?1rd9tp#wBN}pV|D|ou`0Bpewb@=u=Tv&U@$-V!b%t9E
cyz6HQGpfHdHJ<J96zCuZPgg&ebxsLQ0Q-ZKpa1{>
index 955a6e35e3944eadcae5457edebb5d899ef4dc4d..5bc42e6977248e18a31266f8780d2f96b3f28678
GIT binary patch
literal 759
zc%17D@N?(olHy`uVBq!ia0vp^Hb88_!3-p)ZO#q@Qhx$`LR=ZZAQTK75)xu#WBrnn
zqNAfjL(>8h5<)|hLlYB$f}sfs5lKmLaT(FkDG`Z@5eXSFN$Igbl$aJBpAnao7N49B
zB%?uOYHUJ!Tw+FCVtPzcYJ3uqkrtbj5uca_5==^uhmt_O84z8uKz4FEP-T1)keeQt
zoDmCV#3yHfx#{s>G7e}IgiHdP092n0){+bsPtJg=0UHHU29X8Y0x>Ecq6}tuSLv@5
zpyz{2g8YIR7@65PxOw^b1%yP!B&4KeWEJ#H%&l$g9GyMAeEfsM;uA7+atn(pt840;
z+j^!<U9fQ3s?}@PZ`inX_ugZt&YZn?^Y)zwkDtDJ^Xc=~A3uNn{`0T*8G{xB17o<S
zi(^Q|t*4hG{h16!+8#C=G;!~E&}Pikq>*=GM?)7kM~GwKgWv!EyLuZ<42oLv(CJWd
z^(QrTTTNfycEi~|h7A)n|ICPa6F=eXU)_6kS<crgYi*()J)WBEE}LJr;L}6}uf56M
zvi1&ZUmw}+FvDWD<AEPez8QP2FtF^A?v%Kn+;Xw-tZd2mKW%dPx#<PDx4UDdlAd?<
z>zRiv7fOnJwR*CGQP0aiEoUBAo>rOm@^N#M!m^)}irzR)ShO(bn9SoDvpx3wm)2j$
zsUvl;oK0Wub`|5Y!XJ->{}vWR3Mj~m&-rz5nTp*W@i%>fr<bYp&F5OQx#0PWc?qRr
z?{egRt6XuPSGCb;ca`kp#5c=swJ|v=yH9CmySi}VNr%OLl^G_pFPsQo!Q?zuV50q7
zG1cu)#U5l%?KU!htG+a0TjM-8GmppJQ!1nTCJ3%(nWAGPl4o)#MQ7%c|1YJzLNggv
S9_$qXrC(21KbLh*2~7Z#j6Mzk
index cadf9a3d9053879df592b62e4f0d438d0d0ef255..bed8376795390f37ac780804be1a7e8015b48a4c
GIT binary patch
literal 1352
zc%17D@N?(olHy`uVBq!ia0vp^CO~Y%!3-qjzGiM=U|@V7;1lA?00W_+p$-lX#>QzN
zMnVFR^z{X?{gRTRqoYGZ(*hC_qN1WgLzCm;;vyr{LK72XV`C#DQzDX*A`%nh;?g1#
zGGb#>qLPwgVv?g0(&OS%W0I006ViZ!@$sp#$v}-MQ3<JW$;mN^Y4PzXu}SH%iD@y3
zsnPKnaY<?M$>~5cHZdh0M5e|irN$<t$0Vo4C1wB_afx7d5|kaAoEo2)9+!|7pOg+_
zB&NkCWq?$IxIi{gWqMq4I*<{c2sAt$WOy=2Wh_ulBFG}3QD7S(vOse{j93r@WIk9s
z#E^`5FdIaIQ9N7_#Lj?d1Pi7^4Fr=AHdt8(RAVwsFgXK>3pWj<5n(u(9gh%9hgk%&
z5oUM>#8M=?z+AB5FkeIMf{?6<Y{9_DUQ!a|7tFxO#LU9V#=*(O!^_7nASfg(A}S^>
zB`c?(sHCE*rmmr>t)pjPXk=_+Ze?v_=ji0-?&;<2<Le&~7!)2685Nh9lA4~8nVp+o
zP*_w_URhmN-_Y3F(b?16*FRy><f+r9&zL!D-uwkimMveiZvBQ$o40P;zGKhceFqO6
zIePr`ne!JeUb=kc>b2`PZ{59j|KX!2PoKYd`ReuC4<A2$`TG6G&tJd){QbA{Z;v4Z
z15=8pi(^Oy<J7CSqkRHpj(s#YH4bu_7^2Z4Ah>i6|I|sJ9aU6RL^av9RHv9IS*5Hf
zyS%4<MS5Aa?dI>-Ze9CzuU_w3^~Smj=kIg8y;RL{m#Z#7I#l9{Yoq%Ty;Coin%y$?
z%PleUF<R0pG~2W2O*7xA8vjYg*H<=v^E0~jasiu_{rL>@bwQH4*$X49zily!vUk|*
z@A)>w^=Pok>=QZ*mAZC|ES$N~S|G&b<oS$Jq3{)LMeP}(?Q1ekOEg?oi+{{m&J|Up
z>$Lj(=|CsZDVIDirkF7~if&1rv1q4Lhv=S*6YZC*U`k^37l}U|QO>xrN!+XUXoSA7
zLf8kV+inuCy}1M={^?uUos9^#6wt0vh_}sc{qDHtLF2VOOZRGZh}vA}d%xv7gMjvl
ziz_2<RX=WyaP0ciTO#7q3S?ZWexb32Gl^AG>xZZNXLg0K35&d%&z)0@GJL9W!6n?c
zA;PiAsOsga+x#cFm_+?9Ts@IB<E~N43N@edpBtjS8|;zrs&QI<pLy<g{VOgWpA=kf
zT(I#EKk@4Mjos5`hc1l{Klp~v)9$E~>`n%=?Yjd19a~y3<xB69iEPH_uC8a<u=jB1
zzg&CIu;`9CHj^0=o@eK`99Q|xqGTD7GVf)c{}d}nBmFjy&zTypzB*XAZ93OyF}pD%
zRsJc*`ICk7W$y~df4!)v86~acott4Ix#)**yeh}kl-ayf4^CSj)AVRp#uCmWVJoH@
zAI@s5Tk5>A`;G7lnN>f3&D`Fw>(PRkx*yv*(~1`czTLsEqVK%?$itus&2<@}H`v_D
q95=U_m^o}0ZFir%Fg8q5?6v-F%Y&!6_egt#N<&XqKbLh*2~7aWCpWkN
index 8bd72f0430b02f8b8085799944df3beb94cf3c00..8883787cbe22c7f8358c909122f55eddab357ba8
GIT binary patch
literal 2184
zc${69c|24L8^^~InrubXglMr$t}KO{vJA>hXp9-l%$$AZ%)E5l^i~&1rXeY0iF9At
zMGLuU5-F;;v<QV%L|oc*r=pGYo+0(V@A-VrbH2adInVQZ&gXOfIEhUEby{Q!nM5LK
z(S5vF#Fa~&p&BYgO?-AWmqb#IWBRhaNz(_1!*O?acW}T7pwsE@Fie1-pF;YhD38bE
zaBwCJbGcj&2LnMcG!*9`2mk<~p#lyJ^Z9rPih`h+#}kAg2nga_3=4t903hI^C<Nhr
zKG7xQqi86M6D9-+0Sx28I0y>(sE7*-K@8&~I0Oj*RKz1BC<YLm2Ma(#LShib`LF=O
zL;xb>!wLx?0v;yf!y-t*ged}1Apnc`7(P`&RIC702qGc?!y!~W2|@@FfiTYhAyE-X
z<Ps((1ST0!+!7NEAvj1RqPRj%GlUT73IGVeh)_kZf)$QJ61kAVCt8U4B1o}`VgVvf
zu>%n?=|Gd9sEW|3nS?XtizgqLnomslA30ekBYwc-SZJzuny2BXoZ{~@(I35vxbKbF
z|MTQN;_pG|&o!Xw+@E)+d#C;Bx0secH^KgUF>#f~@EhX$Yv0cD4<somtEkRUQ`eYD
z*3{D0(Vaa<Pk*j~;k@~j1xCgu3r)=yEnZ@7VQIC@`WKt!ws!UoD^@x>IsfXidX1}_
zy9dqFd+j<OI%B;bGk_Jy<^%<YgmQU&0E7?}69`4(e}si^+_ZVi)@>0xcJBH;YIn?@
z*u8O*_{0OrsfW|jGqbXf=KL!+zp$vdr1V5t`N>nK&s3hRsy;8fP;;^FQvKyC4UJc?
zUB7X&>Gs{`do8VP_aAh0KJ0q@q`Rm0*}z|eL(gBleD!+x&EN93@7|A$e)#z5^Ov#l
zuM;JUi`7Xa)n2-n2YZ)Nb=PMeyM3X${G+Y4GP&Sc0W^9nNEfjx#sz6d)yVrT!v;19
zQ<DT~Nf!pv5ULHAdVoR=GtWMd5!)BHPn-PrH;ZEtCfX50&40Mu86JK7yYs!VntOfL
z$0O7_u5Y>+wKKx{b;85j(S5h}90nM&-Id)-80oO=eMZdQyFH>(9k^oK+}+yeDI1z5
zT3wrMsBocvWVXhbQAxtKhvqes+#V?qQ?zAJQ<`2$r@2Vatzh&cDc$-uBty$_P2Rz1
ze}rOtsLlCdp9?wIIJUohqme`_zB=Hha;!CDv)Qht`e8JZY0+B86jQO}IP0c_XHsmZ
z(V<3`dG;AZ9&1EY%l0gj&PX+2M(Q<Kl`svJtO}B8^W)lv?4*A^KlcZ1oqgQdWE!Tw
z%6lctIo9;r>LR9wrBr`aT8m#Y61XGa!c#qFWKStGccMyf@67pjr{SUN20OU3bi%uu
zMqlZiEV0-}TRxIGk{M;=mS1{ft6cYEcJ8B?$1-yVJI%pHJ%`Q=FrxKN<h)a*M@5|<
z4TTm`PmhYSSK4g1!VRb2x*Db>4y{!0^c$A$xxV?It(OnUs}4`pod37Vw^~0yW+u14
z=6#6gDPQw7yaJe|=UqKKvRUU%YgYW9F61`7s4EF3i7#fjG&{CrMV%<k8eAWhv@H;Y
z>As9jtv&1Sj<8Z#)HAPy*FTr3v}&mYy8U8P`=)?(ARz2ckn`*%L*!AnNEJ(VIMvvL
zvAOVCX4SmZNA|iInwfR?l}Kh`6KjqooB9SXi#%@hxJ5#ZQoHzDJi}FA&1!uAt!6JL
zVfjG+?B@^q_uXoy$pmEs?|dxxmor_09WR=V{kk+aB|afHlj@?bbUUEQ5QBAj#Z@9R
z_dnV1+MRd3i1HH-Ei$d4)~qry|MHZ1a=W9lEpK?PlX}C0ULG|{+xl2xT6jWZLf=mI
z4ioEJflYf3#%@^rjolT2H1RW^$f@H#Vs_VixM6kXc<`KYMtw!qkY{*3BgUCRu}XO$
zaz9Dug!JdFtX~@Ilo^r14MZBQ1(6@;RUc}+zaoU?j?6!IBp_tQGWJ>LrZ0NkvdWT!
zjU=&_hCGhzCo{kDPXDv<5obWD4&>atWN#F7u!uS5rk1NxkZ*CNzJYbI-0sw;MX46D
zME&}Gxm>lkm8Q9k+96xH9XI4{wN7<UW)0+z+I0J0HLdDxki_{sU0BeW)M&74%V6hQ
z>BVS^)f@9$7DQr})@y_3w`Y_)w6vpSEI(_#*<420(%KUvpY)_PEn_(}$j8T(At`Js
zkIt7L8injBrdm(i{g)5d#(Spito+(iOOpj|c_nM`4m_jvwRZ>OT!qK&w@%%TD()jM
z@h=bRR#8ZfNge6B&ozrHbv1ab4VPbL?Ngd1?Elt6FSKnq9FeYeBe|^?PsWs`O)a4`
z1fQ$b>`&?`%kyaWy>0QrJg0Vi_LCBJ>l1_L<6aI<RmQ1Cp-$pK)$L&~hW%qK^PJsk
zu#TCO!^SJZ##bw=%|5=e?Zjg8#3g^pI`jj@yCTv@5@4XSq^$aaPW1j|n~DXe7v<cs
zdBjj_Nby+u+v{hZpFZZkd{iH$x&mbJvL>>%!wa@s&NQeCT3cqNNs(^rUob1F$CWOt
r$g4h4Rg90#Q-w!9<Q%!>ZTm&NBhkxJ(v`kd@i#&D_V+qNgX8`KU@^ay
index 3e7e5ef75019a22e3795756c9a6e3fe93e24f44c..8a983bf9463ce816fe7ef570aa00b411639142ca
GIT binary patch
literal 1646
zc%17D@N?(olHy`uVBq!ia0vp^(Lfx@!3-qR6jm%?U|<Rg@Ck8c0E32xhE=Op?ccwD
z=FFKu#<p$S=FguGWNhBNdFRfZK*q+68-aqmckkY^Wy_8oJNE3^1C#=a0A+#fty{MO
z1$XV*wRi7ch%z8!`}XZXSs()<1yr_g-#(xSP!^31(Fha)vLOtJI2s#S5Y7MsG!cm5
zK)+xr-U|k}7!Y0ikN}X2CJwh0A_Z4~>==k*Ory{Q;UaKBtRl!7F%@G<VbzYuSgZyT
z6vV@BHFVkzOj1Wng8YIR7@3$^SlQS)IJvlac=`ARg@i>!#l$5frKDx#<P{W^RaDi~
zH8i!fb#(O%jEqgq%q=XftZi)V92}jTUERFAef<LhgF?c>BO;?>V&f7LlTuRCGBUGr
z^YROeO3TVCDywVj8XB9LTiQFidV2dNOrAD<=Bzn$=Pz2kWa+ZyD^{&uvv%FaO`ErD
z-L`$lu08wqA31vL#K}`<&z-+`>GGAU*RJ2VdF%F_yZ7!teDwIq(`PSUzIy%U?fVZO
zKYjl4_1pI!KY#uH^Y>qp{N+6i49skvE{-7;x3=DB4-0k_V0&QC(sN^D`9ZyJ_m18Z
z*pPHIZe#g|#Dn*&wH7qF9n@Q)pw#wZ|Md1niYNEpJbQ2NedaSWZPU-4dE*fGrEk%s
zGkP1EUyFH)mACVL+kB3heT(#y#Jx)|ypxd@F>g%!yZ28ztBLi||6Pj~guc)ErPCfJ
zne$}rwIkJ4FBb0%>AP@#wnxV0wO#+FIm~Y6`4AQTXs(u|2J5Uh$rHl;GA{0GIyh;e
zs}-+EhUXq%$zGi%w%eI6QdxAezxEyzO1zmit0K?x<1<y0%?x5oFNW+pdStQv#XB|;
zo7xngSS2*RwsoGFy>P3a^_dBAXZp((efr`qUpA7P66jRw_-)-0MPJ2(H`A{z6YkfR
zP<y|+`=#gMSqELNH0+GtJn!!^R@3}-Z#*S09@5V_-7;4^u40Av#jmxUb6+c$Tg15j
zUB|ahm(4u)W3f=dEVDxQN7`>MiO<iNZjo|P?Y)e|qouZCOg9{&V^+R?cRZtc&b1Yy
zg_CYQ-R5fh+%oIirp4?1V;6<JTlb;x?gBSg+dt{=a<e`wnicTx^!By;&VBE!TwwR4
z3YP58EmAeoT*_}hWklZe*j(?+sJlvn-&rax;Hk)r*O5NltAqr+?zn4xJ|lG3(=Ish
zYfZ<I=|P^pMn~Fe!@2g#7`aU~tXZw7J~h#L{U1}|-MpSNxoSlx$r;t?D!4BVXmj#%
z*NWiK6kd@ay+mhmlktR2)47Tio*IcxarROSEop7h;1Bg_YpDyF#Kmd6@(F{=n&p$S
zClt)_x-+3*y4N1Pj@qTO8U?x*F0vGm@-6<pIV&Sexg{v$*-M^;52>?GPS`8Ot{wAY
zMg4Wz&F+pGv)%6Lb|0}1mcQts+44#9&FQHB$=Bc5NM*1yri(KFy&+_hp*-mhkC7PD
zMxGDZ{ymA?eg2+c=vFkCRQFHj6_4=6%%xu@vo#jx9GQKSTXD;+z;kY9jtLgEo7f!|
z{Qc+kZ#y58p!|&o&k8(VlwCQ@wpVdeSLr+dmL)qbo31GpOX_|<RXnZ7Q6j0_UiZ9j
bxzdIB_x6om?3WTdK-Gq)tDnm{r-UW|_HdJ$
index 30573ec16c12518327847891b415fc24278ae437..f630b3573c113c71f1f40f7fbc8689d24f9e52a7
GIT binary patch
literal 522
zc%17D@N?(olHy`uVBq!ia0vp^en1?>!3-o7jVCMuQgZ@)LR^7#Lqo%=RjX#soCzei
zZQC|~{`^&7uw%!L&6_s^MfSkJ?%lhA<i36TfD9nH7Yz1;!9Ey(hyclb7ywxrTm&w-
z9|JtIO4tE(pK(c$UoZm`Gb<ZA2Pcn^h^V-dmZ7P&jk{-Lbb47;Z9{X*v?WVdtXaEm
z<H<8;FJ8KK_szRcpMU-H7A<K6YJcVF;uuoF`1VX>&>;sA)(g^y8_sl1VCWKJomIKx
z_x;&N+vV?u-Fo$9^{3}^S!F70MBAlhw>y9A(YjPV?T3fpn}?H^?h|T}x}4k-5M7lc
zxWawC+@X{&rv#QQ2yaw4*%`N-WvSKTPVMLNpE|Rz9E`2@R6K2V;YRS8eU0W^RjmsZ
znvGfKZd|s&beV{W>#l_}e7UxWg?H`Hi`uc8Kc0o>^^b5bQ+Z|6wRz_}idXtszuj^f
zjmSIZVZ8dZgy!a5cFJE9BMnb+ZF@ISurJnv%{pwG^n>Y9d3tLeBz=;qt6|o=`JnZQ
TuHPJ>uNgdD{an^LB{Ts5AeISy
index 0b99ed47257049a6b7b23f26e23aca787a122a07..ac92d2c64eeafc0aa8925c5d09d4171b0ac7ca96
GIT binary patch
literal 998
zc%17D@N?(olHy`uVBq!ia0vp^Q9vBR!3-o#Yc4o2Ffh6V_=LDJfI(<zsDp!pv2hxZ
z;pyoa8ygE`_$4KUhNcB1B!q@014SYuQ(|J$B9f9K5)-46lOqx`Vq;UHl9FO#lA{vR
zBNNi16Vv14Q)82pW0KON5>n&hGUAfbV-iy0lhb1pQ)7VY6I0@o(qa?RfhayHH6}SN
zCJCrMEe@zLDLpPREjB45E;$uQ#sZZkr2)0aC8fnD0<~uV4T(zvl0d<XSTGwX3*^Rw
zxM^VRKtnP>jARfAW<W`>Ibbf74bck`0h<ig0ul#l$$)Bs+6X4W#>PR6h3d_SgR(Qq
z*|wYp24Z$ekY6wZBQpyt8#^}-FTa40n7E{rth|Dvl8UN^mbQ+*ftjVPoxOvTo0qR&
zKyYYyTzo=OYIZ?UX=QayQ%h@Gdq-zaU;l)OQ>IOyxp>KnmFqTa+PQb%k&|c6U%d6;
z;iJb-p1t|-@zduo-+%u4{pVlW$vwKjXnf}B;uuoF`1V?Muu!7Jv5(v$&RQ=QgoTwX
z@UY5QD0|`-&(dPfN{+=I+y?Vr|K6W}yG-VK<(<5=Z*`6D-ZTG+e`9z3Os}%;(=Abc
zTv9T-WHK@?UTtEldi3R?kN=(3dpJM7I=cAw<QvHmv*h&tKl^Vgm;T_S&7Ytt%kBp~
zYY*3{QD6Ti(tPc|+<#(A&(uCxr)Vh{v3)M{1LdPu>H4KFt;8a>$8sMCKUEv?Fg`PJ
z*5BD8bpqesulitq>H1AOkJ|<>`aXQTXx`4O+V@<p=GBGkt7H50-dg_L<UYO9md#}j
z+q&F|`|}vMGGolG%@<BSwp(k<rRDxBmoom{@+N{~*?gO=njQXgCVUago%W=CrqeC+
z4=;<eUOxJ^?@Md9S>zY(pLYx<^qr}g8{*j?x+}zUe*J#uS4*CIWdAy|<7_9#rKaQJ
z%h?tFu1N~+QP33tGWUMExlCkz;f721>uy<mO!?M*xo}q)pJDu&r0{JP@qB^YOIUqu
z`Th6hX)t@u^kVYR3FOJ`3T@OndMje-oi8R&!!|nXOq*Q9?U5gJG)?M+>B<E&o3%IZ
zxR<(Q*XwS{ACr_7-IjZ0^F(O-_V%@SEaLyk`|j2W<sj$2g~u(NJ8t>(ue--1a^{jL
px9I67MkPz6dPSy)a9Mo$$1q`)iZ;`26J=0#^>p=fS?83{1OP$Tt~~$%
index 076b305bab64d89486a9af5e8d35d7bce3d3c3f7..08630f1a381237dd2bec1d51072686c97e9d272d
GIT binary patch
literal 1802
zc$`g@c~lcu6viVdVg&&g1S$a$#MX$$go+v{t6(6+jR+*OX0qW?L4w5^6&EOlQcFQL
z1w|1R6|h>cP*()X5fLy7T@sg4Vh~JJq9AJRn*?g-%)I&C@7?=-?~gfWGFS7$r%e82
zGKE5!!dV%*7QA`j5lo_j)t!}dj6$&pS<Q`JK^edJe12eHAd4jhz~OKLF$_RdloW<x
z06dXMC=~Mf($yFy5D55u0zr_tI4K{;K|@@en2%u)B#jk`5JVn};|L;+#pEz77KlU;
zL<le$f=J>p;3gL0G8C1-1R=zv2qG3>5(t-~s2CQ39yyFlg_sz^B|=n=h@>bXgD?_c
zTr4DHC<zh>xQQe%CW8nmg3AG*A_>VNl3_xMisT3mK!V~j1OufE0TF-+32b6=B>(_t
z5?~NyVgRNrQ#5eE&Cwu^GzW(PjGBT>H^`Nn%t<g=nX(9zXlAlUW)=~1NsJAW2N>Iu
znaz<gag0sl<|&|zH;g-&!6cf^$=MI-AIh<;|B3%akC$YYiHWiC4+k)x)-;Ze^SC&+
zPGrqef)D6g@><?H$^;AQN0t+<CVf2F+GdKao&D5l(`Pu$baZn5WERbJw)-5<xn7@s
zHlM!0dm)3#@>$}$bUEAai@>1Zkg)KToQOzn6mRvKwfyxlv2g+f#YKczzG360gv2dL
zimhL6`%1ZUSL*IP`_j@gva-L=`5`y&KtW+q@xdd<D^66NK6AFJy5`)E=P%UOUHtjd
z<tx`3o0?mGZEd^N(b=WCd#}6ax8MIzYwkaI_~@_Rzx%Y0`=1Ov)f=8Ye=%fyIXv=u
z^v&CM|Lj(0>M0az9Vav>dfSAmZl!RB#HHoA^15b{LvE+d=UvCC8>}k1kx@sFap|qP
z99k%yTYic`_xN^Y)v2@vH@Fe)(VkO@EqgAbf<hLv$Nv|$W5*!th~u+&gyDhW>FkJ1
zJNu(qZkf8fB}7{N*E7o79BT8r`n1E0Mm3e1%@}^`1f*1MSfOxR+End&J7%MrK0Uc4
zYmRR(tD#|4c#d$#f$9K3{1KsVXisx3X8;;}=>zwkVaFe$zs}98+gP@#`bo;7=WDu-
z)YUiOHP7H3?59bV>^n%=`<9!>;mzH?Mw<uwd4fK@Ur<NW@{MKu>RoBZOCSC2{yl_S
zuV9}momu~4-we&i(O%w3ZM?N5B~*=jvcA%@xo-pJKXg0l&#3(BbiMtO6xFctD~E=V
z-_-sx-36;oTCn!8b4ACaIXc@%Ntt&2!^03s4gp;WV&~+H#QHb4yB583qWG|CAR^28
z{%(C4_v%k~PcGKk;z`!qiz|n>lSH>F>L2tYX;C(7-HXjSTlb`9W$`AD<P5GCDtHnV
zn8N7r_i5co1B2T7s_D1B6gMdS-nlDSuNG$vS(mY$9?yK=;u01E8gKhEo1i5@DU9~!
zx{BgRa#I^|Rm3Z`?4;Q!F4APYa9MB)<Cg#S;$?8`B+c~&G0$cyA~kk^d<Q60zk%;~
zz0lSG>YzokRk?b)hfQLcy%$?voj7lw3q8HdHkTgIH2eL+y?}(fvVvOHU%&7IG@kIJ
zejZ%aU&?e$n7hwA9o5v>6uhj+|3W#ESMazyrckf)SYx@$Rc3F99JrYo^0fU>jCaJt
zgY}O6PT$;6)izz;+n#FK=W6KRVdxuGChT`tsgmzC-uO`?`FZ~RL@%RTZ_x5rk^5cL
z%=VpbQIA8k^EI;EgH_AU8}Owk5@<I0H@;2w;oR=xTZVInDj4;RuE<M;bU$GzxW4Aq
zt5of>=#xg{rINR4MMJzhOEYNysLFLhYR{#*1M&I>;)<$#)Fp3oyqj;^QLn0#;ftdu
zY}>41;|D^{9Clqn-J#bpZnpb*pPW~#cVaFuFkA7e5?BA2(>a@(FBbjbYA||Uobr19
z)rW@<Ye%QG{uwrG^en92lU`G*%2U5Nw>Y)vY`!{R?zuZF^@R{^u&F_#Dlezj6=a`}
zXQotIbR!DhDzq?vWDzu3H*j9jGqNxcEvzV|ZUI%a8nxaD*SWs67;QLP+b*4TOc8SV
f%0gxS)}qGuBM*bySel%P<o}Gbf)`pHjP3mwdW<Mr
--- a/mobile/android/base/resources/drawable/firstrun_button_enabled.xml
+++ b/mobile/android/base/resources/drawable/firstrun_button_enabled.xml
@@ -2,10 +2,10 @@
 <!-- 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/. -->
 
 <shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="rectangle" >
   <solid android:color="@color/action_orange" />
   <corners
-      android:radius="@dimen/fxaccount_corner_radius" />
+      android:radius="@dimen/button_corner_radius" />
 </shape>
--- a/mobile/android/base/resources/drawable/firstrun_button_pressed.xml
+++ b/mobile/android/base/resources/drawable/firstrun_button_pressed.xml
@@ -2,10 +2,10 @@
 <!-- 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/. -->
 
 <shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="rectangle" >
   <solid android:color="@color/action_orange_pressed" />
   <corners
-      android:radius="@dimen/fxaccount_corner_radius" />
+      android:radius="@dimen/button_corner_radius" />
 </shape>
--- a/mobile/android/base/resources/drawable/overlay_share_button_background.xml
+++ b/mobile/android/base/resources/drawable/overlay_share_button_background.xml
@@ -1,13 +1,15 @@
 <?xml version="1.0" encoding="utf-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/.
   -->
 
+<!-- Should be kept in sync with overlay_share_button_background_first.xml -->
+
 <selector xmlns:android="http://schemas.android.com/apk/res/android">
 
-    <item android:state_pressed="true" android:drawable="@color/overlay_share_selected" />
-    <item android:drawable="@color/overlay_share_background"/>
+    <item android:state_pressed="true" android:drawable="@color/toolbar_grey_pressed" />
+    <item android:drawable="@color/toolbar_grey"/>
 
 </selector>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/drawable/overlay_share_button_background_first.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-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/.
+  -->
+
+<!-- Should be kept in sync with overlay_share_button_background.xml
+
+     This first item in the list has rounded corners. -->
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <item android:state_pressed="true">
+        <shape>
+            <solid android:color="@color/toolbar_grey_pressed"/>
+            <corners android:topLeftRadius="@dimen/button_corner_radius"
+                     android:topRightRadius="@dimen/button_corner_radius"/>
+        </shape>
+    </item>
+
+    <item>
+        <shape>
+            <solid android:color="@color/toolbar_grey"/>
+            <corners android:topLeftRadius="@dimen/button_corner_radius"
+                     android:topRightRadius="@dimen/button_corner_radius"/>
+        </shape>
+    </item>
+
+</selector>
--- a/mobile/android/base/resources/drawable/remote_tabs_setup_button_background.xml
+++ b/mobile/android/base/resources/drawable/remote_tabs_setup_button_background.xml
@@ -2,19 +2,19 @@
 <!-- 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/. -->
 
 <selector xmlns:android="http://schemas.android.com/apk/res/android">
     <item android:state_pressed="true">
         <shape android:shape="rectangle">
             <solid android:color="@color/remote_tabs_setup_button_background_hit"/>
-            <corners android:radius="@dimen/fxaccount_corner_radius"/>
+            <corners android:radius="@dimen/button_corner_radius"/>
         </shape>
     </item>
 
     <item>
         <shape android:shape="rectangle">
             <solid android:color="@color/action_orange"/>
-            <corners android:radius="@dimen/fxaccount_corner_radius"/>
+            <corners android:radius="@dimen/button_corner_radius"/>
         </shape>
     </item>
 </selector>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/drawable/toolbar_grey_round.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-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/. -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <solid android:color="@color/toolbar_grey"/>
+    <corners android:radius="@dimen/button_corner_radius"/>
+</shape>
+
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/resources/layout-mdpi/firstrun_welcome_fragment.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-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/. -->
+
+
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+            android:layout_height="wrap_content"
+            android:layout_width="match_parent"
+            android:orientation="vertical"
+            android:fillViewport="true">
+
+    <LinearLayout android:layout_width="match_parent"
+                  android:layout_height="match_parent"
+                  android:minHeight="@dimen/firstrun_min_height"
+                  android:orientation="vertical">
+
+       <LinearLayout android:layout_width="match_parent"
+                     android:layout_height="0dp"
+                     android:layout_weight="21"
+                     android:orientation="vertical"
+                     android:gravity="center"
+                     android:padding="10dp">
+
+           <ImageView android:layout_width="100dp"
+                      android:layout_height="100dp"
+                      android:layout_gravity="center"
+                      android:src="@drawable/large_icon"/>
+
+        </LinearLayout>
+        <LinearLayout android:layout_width="match_parent"
+                      android:layout_height="0dp"
+                      android:layout_weight="29"
+                      android:orientation="vertical"
+                      android:gravity="center_horizontal"
+                      android:background="@color/android:white">
+
+            <TextView android:layout_width="@dimen/firstrun_content_width"
+                      android:layout_height="wrap_content"
+                      android:gravity="center_horizontal"
+                      android:paddingTop="30dp"
+                      android:textAppearance="@style/TextAppearance.FirstrunLight.Main"
+                      android:text="@string/firstrun_welcome_message"/>
+
+            <TextView android:layout_width="@dimen/firstrun_content_width"
+                      android:layout_height="wrap_content"
+                      android:paddingTop="20dp"
+                      android:paddingBottom="30dp"
+                      android:gravity="center"
+                      android:textAppearance="@style/TextAppearance.FirstrunRegular.Body"
+                      android:text="@string/firstrun_welcome_subtext"/>
+
+            <Button android:id="@+id/welcome_account"
+                    style="@style/Widget.Firstrun.Button"
+                    android:background="@drawable/firstrun_button_background"
+                    android:layout_gravity="center"
+                    android:text="@string/firstrun_welcome_button_account"/>
+
+            <TextView android:id="@+id/welcome_browse"
+                      android:layout_width="@dimen/firstrun_content_width"
+                      android:layout_height="wrap_content"
+                      android:minHeight="92dp"
+                      android:padding="20dp"
+                      android:gravity="center"
+                      android:textAppearance="@style/TextAppearance.FirstrunRegular.Link"
+                      android:text="@string/firstrun_welcome_button_browser"/>
+        </LinearLayout>
+    </LinearLayout>
+</ScrollView>
--- a/mobile/android/base/resources/layout/overlay_share_button.xml
+++ b/mobile/android/base/resources/layout/overlay_share_button.xml
@@ -1,18 +1,24 @@
 <?xml version="1.0" encoding="utf-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/. -->
 
 <merge xmlns:android="http://schemas.android.com/apk/res/android">
     <ImageView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:paddingLeft="15dp"
-        android:id="@+id/overlaybtn_icon"/>
+        android:layout_width="60dp"
+        android:layout_height="match_parent"
+        android:id="@+id/overlaybtn_icon"
+        android:padding="30dp"
+        android:scaleType="center"/>
 
     <TextView
-        style="@style/ShareOverlayButton.Text"
+        android:textAppearance="@style/ShareOverlayTextAppearance"
         android:id="@+id/overlaybtn_label"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
         android:clickable="false"
-        android:enabled="false"/>
+        android:enabled="false"
+        android:maxLines="1"
+        android:textSize="14sp"
+        android:textColor="@color/primary_text_selector"/>
 </merge>
--- a/mobile/android/base/resources/layout/overlay_share_dialog.xml
+++ b/mobile/android/base/resources/layout/overlay_share_dialog.xml
@@ -1,116 +1,75 @@
 <?xml version="1.0" encoding="utf-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/. -->
 
 <!-- Serves to position the content on the screen (bottom, centered) and provide the drop-shadow -->
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:id="@+id/sharedialog"
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
     android:layout_marginLeft="15dp"
     android:layout_marginRight="15dp"
-    android:layout_marginBottom="-12dp"
-    android:paddingTop="30dp"
-    android:layout_gravity="bottom|center"
     android:clipChildren="false"
     android:clipToPadding="false">
 
     <LinearLayout
-        android:id="@+id/share_overlay_content"
-        android:layout_width="wrap_content"
+        android:layout_width="300dp"
         android:layout_height="wrap_content"
+        android:layout_gravity="bottom|center"
         android:paddingTop="8dp"
-        android:orientation="vertical"
-        android:background="@drawable/share_overlay_background">
+        android:orientation="vertical">
 
-        <!-- Header -->
-        <LinearLayout
-            android:id="@+id/share_overlay_top_bar"
+        <!-- Title -->
+        <TextView
+            android:id="@+id/title"
+            style="@style/ShareOverlayTitle"
+            android:textAppearance="@style/ShareOverlayTextAppearance.Header"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:layout_margin="0dp"
-            android:background="@color/overlay_share_header_background"
-            android:orientation="vertical"
-            android:paddingTop="10dp"
-            android:paddingBottom="15dp"
-            android:paddingLeft="15dp"
-            android:paddingRight="15dp"
-            android:layout_gravity="center">
+            android:layout_marginBottom="8dp"
+            android:maxLines="2"
+            android:textSize="20sp"
+            android:ellipsize="end"/>
 
-            <!-- Title -->
-            <TextView
-                android:id="@+id/title"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:paddingBottom="7dp"
-                android:ellipsize="end"
-                android:maxLines="2"
-                android:scrollHorizontally="true"
-                android:textColor="@color/placeholder_active_grey"
-                android:textSize="17sp"/>
-
-            <!-- Subtitle (url) -->
-            <TextView
-                android:id="@+id/subtitle"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:textColor="@color/placeholder_grey"/>
-
-        </LinearLayout>
-
-        <!-- Buttons -->
-        <LinearLayout
+        <!-- Subtitle (url) -->
+        <TextView
+            android:id="@+id/subtitle"
+            style="@style/ShareOverlayTitle"
+            android:textAppearance="@style/ShareOverlayTextAppearance.Header"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:background="@color/overlay_share_background"
-            android:orientation="vertical">
-
-            <!-- TODO: Once API 11 is available, stick "showDividers=middle" into the parent and get rid
-                       of these evil separator views. -->
+            android:layout_marginBottom="20dp"
+            android:textSize="12sp"
+            android:scrollHorizontally="true"/>
 
-            <!-- "Send to Firefox Sync" -->
-            <org.mozilla.gecko.overlays.ui.SendTabList
-                style="@style/ShareOverlayButton"
-                android:id="@+id/overlay_send_tab_btn"
-                android:padding="0dp"/>
-
-            <!-- Evil separator -->
-            <View
-                android:layout_width="match_parent"
-                android:layout_height="1dp"
-                android:background="@color/background_light"/>
+        <!-- TODO: Add back drop shadow (bug 1146488)? -->
+        <!-- Buttons -->
+        <!-- "Send to Firefox Sync" -->
+        <org.mozilla.gecko.overlays.ui.SendTabList
+            android:id="@+id/overlay_send_tab_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:divider="@null"/>
 
-            <!-- "Add to reading list" -->
-            <org.mozilla.gecko.overlays.ui.OverlayDialogButton
-                style="@style/ShareOverlayButton.Text"
-                android:id="@+id/overlay_share_reading_list_btn"
-                android:enabled="false"/>
+        <!-- "Add to reading list" -->
+        <org.mozilla.gecko.overlays.ui.OverlayDialogButton
+            style="@style/ShareOverlayRow"
+            android:id="@+id/overlay_share_reading_list_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:enabled="false"/>
 
-            <!-- Evil separator -->
-            <View
-                android:layout_width="match_parent"
-                android:layout_height="1dp"
-                android:background="@color/background_light"/>
+        <!-- "Add bookmark" -->
+        <org.mozilla.gecko.overlays.ui.OverlayDialogButton
+            style="@style/ShareOverlayRow"
+            android:id="@+id/overlay_share_bookmark_btn"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:enabled="false"/>
 
-            <!-- "Add bookmark" -->
-            <org.mozilla.gecko.overlays.ui.OverlayDialogButton
-                style="@style/ShareOverlayButton.Text"
-                android:id="@+id/overlay_share_bookmark_btn"
-                android:enabled="false"/>
-
-        </LinearLayout>
     </LinearLayout>
 
-    <!-- Firefox logo (has to appear higher in the z-order than the content. -->
-    <ImageView
-        android:id="@+id/share_overlay_icon"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_above="@+id/share_overlay_content"
-        android:scaleType="center"
-        android:layout_centerHorizontal="true"
-        android:src="@drawable/icon"
-        android:layout_marginBottom="-6dp"/>
-</RelativeLayout>
+</FrameLayout>
deleted file mode 100644
--- a/mobile/android/base/resources/layout/overlay_share_send_tab_button.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="utf-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/. -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical">
-
-    <ListView
-        style="@style/ShareOverlayButton"
-        android:id="@+id/device_list"
-        android:padding="0dp" >
-    </ListView>
-
-</LinearLayout>
--- a/mobile/android/base/resources/layout/overlay_share_send_tab_item.xml
+++ b/mobile/android/base/resources/layout/overlay_share_send_tab_item.xml
@@ -1,5 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
-<TextView xmlns:android="http://schemas.android.com/apk/res/android"
-    style="@style/ShareOverlayButton.Text"
-    android:background="@drawable/overlay_share_button_background"
-    android:clickable="true"/>
+
+<!-- The first item's background is unique and these views are reused
+     so the background is set dynamically. -->
+<org.mozilla.gecko.overlays.ui.OverlayDialogButton
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    style="@style/ShareOverlayRow"
+    android:id="@+id/overlay_send_tab_item"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"/>
--- a/mobile/android/base/resources/layout/overlay_share_toast.xml
+++ b/mobile/android/base/resources/layout/overlay_share_toast.xml
@@ -1,58 +1,26 @@
 <?xml version="1.0" encoding="utf-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/. -->
 
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:id="@+id/overlay_share_toast"
-    android:layout_width="wrap_content"
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
     android:layout_height="wrap_content"
-    android:orientation="vertical"
-    android:background="@drawable/share_overlay_background"
-    android:layout_marginLeft="5dp"
-    android:layout_marginRight="5dp"
-    android:layout_gravity="bottom|center">
+    android:background="@drawable/dropshadow"
+    android:padding="3dp">
 
-    <!-- Header -->
-    <LinearLayout
+    <TextView
+        android:id="@+id/overlay_toast_message"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:layout_margin="0dp"
-        android:background="@color/background_light"
-        android:orientation="horizontal"
-        android:paddingLeft="5dp"
-        android:paddingRight="10dp"
-        android:paddingTop="5dp"
-        android:paddingBottom="5dp">
-
-        <!-- Large attractive green tick with label to the right -->
-        <TextView
-            style="@style/ShareOverlayButton.Text"
-            android:id="@+id/overlay_toast_message"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:maxLines="1"
-            android:textColor="@color/placeholder_active_grey"
-            android:textSize="14sp"
-            android:drawableLeft="@drawable/overlay_check"/>
+        android:layout_gravity="center_vertical"
+        android:padding="20dp"
+        android:background="@drawable/toolbar_grey_round"
+        android:textColor="@color/text_and_tabs_tray_grey"
+        android:textSize="14sp"
+        android:drawablePadding="15dp"
+        android:drawableLeft="@drawable/overlay_check"/>
 
-        <!-- Evil separator -->
-        <View
-            android:id="@+id/overlay_toast_separator"
-            android:layout_marginTop="15dp"
-            android:layout_marginBottom="15dp"
-            android:layout_height="match_parent"
-            android:layout_width="1dp"
-            android:background="@color/background_light"/>
-
-        <!-- Retry button -->
-        <Button
-            android:id="@+id/overlay_toast_retry_btn"
-            android:layout_height="wrap_content"
-            android:layout_width="wrap_content"
-            android:text="@string/overlay_share_retry"
-            android:onClick="selfDestruct" />
-
-    </LinearLayout>
-</LinearLayout>
+</FrameLayout>
--- a/mobile/android/base/resources/values/colors.xml
+++ b/mobile/android/base/resources/values/colors.xml
@@ -76,26 +76,16 @@
   <!-- Default colors -->
   <color name="text_color_tertiary">#9198A1</color>
 
   <!-- Default inverse colors -->
   <color name="text_color_primary_inverse">#F5F5F5</color>
   <color name="text_color_secondary_inverse">#DDDDDD</color>
   <color name="text_color_tertiary_inverse">#A4A7A9</color>
 
-  <!-- Colour used for share overlay button labels -->
-  <color name="text_color_overlaybtn">#666666</color>
-  <color name="text_color_overlaybtn_disabled">#AFB1B3</color>
-
-  <color name="overlay_share_header_background">#FFFFFFFF</color>
-
-  <!-- Colours used for share overlay button background -->
-  <color name="overlay_share_background">#FFEBEBF0</color>
-  <color name="overlay_share_selected">#FFF5F5F5</color>
-
   <!-- Disabled colors -->
   <color name="text_color_primary_disable_only">#999999</color>
 
   <!-- Hint colors -->
   <color name="text_color_hint">#666666</color>
   <color name="text_color_hint_inverse">#7F828A</color>
   <color name="text_color_hint_floating_focused">#33b5e5</color>
 
--- a/mobile/android/base/resources/values/dimens.xml
+++ b/mobile/android/base/resources/values/dimens.xml
@@ -1,15 +1,17 @@
 <?xml version="1.0" encoding="utf-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/. -->
 
 <resources>
 
+    <dimen name="button_corner_radius">3dp</dimen>
+
     <dimen name="autocomplete_min_width">200dp</dimen>
     <dimen name="autocomplete_row_height">32dp</dimen>
 
     <dimen name="browser_toolbar_height">48dp</dimen>
     <dimen name="browser_toolbar_button_padding">12dp</dimen>
     <dimen name="browser_toolbar_icon_width">48dp</dimen>
     <dimen name="browser_toolbar_site_security_width">12dp</dimen>
     <!-- favicon_size includes 4dp of right padding. We can't use margin (which would allow us to
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -773,34 +773,35 @@
     </style>
 
     <style name="GeckoActionBar.Buttons">
         <item name="android:background">@android:color/transparent</item>
         <item name="android:textColor">@color/placeholder_active_grey</item>
         <item name="android:gravity">right</item>
     </style>
 
-    <style name="ShareOverlayButton">
-        <item name="android:layout_width">match_parent</item>
-        <item name="android:layout_height">wrap_content</item>
-        <item name="android:minHeight">60dp</item>
-        <item name="android:gravity">center_vertical</item>
+    <style name="ShareOverlayTitle">
+        <item name="android:gravity">center_horizontal</item>
         <item name="android:paddingLeft">15dp</item>
         <item name="android:paddingRight">15dp</item>
-        <item name="android:paddingTop">17dp</item>
-        <item name="android:paddingBottom">17dp</item>
-        <item name="android:focusableInTouchMode">false</item>
-        <item name="android:layout_margin">0dp</item>
+    </style>
+
+    <style name="ShareOverlayTextAppearance">
+        <item name="android:fontFamily">sans-serif</item>
     </style>
 
-    <style name="ShareOverlayButton.Text">
-        <item name="android:drawablePadding">15dp</item>
-        <item name="android:maxLines">1</item>
-        <item name="android:textSize">14sp</item>
-        <item name="android:textColor">@color/overlay_btn_text</item>
+    <style name="ShareOverlayTextAppearance.Header">
+        <item name="android:textColor">@android:color/white</item>
+    </style>
+
+    <style name="ShareOverlayRow">
+        <item name="android:minHeight">60dp</item>
+        <item name="android:gravity">center_vertical</item>
+        <item name="android:background">@drawable/overlay_share_button_background</item>
+        <item name="android:focusableInTouchMode">false</item>
     </style>
 
     <style name="TabInput"></style>
 
     <style name="TabInput.TabWidget">
         <item name="android:divider">@drawable/divider_vertical</item>
         <item name="android:background">@drawable/tab_indicator_background</item>
     </style>
--- a/services/common/logmanager.js
+++ b/services/common/logmanager.js
@@ -75,17 +75,21 @@ LogManager.prototype = {
 
     allBranches.add(this._prefs._branchStr);
     // We create a preference observer for all our prefs so they are magically
     // reflected if the pref changes after creation.
     let setupAppender = (appender, prefName, defaultLevel, findSmallest = false) => {
       let observer = newVal => {
         let level = Log.Level[newVal] || defaultLevel;
         if (findSmallest) {
-          // We need to find the smallest value from all prefs controlling this appender.
+          // As some of our appenders have global impact (ie, there is only one
+          // place 'dump' goes to), we need to find the smallest value from all
+          // prefs controlling this appender.
+          // For example, if consumerA has dump=Debug then consumerB sets
+          // dump=Error, we need to keep dump=Debug so consumerA is respected.
           for (let branch of allBranches) {
             let lookPrefBranch = new Preferences(branch);
             let lookVal = Log.Level[lookPrefBranch.get(prefName)];
             if (lookVal && lookVal < level) {
               level = lookVal;
             }
           }
         }
@@ -105,16 +109,22 @@ LogManager.prototype = {
     let fapp = this._fileAppender = new Log.StorageStreamAppender(formatter);
     // the stream gets a default of Debug as the user must go out of their way
     // to see the stuff spewed to it.
     this._observeStreamPref = setupAppender(fapp, "log.appender.file.level", Log.Level.Debug);
 
     // now attach the appenders to all our logs.
     for (let logName of logNames) {
       let log = Log.repository.getLogger(logName);
+      // Set all of the logs themselves to log all messages, and rely on the
+      // more restrictive levels on the appenders to restrict what is seen.
+      // (We possibly could find the smallest appender level and set the logs
+      // to that, but that gets tricky when we consider a singe log might end
+      // up being managed by multiple log managers - so this is fine for now.)
+      log.level = Log.Level.All;
       for (let appender of [fapp, dumpAppender, consoleAppender]) {
         log.addAppender(appender);
       }
     }
     // and use the first specified log as a "root" for our log.
     this._log = Log.repository.getLogger(logNames[0] + ".LogManager");
   },
 
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -109,39 +109,17 @@ ClientEngine.prototype = {
     return brand.get("brandShortName");
   },
 
   get localName() {
     let localName = Svc.Prefs.get("client.name", "");
     if (localName != "")
       return localName;
 
-    // Generate a client name if we don't have a useful one yet
-    let env = Cc["@mozilla.org/process/environment;1"]
-                .getService(Ci.nsIEnvironment);
-    let user = env.get("USER") || env.get("USERNAME") ||
-               Svc.Prefs.get("account") || Svc.Prefs.get("username");
-
-    let brandName = this.brandName;
-    let appName;
-    try {
-      let syncStrings = new StringBundle("chrome://browser/locale/sync.properties");
-      appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]);
-    } catch (ex) {}
-    appName = appName || brandName;
-
-    let system =
-      // 'device' is defined on unix systems
-      Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
-      // hostname of the system, usually assigned by the user or admin
-      Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
-      // fall back on ua info string
-      Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
-
-    return this.localName = Str.sync.get("client.name2", [user, appName, system]);
+    return this.localName = Utils.getDefaultDeviceName();
   },
   set localName(value) Svc.Prefs.set("client.name", value),
 
   get localType() Svc.Prefs.get("client.type", "desktop"),
   set localType(value) Svc.Prefs.set("client.type", value),
 
   isMobile: function isMobile(id) {
     if (this._store._remoteClients[id])
--- a/services/sync/modules/util.js
+++ b/services/sync/modules/util.js
@@ -635,16 +635,49 @@ this.Utils = {
       } catch (_) {
         continue;
       }
       let uri = Services.io.newURI(prefVal, null, null);
       result.add(uri.prePath);
     }
     return this._syncCredentialsHostsFxA = result;
   },
+
+  getDefaultDeviceName() {
+    // Generate a client name if we don't have a useful one yet
+    let env = Cc["@mozilla.org/process/environment;1"]
+                .getService(Ci.nsIEnvironment);
+    let user = env.get("USER") || env.get("USERNAME") ||
+               Svc.Prefs.get("account") || Svc.Prefs.get("username");
+    // A little hack for people using the the moz-build environment on Windows
+    // which sets USER to the literal "%USERNAME%" (yes, really)
+    if (user == "%USERNAME%" && env.get("USERNAME")) {
+      user = env.get("USERNAME");
+    }
+
+    let brand = new StringBundle("chrome://branding/locale/brand.properties");
+    let brandName = brand.get("brandShortName");
+
+    let appName;
+    try {
+      let syncStrings = new StringBundle("chrome://browser/locale/sync.properties");
+      appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]);
+    } catch (ex) {}
+    appName = appName || brandName;
+
+    let system =
+      // 'device' is defined on unix systems
+      Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
+      // hostname of the system, usually assigned by the user or admin
+      Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
+      // fall back on ua info string
+      Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
+
+    return Str.sync.get("client.name2", [user, appName, system]);
+  }
 };
 
 XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
   let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                     .createInstance(Ci.nsIScriptableUnicodeConverter);
   converter.charset = "UTF-8";
   return converter;
 });
--- a/toolkit/components/reader/ReaderMode.jsm
+++ b/toolkit/components/reader/ReaderMode.jsm
@@ -63,17 +63,17 @@ this.ReaderMode = {
 
   /**
    * Decides whether or not a document is reader-able without parsing the whole thing.
    *
    * @param doc A document to parse.
    * @return boolean Whether or not we should show the reader mode button.
    */
   isProbablyReaderable: function(doc) {
-    let uri = Services.io.newURI(doc.documentURI, null, null);
+    let uri = Services.io.newURI(doc.location.href, null, null);
 
     if (!this._shouldCheckUri(uri)) {
       return false;
     }
 
     let REGEXPS = {
       unlikelyCandidates: /combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter/i,
       okMaybeItsACandidate: /and|article|body|column|main|shadow/i,
--- a/toolkit/content/widgets/findbar.xml
+++ b/toolkit/content/widgets/findbar.xml
@@ -628,17 +628,16 @@
         <body><![CDATA[
           if (this.hidden)
             return;
 
           this.hidden = true;
 
           this.browser.finder.focusContent();
           this.browser.finder.enableSelection();
-          this._findField.blur();
 
           this._cancelTimers();
 
           this._findFailedString = null;
         ]]></body>
       </method>
 
       <method name="clear">
--- a/toolkit/content/widgets/notification.xml
+++ b/toolkit/content/widgets/notification.xml
@@ -465,28 +465,34 @@
             }
           ]]>
         </body>
       </method>
     </implementation>
   </binding>
 
   <binding id="popup-notification">
-    <content align="start">
-      <xul:image class="popup-notification-icon"
-                 xbl:inherits="popupid,src=icon"/>
+    <content>
+      <xul:vbox>
+        <xul:image class="popup-notification-icon"
+                   xbl:inherits="popupid,src=icon"/>
+      </xul:vbox>
       <xul:vbox flex="1">
+        <xul:label class="popup-notification-originHost header"
+                   xbl:inherits="value=originhost"
+                   crop="end"/>
         <xul:description class="popup-notification-description"
-                         xbl:inherits="xbl:text=label"/>
+                         xbl:inherits="xbl:text=label,popupid"/>
         <children includes="popupnotificationcontent"/>
         <xul:label class="text-link popup-notification-learnmore-link"
                xbl:inherits="href=learnmoreurl">&learnMore;</xul:label>
         <xul:spacer flex="1"/>
         <xul:hbox class="popup-notification-button-container"
                   pack="end" align="center">
+          <children includes="button"/>
           <xul:button anonid="button"
                       class="popup-notification-menubutton"
                       type="menu-button"
                       xbl:inherits="oncommand=buttoncommand,label=buttonlabel,accesskey=buttonaccesskey">
             <xul:menupopup anonid="menupopup"
                            xbl:inherits="oncommand=menucommand">
               <children/>
               <xul:menuitem class="menuitem-iconic popup-notification-closeitem"
--- a/toolkit/modules/PopupNotifications.jsm
+++ b/toolkit/modules/PopupNotifications.jsm
@@ -265,16 +265,18 @@ PopupNotifications.prototype = {
    *        popupIconURL:
    *                     A string. URL of the image to be displayed in the popup.
    *                     Normally specified in CSS using list-style-image and the
    *                     .popup-notification-icon[popupid=...] selector.
    *        learnMoreURL:
    *                     A string URL. Setting this property will make the
    *                     prompt display a "Learn More" link that, when clicked,
    *                     opens the URL in a new tab.
+   *        originHost:  The host name of the page the notification came from.
+   *                     If present, this will be displayed above the message.
    * @returns the Notification object corresponding to the added notification.
    */
   show: function PopupNotifications_show(browser, id, message, anchorID,
                                          mainAction, secondaryActions, options) {
     function isInvalidAction(a) {
       return !a || !(typeof(a.callback) == "function") || !a.label || !a.accessKey;
     }
 
@@ -494,22 +496,22 @@ PopupNotifications.prototype = {
 
       // If this notification was provided by the chrome document rather than
       // created ad hoc, move it back to where we got it from.
       let originalParent = gNotificationParents.get(popupnotification);
       if (originalParent) {
         popupnotification.notification = null;
 
         // Remove nodes dynamically added to the notification's menu button
-        // in _refreshPanel. Keep popupnotificationcontent nodes; they are
-        // provided by the chrome document.
+        // in _refreshPanel.
         let contentNode = popupnotification.lastChild;
         while (contentNode) {
           let previousSibling = contentNode.previousSibling;
-          if (contentNode.nodeName != "popupnotificationcontent")
+          if (contentNode.nodeName == "menuitem" ||
+              contentNode.nodeName == "menuseparator")
             popupnotification.removeChild(contentNode);
           contentNode = previousSibling;
         }
 
         // Re-hide the notification such that it isn't rendered in the chrome
         // document. _refreshPanel will unhide it again when needed.
         popupnotification.hidden = true;
 
@@ -554,21 +556,27 @@ PopupNotifications.prototype = {
         popupnotification.removeAttribute("buttonaccesskey");
         popupnotification.removeAttribute("buttoncommand");
         popupnotification.removeAttribute("menucommand");
         popupnotification.removeAttribute("closeitemcommand");
       }
 
       if (n.options.popupIconURL)
         popupnotification.setAttribute("icon", n.options.popupIconURL);
+
       if (n.options.learnMoreURL)
         popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
       else
         popupnotification.removeAttribute("learnmoreurl");
 
+      if (n.options.originHost)
+        popupnotification.setAttribute("originhost", n.options.originHost);
+      else
+        popupnotification.removeAttribute("originhost");
+
       popupnotification.notification = n;
 
       if (n.secondaryActions) {
         n.secondaryActions.forEach(function (a) {
           let item = doc.createElementNS(XUL_NS, "menuitem");
           item.setAttribute("label", a.label);
           item.setAttribute("accesskey", a.accessKey);
           item.notification = n;
--- a/toolkit/modules/RemoteFinder.jsm
+++ b/toolkit/modules/RemoteFinder.jsm
@@ -100,26 +100,28 @@ RemoteFinder.prototype = {
     this._browser.messageManager.sendAsyncMessage("Finder:CaseSensitive",
                                                   { caseSensitive: aSensitive });
   },
 
   getInitialSelection: function() {
     this._browser.messageManager.sendAsyncMessage("Finder:GetInitialSelection", {});
   },
 
-  fastFind: function (aSearchString, aLinksOnly) {
+  fastFind: function (aSearchString, aLinksOnly, aDrawOutline) {
     this._browser.messageManager.sendAsyncMessage("Finder:FastFind",
                                                   { searchString: aSearchString,
-                                                    linksOnly: aLinksOnly });
+                                                    linksOnly: aLinksOnly,
+                                                    drawOutline: aDrawOutline });
   },
 
-  findAgain: function (aFindBackwards, aLinksOnly) {
+  findAgain: function (aFindBackwards, aLinksOnly, aDrawOutline) {
     this._browser.messageManager.sendAsyncMessage("Finder:FindAgain",
                                                   { findBackwards: aFindBackwards,
-                                                    linksOnly: aLinksOnly });
+                                                    linksOnly: aLinksOnly,
+                                                    drawOutline: aDrawOutline });
   },
 
   highlight: function (aHighlight, aWord) {
     this._browser.messageManager.sendAsyncMessage("Finder:Highlight",
                                                   { highlight: aHighlight,
                                                     word: aWord });
   },
 
@@ -138,16 +140,17 @@ RemoteFinder.prototype = {
         if ("shouldFocusContent" in l &&
             !l.shouldFocusContent())
           return;
       } catch (ex) {
         Cu.reportError(ex);
       }
     }
 
+    this._browser.focus();
     this._browser.messageManager.sendAsyncMessage("Finder:FocusContent");
   },
 
   keyPress: function (aEvent) {
     this._browser.messageManager.sendAsyncMessage("Finder:KeyPress",
                                                   { keyCode: aEvent.keyCode,
                                                     shiftKey: aEvent.shiftKey });
   },
@@ -216,21 +219,23 @@ RemoteFinderListener.prototype = {
         let selection = this._finder.getActiveSelectionText();
         this._global.sendAsyncMessage("Finder:CurrentSelectionResult",
                                       { selection: selection,
                                         initial: true });
         break;
       }
 
       case "Finder:FastFind":
-        this._finder.fastFind(data.searchString, data.linksOnly);
+
+        this._finder.fastFind(data.searchString, data.linksOnly, data.drawOutline);
+        this._finder.setSelectionModeAndRepaint(Ci.nsISelectionController.SELECTION_ON);
         break;
 
       case "Finder:FindAgain":
-        this._finder.findAgain(data.findBackwards, data.linksOnly);
+        this._finder.findAgain(data.findBackwards, data.linksOnly, data.drawOutline);
         break;
 
       case "Finder:Highlight":
         this._finder.highlight(data.highlight, data.word);
         break;
 
       case "Finder:RemoveSelection":
         this._finder.removeSelection();
--- a/toolkit/mozapps/extensions/amWebInstallListener.js
+++ b/toolkit/mozapps/extensions/amWebInstallListener.js
@@ -14,16 +14,17 @@
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/AddonManager.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PromptUtils", "resource://gre/modules/SharedPromptUtils.jsm");
 
 const URI_XPINSTALL_DIALOG = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
 
 // Installation can begin from any of these states
 const READY_STATES = [
   AddonManager.STATE_AVAILABLE,
@@ -161,16 +162,21 @@ Installer.prototype = {
         let prompt = Cc["@mozilla.org/addons/web-install-prompt;1"].
                      getService(Ci.amIWebInstallPrompt);
         prompt.confirm(this.browser, this.url, this.downloads, this.downloads.length);
         return;
       }
       catch (e) {}
     }
 
+    if (Preferences.get("xpinstall.customConfirmationUI", false)) {
+      notifyObservers("addon-install-confirmation", this.browser, this.url, this.downloads);
+      return;
+    }
+
     let args = {};
     args.url = this.url;
     args.installs = this.downloads;
     args.wrappedJSObject = args;
 
     try {
       Cc["@mozilla.org/base/telemetry;1"].
             getService(Ci.nsITelemetry).
--- a/toolkit/mozapps/extensions/test/browser/head.js
+++ b/toolkit/mozapps/extensions/test/browser/head.js
@@ -27,16 +27,17 @@ const RELATIVE_DIR = pathParts.slice(4).
 const TESTROOT = "http://example.com/" + RELATIVE_DIR;
 const TESTROOT2 = "http://example.org/" + RELATIVE_DIR;
 const CHROMEROOT = pathParts.join("/") + "/";
 const PREF_DISCOVERURL = "extensions.webservice.discoverURL";
 const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
 const PREF_XPI_ENABLED = "xpinstall.enabled";
 const PREF_UPDATEURL = "extensions.update.url";
 const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_CUSTOM_XPINSTALL_CONFIRMATION_UI = "xpinstall.customConfirmationUI";
 
 const MANAGER_URI = "about:addons";
 const INSTALL_URI = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
 const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
 const PREF_SEARCH_MAXRESULTS = "extensions.getAddons.maxResults";
 const PREF_STRICT_COMPAT = "extensions.strictCompatibility";
 
 var PREF_CHECK_COMPATIBILITY;
@@ -57,16 +58,17 @@ var PREF_CHECK_COMPATIBILITY;
 
 var gPendingTests = [];
 var gTestsRun = 0;
 var gTestStart = null;
 
 var gUseInContentUI = !gTestInWindow && ("switchToTabHavingURI" in window);
 
 var gRestorePrefs = [{name: PREF_LOGGING_ENABLED},
+                     {name: PREF_CUSTOM_XPINSTALL_CONFIRMATION_UI},
                      {name: "extensions.webservice.discoverURL"},
                      {name: "extensions.update.url"},
                      {name: "extensions.update.background.url"},
                      {name: "extensions.update.enabled"},
                      {name: "extensions.update.autoUpdateDefault"},
                      {name: "extensions.getAddons.get.url"},
                      {name: "extensions.getAddons.getWithPerformance.url"},
                      {name: "extensions.getAddons.search.browseURL"},
@@ -90,16 +92,18 @@ for (let pref of gRestorePrefs) {
     pref.value = Services.prefs.getIntPref(pref.name);
   else if (pref.type == Services.prefs.PREF_STRING)
     pref.value = Services.prefs.getCharPref(pref.name);
 }
 
 // Turn logging on for all tests
 Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
 
+Services.prefs.setBoolPref(PREF_CUSTOM_XPINSTALL_CONFIRMATION_UI, false);
+
 // Helper to register test failures and close windows if any are left open
 function checkOpenWindows(aWindowID) {
   let windows = Services.wm.getEnumerator(aWindowID);
   let found = false;
   while (windows.hasMoreElements()) {
     let win = windows.getNext().QueryInterface(Ci.nsIDOMWindow);
     if (!win.closed) {
       found = true;
--- a/toolkit/mozapps/extensions/test/xpinstall/head.js
+++ b/toolkit/mozapps/extensions/test/xpinstall/head.js
@@ -3,16 +3,17 @@ const RELATIVE_DIR = "toolkit/mozapps/ex
 const TESTROOT = "http://example.com/browser/" + RELATIVE_DIR;
 const TESTROOT2 = "http://example.org/browser/" + RELATIVE_DIR;
 const XPINSTALL_URL = "chrome://mozapps/content/xpinstall/xpinstallConfirm.xul";
 const PROMPT_URL = "chrome://global/content/commonDialog.xul";
 const ADDONS_URL = "chrome://mozapps/content/extensions/extensions.xul";
 const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
 const PREF_INSTALL_REQUIREBUILTINCERTS = "extensions.install.requireBuiltInCerts";
 const PREF_INSTALL_REQUIRESECUREORIGIN = "extensions.install.requireSecureOrigin";
+const PREF_CUSTOM_CONFIRMATION_UI = "xpinstall.customConfirmationUI";
 const CHROME_NAME = "mochikit";
 
 function getChromeRoot(path) {
   if (path === undefined) {
     return "chrome://" + CHROME_NAME + "/content/browser/" + RELATIVE_DIR
   }
   return getRootDirectory(path);
 }
@@ -22,16 +23,21 @@ function extractChromeRoot(path) {
   var jar = getJar(chromeRootPath);
   if (jar) {
     var tmpdir = extractJarToTmp(jar);
     return "file://" + tmpdir.path + "/";
   }
   return chromeRootPath;
 }
 
+Services.prefs.setBoolPref(PREF_CUSTOM_CONFIRMATION_UI, false);
+registerCleanupFunction(() => {
+  Services.prefs.clearUserPref(PREF_CUSTOM_CONFIRMATION_UI);
+});
+
 /**
  * This is a test harness designed to handle responding to UI during the process
  * of installing an XPI. A test can set callbacks to hear about specific parts
  * of the sequence.
  * Before use setup must be called and finish must be called afterwards.
  */
 var Harness = {
   // If set then the callback is called when an install is attempted and
--- a/toolkit/themes/linux/global/global.css
+++ b/toolkit/themes/linux/global/global.css
@@ -276,16 +276,20 @@ label[disabled="true"] {
   border: 1px dotted -moz-DialogText;
 }
 
 notification > button {
   margin-top: 0;
   margin-bottom: 0;
 }
 
+popupnotificationcontent {
+  margin-top: .5em;
+}
+
 /* :::::: autoscroll popup ::::: */
 
 .autoscroller {
   height: 28px;
   width: 28px;
   border: none;
   margin: -14px;
   padding: 0;
--- a/toolkit/themes/linux/global/notification.css
+++ b/toolkit/themes/linux/global/notification.css
@@ -62,19 +62,24 @@ notification[type="critical"] {
 }
 
 /* Popup notification */
 
 .popup-notification-description {
   max-width: 24em;
 }
 
-.popup-notification-learnmore-link {
-  margin-top: 1em !important;
+.popup-notification-originHost:not([value]),
+.popup-notification-learnmore-link:not([href]) {
+  display: none;
 }
 
-.popup-notification-learnmore-link:not([href]) {
-  display: none;
+.popup-notification-originHost {
+  margin-bottom: .3em !important;
+}
+
+.popup-notification-learnmore-link {
+  margin-top: .5em !important;
 }
 
 .popup-notification-button-container {
   margin-top: 17px;
 }
--- a/toolkit/themes/osx/global/global.css
+++ b/toolkit/themes/osx/global/global.css
@@ -261,16 +261,20 @@ notification > button:-moz-focusring {
 notification > button:active:hover:-moz-focusring {
   box-shadow: @focusRingShadow@, @roundButtonPressedShadow@;
 }
 
 notification > button > .button-box > .button-text {
   margin: 0 !important;
 }
 
+popupnotificationcontent {
+  margin-top: .5em;
+}
+
 /* :::::: autoscroll popup ::::: */
 
 .autoscroller {
   height: 28px;
   width: 28px;
   border: none;
   margin: -14px;
   padding: 0;
--- a/toolkit/themes/osx/global/notification.css
+++ b/toolkit/themes/osx/global/notification.css
@@ -100,23 +100,29 @@ notification[type="info"]:not([value="tr
 }
 
 /* Popup notification */
 
 .popup-notification-description {
   max-width: 24em;
 }
 
-.popup-notification-learnmore-link {
-  margin-top: 1em !important;
+.popup-notification-originHost:not([value]),
+.popup-notification-learnmore-link:not([href]) {
+  display: none;
+}
+
+.popup-notification-originHost {
+  margin-bottom: .3em !important;
   -moz-margin-start: 0 !important; /* override default label margin to match description margin */
 }
 
-.popup-notification-learnmore-link:not([href]) {
-  display: none;
+.popup-notification-learnmore-link {
+  margin-top: .5em !important;
+  -moz-margin-start: 0 !important; /* override default label margin to match description margin */
 }
 
 .popup-notification-button-container {
   margin-top: 17px;
 }
 
 .popup-notification-menubutton {
   -moz-appearance: none;
--- a/toolkit/themes/windows/global/global.css
+++ b/toolkit/themes/windows/global/global.css
@@ -281,16 +281,20 @@ label[disabled="true"]:-moz-system-metri
 .text-link:hover {
   text-decoration: underline;
 }
 
 .text-link:-moz-focusring {
   border: 1px dotted -moz-DialogText;
 }
 
+popupnotificationcontent {
+  margin-top: .5em;
+}
+
 /* :::::: autoscroll popup ::::: */
 
 .autoscroller {
   height: 28px;
   width: 28px;
   border: none;
   margin: -14px;
   padding: 0;
--- a/toolkit/themes/windows/global/notification.css
+++ b/toolkit/themes/windows/global/notification.css
@@ -57,22 +57,27 @@ notification[type="critical"] {
 }
 
 /* Popup notification */
 
 .popup-notification-description {
   max-width: 24em;
 }
 
-.popup-notification-learnmore-link {
-  margin-top: 1em !important;
+.popup-notification-originHost:not([value]),
+.popup-notification-learnmore-link:not([href]) {
+  display: none;
 }
 
-.popup-notification-learnmore-link:not([href]) {
-  display: none;
+.popup-notification-originHost {
+  margin-bottom: .3em !important;
+}
+
+.popup-notification-learnmore-link {
+  margin-top: .5em !important;
 }
 
 .popup-notification-button-container {
   margin-top: 17px;
 }
 
 %ifdef XP_WIN
 /*