Merge fx-team to m-c.
authorRyan VanderMeulen <ryanvm@gmail.com>
Fri, 07 Mar 2014 15:26:12 -0500
changeset 172500 0d70e6efa22c9479c58b7089cd6d4c63b7db583e
parent 172476 8b82f5009d1403a0e7b3b4e7bdb628a591c44b87 (current diff)
parent 172499 01d7be943857acf43286cdb6aff94f802fcba4fd (diff)
child 172532 99e60b1adf71afb93e41aded40f899c20f18ee93
push id26363
push userryanvm@gmail.com
push dateFri, 07 Mar 2014 20:25:45 +0000
treeherdermozilla-central@0d70e6efa22c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone30.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/browser/base/content/browser-fxaccounts.js
+++ b/browser/base/content/browser-fxaccounts.js
@@ -51,18 +51,20 @@ let gFxAccounts = {
     // Referencing Weave.Service will implicitly initialize sync, and we don't
     // want to force that - so first check if it is ready.
     let service = Cc["@mozilla.org/weave/service;1"]
                   .getService(Components.interfaces.nsISupports)
                   .wrappedJSObject;
     if (!service.ready) {
       return false;
     }
-    return Weave.Service.identity.readyToAuthenticate &&
-           Weave.Status.login != Weave.LOGIN_SUCCEEDED;
+    // LOGIN_FAILED_LOGIN_REJECTED explicitly means "you must log back in".
+    // All other login failures are assumed to be transient and should go
+    // away by themselves, so aren't reflected here.
+    return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED;
   },
 
   get isActiveWindow() {
     let mostRecentNonPopupWindow =
       RecentWindow.getMostRecentBrowserWindow({allowPopups: false});
     return window == mostRecentNonPopupWindow;
   },
 
--- a/browser/base/content/browser-menubar.inc
+++ b/browser/base/content/browser-menubar.inc
@@ -464,16 +464,21 @@
                         accesskey="&syncSetup.accesskey;"
                         observes="sync-setup-state"
                         oncommand="gSyncUI.openSetup()"/>
               <menuitem id="sync-syncnowitem"
                         label="&syncSyncNowItem.label;"
                         accesskey="&syncSyncNowItem.accesskey;"
                         observes="sync-syncnow-state"
                         oncommand="gSyncUI.doSync(event);"/>
+              <menuitem id="sync-reauthitem"
+                        label="&syncReAuthItem.label;"
+                        accesskey="&syncReAuthItem.accesskey;"
+                        observes="sync-reauth-state"
+                        oncommand="gSyncUI.openSignInAgainPage();"/>
 #endif
               <menuseparator id="devToolsSeparator"/>
               <menu id="webDeveloperMenu"
                     label="&webDeveloperMenu.label;"
                     accesskey="&webDeveloperMenu.accesskey;">
                 <menupopup id="menuWebDeveloperPopup">
                   <menuitem id="menu_devToolbox"
                             observes="devtoolsMenuBroadcaster_DevToolbox"
--- a/browser/base/content/browser-sets.inc
+++ b/browser/base/content/browser-sets.inc
@@ -172,16 +172,17 @@
     <broadcaster id="isImage"/>
     <broadcaster id="isFrameImage"/>
     <broadcaster id="singleFeedMenuitemState" disabled="true"/>
     <broadcaster id="multipleFeedsMenuState" hidden="true"/>
     <broadcaster id="tabviewGroupsNumber" groups="1"/>
 #ifdef MOZ_SERVICES_SYNC
     <broadcaster id="sync-setup-state"/>
     <broadcaster id="sync-syncnow-state"/>
+    <broadcaster id="sync-reauth-state"/>
 #endif
     <broadcaster id="workOfflineMenuitemState"/>
     <broadcaster id="socialSidebarBroadcaster" hidden="true"/>
 
     <!-- DevTools broadcasters -->
     <broadcaster id="devtoolsMenuBroadcaster_DevToolbox"
                  label="&devToolboxMenuItem.label;"
                  type="checkbox" autocheck="false"
--- a/browser/base/content/browser-syncui.js
+++ b/browser/base/content/browser-syncui.js
@@ -91,20 +91,34 @@ let gSyncUI = {
     let firstSync = "";
     try {
       firstSync = Services.prefs.getCharPref("services.sync.firstSync");
     } catch (e) { }
     return Weave.Status.checkSetup() == Weave.CLIENT_NOT_CONFIGURED ||
            firstSync == "notReady";
   },
 
+  _loginFailed: function () {
+    // Referencing Weave.Service will implicitly initialize sync, and we don't
+    // want to force that - so first check if it is ready.
+    let service = Cc["@mozilla.org/weave/service;1"]
+                  .getService(Components.interfaces.nsISupports)
+                  .wrappedJSObject;
+    if (!service.ready) {
+      return false;
+    }
+    return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED;
+  },
+
   updateUI: function SUI_updateUI() {
     let needsSetup = this._needsSetup();
-    document.getElementById("sync-setup-state").hidden = !needsSetup;
-    document.getElementById("sync-syncnow-state").hidden = needsSetup;
+    let loginFailed = this._loginFailed();
+    document.getElementById("sync-setup-state").hidden = loginFailed || !needsSetup;
+    document.getElementById("sync-syncnow-state").hidden = loginFailed || needsSetup;
+    document.getElementById("sync-reauth-state").hidden = !loginFailed;
 
     if (!gBrowser)
       return;
 
     let syncButton = document.getElementById("sync-button");
     let panelHorizontalButton = document.getElementById("PanelUI-fxa-status");
     [syncButton, panelHorizontalButton].forEach(function(button) {
       if (!button)
@@ -333,16 +347,19 @@ let gSyncUI = {
         "chrome://browser/content/sync/quota.xul", "",
         "centerscreen,chrome,dialog,modal");
   },
 
   openPrefs: function SUI_openPrefs() {
     openPreferences("paneSync");
   },
 
+  openSignInAgainPage: function () {
+    switchToTabHavingURI("about:accounts?action=reauth", true);
+  },
 
   // Helpers
   _updateLastSyncTime: function SUI__updateLastSyncTime() {
     if (!gBrowser)
       return;
 
     let syncButton = document.getElementById("sync-button");
     if (!syncButton)
--- a/browser/base/content/test/general/browser_get_user_media.js
+++ b/browser/base/content/test/general/browser_get_user_media.js
@@ -17,17 +17,17 @@ XPCOMUtils.defineLazyServiceGetter(this,
 var gObservedTopics = {};
 function observer(aSubject, aTopic, aData) {
   if (!(aTopic in gObservedTopics))
     gObservedTopics[aTopic] = 1;
   else
     ++gObservedTopics[aTopic];
 }
 
-function promiseNotification(aTopic, aAction) {
+function promiseObserverCalled(aTopic, aAction) {
   let deferred = Promise.defer();
 
   Services.obs.addObserver(function observer() {
     ok(true, "got " + aTopic + " notification");
     Services.obs.removeObserver(observer, aTopic);
 
     if (kObservedTopics.indexOf(aTopic) != -1) {
       if (!(aTopic in gObservedTopics))
@@ -40,23 +40,23 @@ function promiseNotification(aTopic, aAc
   }, aTopic, false);
 
   if (aAction)
     aAction();
 
   return deferred.promise;
 }
 
-function expectNotification(aTopic) {
+function expectObserverCalled(aTopic) {
   is(gObservedTopics[aTopic], 1, "expected notification " + aTopic);
   if (aTopic in gObservedTopics)
     --gObservedTopics[aTopic];
 }
 
-function expectNoNotifications() {
+function expectNoObserverCalled() {
   for (let topic in gObservedTopics) {
     if (gObservedTopics[topic])
       is(gObservedTopics[topic], 0, topic + " notification unexpected");
   }
   gObservedTopics = {}
 }
 
 function promiseMessage(aMessage, aAction) {
@@ -72,45 +72,43 @@ function promiseMessage(aMessage, aActio
   });
 
   if (aAction)
     aAction();
 
   return deferred.promise;
 }
 
+function promisePopupNotificationShown(aName, aAction) {
+  let deferred = Promise.defer();
 
-function promisePopupNotification(aName, aShown) {
+  PopupNotifications.panel.addEventListener("popupshown", function popupNotifShown() {
+    PopupNotifications.panel.removeEventListener("popupshown", popupNotifShown);
+
+    ok(!!PopupNotifications.getNotification(aName), aName + " notification shown");
+    ok(!!PopupNotifications.panel.firstChild, "notification panel populated");
+
+    deferred.resolve();
+  });
+
+  if (aAction)
+    aAction();
+
+  return deferred.promise;
+}
+
+function promisePopupNotification(aName) {
   let deferred = Promise.defer();
 
   waitForCondition(() => PopupNotifications.getNotification(aName),
                    () => {
-    let notification = PopupNotifications.getNotification(aName);
-    ok(!!notification, aName + " notification appeared");
-
-    if (!notification || !aShown) {
-      deferred.resolve();
-      return;
-    }
+    ok(!!PopupNotifications.getNotification(aName),
+       aName + " notification appeared");
 
-    // If aShown is true, the notification is expected to be opened by
-    // default, so we check that the panel has been populated.
-    if (PopupNotifications.panel.firstChild) {
-      ok(true, "notification panel populated");
-      deferred.resolve();
-    }
-    else {
-      todo(false, "We shouldn't have to force re-open the panel, see bug 976544");
-      notification.reshow();
-      waitForCondition(() => PopupNotifications.panel.firstChild,
-                       () => {
-        ok(PopupNotifications.panel.firstChild, "notification panel populated");
-        deferred.resolve();
-      }, "timeout waiting for notification to be reshown");
-    }
+    deferred.resolve();
   }, "timeout waiting for popup notification " + aName);
 
   return deferred.promise;
 }
 
 function promiseNoPopupNotification(aName) {
   let deferred = Promise.defer();
 
@@ -165,27 +163,27 @@ function getMediaCaptureState() {
   if (hasVideo.value)
     return "Camera";
   if (hasAudio.value)
     return "Microphone";
   return "none";
 }
 
 function closeStream(aAlreadyClosed) {
-  expectNoNotifications();
+  expectNoObserverCalled();
 
   info("closing the stream");
   content.wrappedJSObject.closeStream();
 
   if (!aAlreadyClosed)
-    yield promiseNotification("recording-device-events");
+    yield promiseObserverCalled("recording-device-events");
 
   yield promiseNoPopupNotification("webRTC-sharingDevices");
   if (!aAlreadyClosed)
-    expectNotification("recording-window-ended");
+    expectObserverCalled("recording-window-ended");
 
   let statusButton = document.getElementById("webrtc-status-button");
   ok(statusButton.hidden, "WebRTC status button hidden");
 }
 
 function checkDeviceSelectors(aAudio, aVideo) {
   let micSelector = document.getElementById("webRTC-selectMicrophone");
   if (aAudio)
@@ -216,261 +214,255 @@ function checkNotSharing() {
   ok(statusButton.hidden, "WebRTC status button hidden");
 }
 
 let gTests = [
 
 {
   desc: "getUserMedia audio+video",
   run: function checkAudioVideo() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
+    expectObserverCalled("getUserMedia:request");
 
-    yield promisePopupNotification("webRTC-shareDevices", true);
     is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
        "webRTC-shareDevices-notification-icon", "anchored to device icon");
     checkDeviceSelectors(true, true);
     is(PopupNotifications.panel.firstChild.getAttribute("popupid"),
        "webRTC-shareDevices", "panel using devices icon");
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "CameraAndMicrophone",
        "expected camera and microphone to be shared");
 
     yield checkSharingUI();
     yield closeStream();
   }
 },
 
 {
   desc: "getUserMedia audio only",
   run: function checkAudioOnly() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true);
     });
+    expectObserverCalled("getUserMedia:request");
 
-    yield promisePopupNotification("webRTC-shareDevices", true);
     is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
        "webRTC-shareMicrophone-notification-icon", "anchored to mic icon");
     checkDeviceSelectors(true);
     is(PopupNotifications.panel.firstChild.getAttribute("popupid"),
        "webRTC-shareMicrophone", "panel using microphone icon");
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "Microphone", "expected microphone to be shared");
 
     yield checkSharingUI();
     yield closeStream();
   }
 },
 
 {
   desc: "getUserMedia video only",
   run: function checkVideoOnly() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(false, true);
     });
+    expectObserverCalled("getUserMedia:request");
 
-    yield promisePopupNotification("webRTC-shareDevices", true);
     is(PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
        "webRTC-shareDevices-notification-icon", "anchored to device icon");
     checkDeviceSelectors(false, true);
     is(PopupNotifications.panel.firstChild.getAttribute("popupid"),
        "webRTC-shareDevices", "panel using devices icon");
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "Camera", "expected camera to be shared");
 
     yield checkSharingUI();
     yield closeStream();
   }
 },
 
 {
   desc: "getUserMedia audio+video, user disables video",
   run: function checkDisableVideo() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
-
-    yield promisePopupNotification("webRTC-shareDevices", true);
+    expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(true, true);
 
     // disable the camera
     document.getElementById("webRTC-selectCamera-menulist").value = -1;
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
 
     // reset the menuitem to have no impact on the following tests.
     document.getElementById("webRTC-selectCamera-menulist").value = 0;
 
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "Microphone",
        "expected microphone to be shared");
 
     yield checkSharingUI();
     yield closeStream();
   }
 },
 
 {
   desc: "getUserMedia audio+video, user disables audio",
   run: function checkDisableAudio() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
-
-    yield promisePopupNotification("webRTC-shareDevices", true);
+    expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(true, true);
 
     // disable the microphone
     document.getElementById("webRTC-selectMicrophone-menulist").value = -1;
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
 
     // reset the menuitem to have no impact on the following tests.
     document.getElementById("webRTC-selectMicrophone-menulist").value = 0;
 
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "Camera",
        "expected microphone to be shared");
 
     yield checkSharingUI();
     yield closeStream();
   }
 },
 
 {
   desc: "getUserMedia audio+video, user disables both audio and video",
   run: function checkDisableAudioVideo() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
-
-    yield promisePopupNotification("webRTC-shareDevices", true);
+    expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(true, true);
 
     // disable the camera and microphone
     document.getElementById("webRTC-selectCamera-menulist").value = -1;
     document.getElementById("webRTC-selectMicrophone-menulist").value = -1;
 
     yield promiseMessage("error: PERMISSION_DENIED", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
 
     // reset the menuitems to have no impact on the following tests.
     document.getElementById("webRTC-selectCamera-menulist").value = 0;
     document.getElementById("webRTC-selectMicrophone-menulist").value = 0;
 
-    expectNotification("getUserMedia:response:deny");
-    expectNotification("recording-window-ended");
+    expectObserverCalled("getUserMedia:response:deny");
+    expectObserverCalled("recording-window-ended");
     checkNotSharing();
   }
 },
 
 {
   desc: "getUserMedia audio+video, user clicks \"Don't Share\"",
   run: function checkDontShare() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
-
-    yield promisePopupNotification("webRTC-shareDevices", true);
+    expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(true, true);
 
     yield promiseMessage("error: PERMISSION_DENIED", () => {
       activateSecondaryAction(kActionDeny);
     });
 
-    expectNotification("getUserMedia:response:deny");
-    expectNotification("recording-window-ended");
+    expectObserverCalled("getUserMedia:response:deny");
+    expectObserverCalled("recording-window-ended");
     checkNotSharing();
   }
 },
 
 {
   desc: "getUserMedia audio+video: stop sharing",
   run: function checkStopSharing() {
-    yield promiseNotification("getUserMedia:request", () => {
+    yield promisePopupNotificationShown("webRTC-shareDevices", () => {
       info("requesting devices");
       content.wrappedJSObject.requestDevice(true, true);
     });
-
-    yield promisePopupNotification("webRTC-shareDevices", true);
+    expectObserverCalled("getUserMedia:request");
     checkDeviceSelectors(true, true);
 
     yield promiseMessage("ok", () => {
       PopupNotifications.panel.firstChild.button.click();
     });
-    expectNotification("getUserMedia:response:allow");
-    expectNotification("recording-device-events");
+    expectObserverCalled("getUserMedia:response:allow");
+    expectObserverCalled("recording-device-events");
     is(getMediaCaptureState(), "CameraAndMicrophone",
        "expected camera and microphone to be shared");
 
     yield checkSharingUI();
 
     PopupNotifications.getNotification("webRTC-sharingDevices").reshow();
     activateSecondaryAction(kActionDeny);
 
-    yield promiseNotification("recording-device-events");
-    expectNotification("getUserMedia:revoke");
+    yield promiseObserverCalled("recording-device-events");
+    expectObserverCalled("getUserMedia:revoke");
 
     yield promiseNoPopupNotification("webRTC-sharingDevices");
 
     if (gObservedTopics["recording-device-events"] == 1) {
       todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
       gObservedTopics["recording-device-events"] = 0;
     }
 
-    expectNoNotifications();
+    expectNoObserverCalled();
     checkNotSharing();
 
     // the stream is already closed, but this will do some cleanup anyway
     yield closeStream(true);
   }
 },
 
 {
   desc: "getUserMedia prompt: Always/Never Share",
   run: function checkRememberCheckbox() {
     let elt = id => document.getElementById(id);
 
     function checkPerm(aRequestAudio, aRequestVideo, aAllowAudio, aAllowVideo,
                        aExpectedAudioPerm, aExpectedVideoPerm, aNever) {
-      yield promiseNotification("getUserMedia:request", () => {
+      yield promisePopupNotificationShown("webRTC-shareDevices", () => {
         content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
       });
-
-      yield promisePopupNotification("webRTC-shareDevices", true);
+      expectObserverCalled("getUserMedia:request");
 
       let noAudio = aAllowAudio === undefined;
       is(elt("webRTC-selectMicrophone").hidden, noAudio,
          "microphone selector expected to be " + (noAudio ? "hidden" : "visible"));
       if (!noAudio)
         elt("webRTC-selectMicrophone-menulist").value = (aAllowAudio || aNever) ? 0 : -1;
 
       let noVideo = aAllowVideo === undefined;
@@ -481,27 +473,27 @@ let gTests = [
 
       let expectedMessage =
         (aAllowVideo || aAllowAudio) ? "ok" : "error: PERMISSION_DENIED";
       yield promiseMessage(expectedMessage, () => {
         activateSecondaryAction(aNever ? kActionNever : kActionAlways);
       });
       let expected = [];
       if (expectedMessage == "ok") {
-        expectNotification("getUserMedia:response:allow");
-        expectNotification("recording-device-events");
+        expectObserverCalled("getUserMedia:response:allow");
+        expectObserverCalled("recording-device-events");
         if (aAllowVideo)
           expected.push("Camera");
         if (aAllowAudio)
           expected.push("Microphone");
         expected = expected.join("And");
       }
       else {
-        expectNotification("getUserMedia:response:deny");
-        expectNotification("recording-window-ended");
+        expectObserverCalled("getUserMedia:response:deny");
+        expectObserverCalled("recording-window-ended");
         expected = "none";
       }
       is(getMediaCaptureState(), expected,
          "expected " + expected + " to be shared");
 
       function checkDevicePermissions(aDevice, aExpected) {
         let Perms = Services.perms;
         let uri = content.document.documentURIObject;
@@ -576,48 +568,48 @@ let gTests = [
       }
 
       let gum = function() {
         content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
       };
 
       if (aExpectStream === undefined) {
         // Check that we get a prompt.
-        yield promiseNotification("getUserMedia:request", gum);
-        yield promisePopupNotification("webRTC-shareDevices", true);
+        yield promisePopupNotificationShown("webRTC-shareDevices", gum);
+        expectObserverCalled("getUserMedia:request");
 
         // Deny the request to cleanup...
         yield promiseMessage("error: PERMISSION_DENIED", () => {
           activateSecondaryAction(kActionDeny);
         });
-        expectNotification("getUserMedia:response:deny");
-        expectNotification("recording-window-ended");
+        expectObserverCalled("getUserMedia:response:deny");
+        expectObserverCalled("recording-window-ended");
       }
       else {
         let allow = (aAllowVideo && aRequestVideo) || (aAllowAudio && aRequestAudio);
         let expectedMessage = allow ? "ok" : "error: PERMISSION_DENIED";
         yield promiseMessage(expectedMessage, gum);
 
         if (expectedMessage == "ok") {
-          expectNotification("recording-device-events");
+          expectObserverCalled("recording-device-events");
 
           // Check what's actually shared.
           let expected = [];
           if (aAllowVideo && aRequestVideo)
             expected.push("Camera");
           if (aAllowAudio && aRequestAudio)
             expected.push("Microphone");
           expected = expected.join("And");
           is(getMediaCaptureState(), expected,
              "expected " + expected + " to be shared");
 
           yield closeStream();
         }
         else {
-          expectNotification("recording-window-ended");
+          expectObserverCalled("recording-window-ended");
         }
       }
 
       Perms.remove(uri.host, "camera");
       Perms.remove(uri.host, "microphone");
     }
 
     // Set both permissions identically
@@ -686,33 +678,33 @@ let gTests = [
       // Initially set both permissions to 'allow'.
       Perms.add(uri, "microphone", Perms.ALLOW_ACTION);
       Perms.add(uri, "camera", Perms.ALLOW_ACTION);
 
       // Start sharing what's been requested.
       yield promiseMessage("ok", () => {
         content.wrappedJSObject.requestDevice(aRequestAudio, aRequestVideo);
       });
-      expectNotification("recording-device-events");
+      expectObserverCalled("recording-device-events");
       yield checkSharingUI();
 
       PopupNotifications.getNotification("webRTC-sharingDevices").reshow();
       let expectedIcon = "webRTC-sharingDevices";
       if (aRequestAudio && !aRequestVideo)
         expectedIcon = "webRTC-sharingMicrophone";
       is(PopupNotifications.getNotification("webRTC-sharingDevices").anchorID,
          expectedIcon + "-notification-icon", "anchored to correct icon");
       is(PopupNotifications.panel.firstChild.getAttribute("popupid"), expectedIcon,
          "panel using correct icon");
 
       // Stop sharing.
       activateSecondaryAction(kActionDeny);
 
-      yield promiseNotification("recording-device-events");
-      expectNotification("getUserMedia:revoke");
+      yield promiseObserverCalled("recording-device-events");
+      expectObserverCalled("getUserMedia:revoke");
 
       yield promiseNoPopupNotification("webRTC-sharingDevices");
 
       if (gObservedTopics["recording-device-events"] == 1) {
         todo(false, "Got the 'recording-device-events' notification twice, likely because of bug 962719");
         gObservedTopics["recording-device-events"] = 0;
       }
 
@@ -761,17 +753,17 @@ function test() {
     Services.prefs.setBoolPref(PREF_PERMISSION_FAKE, true);
 
     Task.spawn(function () {
       for (let test of gTests) {
         info(test.desc);
         yield test.run();
 
         // Cleanup before the next test
-        expectNoNotifications();
+        expectNoObserverCalled();
       }
     }).then(finish, ex => {
      ok(false, "Unexpected Exception: " + ex);
      finish();
     });
   }, true);
   let rootDir = getRootDirectory(gTestPath)
   rootDir = rootDir.replace("chrome://mochitests/content/",
--- a/browser/components/customizableui/content/panelUI.xml
+++ b/browser/components/customizableui/content/panelUI.xml
@@ -261,28 +261,35 @@
 
       <method name="handleEvent">
         <parameter name="aEvent"/>
         <body><![CDATA[
           if (aEvent.type.startsWith("popup") && aEvent.target != this._panel) {
             // Shouldn't act on e.g. context menus being shown from within the panel.
             return;
           }
-          switch(aEvent.type) {
+          switch (aEvent.type) {
             case "click":
               if (aEvent.originalTarget == this._clickCapturer) {
                 this.showMainView();
               }
               break;
             case "overflow":
-              // Resize the right view on the next tick.
-              if (this.showingSubView) {
-                setTimeout(this._syncContainerWithSubView.bind(this), 0);
-              } else if (!this.transitioning) {
-                setTimeout(this._syncContainerWithMainView.bind(this), 0);
+              switch (aEvent.target.localName) {
+                case "vbox":
+                  // Resize the right view on the next tick.
+                  if (this.showingSubView) {
+                    setTimeout(this._syncContainerWithSubView.bind(this), 0);
+                  } else if (!this.transitioning) {
+                    setTimeout(this._syncContainerWithMainView.bind(this), 0);
+                  }
+                  break;
+                case "toolbarbutton":
+                  aEvent.target.setAttribute("fadelabel", "true");
+                  break;
               }
               break;
             case "popupshowing":
               this.setAttribute("panelopen", "true");
               // Bug 941196 - The panel can get taller when opening a subview. Disabling
               // autoPositioning means that the panel won't jump around if an opened
               // subview causes the panel to exceed the dimensions of the screen in the
               // direction that the panel originally opened in. This property resets
--- a/browser/components/downloads/content/contentAreaDownloadsView.xul
+++ b/browser/components/downloads/content/contentAreaDownloadsView.xul
@@ -33,13 +33,14 @@
 #ifdef XP_MACOSX
     <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/>
 #endif
   </keyset>
 
   <stack flex="1">
     <richlistbox id="downloadsRichListBox"/>
     <description id="downloadsListEmptyDescription"
-                 value="&downloadsListEmpty.label;"/>
+                 value="&downloadsListEmpty.label;"
+                 mousethrough="always"/>
   </stack>
   <commandset id="downloadCommands"/>
   <menupopup id="downloadsContextMenu"/>
 </window>
--- a/browser/components/preferences/sync.js
+++ b/browser/components/preferences/sync.js
@@ -129,21 +129,24 @@ let gSyncPane = {
         let enginesListDisabled;
         // Not Verfied implies login error state, so check that first.
         if (!data.verified) {
           fxaLoginStatus.selectedIndex = FXA_LOGIN_UNVERIFIED;
           enginesListDisabled = true;
         // So we think we are logged in, so login problems are next.
         // (Although if the Sync identity manager is still initializing, we
         // ignore login errors and assume all will eventually be good.)
-        } else if (Weave.Service.identity.readyToAuthenticate &&
-                   Weave.Status.login != Weave.LOGIN_SUCCEEDED) {
+        // LOGIN_FAILED_LOGIN_REJECTED explicitly means "you must log back in".
+        // All other login failures are assumed to be transient and should go
+        // away by themselves, so aren't reflected here.
+        } else if (Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED) {
           fxaLoginStatus.selectedIndex = FXA_LOGIN_FAILED;
           enginesListDisabled = true;
-        // Else we must be golden!
+        // Else we must be golden (or in an error state we expect to magically
+        // resolve itself)
         } else {
           fxaLoginStatus.selectedIndex = FXA_LOGIN_VERIFIED;
           enginesListDisabled = false;
         }
         document.getElementById("fxaEmailAddress1").textContent = data.email;
         document.getElementById("fxaEmailAddress2").textContent = data.email;
         document.getElementById("fxaEmailAddress3").textContent = data.email;
         document.getElementById("fxaSyncComputerName").value = Weave.Service.clientsEngine.localName;
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -655,16 +655,18 @@ just addresses the organization to follo
 <!ENTITY syncTabsMenu2.label     "Tabs From Other Devices">
 
 <!ENTITY syncBrand.shortName.label    "Sync">
 
 <!ENTITY syncSetup.label              "Set Up &syncBrand.shortName.label;…">
 <!ENTITY syncSetup.accesskey          "Y">
 <!ENTITY syncSyncNowItem.label        "Sync Now">
 <!ENTITY syncSyncNowItem.accesskey    "S">
+<!ENTITY syncReAuthItem.label         "Reconnect to &syncBrand.shortName.label;…">
+<!ENTITY syncReAuthItem.accesskey     "R">
 <!ENTITY syncToolbarButton.label      "Sync">
 
 <!ENTITY socialToolbar.title        "Social Toolbar Button">
 
 <!ENTITY social.ok.label       "OK">
 <!ENTITY social.ok.accesskey   "O">
 
 <!ENTITY social.toggleSidebar.label "Show sidebar">
--- a/browser/modules/UITour.jsm
+++ b/browser/modules/UITour.jsm
@@ -50,16 +50,17 @@ this.UITour = {
   originTabs: new WeakMap(),
   /* Map from browser windows to a set of pinned tabs opened by (a) tour(s) */
   pinnedTabs: new WeakMap(),
   urlbarCapture: new WeakMap(),
   appMenuOpenForAnnotation: new Set(),
   availableTargetsCache: new WeakMap(),
 
   _detachingTab: false,
+  _annotationPanelMutationObservers: new WeakMap(),
   _queuedEvents: [],
   _pendingDoc: null,
 
   highlightEffects: ["random", "wobble", "zoom", "color"],
   targets: new Map([
     ["accountStatus", {
       query: (aDocument) => {
         let statusButton = aDocument.getElementById("PanelUI-fxa-status");
@@ -319,17 +320,22 @@ this.UITour = {
                 buttons.push(button);
 
                 if (buttons.length == MAX_BUTTONS)
                   break;
               }
             }
           }
 
-          this.showInfo(contentDocument, target, data.title, data.text, iconURL, buttons);
+          let infoOptions = {};
+
+          if (typeof data.closeButtonCallbackID == "string")
+            infoOptions.closeButtonCallbackID = data.closeButtonCallbackID;
+
+          this.showInfo(contentDocument, target, data.title, data.text, iconURL, buttons, infoOptions);
         }).then(null, Cu.reportError);
         break;
       }
 
       case "hideInfo": {
         this.hideInfo(window);
         break;
       }
@@ -502,24 +508,16 @@ this.UITour = {
 
       case "input": {
         if (aEvent.target.id == "urlbar") {
           let window = aEvent.target.ownerDocument.defaultView;
           this.handleUrlbarInput(window);
         }
         break;
       }
-
-      case "command": {
-        if (aEvent.target.id == "UITourTooltipClose") {
-          let window = aEvent.target.ownerDocument.defaultView;
-          this.hideInfo(window);
-        }
-        break;
-      }
     }
   },
 
   setTelemetryBucket: function(aPageID) {
     let bucket = BUCKET_NAME + BrowserUITelemetry.BUCKET_SEPARATOR + aPageID;
     BrowserUITelemetry.setBucket(bucket);
   },
 
@@ -837,16 +835,18 @@ this.UITour = {
       let paddingLeftPx = 0 - parseFloat(containerStyle.paddingLeft);
       let highlightStyle = highlightWindow.getComputedStyle(highlighter);
       let highlightHeightWithMin = Math.max(highlightHeight, parseFloat(highlightStyle.minHeight));
       let highlightWidthWithMin = Math.max(highlightWidth, parseFloat(highlightStyle.minWidth));
       let offsetX = paddingTopPx
                       - (Math.max(0, highlightWidthWithMin - targetRect.width) / 2);
       let offsetY = paddingLeftPx
                       - (Math.max(0, highlightHeightWithMin - targetRect.height) / 2);
+
+      this._addAnnotationPanelMutationObserver(highlighter.parentElement);
       highlighter.parentElement.openPopup(aTargetEl, "overlap", offsetX, offsetY);
     }
 
     // Prevent showing a panel at an undefined position.
     if (!this.isElementVisible(aTarget.node))
       return;
 
     this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight",
@@ -855,23 +855,37 @@ this.UITour = {
   },
 
   hideHighlight: function(aWindow) {
     let tabData = this.pinnedTabs.get(aWindow);
     if (tabData && !tabData.sticky)
       this.removePinnedTab(aWindow);
 
     let highlighter = aWindow.document.getElementById("UITourHighlight");
+    this._removeAnnotationPanelMutationObserver(highlighter.parentElement);
     highlighter.parentElement.hidePopup();
     highlighter.removeAttribute("active");
 
     this._setAppMenuStateForAnnotation(aWindow, "highlight", false);
   },
 
-  showInfo: function(aContentDocument, aAnchor, aTitle = "", aDescription = "", aIconURL = "", aButtons = []) {
+  /**
+   * Show an info panel.
+   *
+   * @param {Document} aContentDocument
+   * @param {Node}     aAnchor
+   * @param {String}   [aTitle=""]
+   * @param {String}   [aDescription=""]
+   * @param {String}   [aIconURL=""]
+   * @param {Object[]} [aButtons=[]]
+   * @param {Object}   [aOptions={}]
+   * @param {String}   [aOptions.closeButtonCallbackID]
+   */
+  showInfo: function(aContentDocument, aAnchor, aTitle = "", aDescription = "", aIconURL = "",
+                     aButtons = [], aOptions = {}) {
     function showInfoPanel(aAnchorEl) {
       aAnchorEl.focus();
 
       let document = aAnchorEl.ownerDocument;
       let tooltip = document.getElementById("UITourTooltip");
       let tooltipTitle = document.getElementById("UITourTooltipTitle");
       let tooltipDesc = document.getElementById("UITourTooltipDescription");
       let tooltipIcon = document.getElementById("UITourTooltipIcon");
@@ -905,37 +919,48 @@ this.UITour = {
         });
 
         tooltipButtons.appendChild(el);
       }
 
       tooltipButtons.hidden = !aButtons.length;
 
       let tooltipClose = document.getElementById("UITourTooltipClose");
-      tooltipClose.addEventListener("command", this);
+      let closeButtonCallback = (event) => {
+        this.hideInfo(document.defaultView);
+        if (aOptions && aOptions.closeButtonCallbackID)
+          this.sendPageCallback(aContentDocument, aOptions.closeButtonCallbackID);
+      };
+      tooltipClose.addEventListener("command", closeButtonCallback);
+      tooltip.addEventListener("popuphiding", function tooltipHiding(event) {
+        tooltip.removeEventListener("popuphiding", tooltipHiding);
+        tooltipClose.removeEventListener("command", closeButtonCallback);
+      });
 
       tooltip.setAttribute("targetName", aAnchor.targetName);
       tooltip.hidden = false;
       let alignment = "bottomcenter topright";
+      this._addAnnotationPanelMutationObserver(tooltip);
       tooltip.openPopup(aAnchorEl, alignment);
     }
 
     // Prevent showing a panel at an undefined position.
     if (!this.isElementVisible(aAnchor.node))
       return;
 
     this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info",
                                        this.targetIsInAppMenu(aAnchor),
                                        showInfoPanel.bind(this, aAnchor.node));
   },
 
   hideInfo: function(aWindow) {
     let document = aWindow.document;
 
     let tooltip = document.getElementById("UITourTooltip");
+    this._removeAnnotationPanelMutationObserver(tooltip);
     tooltip.hidePopup();
     this._setAppMenuStateForAnnotation(aWindow, "info", false);
 
     let tooltipButtons = document.getElementById("UITourTooltipButtons");
     while (tooltipButtons.firstChild)
       tooltipButtons.firstChild.remove();
   },
 
@@ -1105,11 +1130,52 @@ this.UITour = {
       this.sendPageCallback(aContentDocument, aCallbackID, data);
     }, (err) => {
       Cu.reportError(err);
       this.sendPageCallback(aContentDocument, aCallbackID, {
         targets: [],
       });
     });
   },
+
+  _addAnnotationPanelMutationObserver: function(aPanelEl) {
+#ifdef XP_LINUX
+    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+    if (observer) {
+      return;
+    }
+    let win = aPanelEl.ownerDocument.defaultView;
+    observer = new win.MutationObserver(this._annotationMutationCallback);
+    this._annotationPanelMutationObservers.set(aPanelEl, observer);
+    let observerOptions = {
+      attributeFilter: ["height", "width"],
+      attributes: true,
+    };
+    observer.observe(aPanelEl, observerOptions);
+#endif
+  },
+
+  _removeAnnotationPanelMutationObserver: function(aPanelEl) {
+#ifdef XP_LINUX
+    let observer = this._annotationPanelMutationObservers.get(aPanelEl);
+    if (observer) {
+      observer.disconnect();
+      this._annotationPanelMutationObservers.delete(aPanelEl);
+    }
+#endif
+  },
+
+/**
+ * Workaround for Ubuntu panel craziness in bug 970788 where incorrect sizes get passed to
+ * nsXULPopupManager::PopupResized and lead to incorrect width and height attributes getting
+ * set on the panel.
+ */
+  _annotationMutationCallback: function(aMutations) {
+    for (let mutation of aMutations) {
+      // Remove both attributes at once and ignore remaining mutations to be proccessed.
+      mutation.target.removeAttribute("width");
+      mutation.target.removeAttribute("height");
+      return;
+    }
+  },
 };
 
 this.UITour.init();
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -15,26 +15,26 @@ EXTRA_JS_MODULES += [
     'Feeds.jsm',
     'NetworkPrioritizer.jsm',
     'offlineAppCache.jsm',
     'SharedFrame.jsm',
     'SignInToWebsite.jsm',
     'SitePermissions.jsm',
     'Social.jsm',
     'TabCrashReporter.jsm',
-    'UITour.jsm',
     'webappsUI.jsm',
     'webrtcUI.jsm',
 ]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows':
     EXTRA_JS_MODULES += [
         'WindowsJumpLists.jsm',
         'WindowsPreviewPerTab.jsm',
     ]
 
 EXTRA_PP_JS_MODULES += [
     'AboutHome.jsm',
     'RecentWindow.jsm',
+    'UITour.jsm',
 ]
 
 if CONFIG['MOZILLA_OFFICIAL']:
     DEFINES['MOZILLA_OFFICIAL'] = 1
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -7,14 +7,15 @@ support-files =
 [browser_BrowserUITelemetry_buckets.js]
 [browser_NetworkPrioritizer.js]
 [browser_SignInToWebsite.js]
 [browser_UITour.js]
 skip-if = os == "linux" # Intermittent failures, bug 951965
 [browser_UITour2.js]
 [browser_UITour3.js]
 [browser_UITour_availableTargets.js]
+[browser_UITour_detach_tab.js]
+[browser_UITour_annotation_size_attributes.js]
 [browser_UITour_panel_close_annotation.js]
-[browser_UITour_detach_tab.js]
 [browser_UITour_registerPageID.js]
 [browser_UITour_sync.js]
 [browser_taskbar_preview.js]
 run-if = os == "win"
--- a/browser/modules/test/browser_UITour3.js
+++ b/browser/modules/test/browser_UITour3.js
@@ -117,9 +117,26 @@ let tests = [
       });
 
       EventUtils.synthesizeMouseAtCenter(buttons.childNodes[1], {}, window);
     });
 
     let buttons = gContentWindow.makeButtons();
     gContentAPI.showInfo("urlbar", "another title", "moar text", "./image.png", buttons);
   },
+
+  function test_info_close_button(done) {
+    let popup = document.getElementById("UITourTooltip");
+    let closeButton = document.getElementById("UITourTooltipClose");
+
+    popup.addEventListener("popupshown", function onPopupShown() {
+      popup.removeEventListener("popupshown", onPopupShown);
+      EventUtils.synthesizeMouseAtCenter(closeButton, {}, window);
+      executeSoon(function() {
+        is(gContentWindow.callbackResult, "closeButton", "Close button callback called");
+        done();
+      });
+    });
+
+    let infoOptions = gContentWindow.makeInfoOptions();
+    gContentAPI.showInfo("urlbar", "Close me", "X marks the spot", null, null, infoOptions);
+  }
 ];
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_UITour_annotation_size_attributes.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test that width and height attributes don't get set by widget code on the highlight panel.
+ */
+
+"use strict";
+
+let gTestTab;
+let gContentAPI;
+let gContentWindow;
+let highlight = document.getElementById("UITourHighlightContainer");
+let tooltip = document.getElementById("UITourTooltip");
+
+Components.utils.import("resource:///modules/UITour.jsm");
+
+function test() {
+  UITourTest();
+}
+
+let tests = [
+  function test_highlight_size_attributes(done) {
+    gContentAPI.showHighlight("appMenu");
+    waitForElementToBeVisible(highlight, function moveTheHighlight() {
+      gContentAPI.showHighlight("urlbar");
+      waitForElementToBeVisible(highlight, function checkPanelAttributes() {
+        SimpleTest.executeSoon(() => {
+          ise(highlight.height, "", "Highlight panel should have no explicit height set");
+          ise(highlight.width, "", "Highlight panel should have no explicit width set");
+          done();
+        });
+      }, "Highlight should be moved to the urlbar");
+    }, "Highlight should be shown after showHighlight() for the appMenu");
+  },
+
+  function test_info_size_attributes(done) {
+    gContentAPI.showInfo("appMenu", "test title", "test text");
+    waitForElementToBeVisible(tooltip, function moveTheTooltip() {
+      gContentAPI.showInfo("urlbar", "new title", "new text");
+      waitForElementToBeVisible(tooltip, function checkPanelAttributes() {
+        SimpleTest.executeSoon(() => {
+          ise(tooltip.height, "", "Info panel should have no explicit height set");
+          ise(tooltip.width, "", "Info panel should have no explicit width set");
+          done();
+        });
+      }, "Tooltip should be moved to the urlbar");
+    }, "Tooltip should be shown after showInfo() for the appMenu");
+  },
+
+];
--- a/browser/modules/test/browser_UITour_detach_tab.js
+++ b/browser/modules/test/browser_UITour_detach_tab.js
@@ -1,13 +1,13 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
- * Tests that annotations disappear when their target is hidden.
+ * Detaching a tab to a new window shouldn't break the menu panel.
  */
 
 "use strict";
 
 let gTestTab;
 let gContentAPI;
 let gContentWindow;
 let gContentDoc;
--- a/browser/modules/test/uitour.html
+++ b/browser/modules/test/uitour.html
@@ -15,16 +15,22 @@
 
       // Defined in content to avoid weird issues when crossing between chrome/content.
       function makeButtons() {
         return [
           {label: "Button 1", callback: makeCallback("button1")},
           {label: "Button 2", callback: makeCallback("button2"), icon: "image.png"}
         ];
       }
+
+      function makeInfoOptions() {
+        return {
+          closeButtonCallback: makeCallback("closeButton")
+        };
+      }
     </script>
   </head>
   <body>
     <h1>UITour tests</h1>
     <p>Because Firefox is...</p>
     <p>Never gonna let you down</p>
     <p>Never gonna give you up</p>
   </body>
--- a/browser/modules/test/uitour.js
+++ b/browser/modules/test/uitour.js
@@ -72,35 +72,40 @@ if (typeof Mozilla == 'undefined') {
 			effect: effect
 		});
 	};
 
 	Mozilla.UITour.hideHighlight = function() {
 		_sendEvent('hideHighlight');
 	};
 
-	Mozilla.UITour.showInfo = function(target, title, text, icon, buttons) {
+	Mozilla.UITour.showInfo = function(target, title, text, icon, buttons, options) {
 		var buttonData = [];
 		if (Array.isArray(buttons)) {
 			for (var i = 0; i < buttons.length; i++) {
 				buttonData.push({
 					label: buttons[i].label,
 					icon: buttons[i].icon,
 					style: buttons[i].style,
 					callbackID: _waitForCallback(buttons[i].callback)
 			});
 			}
 		}
 
+		var closeButtonCallbackID;
+		if (options && options.closeButtonCallback)
+			closeButtonCallbackID = _waitForCallback(options.closeButtonCallback);
+
 		_sendEvent('showInfo', {
 			target: target,
 			title: title,
 			text: text,
 			icon: icon,
-			buttons: buttonData
+			buttons: buttonData,
+			closeButtonCallbackID: closeButtonCallbackID
 		});
 	};
 
 	Mozilla.UITour.hideInfo = function() {
 		_sendEvent('hideInfo');
 	};
 
 	Mozilla.UITour.previewTheme = function(theme) {
--- a/browser/themes/linux/customizableui/panelUIOverlay.css
+++ b/browser/themes/linux/customizableui/panelUIOverlay.css
@@ -12,14 +12,26 @@
   -moz-appearance: none;
   border: 0;
 }
 
 .widget-overflow-list .toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
   -moz-margin-start: 0;
 }
 
+.subviewbutton > .toolbarbutton-text {
+  -moz-padding-start: 16px;
+}
+
+.subviewbutton:-moz-any([image],[targetURI],.cui-withicon, .restoreallitem, .bookmark-item) > .toolbarbutton-text {
+  -moz-padding-start: 0;
+}
+
+.subviewbutton.bookmark-item > .toolbarbutton-icon {
+  -moz-margin-start: 3px;
+}
+
 .PanelUI-subView toolbarseparator,
 .PanelUI-subView menuseparator,
 .cui-widget-panelview menuseparator,
 #PanelUI-footer-inner > toolbarseparator {
   -moz-appearance: none !important;
 }
--- a/browser/themes/osx/customizableui/panelUIOverlay.css
+++ b/browser/themes/osx/customizableui/panelUIOverlay.css
@@ -53,24 +53,54 @@
     -moz-image-region: rect(0, 64px, 32px, 32px);
   }
 
   #PanelUI-customize:hover:active,
   #PanelUI-help:not([disabled]):hover:active,
   #PanelUI-quit:not([disabled]):hover:active {
     -moz-image-region: rect(0, 96px, 32px, 64px);
   }
+
+  .subviewbutton[checked="true"] {
+    background-image: url("chrome://global/skin/menu/menu-check@2x.png");
+  }
+
 }
 
 .panelUI-grid .toolbarbutton-1 {
   margin-right: 0;
   margin-left: 0;
   margin-bottom: 0;
 }
 
+.subviewbutton > .toolbarbutton-text,
+.subviewbutton > .menu-iconic-text {
+  margin: 2px 0px;
+}
+
+.subviewbutton:-moz-any([image],[targetURI],.cui-withicon, .bookmark-item) > .toolbarbutton-text {
+  margin: 2px 6px;
+}
+
+.restoreallitem > .toolbarbutton-icon {
+  display: none;
+}
+
+.subviewbutton {
+  -moz-padding-start: 18px;
+}
+
+.subviewbutton[checked="true"] {
+  background-position: top 5px left 4px;
+}
+
+.subviewbutton:not(:-moz-any([image],[targetURI],.cui-withicon, .bookmark-item)) > .menu-iconic-left {
+  display: none;
+}
+
 #BMB_bookmarksPopup > menu,
 #BMB_bookmarksPopup > menuitem:not(.panel-subview-footer) {
   padding-top: 5px;
   padding-bottom: 5px;
 }
 
 /* Override OSX-specific toolkit styles for the bookmarks panel */
 #BMB_bookmarksPopup > menu > .menu-right {
@@ -89,13 +119,8 @@
 .widget-overflow-list .toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker {
   -moz-margin-start: 4px;
 }
 
 .PanelUI-subView menuseparator,
 .cui-widget-panelview menuseparator {
   padding: 0 !important;
 }
-
-.PanelUI-subView menuitem[checked="true"]::before,
-.PanelUI-subView toolbarbutton[checked="true"]::before {
-  margin: 0 5px;
-}
--- a/browser/themes/shared/UITour.inc.css
+++ b/browser/themes/shared/UITour.inc.css
@@ -46,17 +46,16 @@
 #UITourTooltipDescription {
   -moz-margin-start: 0;
   -moz-margin-end: 0;
   font-size: 1.15rem;
   line-height: 1.8rem;
 }
 
 #UITourTooltipClose {
-  visibility: hidden; /* XXX Temporarily disabled by bug 966913 */
   -moz-appearance: none;
   border: none;
   background-color: transparent;
   min-width: 0;
   -moz-margin-start: 4px;
   -moz-margin-end: -10px;
   margin-top: -8px;
 }
--- a/browser/themes/shared/customizableui/panelUIOverlay.inc.css
+++ b/browser/themes/shared/customizableui/panelUIOverlay.inc.css
@@ -7,20 +7,20 @@
 %define menuPanelWidth 22.35em
 % XXXgijs This is the ugliest bit of code I think I've ever written for Mozilla.
 % Basically, the 0.1px is there to avoid CSS rounding errors causing buttons to wrap.
 % For gory details, refer to https://bugzilla.mozilla.org/show_bug.cgi?id=963365#c11
 % There's no calc() here (and therefore lots of calc() where this is used) because
 % we don't support nested calc(): https://bugzilla.mozilla.org/show_bug.cgi?id=968761
 %define menuPanelButtonWidth (@menuPanelWidth@ / 3 - 0.1px)
 %define exitSubviewGutterWidth 38px
-%define buttonStateHover :not(:-moz-any([disabled],[open],[checked="true"],:active)):hover
-%define menuStateHover :not(:-moz-any([disabled],[checked="true"],:active))[_moz-menuactive]
-%define buttonStateActive :not([disabled]):-moz-any([open],[checked="true"],:hover:active)
-%define menuStateActive :not([disabled]):-moz-any([checked="true"],[_moz-menuactive]:active)
+%define buttonStateHover :not(:-moz-any([disabled],[open],:active)):hover
+%define menuStateHover :not(:-moz-any([disabled],:active))[_moz-menuactive]
+%define buttonStateActive :not([disabled]):-moz-any([open],:hover:active)
+%define menuStateActive :not([disabled])[_moz-menuactive]:active
 
 %include ../browser.inc
 
 #PanelUI-button {
   background-image: linear-gradient(to bottom, hsla(0,0%,100%,0), hsla(0,0%,100%,.3) 30%, hsla(0,0%,100%,.3) 70%, hsla(0,0%,100%,0)),
                     linear-gradient(to bottom, hsla(210,54%,20%,0), hsla(210,54%,20%,.3) 30%, hsla(210,54%,20%,.3) 70%, hsla(210,54%,20%,0)),
                     linear-gradient(to bottom, hsla(0,0%,100%,0), hsla(0,0%,100%,.3) 30%, hsla(0,0%,100%,.3) 70%, hsla(0,0%,100%,0));
   background-size: 1px calc(100% - 1px), 1px calc(100% - 1px), 1px  calc(100% - 1px) !important;
@@ -50,20 +50,16 @@
 .panel-viewstack[viewtype="main"] > .panel-subviews:-moz-locale-dir(rtl) {
   transform: translateX(-@menuPanelWidth@);
 }
 
 .panel-viewstack:not([viewtype="main"]) > .panel-mainview > #PanelUI-mainView {
   -moz-box-flex: 1;
 }
 
-.subviewbutton:not(:-moz-any([image],[targetURI],.cui-withicon)) > .toolbarbutton-text {
-  -moz-margin-start: 0;
-}
-
 .panel-subview-body {
   overflow-y: auto;
   overflow-x: hidden;
   -moz-box-flex: 1;
 }
 
 #PanelUI-popup .panel-subview-body {
   margin: -4px;
@@ -117,34 +113,33 @@
   overflow: hidden;
 }
 
 #PanelUI-popup > .panel-arrowcontainer > .panel-arrowcontent,
 .cui-widget-panel > .panel-arrowcontainer > .panel-arrowcontent > .popup-internal-box {
   padding: 0;
 }
 
-.panelUI-grid .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text,
+.panelUI-grid .toolbarbutton-1 > .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text,
 .panelUI-grid .toolbarbutton-1 > .toolbarbutton-multiline-text {
   -moz-hyphens: auto;
-  min-height: 3.5em;
 }
 
-.panelUI-grid:not([customize-transitioning]) .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text,
-.panelUI-grid:not([customize-transitioning]) .toolbarbutton-1 > .toolbarbutton-multiline-text {
+.panelUI-grid:not([customize-transitioning]) .toolbarbutton-1[fadelabel] > .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text,
+.panelUI-grid:not([customize-transitioning]) .toolbarbutton-1[fadelabel] > .toolbarbutton-multiline-text {
   mask: url(chrome://browser/content/browser.xul#menuPanelButtonTextFadeOutMask);
 }
 
 .panelUI-grid .toolbarbutton-1 > .toolbarbutton-text,
 .panelUI-grid .toolbarbutton-1 > .toolbarbutton-multiline-text {
   text-align: center;
   margin: 2px 0 0;
 }
 
-.panelUI-grid .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text {
+.panelUI-grid .toolbarbutton-1 > .toolbarbutton-menubutton-button > .toolbarbutton-multiline-text {
   text-align: center;
   margin: -1px 0 0;
 }
 
 #wrapper-edit-controls:-moz-any([place="palette"],[place="panel"]) > #edit-controls,
 #wrapper-zoom-controls:-moz-any([place="palette"],[place="panel"]) > #zoom-controls {
   -moz-margin-start: 0;
 }
@@ -684,20 +679,16 @@ menuitem.subviewbutton@menuStateActive@,
   -moz-margin-end: auto;
   color: hsl(0,0%,50%);
 }
 
 #PanelUI-historyItems > toolbarbutton {
   list-style-image: url("chrome://mozapps/skin/places/defaultFavicon.png");
 }
 
-.restoreallitem.subviewbutton > .toolbarbutton-icon {
-  display: none;
-}
-
 #PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon,
 #PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon,
 #PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon {
   width: 16px;
   height: 16px;
 }
 
 #PanelUI-footer > #PanelUI-footer-inner[panel-multiview-anchor=true],
@@ -815,24 +806,19 @@ toolbarpaletteitem[place="palette"] > #s
                     linear-gradient(to bottom, hsla(210,54%,20%,0), hsla(210,54%,20%,.15) 40%, hsla(210,54%,20%,.15) 60%, hsla(210,54%,20%,0)),
                     linear-gradient(to bottom, hsla(0,0%,100%,0), hsla(0,0%,100%,.3) 40%, hsla(0,0%,100%,.3) 60%, hsla(0,0%,100%,0));
   background-size: 1px, 1px, 1px;
   background-position: 0 0, 1px 0, 2px 0;
   background-repeat: no-repeat;
 }
 
 .toolbaritem-combined-buttons@inAnyPanel@ > separator {
-  /**
-   * The calculation below is a layout hack. Without it, when hovering over
-   * a .toolbaritem-combined-buttons element in the menu panel, the disappearance
-   * of the separator margins causes things in the menu panel to shift by a few
-   * pixels on Linux. See bug 978767.
-   */
-  margin: calc(0.5em - 1px) 0;
+  margin: .5em 0;
   width: 1px;
+  height: auto;
   background: hsla(210,4%,10%,.15);
   transition-property: margin;
   transition-duration: 10ms;
   transition-timing-function: ease;
 }
 
 .toolbaritem-combined-buttons@inAnyPanel@:hover > separator {
   margin: 0;
@@ -894,27 +880,21 @@ toolbaritem[overflowedItem=true],
   background-image: linear-gradient(hsla(210,54%,20%,.2) 0, hsla(210,54%,20%,.2) 18px);
   background-clip: padding-box;
   background-position: center;
   background-repeat: no-repeat;
   background-size: 1px 18px;
   box-shadow: 0 0 0 1px hsla(0,0%,100%,.2);
 }
 
-.PanelUI-subView toolbarbutton[checked="true"] {
-  -moz-padding-start: 4px;
-}
-
-.PanelUI-subView toolbarbutton[checked="true"] > .toolbarbutton-text {
-  -moz-padding-start: 0px;
+.subviewbutton[checked="true"] {
+  background: url("chrome://global/skin/menu/menu-check.png") top 7px left 7px / 11px 11px no-repeat transparent;
 }
 
-.PanelUI-subView menuitem[checked="true"]::before,
-.PanelUI-subView toolbarbutton[checked="true"]::before {
-  content: "✓";
-  display: -moz-box;
-  width: 12px;
-  margin: 0 2px;
+.PanelUI-subView > menu > .menu-iconic-left,
+.PanelUI-subView > menuitem > .menu-iconic-left {
+  -moz-appearance: none;
+  -moz-margin-end: 3px;
 }
 
-#BMB_bookmarksPopup > menuitem[checked="true"] > .menu-iconic-left {
-  display: none;
+.PanelUI-subView > menuitem[checked="true"] > .menu-iconic-left {
+  visibility: hidden;
 }
--- a/browser/themes/windows/customizableui/panelUIOverlay.css
+++ b/browser/themes/windows/customizableui/panelUIOverlay.css
@@ -30,19 +30,26 @@
   -moz-padding-start: 0;
   height: 18px;
 }
 
 .widget-overflow-list .toolbarbutton-1 > .toolbarbutton-menubutton-dropmarker > .dropmarker-icon {
   padding: 0 6px;
 }
 
-#BMB_bookmarksPopup menuitem[checked="true"]::before,
-#BMB_bookmarksPopup toolbarbutton[checked="true"]::before {
-  margin: 0 9px;
+.subviewbutton > .toolbarbutton-text {
+  -moz-padding-start: 16px;
+}
+
+.subviewbutton:-moz-any([image],[targetURI],.cui-withicon, .restoreallitem, .bookmark-item) > .toolbarbutton-text {
+  -moz-padding-start: 0;
+}
+
+.subviewbutton.bookmark-item > .toolbarbutton-icon {
+  -moz-margin-start: 3px;
 }
 
 %ifdef WINDOWS_AERO
 /* Win8 and beyond. */
 @media not all and (-moz-os-version: windows-vista) {
   @media not all and (-moz-os-version: windows-win7) {
     panelview .toolbarbutton-1,
     .subviewbutton,
--- a/mobile/android/base/Tab.java
+++ b/mobile/android/base/Tab.java
@@ -62,17 +62,17 @@ public class Tab {
     private int mBackgroundColor;
     private int mState;
     private Bitmap mThumbnailBitmap;
     private boolean mDesktopMode;
     private boolean mEnteringReaderMode;
     private Context mAppContext;
     private ErrorType mErrorType = ErrorType.NONE;
     private static final int MAX_HISTORY_LIST_SIZE = 50;
-    private int mLoadProgress;
+    private volatile int mLoadProgress;
 
     public static final int STATE_DELAYED = 0;
     public static final int STATE_LOADING = 1;
     public static final int STATE_SUCCESS = 2;
     public static final int STATE_ERROR = 3;
 
     public static final int LOAD_PROGRESS_INIT = 10;
     public static final int LOAD_PROGRESS_START = 20;
@@ -630,17 +630,17 @@ public class Tab {
         setHasFeeds(false);
         setHasOpenSearch(false);
         updateIdentityData(null);
         setReaderEnabled(false);
         setZoomConstraints(new ZoomConstraints(true));
         setHasTouchListeners(false);
         setBackgroundColor(DEFAULT_BACKGROUND_COLOR);
         setErrorType(ErrorType.NONE);
-        setLoadProgress(LOAD_PROGRESS_LOCATION_CHANGE);
+        setLoadProgressIfLoading(LOAD_PROGRESS_LOCATION_CHANGE);
 
         Tabs.getInstance().notifyListeners(this, Tabs.TabEvents.LOCATION_CHANGE, oldUrl);
     }
 
     private static boolean shouldShowProgress(final String url) {
         return AboutPages.isAboutHome(url) ||
                AboutPages.isAboutReader(url) ||
                AboutPages.isAboutPrivateBrowsing(url);
@@ -667,17 +667,17 @@ public class Tab {
                     return;
 
                 ThumbnailHelper.getInstance().getAndProcessThumbnailFor(tab);
             }
         }, 500);
     }
 
     void handleContentLoaded() {
-        setLoadProgress(LOAD_PROGRESS_LOADED);
+        setLoadProgressIfLoading(LOAD_PROGRESS_LOADED);
     }
 
     protected void saveThumbnailToDB() {
         try {
             String url = getURL();
             if (url == null)
                 return;
 
@@ -772,16 +772,32 @@ public class Tab {
      *
      * @param progressPercentage Percentage to set progress to (0-100)
      */
     void setLoadProgress(int progressPercentage) {
         mLoadProgress = progressPercentage;
     }
 
     /**
+     * Sets the tab load progress to the given percentage only if the tab is
+     * currently loading.
+     *
+     * about:neterror can trigger a STOP before other page load events (bug
+     * 976426), so any post-START events should make sure the page is loading
+     * before updating progress.
+     *
+     * @param progressPercentage Percentage to set progress to (0-100)
+     */
+    void setLoadProgressIfLoading(int progressPercentage) {
+        if (getState() == STATE_LOADING) {
+            setLoadProgress(progressPercentage);
+        }
+    }
+
+    /**
      * Gets the tab load progress percentage.
      *
      * @return Current progress percentage
      */
     public int getLoadProgress() {
         return mLoadProgress;
     }
 }
--- a/mobile/android/base/gfx/GeckoLayerClient.java
+++ b/mobile/android/base/gfx/GeckoLayerClient.java
@@ -882,17 +882,17 @@ public class GeckoLayerClient implements
     /** Implementation of PanZoomTarget
      * Notification that a subdocument has been scrolled by a certain amount.
      * This is used here to make sure that the margins are still accessible
      * during subdocument scrolling.
      *
      * You must hold the monitor while calling this.
      */
     @Override
-    public void onSubdocumentScrollBy(float dx, float dy) {
+    public void scrollMarginsBy(float dx, float dy) {
         ImmutableViewportMetrics newMarginsMetrics =
             mMarginsAnimator.scrollBy(mViewportMetrics, dx, dy);
         mViewportMetrics = mViewportMetrics.setMarginsFrom(newMarginsMetrics);
         viewportMetricsChanged(true);
     }
 
     /** Implementation of PanZoomTarget */
     @Override
--- a/mobile/android/base/gfx/JavaPanZoomController.java
+++ b/mobile/android/base/gfx/JavaPanZoomController.java
@@ -123,16 +123,18 @@ class JavaPanZoomController
     /* The per-frame zoom delta for the currently-running AUTONAV animation. */
     private float mAutonavZoomDelta;
     /* The user selected panning mode */
     private AxisLockMode mMode;
     /* A medium-length tap/press is happening */
     private boolean mMediumPress;
     /* Used to change the scrollY direction */
     private boolean mNegateWheelScrollY;
+    /* Whether the current event has been default-prevented. */
+    private boolean mDefaultPrevented;
 
     // Handler to be notified when overscroll occurs
     private Overscroll mOverscroll;
 
     public JavaPanZoomController(PanZoomTarget target, View view, EventDispatcher eventDispatcher) {
         mTarget = target;
         mSubscroller = new SubdocumentScrollHelper(eventDispatcher);
         mX = new AxisX(mSubscroller);
@@ -338,17 +340,19 @@ class JavaPanZoomController
     }
 
     /** This function MUST be called on the UI thread */
     @Override
     public boolean onTouchEvent(MotionEvent event) {
         return mTouchEventHandler.handleEvent(event);
     }
 
-    boolean handleEvent(MotionEvent event) {
+    boolean handleEvent(MotionEvent event, boolean defaultPrevented) {
+        mDefaultPrevented = defaultPrevented;
+
         switch (event.getAction() & MotionEvent.ACTION_MASK) {
         case MotionEvent.ACTION_DOWN:   return handleTouchStart(event);
         case MotionEvent.ACTION_MOVE:   return handleTouchMove(event);
         case MotionEvent.ACTION_UP:     return handleTouchEnd(event);
         case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event);
         }
         return false;
     }
@@ -396,27 +400,16 @@ class JavaPanZoomController
         if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
             // this is the first touch point going down, so we enter the pending state
             // seting the state will kill any animations in progress, possibly leaving
             // the page in overscroll
             setState(PanZoomState.WAITING_LISTENERS);
         }
     }
 
-    /** This function must be called on the UI thread. */
-    public void preventedTouchFinished() {
-        checkMainThread();
-        if (mState == PanZoomState.WAITING_LISTENERS) {
-            // if we enter here, we just finished a block of events whose default actions
-            // were prevented by touch listeners. Now there are no touch points left, so
-            // we need to reset our state and re-bounce because we might be in overscroll
-            bounce();
-        }
-    }
-
     /** This must be called on the UI thread. */
     @Override
     public void pageRectUpdated() {
         if (mState == PanZoomState.NOTHING) {
             synchronized (mTarget.getLock()) {
                 ImmutableViewportMetrics validated = getValidViewportMetrics();
                 if (!getMetrics().fuzzyEquals(validated)) {
                     // page size changed such that we are now in overscroll. snap to the
@@ -519,26 +512,28 @@ class JavaPanZoomController
     }
 
     private boolean handleTouchEnd(MotionEvent event) {
 
         switch (mState) {
         case FLING:
         case AUTONAV:
         case BOUNCE:
-        case WAITING_LISTENERS:
-            // should never happen
-            Log.e(LOGTAG, "Received impossible touch end while in " + mState);
-            // fall through
         case ANIMATED_ZOOM:
         case NOTHING:
             // may happen if user double-taps and drags without lifting after the
             // second tap. ignore if this happens.
             return false;
 
+        case WAITING_LISTENERS:
+            if (!mDefaultPrevented) {
+              // should never happen
+              Log.e(LOGTAG, "Received impossible touch end while in " + mState);
+            }
+            // fall through
         case TOUCHING:
             // the switch into TOUCHING might have happened while the page was
             // snapping back after overscroll. we need to finish the snap if that
             // was the case
             bounce();
             return false;
 
         case PANNING:
@@ -557,26 +552,16 @@ class JavaPanZoomController
         }
         Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd");
         return false;
     }
 
     private boolean handleTouchCancel(MotionEvent event) {
         cancelTouch();
 
-        if (mState == PanZoomState.WAITING_LISTENERS) {
-            // we might get a cancel event from the TouchEventHandler while in the
-            // WAITING_LISTENERS state if the touch listeners prevent-default the
-            // block of events. at this point being in WAITING_LISTENERS is equivalent
-            // to being in NOTHING with the exception of possibly being in overscroll.
-            // so here we don't want to do anything right now; the overscroll will be
-            // corrected in preventedTouchFinished().
-            return false;
-        }
-
         // ensure we snap back if we're overscrolled
         bounce();
         return false;
     }
 
     private boolean handlePointerScroll(MotionEvent event) {
         if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) {
             float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
@@ -822,19 +807,19 @@ class JavaPanZoomController
 
     private void updatePosition() {
         mX.displace();
         mY.displace();
         PointF displacement = resetDisplacement();
         if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) {
             return;
         }
-        if (mSubscroller.scrollBy(displacement)) {
+        if (mDefaultPrevented || mSubscroller.scrollBy(displacement)) {
             synchronized (mTarget.getLock()) {
-                mTarget.onSubdocumentScrollBy(displacement.x, displacement.y);
+                mTarget.scrollMarginsBy(displacement.x, displacement.y);
             }
         } else {
             synchronized (mTarget.getLock()) {
                 scrollBy(displacement.x, displacement.y);
             }
         }
     }
 
--- a/mobile/android/base/gfx/PanZoomTarget.java
+++ b/mobile/android/base/gfx/PanZoomTarget.java
@@ -14,17 +14,17 @@ public interface PanZoomTarget {
     public ImmutableViewportMetrics getViewportMetrics();
     public ZoomConstraints getZoomConstraints();
     public boolean isFullScreen();
     public RectF getMaxMargins();
 
     public void setAnimationTarget(ImmutableViewportMetrics viewport);
     public void setViewportMetrics(ImmutableViewportMetrics viewport);
     public void scrollBy(float dx, float dy);
-    public void onSubdocumentScrollBy(float dx, float dy);
+    public void scrollMarginsBy(float dx, float dy);
     public void panZoomStopped();
     /** This triggers an (asynchronous) viewport update/redraw. */
     public void forceRedraw(DisplayPortMetrics displayPort);
 
     public boolean post(Runnable action);
     public boolean postDelayed(Runnable action, long delayMillis);
     public void postRenderTask(RenderTask task);
     public void removeRenderTask(RenderTask task);
--- a/mobile/android/base/gfx/TouchEventHandler.java
+++ b/mobile/android/base/gfx/TouchEventHandler.java
@@ -69,21 +69,21 @@ final class TouchEventHandler implements
     // per-tab and is updated when we switch tabs).
     private boolean mWaitForTouchListeners;
 
     // true if we should hold incoming events in our queue. this is re-set for every
     // block of events, this is cleared once we find out if the block has been
     // default-prevented or not (or we time out waiting for that).
     private boolean mHoldInQueue;
 
-    // true if we should dispatch incoming events to the gesture detector and the pan/zoom
-    // controller. if this is false, then the current block of events has been
-    // default-prevented, and we should not dispatch these events (although we'll still send
-    // them to gecko listeners).
-    private boolean mDispatchEvents;
+    // false if the current event block has been default-prevented. In this case,
+    // we still pass the event to both Gecko and the pan/zoom controller, but the
+    // latter will not use it to scroll content. It may still use the events for
+    // other things, such as making the dynamic toolbar visible.
+    private boolean mAllowDefaultAction;
 
     // this next variable requires some explanation. strap yourself in.
     //
     // for each block of events, we do two things: (1) send the events to gecko and expect
     // exactly one default-prevented notification in return, and (2) kick off a delayed
     // ListenerTimeoutProcessor that triggers in case we don't hear from the listener in
     // a timely fashion.
     // since events are constantly coming in, we need to be able to handle more than one
@@ -123,37 +123,37 @@ final class TouchEventHandler implements
     TouchEventHandler(Context context, View view, JavaPanZoomController panZoomController) {
         mView = view;
 
         mEventQueue = new LinkedList<MotionEvent>();
         mPanZoomController = panZoomController;
         mGestureDetector = new GestureDetector(context, mPanZoomController);
         mScaleGestureDetector = new SimpleScaleGestureDetector(mPanZoomController);
         mListenerTimeoutProcessor = new ListenerTimeoutProcessor();
-        mDispatchEvents = true;
+        mAllowDefaultAction = true;
 
         mGestureDetector.setOnDoubleTapListener(mPanZoomController);
 
         Tabs.registerOnTabsChangedListener(this);
     }
 
     public void destroy() {
         Tabs.unregisterOnTabsChangedListener(this);
     }
 
     /* This function MUST be called on the UI thread */
     public boolean handleEvent(MotionEvent event) {
         if (isDownEvent(event)) {
             // this is the start of a new block of events! whee!
             mHoldInQueue = mWaitForTouchListeners;
 
-            // Set mDispatchEvents to true so that we are guaranteed to either queue these
-            // events or dispatch them. The only time we should not do either is once we've
-            // heard back from content to preventDefault this block.
-            mDispatchEvents = true;
+            // Set mAllowDefaultAction to true so that in the event we dispatch events, the
+            // PanZoomController doesn't treat them as if they've been prevent-defaulted
+            // when they haven't.
+            mAllowDefaultAction = true;
             if (mHoldInQueue) {
                 // if the new block we are starting is the current block (i.e. there are no
                 // other blocks waiting in the queue, then we should let the pan/zoom controller
                 // know we are waiting for the touch listeners to run
                 if (mEventQueue.isEmpty()) {
                     mPanZoomController.startingNewEventBlock(event, true);
                 }
             } else {
@@ -165,27 +165,22 @@ final class TouchEventHandler implements
                 mPanZoomController.startingNewEventBlock(event, false);
             }
 
             // set the timeout so that we dispatch these events and update mProcessingBalance
             // if we don't get a default-prevented notification
             mView.postDelayed(mListenerTimeoutProcessor, EVENT_LISTENER_TIMEOUT);
         }
 
-        // if we need to hold the events, add it to the queue. if we need to dispatch
-        // it directly, do that. it is possible that both mHoldInQueue and mDispatchEvents
-        // are false, in which case we are processing a block of events that we know
-        // has been default-prevented. in that case we don't keep the events as we don't
-        // need them (but we still pass them to the gecko listener).
+        // if we need to hold the events, add it to the queue, otherwise dispatch
+        // it directly.
         if (mHoldInQueue) {
             mEventQueue.add(MotionEvent.obtain(event));
-        } else if (mDispatchEvents) {
-            dispatchEvent(event);
-        } else if (touchFinished(event)) {
-            mPanZoomController.preventedTouchFinished();
+        } else {
+            dispatchEvent(event, mAllowDefaultAction);
         }
 
         return false;
     }
 
     /**
      * This function is how gecko sends us a default-prevented notification. It is called
      * once gecko knows definitively whether the block of events has had preventDefault
@@ -219,71 +214,60 @@ final class TouchEventHandler implements
     private boolean touchFinished(MotionEvent event) {
         int action = (event.getAction() & MotionEvent.ACTION_MASK);
         return (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL);
     }
 
     /**
      * Dispatch the event to the gesture detectors and the pan/zoom controller.
      */
-    private void dispatchEvent(MotionEvent event) {
-        if (mGestureDetector.onTouchEvent(event)) {
-            return;
+    private void dispatchEvent(MotionEvent event, boolean allowDefaultAction) {
+        if (allowDefaultAction) {
+            if (mGestureDetector.onTouchEvent(event)) {
+                return;
+            }
+            mScaleGestureDetector.onTouchEvent(event);
+            if (mScaleGestureDetector.isInProgress()) {
+                return;
+            }
         }
-        mScaleGestureDetector.onTouchEvent(event);
-        if (mScaleGestureDetector.isInProgress()) {
-            return;
-        }
-        mPanZoomController.handleEvent(event);
+        mPanZoomController.handleEvent(event, !allowDefaultAction);
     }
 
     /**
      * Process the block of events at the head of the queue now that we know
      * whether it has been default-prevented or not.
      */
     private void processEventBlock(boolean allowDefaultAction) {
-        if (!allowDefaultAction) {
-            // if the block has been default-prevented, cancel whatever stuff we had in
-            // progress in the gesture detector and pan zoom controller
-            long now = SystemClock.uptimeMillis();
-            dispatchEvent(MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0, 0, 0));
-        }
-
         if (mEventQueue.isEmpty()) {
             Log.e(LOGTAG, "Unexpected empty event queue in processEventBlock!", new Exception());
             return;
         }
 
         // the odd loop condition is because the first event in the queue will
         // always be a DOWN or POINTER_DOWN event, and we want to process all
         // the events in the queue starting at that one, up to but not including
         // the next DOWN or POINTER_DOWN event.
 
         MotionEvent event = mEventQueue.poll();
         while (true) {
             // event being null here is valid and represents a block of events
             // that has already been dispatched.
 
             if (event != null) {
-                // for each event we process, only dispatch it if the block hasn't been
-                // default-prevented.
-                if (allowDefaultAction) {
-                    dispatchEvent(event);
-                } else if (touchFinished(event)) {
-                    mPanZoomController.preventedTouchFinished();
-                }
+                dispatchEvent(event, allowDefaultAction);
             }
             if (mEventQueue.isEmpty()) {
                 // we have processed the backlog of events, and are all caught up.
                 // now we can set clear the hold flag and set the dispatch flag so
                 // that the handleEvent() function can do the right thing for all
                 // remaining events in this block (which is still ongoing) without
                 // having to put them in the queue.
                 mHoldInQueue = false;
-                mDispatchEvents = allowDefaultAction;
+                mAllowDefaultAction = allowDefaultAction;
                 break;
             }
             event = mEventQueue.peek();
             if (event == null || isDownEvent(event)) {
                 // we have finished processing the block we were interested in.
                 // now we wait for the next call to processEventBlock
                 if (event != null) {
                     mPanZoomController.startingNewEventBlock(event, true);
--- a/mobile/android/base/home/HomeConfig.java
+++ b/mobile/android/base/home/HomeConfig.java
@@ -1,28 +1,33 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.home;
 
 import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ThreadUtils;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import android.content.Context;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.text.TextUtils;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
 
 public final class HomeConfig {
     /**
      * Used to determine what type of HomeFragment subclass to use when creating
      * a given panel. With the exception of DYNAMIC, all of these types correspond
      * to a default set of built-in panels. The DYNAMIC panel type is used by
      * third-party services to create panels with varying types of content.
@@ -260,29 +265,29 @@ public final class HomeConfig {
         public boolean isDynamic() {
             return (mType == PanelType.DYNAMIC);
         }
 
         public boolean isDefault() {
             return mFlags.contains(Flags.DEFAULT_PANEL);
         }
 
-        public void setIsDefault(boolean isDefault) {
+        private void setIsDefault(boolean isDefault) {
             if (isDefault) {
                 mFlags.add(Flags.DEFAULT_PANEL);
             } else {
                 mFlags.remove(Flags.DEFAULT_PANEL);
             }
         }
 
         public boolean isDisabled() {
             return mFlags.contains(Flags.DISABLED_PANEL);
         }
 
-        public void setIsDisabled(boolean isDisabled) {
+        private void setIsDisabled(boolean isDisabled) {
             if (isDisabled) {
                 mFlags.add(Flags.DISABLED_PANEL);
             } else {
                 mFlags.remove(Flags.DISABLED_PANEL);
             }
         }
 
         public JSONObject toJSON() throws JSONException {
@@ -687,16 +692,384 @@ public final class HomeConfig {
 
             @Override
             public ViewConfig[] newArray(final int size) {
                 return new ViewConfig[size];
             }
         };
     }
 
+    /**
+     * Immutable representation of the current state of {@code HomeConfig}.
+     * This is what HomeConfig returns from a load() call and takes as
+     * input to save a new state.
+     *
+     * Users of {@code State} should use an {@code Iterator} to iterate
+     * through the contained {@code PanelConfig} instances.
+     *
+     * {@code State} is immutable i.e. you can't add, remove, or update
+     * contained elements directly. You have to use an {@code Editor} to
+     * change the state, which can be created through the {@code edit()}
+     * method.
+     */
+    public static class State implements Iterable<PanelConfig> {
+        private final HomeConfig mHomeConfig;
+        private final List<PanelConfig> mPanelConfigs;
+
+        private State(HomeConfig homeConfig, List<PanelConfig> panelConfigs) {
+            mHomeConfig = homeConfig;
+            mPanelConfigs = Collections.unmodifiableList(panelConfigs);
+        }
+
+        @Override
+        public Iterator<PanelConfig> iterator() {
+            return mPanelConfigs.iterator();
+        }
+
+        /**
+         * Creates an {@code Editor} for this state.
+         */
+        public Editor edit() {
+            return new Editor(mHomeConfig, this);
+        }
+    }
+
+    /**
+     * {@code Editor} allows you to make changes to a {@code State}. You
+     * can create {@code Editor} by calling {@code edit()} on the target
+     * {@code State} instance.
+     *
+     * {@code Editor} works on a copy of the {@code State} that originated
+     * it. This means that adding, removing, or updating panels in an
+     * {@code Editor} will never change the {@code State} which you
+     * created the {@code Editor} from. Calling {@code commit()} or
+     * {@code apply()} will cause the new {@code State} instance to be
+     * created and saved using the {@code HomeConfig} instance that
+     * created the source {@code State}.
+     *
+     * {@code Editor} is *not* thread-safe. You can only make calls on it
+     * from the thread where it was originally created. It will throw an
+     * exception if you don't follow this invariant.
+     */
+    public static class Editor implements Iterable<PanelConfig> {
+        private final HomeConfig mHomeConfig;
+        private final HashMap<String, PanelConfig> mConfigMap;
+        private final Thread mOriginalThread;
+
+        private PanelConfig mDefaultPanel;
+        private int mEnabledCount;
+
+        private Editor(HomeConfig homeConfig, State configState) {
+            mHomeConfig = homeConfig;
+            mOriginalThread = Thread.currentThread();
+            mConfigMap = new LinkedHashMap<String, PanelConfig>();
+            mEnabledCount = 0;
+
+            initFromState(configState);
+        }
+
+        /**
+         * Initialize the initial state of the editor from the given
+         * {@sode State}. A LinkedHashMap is used to represent the list of
+         * panels as it provides fast access to specific panels from IDs
+         * while also being order-aware. We keep a reference to the
+         * default panel and the number of enabled panels to avoid iterating
+         * through the map every time we need those.
+         *
+         * @param configState The source State to load the editor from.
+         */
+        private void initFromState(State configState) {
+            for (PanelConfig panelConfig : configState) {
+                final PanelConfig panelCopy = new PanelConfig(panelConfig);
+
+                if (!panelCopy.isDisabled()) {
+                    mEnabledCount++;
+                }
+
+                if (panelCopy.isDefault()) {
+                    if (mDefaultPanel == null) {
+                        mDefaultPanel = panelCopy;
+                    } else {
+                        throw new IllegalStateException("Multiple default panels in HomeConfig state");
+                    }
+                }
+
+                mConfigMap.put(panelConfig.getId(), panelCopy);
+            }
+
+            // We should always have a defined default panel if there's
+            // at least one enabled panel around.
+            if (mEnabledCount > 0 && mDefaultPanel == null) {
+                throw new IllegalStateException("Default panel in HomeConfig state is undefined");
+            }
+        }
+
+        private PanelConfig getPanelOrThrow(String panelId) {
+            final PanelConfig panelConfig = mConfigMap.get(panelId);
+            if (panelConfig == null) {
+                throw new IllegalStateException("Tried to access non-existing panel: " + panelId);
+            }
+
+            return panelConfig;
+        }
+
+        private boolean isCurrentDefaultPanel(PanelConfig panelConfig) {
+            if (mDefaultPanel == null) {
+                return false;
+            }
+
+            return mDefaultPanel.equals(panelConfig);
+        }
+
+        private void findNewDefault() {
+            // Pick the first panel that is neither disabled nor currently
+            // set as default.
+            for (PanelConfig panelConfig : mConfigMap.values()) {
+                if (!panelConfig.isDefault() && !panelConfig.isDisabled()) {
+                    setDefault(panelConfig.getId());
+                    return;
+                }
+            }
+
+            mDefaultPanel = null;
+        }
+
+        private List<PanelConfig> makeDeepCopy() {
+            List<PanelConfig> copiedList = new ArrayList<PanelConfig>();
+            for (PanelConfig panelConfig : mConfigMap.values()) {
+                copiedList.add(new PanelConfig(panelConfig));
+            }
+
+            return copiedList;
+        }
+
+        private void setPanelIsDisabled(PanelConfig panelConfig, boolean disabled) {
+            if (panelConfig.isDisabled() == disabled) {
+                return;
+            }
+
+            panelConfig.setIsDisabled(disabled);
+            mEnabledCount += (disabled ? -1 : 1);
+        }
+
+        /**
+         * Gets the ID of the current default panel.
+         */
+        public String getDefaultPanelId() {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            if (mDefaultPanel == null) {
+                return null;
+            }
+
+            return mDefaultPanel.getId();
+        }
+
+        /**
+         * Set a new default panel.
+         *
+         * @param panelId the ID of the new default panel.
+         */
+        public void setDefault(String panelId) {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            final PanelConfig panelConfig = getPanelOrThrow(panelId);
+            if (isCurrentDefaultPanel(panelConfig)) {
+                return;
+            }
+
+            if (mDefaultPanel != null) {
+                mDefaultPanel.setIsDefault(false);
+            }
+
+            panelConfig.setIsDefault(true);
+            setPanelIsDisabled(panelConfig, false);
+
+            mDefaultPanel = panelConfig;
+        }
+
+        /**
+         * Toggles disabled state for a panel.
+         *
+         * @param panelId the ID of the target panel.
+         * @param disabled true to disable the panel.
+         */
+        public void setDisabled(String panelId, boolean disabled) {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            final PanelConfig panelConfig = getPanelOrThrow(panelId);
+            if (panelConfig.isDisabled() == disabled) {
+                return;
+            }
+
+            setPanelIsDisabled(panelConfig, disabled);
+
+            if (disabled) {
+                if (isCurrentDefaultPanel(panelConfig)) {
+                    panelConfig.setIsDefault(false);
+                    findNewDefault();
+                }
+            } else if (mEnabledCount == 1) {
+                setDefault(panelId);
+            }
+        }
+
+        /**
+         * Adds a new {@code PanelConfig}. It will do nothing if the
+         * {@code Editor} already contains a panel with the same ID.
+         *
+         * @param panelConfig the {@code PanelConfig} instance to be added.
+         * @return true if the item has been added.
+         */
+        public boolean install(PanelConfig panelConfig) {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            if (panelConfig == null) {
+                throw new IllegalStateException("Can't install a null panel");
+            }
+
+            if (!panelConfig.isDynamic()) {
+                throw new IllegalStateException("Can't install a built-in panel: " + panelConfig.getId());
+            }
+
+            if (panelConfig.isDisabled()) {
+                throw new IllegalStateException("Can't install a disabled panel: " + panelConfig.getId());
+            }
+
+            boolean installed = false;
+
+            final String id = panelConfig.getId();
+            if (!mConfigMap.containsKey(id)) {
+                mConfigMap.put(id, panelConfig);
+
+                mEnabledCount++;
+                if (mEnabledCount == 1 || panelConfig.isDefault()) {
+                    setDefault(panelConfig.getId());
+                }
+
+                installed = true;
+            }
+
+            return installed;
+        }
+
+        /**
+         * Removes an existing panel.
+         *
+         * @return true if the item has been removed.
+         */
+        public boolean uninstall(String panelId) {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            final PanelConfig panelConfig = mConfigMap.get(panelId);
+            if (panelConfig == null) {
+                return false;
+            }
+
+            if (!panelConfig.isDynamic()) {
+                throw new IllegalStateException("Can't uninstall a built-in panel: " + panelConfig.getId());
+            }
+
+            mConfigMap.remove(panelId);
+
+            if (!panelConfig.isDisabled()) {
+                mEnabledCount--;
+            }
+
+            if (isCurrentDefaultPanel(panelConfig)) {
+                findNewDefault();
+            }
+
+            return true;
+        }
+
+        /**
+         * Replaces an existing panel with a new {@code PanelConfig} instance.
+         *
+         * @return true if the item has been updated.
+         */
+        public boolean update(PanelConfig panelConfig) {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            if (panelConfig == null) {
+                throw new IllegalStateException("Can't update a null panel");
+            }
+
+            boolean updated = false;
+
+            final String id = panelConfig.getId();
+            if (mConfigMap.containsKey(id)) {
+                final PanelConfig oldPanelConfig = mConfigMap.put(id, panelConfig);
+
+                // The disabled and default states can't never be
+                // changed by an update operation.
+                panelConfig.setIsDefault(oldPanelConfig.isDefault());
+                panelConfig.setIsDisabled(oldPanelConfig.isDisabled());
+
+                updated = true;
+            }
+
+            return updated;
+        }
+
+        /**
+         * Saves the current {@code Editor} state asynchronously in the
+         * background thread.
+         *
+         * @return the resulting {@code State} instance.
+         */
+        public State apply() {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            // We're about to save the current state in the background thread
+            // so we should use a deep copy of the PanelConfig instances to
+            // avoid saving corrupted state.
+            final State newConfigState = new State(mHomeConfig, makeDeepCopy());
+
+            ThreadUtils.getBackgroundHandler().post(new Runnable() {
+                @Override
+                public void run() {
+                    mHomeConfig.save(newConfigState);
+                }
+            });
+
+            return newConfigState;
+        }
+
+        /**
+         * Saves the current {@code Editor} state synchronously in the
+         * current thread.
+         *
+         * @return the resulting {@code State} instance.
+         */
+        public State commit() {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            final State newConfigState =
+                    new State(mHomeConfig, new ArrayList<PanelConfig>(mConfigMap.values()));
+
+            // This is a synchronous blocking operation, hence no
+            // need to deep copy the current PanelConfig instances.
+            mHomeConfig.save(newConfigState);
+
+            return newConfigState;
+        }
+
+        public boolean isEmpty() {
+            return mConfigMap.isEmpty();
+        }
+
+        @Override
+        public Iterator<PanelConfig> iterator() {
+            ThreadUtils.assertOnThread(mOriginalThread);
+
+            return mConfigMap.values().iterator();
+        }
+    }
+
     public interface OnChangeListener {
         public void onChange();
     }
 
     public interface HomeConfigBackend {
         public List<PanelConfig> load();
         public void save(List<PanelConfig> entries);
         public String getLocale();
@@ -710,26 +1083,27 @@ public final class HomeConfig {
     private static final String HISTORY_PANEL_ID = "f134bf20-11f7-4867-ab8b-e8e705d7fbe8";
 
     private final HomeConfigBackend mBackend;
 
     public HomeConfig(HomeConfigBackend backend) {
         mBackend = backend;
     }
 
-    public List<PanelConfig> load() {
-        return mBackend.load();
+    public State load() {
+        final List<PanelConfig> panelConfigs = mBackend.load();
+        return new State(this, panelConfigs);
     }
 
     public String getLocale() {
         return mBackend.getLocale();
     }
 
-    public void save(List<PanelConfig> panelConfigs) {
-        mBackend.save(panelConfigs);
+    public void save(State configState) {
+        mBackend.save(configState.mPanelConfigs);
     }
 
     public void setOnChangeListener(OnChangeListener listener) {
         mBackend.setOnChangeListener(listener);
     }
 
     public static PanelConfig createBuiltinPanelConfig(Context context, PanelType panelType) {
         return createBuiltinPanelConfig(context, panelType, EnumSet.noneOf(PanelConfig.Flags.class));
--- a/mobile/android/base/home/HomeConfigInvalidator.java
+++ b/mobile/android/base/home/HomeConfigInvalidator.java
@@ -185,141 +185,104 @@ public class HomeConfigInvalidator imple
         } else {
             handler.postDelayed(mInvalidationRunnable, INVALIDATION_DELAY_MSEC);
         }
 
         Log.d(LOGTAG, "scheduleInvalidation: scheduled new invalidation: " + mode);
     }
 
     /**
-     * Replace an element if a matching PanelConfig is
-     * present in the given list.
-     */
-    private boolean replacePanelConfig(List<PanelConfig> panelConfigs, PanelConfig panelConfig) {
-        final int index = panelConfigs.indexOf(panelConfig);
-        if (index >= 0) {
-            panelConfigs.set(index, panelConfig);
-            Log.d(LOGTAG, "executePendingChanges: replaced position " + index + " with " + panelConfig.getId());
-
-            return true;
-        }
-
-        return false;
-    }
-
-    private PanelConfig findPanelConfigWithId(List<PanelConfig> panelConfigs, String panelId) {
-        for (PanelConfig panelConfig : panelConfigs) {
-            if (panelConfig.getId().equals(panelId)) {
-                return panelConfig;
-            }
-        }
-
-        return null;
-    }
-
-    /**
      * Runs in the background thread.
      */
-    private List<PanelConfig> executePendingChanges(List<PanelConfig> panelConfigs) {
+    private void executePendingChanges(HomeConfig.Editor editor) {
         boolean shouldRefresh = false;
 
         while (!mPendingChanges.isEmpty()) {
             final ConfigChange pendingChange = mPendingChanges.poll();
 
             switch (pendingChange.type) {
                 case UNINSTALL: {
                     final String panelId = (String) pendingChange.target;
-                    final PanelConfig panelConfig = findPanelConfigWithId(panelConfigs, panelId);
-                    if (panelConfig != null && panelConfigs.remove(panelConfig)) {
-                        Log.d(LOGTAG, "executePendingChanges: removed panel " + panelConfig.getId());
+                    if (editor.uninstall(panelId)) {
+                        Log.d(LOGTAG, "executePendingChanges: uninstalled panel " + panelId);
                     }
                     break;
                 }
 
                 case INSTALL: {
                     final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
-                    if (!replacePanelConfig(panelConfigs, panelConfig)) {
-                        panelConfigs.add(panelConfig);
+                    if (editor.install(panelConfig)) {
                         Log.d(LOGTAG, "executePendingChanges: added panel " + panelConfig.getId());
                     }
                     break;
                 }
 
                 case UPDATE: {
                     final PanelConfig panelConfig = (PanelConfig) pendingChange.target;
-                    if (!replacePanelConfig(panelConfigs, panelConfig)) {
-                        Log.w(LOGTAG, "Tried to update non-existing panel " + panelConfig.getId());
+                    if (editor.update(panelConfig)) {
+                        Log.w(LOGTAG, "executePendingChanges: updated panel " + panelConfig.getId());
                     }
                     break;
                 }
 
                 case REFRESH: {
                     shouldRefresh = true;
                 }
             }
         }
 
         if (shouldRefresh) {
-            return executeRefresh(panelConfigs);
-        } else {
-            return panelConfigs;
+            executeRefresh(editor);
         }
     }
 
     /**
      * Runs in the background thread.
      */
-    private List<PanelConfig> refreshFromPanelInfos(List<PanelConfig> panelConfigs, List<PanelInfo> panelInfos) {
+    private void refreshFromPanelInfos(HomeConfig.Editor editor, List<PanelInfo> panelInfos) {
         Log.d(LOGTAG, "refreshFromPanelInfos");
 
-        final int count = panelConfigs.size();
-        for (int i = 0; i < count; i++) {
-            final PanelConfig panelConfig = panelConfigs.get(i);
+        for (PanelConfig panelConfig : editor) {
+            PanelConfig refreshedPanelConfig = null;
 
-            PanelConfig refreshedPanelConfig = null;
             if (panelConfig.isDynamic()) {
                 for (PanelInfo panelInfo : panelInfos) {
                     if (panelInfo.getId().equals(panelConfig.getId())) {
                         refreshedPanelConfig = panelInfo.toPanelConfig();
                         Log.d(LOGTAG, "refreshFromPanelInfos: refreshing from panel info: " + panelInfo.getId());
                         break;
                     }
                 }
             } else {
                 refreshedPanelConfig = createBuiltinPanelConfig(mContext, panelConfig.getType());
                 Log.d(LOGTAG, "refreshFromPanelInfos: refreshing built-in panel: " + panelConfig.getId());
             }
 
             if (refreshedPanelConfig == null) {
                 Log.d(LOGTAG, "refreshFromPanelInfos: no refreshed panel, falling back: " + panelConfig.getId());
-                refreshedPanelConfig = panelConfig;
+                continue;
             }
 
-            refreshedPanelConfig.setIsDefault(panelConfig.isDefault());
-            refreshedPanelConfig.setIsDisabled(panelConfig.isDisabled());
-
-            Log.d(LOGTAG, "refreshFromPanelInfos: set " + i + " with " + refreshedPanelConfig.getId());
-            panelConfigs.set(i, refreshedPanelConfig);
+            Log.d(LOGTAG, "refreshFromPanelInfos: refreshed panel " + refreshedPanelConfig.getId());
+            editor.update(refreshedPanelConfig);
         }
-
-        return panelConfigs;
     }
 
     /**
      * Runs in the background thread.
      */
-    private List<PanelConfig> executeRefresh(List<PanelConfig> panelConfigs) {
-        if (panelConfigs.isEmpty()) {
-            return panelConfigs;
+    private void executeRefresh(HomeConfig.Editor editor) {
+        if (editor.isEmpty()) {
+            return;
         }
 
         Log.d(LOGTAG, "executeRefresh");
 
         final Set<String> ids = new HashSet<String>();
-        for (PanelConfig panelConfig : panelConfigs) {
+        for (PanelConfig panelConfig : editor) {
             ids.add(panelConfig.getId());
         }
 
         final Object panelRequestLock = new Object();
         final List<PanelInfo> latestPanelInfos = new ArrayList<PanelInfo>();
 
         final PanelManager pm = new PanelManager();
         pm.requestPanelsById(ids, new RequestCallback() {
@@ -334,26 +297,27 @@ public class HomeConfigInvalidator imple
             }
         });
 
         try {
             synchronized(panelRequestLock) {
                 panelRequestLock.wait(PANEL_INFO_TIMEOUT_MSEC);
 
                 Log.d(LOGTAG, "executeRefresh: done fetching panel infos");
-                return refreshFromPanelInfos(panelConfigs, latestPanelInfos);
+                refreshFromPanelInfos(editor, latestPanelInfos);
             }
         } catch (InterruptedException e) {
             Log.e(LOGTAG, "Failed to fetch panels from gecko", e);
-            return panelConfigs;
         }
     }
 
     /**
      * Runs in the background thread.
      */
     private class InvalidationRunnable implements Runnable {
         @Override
         public void run() {
-            mHomeConfig.save(executePendingChanges(mHomeConfig.load()));
+            final HomeConfig.Editor editor = mHomeConfig.load().edit();
+            executePendingChanges(editor);
+            editor.commit();
         }
     };
 }
--- a/mobile/android/base/home/HomeConfigLoader.java
+++ b/mobile/android/base/home/HomeConfigLoader.java
@@ -8,74 +8,74 @@ package org.mozilla.gecko.home;
 import org.mozilla.gecko.home.HomeConfig.PanelConfig;
 import org.mozilla.gecko.home.HomeConfig.OnChangeListener;
 
 import android.content.Context;
 import android.support.v4.content.AsyncTaskLoader;
 
 import java.util.List;
 
-public class HomeConfigLoader extends AsyncTaskLoader<List<PanelConfig>> {
+public class HomeConfigLoader extends AsyncTaskLoader<HomeConfig.State> {
     private final HomeConfig mConfig;
-    private List<PanelConfig> mPanelConfigs;
+    private HomeConfig.State mConfigState;
 
     public HomeConfigLoader(Context context, HomeConfig homeConfig) {
         super(context);
         mConfig = homeConfig;
     }
 
     @Override
-    public List<PanelConfig> loadInBackground() {
+    public HomeConfig.State loadInBackground() {
         return mConfig.load();
     }
 
     @Override
-    public void deliverResult(List<PanelConfig> panelConfigs) {
+    public void deliverResult(HomeConfig.State configState) {
         if (isReset()) {
-            mPanelConfigs = null;
+            mConfigState = null;
             return;
         }
 
-        mPanelConfigs = panelConfigs;
+        mConfigState = configState;
         mConfig.setOnChangeListener(new ForceLoadChangeListener());
 
         if (isStarted()) {
-            super.deliverResult(panelConfigs);
+            super.deliverResult(configState);
         }
     }
 
     @Override
     protected void onStartLoading() {
-        if (mPanelConfigs != null) {
-            deliverResult(mPanelConfigs);
+        if (mConfigState != null) {
+            deliverResult(mConfigState);
         }
 
-        if (takeContentChanged() || mPanelConfigs == null) {
+        if (takeContentChanged() || mConfigState == null) {
             forceLoad();
         }
     }
 
     @Override
     protected void onStopLoading() {
         cancelLoad();
     }
 
     @Override
-    public void onCanceled(List<PanelConfig> panelConfigs) {
-        mPanelConfigs = null;
+    public void onCanceled(HomeConfig.State configState) {
+        mConfigState = null;
     }
 
     @Override
     protected void onReset() {
         super.onReset();
 
         // Ensure the loader is stopped.
         onStopLoading();
 
-        mPanelConfigs = null;
+        mConfigState = null;
         mConfig.setOnChangeListener(null);
     }
 
     private class ForceLoadChangeListener implements OnChangeListener {
         @Override
         public void onChange() {
             onContentChanged();
         }
--- a/mobile/android/base/home/HomePager.java
+++ b/mobile/android/base/home/HomePager.java
@@ -245,17 +245,17 @@ public class HomePager extends ViewPager
     }
 
     public void onToolbarFocusChange(boolean hasFocus) {
         // We should only make the banner active if the toolbar is not focused and we are on the default page
         final boolean active = !hasFocus && getCurrentItem() == mDefaultPageIndex;
         mHomeBanner.setActive(active);
     }
 
-    private void updateUiFromPanelConfigs(List<PanelConfig> panelConfigs) {
+    private void updateUiFromConfigState(HomeConfig.State configState) {
         // We only care about the adapter if HomePager is currently
         // loaded, which means it's visible in the activity.
         if (!mLoaded) {
             return;
         }
 
         if (mDecor != null) {
             mDecor.removeAllPagerViews();
@@ -265,17 +265,17 @@ public class HomePager extends ViewPager
 
         // Destroy any existing panels currently loaded
         // in the pager.
         setAdapter(null);
 
         // Only keep enabled panels.
         final List<PanelConfig> enabledPanels = new ArrayList<PanelConfig>();
 
-        for (PanelConfig panelConfig : panelConfigs) {
+        for (PanelConfig panelConfig : configState) {
             if (!panelConfig.isDisabled()) {
                 enabledPanels.add(panelConfig);
             }
         }
 
         // Update the adapter with the new panel configs
         adapter.update(enabledPanels);
 
@@ -309,29 +309,29 @@ public class HomePager extends ViewPager
                     mDefaultPageIndex = i;
                     setCurrentItem(i, false);
                     break;
                 }
             }
         }
     }
 
-    private class ConfigLoaderCallbacks implements LoaderCallbacks<List<PanelConfig>> {
+    private class ConfigLoaderCallbacks implements LoaderCallbacks<HomeConfig.State> {
         @Override
-        public Loader<List<PanelConfig>> onCreateLoader(int id, Bundle args) {
+        public Loader<HomeConfig.State> onCreateLoader(int id, Bundle args) {
             return new HomeConfigLoader(mContext, mConfig);
         }
 
         @Override
-        public void onLoadFinished(Loader<List<PanelConfig>> loader, List<PanelConfig> panelConfigs) {
-            updateUiFromPanelConfigs(panelConfigs);
+        public void onLoadFinished(Loader<HomeConfig.State> loader, HomeConfig.State configState) {
+            updateUiFromConfigState(configState);
         }
 
         @Override
-        public void onLoaderReset(Loader<List<PanelConfig>> loader) {
+        public void onLoaderReset(Loader<HomeConfig.State> loader) {
         }
     }
 
     private class PageChangeListener implements ViewPager.OnPageChangeListener {
         @Override
         public void onPageSelected(int position) {
             if (mDecor != null) {
                 mDecor.onPageSelected(position);
--- a/mobile/android/base/home/HomePanelPicker.java
+++ b/mobile/android/base/home/HomePanelPicker.java
@@ -219,29 +219,29 @@ public class HomePanelPicker extends Fra
             mPanelInfos = panelInfos;
             notifyDataSetChanged();
         }
     }
 
     /**
      * Fetch installed Home panels and update the adapter for this activity.
      */
-    private class ConfigLoaderCallbacks implements LoaderCallbacks<List<PanelConfig>> {
+    private class ConfigLoaderCallbacks implements LoaderCallbacks<HomeConfig.State> {
         @Override
-        public Loader<List<PanelConfig>> onCreateLoader(int id, Bundle args) {
+        public Loader<HomeConfig.State> onCreateLoader(int id, Bundle args) {
             final HomeConfig homeConfig = HomeConfig.getDefault(HomePanelPicker.this);
             return new HomeConfigLoader(HomePanelPicker.this, homeConfig);
         }
 
         @Override
-        public void onLoadFinished(Loader<List<PanelConfig>> loader, List<PanelConfig> panelConfigs) {
+        public void onLoadFinished(Loader<HomeConfig.State> loader, HomeConfig.State configState) {
             mCurrentPanelsIds = new ArrayList<String>();
-            for (PanelConfig panelConfig : panelConfigs) {
+            for (PanelConfig panelConfig : configState) {
                 mCurrentPanelsIds.add(panelConfig.getId());
             }
 
             updatePanelsAdapter(mPanelInfos);
         }
 
         @Override
-        public void onLoaderReset(Loader<List<PanelConfig>> loader) {}
+        public void onLoaderReset(Loader<HomeConfig.State> loader) {}
     }
 }
--- a/mobile/android/base/preferences/PanelsPreferenceCategory.java
+++ b/mobile/android/base/preferences/PanelsPreferenceCategory.java
@@ -15,20 +15,19 @@ import org.mozilla.gecko.util.UiAsyncTas
 import android.content.Context;
 import android.text.TextUtils;
 import android.util.AttributeSet;
 
 public class PanelsPreferenceCategory extends CustomListCategory {
     public static final String LOGTAG = "PanelsPrefCategory";
 
     protected HomeConfig mHomeConfig;
-    protected List<PanelConfig> mPanelConfigs;
+    protected HomeConfig.Editor mConfigEditor;
 
-    protected UiAsyncTask<Void, Void, List<PanelConfig>> mLoadTask;
-    protected UiAsyncTask<Void, Void, Void> mSaveTask;
+    protected UiAsyncTask<Void, Void, HomeConfig.State> mLoadTask;
 
     public PanelsPreferenceCategory(Context context) {
         super(context);
         initConfig(context);
     }
 
     public PanelsPreferenceCategory(Context context, AttributeSet attrs) {
         super(context, attrs);
@@ -50,193 +49,114 @@ public class PanelsPreferenceCategory ex
 
         loadHomeConfig();
     }
 
     /**
      * Load the Home Panels config and populate the preferences screen and maintain local state.
      */
     private void loadHomeConfig() {
-        mLoadTask = new UiAsyncTask<Void, Void, List<PanelConfig>>(ThreadUtils.getBackgroundHandler()) {
+        mLoadTask = new UiAsyncTask<Void, Void, HomeConfig.State>(ThreadUtils.getBackgroundHandler()) {
             @Override
-            public List<PanelConfig> doInBackground(Void... params) {
+            public HomeConfig.State doInBackground(Void... params) {
                 return mHomeConfig.load();
             }
 
             @Override
-            public void onPostExecute(List<PanelConfig> panelConfigs) {
-                mPanelConfigs = panelConfigs;
-                displayHomeConfig();
+            public void onPostExecute(HomeConfig.State configState) {
+                mConfigEditor = configState.edit();
+                displayHomeConfig(configState);
             }
         };
         mLoadTask.execute();
     }
 
-    private void displayHomeConfig() {
-        for (PanelConfig panelConfig : mPanelConfigs) {
+    private void displayHomeConfig(HomeConfig.State configState) {
+        for (PanelConfig panelConfig : configState) {
             // Create and add the pref.
             final PanelsPreference pref = new PanelsPreference(getContext(), PanelsPreferenceCategory.this);
             pref.setTitle(panelConfig.getTitle());
             pref.setKey(panelConfig.getId());
             // XXX: Pull icon from PanelInfo.
             addPreference(pref);
 
-            if (panelConfig.isDefault()) {
-                mDefaultReference = pref;
-                pref.setIsDefault(true);
-            }
-
             if (panelConfig.isDisabled()) {
                 pref.setHidden(true);
             }
         }
+
+        setDefaultFromConfig();
     }
 
-    /**
-     * Update HomeConfig off the main thread.
-     *
-     * @param panelConfigs Configuration to be saved
-     */
-    private void saveHomeConfig() {
-        if (mPanelConfigs == null) {
+    private void setDefaultFromConfig() {
+        final String defaultPanelId = mConfigEditor.getDefaultPanelId();
+        if (defaultPanelId == null) {
+            mDefaultReference = null;
             return;
         }
 
-        final List<PanelConfig> panelConfigs = makeConfigListDeepCopy();
-        mSaveTask = new UiAsyncTask<Void, Void, Void>(ThreadUtils.getBackgroundHandler()) {
-            @Override
-            public Void doInBackground(Void... params) {
-                mHomeConfig.save(panelConfigs);
-                return null;
+        final int prefCount = getPreferenceCount();
+
+        // First preference (index 0) is Preference to add panels.
+        for (int i = 1; i < prefCount; i++) {
+            final PanelsPreference pref = (PanelsPreference) getPreference(i);
+
+            if (defaultPanelId.equals(pref.getKey())) {
+                super.setDefault(pref);
+                break;
             }
-        };
-        mSaveTask.execute();
-    }
-
-    private List<PanelConfig> makeConfigListDeepCopy() {
-        List<PanelConfig> copiedList = new ArrayList<PanelConfig>();
-        for (PanelConfig panelConfig : mPanelConfigs) {
-            copiedList.add(new PanelConfig(panelConfig));
         }
-        return copiedList;
     }
 
     @Override
     public void setDefault(CustomListPreference pref) {
         super.setDefault(pref);
-        updateConfigDefault();
-        saveHomeConfig();
+
+        final String id = pref.getKey();
+
+        final String defaultPanelId = mConfigEditor.getDefaultPanelId();
+        if (defaultPanelId != null && defaultPanelId.equals(id)) {
+            return;
+        }
+
+        mConfigEditor.setDefault(id);
+        mConfigEditor.apply();
     }
 
     @Override
     protected void onPrepareForRemoval() {
         if (mLoadTask != null) {
             mLoadTask.cancel(true);
         }
-
-        if (mSaveTask != null) {
-            mSaveTask.cancel(true);
-        }
-     }
-
-    /**
-     * Update the local HomeConfig default state from mDefaultReference.
-     */
-    private void updateConfigDefault() {
-        String id = null;
-        if (mDefaultReference != null) {
-            id = mDefaultReference.getKey();
-        }
-
-        for (PanelConfig panelConfig : mPanelConfigs) {
-            if (TextUtils.equals(panelConfig.getId(), id)) {
-                panelConfig.setIsDefault(true);
-                panelConfig.setIsDisabled(false);
-            } else {
-                panelConfig.setIsDefault(false);
-            }
-        }
     }
 
     @Override
     public void uninstall(CustomListPreference pref) {
-        super.uninstall(pref);
-        // This could change the default, so update the local version of the config.
-        updateConfigDefault();
+        mConfigEditor.uninstall(pref.getKey());
+        mConfigEditor.apply();
 
-        final String id = pref.getKey();
-        PanelConfig toRemove = null;
-        for (PanelConfig panelConfig : mPanelConfigs) {
-            if (TextUtils.equals(panelConfig.getId(), id)) {
-                toRemove = panelConfig;
-                break;
-            }
-        }
-        mPanelConfigs.remove(toRemove);
-
-        saveHomeConfig();
+        super.uninstall(pref);
     }
 
     /**
      * Update the hide/show state of the preference and save the HomeConfig
      * changes.
      *
      * @param pref Preference to update
      * @param toHide New hidden state of the preference
      */
     protected void setHidden(PanelsPreference pref, boolean toHide) {
-        pref.setHidden(toHide);
-        ensureDefaultForHide(pref, toHide);
-
-        final String id = pref.getKey();
-        for (PanelConfig panelConfig : mPanelConfigs) {
-            if (TextUtils.equals(panelConfig.getId(), id)) {
-                panelConfig.setIsDisabled(toHide);
-                break;
-            }
-        }
-
-        saveHomeConfig();
-    }
+        mConfigEditor.setDisabled(pref.getKey(), toHide);
+        mConfigEditor.apply();
 
-    /**
-     * Ensure a default is set (if possible) for hiding/showing a pref.
-     * If hiding, try to find an enabled pref to set as the default.
-     * If showing, set it as the default if there is no default currently.
-     *
-     * This updates the local HomeConfig state.
-     *
-     * @param pref Preference getting updated
-     * @param toHide Boolean of the new hidden state
-     */
-    private void ensureDefaultForHide(PanelsPreference pref, boolean toHide) {
-        if (toHide) {
-            // Set a default if there is an enabled panel left.
-            if (pref == mDefaultReference) {
-                setFallbackDefault();
-                updateConfigDefault();
-            }
-        } else {
-            if (mDefaultReference == null) {
-                super.setDefault(pref);
-                updateConfigDefault();
-            }
-        }
+        pref.setHidden(toHide);
+        setDefaultFromConfig();
     }
 
     /**
      * When the default panel is removed or disabled, find an enabled panel
      * if possible and set it as mDefaultReference.
      */
     @Override
     protected void setFallbackDefault() {
-        // First preference (index 0) is Preference to add panels.
-        final int prefsCount = getPreferenceCount();
-        for (int i = 1; i < prefsCount; i++) {
-            final PanelsPreference pref = (PanelsPreference) getPreference(i);
-            if (!pref.isHidden()) {
-                super.setDefault(pref);
-                return;
-            }
-        }
-        mDefaultReference = null;
+        setDefaultFromConfig();
     }
 }
index ae39f6583c7ebdc21b64b8e07f169e8b7f5f1e58..42566ff2e3322f62ecb36bb3b6b8a4f094abc11e
GIT binary patch
literal 451
zc$@*l0X+VRP)<h;3K|Lk000e1NJLTq001cf001Be1^@s6k8e>v0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzWl2OqR9M69n9EATKoo|*X?n4^bLV^X
zA>8;9g7k&lWbd1}Ev^Mo#7#voUO>f5=6BJPc1TlgW3?&GfuEC^keqK0XU@PlNjT@k
z#2gheNjN}N1Iu&PkjsRuy48?1WDWVJkV$_vggfyp$apaF{oZ(-oc7Abae*3vve@2~
zn%B3GI^LZb>HcK6C`c6eOf8^kY8cQ5@M?MkK10wlkVPP&GJsa#Gqp_}^O0rm8AyOT
z;L%{yDM%enm#Q>kk|yJ)v(yDZ?SkN2rmiV`?&jv3mbnDD1}=di@Bq9QL24dId@%MP
zs~~v?9weKyck&wh(YJzB_79tvkmm~Nz(8*;o0WqEX%9FDZh)66LS7&p{*=kz)?_EB
z$)Z{;s?%tpnq1QE&hPA5yE|J{WbJ0Y-Mz#<*~lsDmCf8j%J)g)0ovX#1Nna^&U&5Q
t>k7F-t06ZJvcAFC9LR#JziJUm?hD*qSqwLXHrfCH002ovPDHLkV1f={&Ab2r
index 31d7f0bd004d28cd419ecc14458ab327038d4869..370898c4a4b6460acca43de7c87a5b11bf83d18c
GIT binary patch
literal 426
zc$@*M0agBqP)<h;3K|Lk000e1NJLTq000~S000&U1^@s6)0X`50000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzOi4sRR7l6&mO)O#Fc3xG#3`lQT#`F*
z1}=~l7vT&n*dnoDQ-nkXi&9l>=CkOyvZ6o>sIAnIzJBB=|9d=RJ0=M;6Pqv&7?LnR
zhc+rbPhxn?IFaG(>Nsvq`#(b~P!-6l=7BC)`xp+1rh+p@Kpm)}5m&$>umsjki)X_j
z(Gpo~RB*;cU>uwxt2r<O-hdBa(X^~f!z_L=Aaog+1j^@0Z@i9-X23J>1WbYVre$dW
ztDE``nW@9%REWym5*?L9k}KgPIkj9{#w|%oblR+nsN@(@Cox631s=lmwQ2dxhC+rM
z<eLh}4IHExi+6Hu88;+BC#3wHKnfjQ1NXoq@Y1x*f0JP?C&`uh?-~A3!*Nl=q1p}A
zHSMh7jxOzY=yzS3Czycst}flR533&XUaWhQ+It@!_oO-d+v3b`{N7L0VcjJA0!H`s
U{aZymmjD0&07*qoM6N<$g1vFKga7~l
index f9cfb79c91e5faf762e0d0140b39700b01da8502..93546b15fb9bc0cd40c8ccd80ddf1270f24ca226
GIT binary patch
literal 572
zc$@(|0>k}@P)<h;3K|Lk000e1NJLTq001@s001fo1^@s6_)(}m0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz-bqA3RA}Dqn!Qp2F%ZYI$DI~d+VLK?
zK7fVK!4p{e7B=+0fiK{UjfJI^mD;G|2!0_tj9E8#8_31naexSiyEA__At9GvlG!W+
zfQSkJP~{C&jV)y|zV09)HUSf`B?4PT6EFc2FaZ-V0h`_0=h;&>_EHbM<6M0fOUG($
z&k2kt9vpd^$!pByCqj`CgyD?+1oXhX74Dwi*6s{$wF6*y)Xa7ILojd@vB2>*r`4n{
z$KxWv!0}Ouw`!CqVweHxgVbj~;P){IhKJ?x#eC}N0wJ)9YRu#tfl0>&^@Pi=w`B`v
zfSyT@X5C3|q)*efxzhyN2%HG4n?XhVkl1;ct2i(@e}bX1)taMquioIJ_d+tq=!SGf
zdLX@;oht&vaXXfFMU^iPJ@-R3s%xiBt5KWwJT;(p`yn4L^v1(sfAVKk*k?Gid;L`b
zGlgAxy(Ha}dIGlOU?)^KsB#pi4IXaPiwmM!GA82Lz>yRLkHiTcCL-REt}6wPiuYJ3
z+=moRa6iQ*ixLOI{X4^TSmoh<ju$li(gkfZj{)Ch)h_4-r&MLT4qi%yd?amJr7NXW
zE1LxS)@jv_>z{+w*aS?#1WdrD2a{hk1x&!^2v+b1mU>gsSNm`1d+`kRa$@2D0000<
KMNUMnLSTX$K>q0f
--- a/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
+++ b/mobile/android/base/resources/layout-large-v11/browser_toolbar.xml
@@ -102,15 +102,16 @@
                android:layout_width="fill_parent"
                android:layout_height="2dp"
                android:layout_alignParentBottom="true"
                android:background="@color/url_bar_shadow"
                android:contentDescription="@null"/>
 
     <org.mozilla.gecko.toolbar.ToolbarProgressView android:id="@+id/progress"
                                                    android:layout_width="fill_parent"
-                                                   android:layout_height="2dp"
+                                                   android:layout_height="14dp"
+                                                   android:layout_marginBottom="-7dp"
                                                    android:layout_alignBottom="@id/shadow"
                                                    android:src="@drawable/progress"
                                                    android:background="@null"
                                                    android:visibility="gone" />
 
 </merge>
--- a/mobile/android/base/resources/layout/browser_toolbar.xml
+++ b/mobile/android/base/resources/layout/browser_toolbar.xml
@@ -102,16 +102,16 @@
                android:layout_width="fill_parent"
                android:layout_height="2dp"
                android:layout_alignParentBottom="true"
                android:background="@color/url_bar_shadow"
                android:contentDescription="@null"/>
 
     <org.mozilla.gecko.toolbar.ToolbarProgressView android:id="@+id/progress"
                                                    android:layout_width="fill_parent"
-                                                   android:layout_height="16dp"
-                                                   android:layout_marginBottom="-8dp"
+                                                   android:layout_height="14dp"
+                                                   android:layout_marginBottom="-7dp"
                                                    android:layout_alignBottom="@id/shadow"
                                                    android:src="@drawable/progress"
                                                    android:background="@null"
                                                    android:visibility="gone" />
 
 </merge>
--- a/mobile/android/base/resources/layout/gecko_app.xml
+++ b/mobile/android/base/resources/layout/gecko_app.xml
@@ -89,16 +89,20 @@
 
         <org.mozilla.gecko.widget.GeckoViewFlipper android:id="@id/browser_actionbar"
                 android:layout_width="fill_parent"
                 android:layout_height="@dimen/browser_toolbar_height"
                 android:clickable="true"
                 android:clipChildren="false"
                 android:focusable="true">
 
+            <!-- clipChildren="false" allows the child ToolbarProgressView to be drawn
+                 outside of BrowserToolbar's boundaries. Likewise, we need this property
+                 on BrowserToolbar's parent ViewFlipper, then on its parent MainLayout
+                 to allow the progress to overlap the content LayerView. -->
             <org.mozilla.gecko.toolbar.BrowserToolbar
                 android:id="@+id/browser_toolbar"
                 style="@style/BrowserToolbar"
                 android:layout_width="fill_parent"
                 android:layout_height="@dimen/browser_toolbar_height"
                 android:clickable="true"
                 android:focusable="true"
                 android:clipChildren="false"
--- a/services/common/hawkclient.js
+++ b/services/common/hawkclient.js
@@ -194,18 +194,23 @@ this.HawkClient.prototype = {
       deferred.resolve(this.response.body);
     };
 
     let extra = {
       now: this.now(),
       localtimeOffsetMsec: this.localtimeOffsetMsec,
     };
 
-    let request = new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
+    let request = this.newHAWKAuthenticatedRESTRequest(uri, credentials, extra);
     if (method == "post" || method == "put") {
       request[method](payloadObj, onComplete);
     } else {
       request[method](onComplete);
     }
 
     return deferred.promise;
-  }
+  },
+
+  // override points for testing.
+  newHAWKAuthenticatedRESTRequest: function(uri, credentials, extra) {
+    return new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
+  },
 }
--- a/services/common/tokenserverclient.js
+++ b/services/common/tokenserverclient.js
@@ -240,17 +240,17 @@ TokenServerClient.prototype = {
     }
 
     if (!cb) {
       throw new TokenServerClientError("cb argument is not valid.");
     }
 
     this._log.debug("Beginning BID assertion exchange: " + url);
 
-    let req = new RESTRequest(url);
+    let req = this.newRESTRequest(url);
     req.setHeader("Accept", "application/json");
     req.setHeader("Authorization", "BrowserID " + assertion);
 
     for (let header in addHeaders) {
       req.setHeader(header, addHeaders[header]);
     }
 
     let client = this;
@@ -398,10 +398,15 @@ TokenServerClient.prototype = {
     this._log.debug("Successful token response: " + result.id);
     cb(null, {
       id:       result.id,
       key:      result.key,
       endpoint: result.api_endpoint,
       uid:      result.uid,
       duration: result.duration,
     });
+  },
+
+  // override points for testing.
+  newRESTRequest: function(url) {
+    return new RESTRequest(url);
   }
 };
--- a/services/sync/modules-testing/utils.js
+++ b/services/sync/modules-testing/utils.js
@@ -112,16 +112,19 @@ this.makeIdentityConfig = function(overr
 
 // Configure an instance of an FxAccount identity provider with the specified
 // config (or the default config if not specified).
 this.configureFxAccountIdentity = function(authService,
                                            config = makeIdentityConfig()) {
   let MockInternal = {};
   let fxa = new FxAccounts(MockInternal);
 
+  // until we get better test infrastructure for bid_identity, we set the
+  // signedin user's "email" to the username, simply as many tests rely on this.
+  config.fxaccount.user.email = config.username;
   fxa.internal.currentAccountState.signedInUser = {
     version: DATA_FORMAT_VERSION,
     accountData: config.fxaccount.user
   };
   fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
     this.cert = {
       validUntil: fxa.internal.now() + CERT_LIFETIME,
       cert: "certificate",
@@ -134,16 +137,17 @@ this.configureFxAccountIdentity = functi
       config.fxaccount.token.uid = config.username;
       cb(null, config.fxaccount.token);
     },
   };
   authService._fxaService = fxa;
   authService._tokenServerClient = mockTSC;
   // Set the "account" of the browserId manager to be the "email" of the
   // logged in user of the mockFXA service.
+  authService._signedInUser = fxa.internal.currentAccountState.signedInUser.accountData;
   authService._account = config.fxaccount.user.email;
 }
 
 this.configureIdentity = function(identityOverrides) {
   let config = makeIdentityConfig(identityOverrides);
   let ns = {};
   Cu.import("resource://services-sync/service.js", ns);
 
--- a/services/sync/modules/browserid_identity.js
+++ b/services/sync/modules/browserid_identity.js
@@ -18,35 +18,41 @@ Cu.import("resource://services-sync/util
 Cu.import("resource://services-common/tokenserverclient.js");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://services-sync/stages/cluster.js");
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 
 // Lazy imports to prevent unnecessary load on startup.
+XPCOMUtils.defineLazyModuleGetter(this, "Weave",
+                                  "resource://services-sync/main.js");
+
 XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle",
                                   "resource://services-sync/keys.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                   "resource://gre/modules/FxAccounts.jsm");
 
-XPCOMUtils.defineLazyGetter(this, 'fxAccountsCommon', function() {
-  let ob = {};
-  Cu.import("resource://gre/modules/FxAccountsCommon.js", ob);
-  return ob;
-});
-
 XPCOMUtils.defineLazyGetter(this, 'log', function() {
   let log = Log.repository.getLogger("Sync.BrowserIDManager");
   log.addAppender(new Log.DumpAppender());
   log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error;
   return log;
 });
 
+// FxAccountsCommon.js doesn't use a "namespace", so create one here.
+let fxAccountsCommon = {};
+Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
+
+const OBSERVER_TOPICS = [
+  fxAccountsCommon.ONLOGIN_NOTIFICATION,
+  fxAccountsCommon.ONLOGOUT_NOTIFICATION,
+];
+
 const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync.ui.showCustomizationDialog";
 
 function deriveKeyBundle(kB) {
   let out = CryptoUtils.hkdf(kB, undefined,
                              "identity.mozilla.com/picl/v1/oldsync", 2*32);
   let bundle = new BulkKeyBundle();
   // [encryptionKey, hmacKey]
   bundle.keyPair = [out.slice(0, 32), out.slice(32, 64)];
@@ -82,17 +88,22 @@ this.BrowserIDManager = function Browser
 
 this.BrowserIDManager.prototype = {
   __proto__: IdentityManager.prototype,
 
   _fxaService: null,
   _tokenServerClient: null,
   // https://docs.services.mozilla.com/token/apis.html
   _token: null,
-  _account: null,
+  _signedInUser: null, // the signedinuser we got from FxAccounts.
+
+  // null if no error, otherwise a LOGIN_FAILED_* value that indicates why
+  // we failed to authenticate (but note it might not be an actual
+  // authentication problem, just a transient network error or similar)
+  _authFailureReason: null,
 
   // it takes some time to fetch a sync key bundle, so until this flag is set,
   // we don't consider the lack of a keybundle as a failure state.
   _shouldHaveSyncKeyBundle: false,
 
   get readyToAuthenticate() {
     // We are finished initializing when we *should* have a sync key bundle,
     // although we might not actually have one due to auth failures etc.
@@ -103,44 +114,89 @@ this.BrowserIDManager.prototype = {
     try {
       return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
     } catch (e) {
       return false;
     }
   },
 
   initialize: function() {
-    Services.obs.addObserver(this, fxAccountsCommon.ONLOGIN_NOTIFICATION, false);
-    Services.obs.addObserver(this, fxAccountsCommon.ONLOGOUT_NOTIFICATION, false);
-    Services.obs.addObserver(this, "weave:service:logout:finish", false);
+    for (let topic of OBSERVER_TOPICS) {
+      Services.obs.addObserver(this, topic, false);
+    }
     return this.initializeWithCurrentIdentity();
   },
 
+  /**
+   * Ensure the user is logged in.  Returns a promise that resolves when
+   * the user is logged in, or is rejected if the login attempt has failed.
+   */
+  ensureLoggedIn: function() {
+    if (!this._shouldHaveSyncKeyBundle) {
+      // We are already in the process of logging in.
+      return this.whenReadyToAuthenticate.promise;
+    }
+
+    // If we are already happy then there is nothing more to do.
+    if (Weave.Status.login == LOGIN_SUCCEEDED) {
+      return Promise.resolve();
+    }
+
+    // Similarly, if we have a previous failure that implies an explicit
+    // re-entering of credentials by the user is necessary we don't take any
+    // further action - an observer will fire when the user does that.
+    if (Weave.Status.login == LOGIN_FAILED_LOGIN_REJECTED) {
+      return Promise.reject();
+    }
+
+    // So - we've a previous auth problem and aren't currently attempting to
+    // log in - so fire that off.
+    this.initializeWithCurrentIdentity();
+    return this.whenReadyToAuthenticate.promise;
+  },
+
+  finalize: function() {
+    // After this is called, we can expect Service.identity != this.
+    for (let topic of OBSERVER_TOPICS) {
+      Services.obs.removeObserver(this, topic);
+    }
+    this.resetCredentials();
+    this._signedInUser = null;
+    return Promise.resolve();
+  },
+
   initializeWithCurrentIdentity: function(isInitialSync=false) {
+    // While this function returns a promise that resolves once we've started
+    // the auth process, that process is complete when
+    // this.whenReadyToAuthenticate.promise resolves.
     this._log.trace("initializeWithCurrentIdentity");
-    Components.utils.import("resource://services-sync/main.js");
 
     // Reset the world before we do anything async.
     this.whenReadyToAuthenticate = Promise.defer();
     this._shouldHaveSyncKeyBundle = false;
+    this._authFailureReason = null;
 
     return this._fxaService.getSignedInUser().then(accountData => {
       if (!accountData) {
         this._log.info("initializeWithCurrentIdentity has no user logged in");
-        this._account = null;
+        this.account = null;
+        // and we are as ready as we can ever be for auth.
+        this._shouldHaveSyncKeyBundle = true;
+        this.whenReadyToAuthenticate.reject("no user is logged in");
         return;
       }
 
-      this._account = accountData.email;
+      this.account = accountData.email;
+      this._updateSignedInUser(accountData);
       // The user must be verified before we can do anything at all; we kick
       // this and the rest of initialization off in the background (ie, we
       // don't return the promise)
       this._log.info("Waiting for user to be verified.");
       this._fxaService.whenVerified(accountData).then(accountData => {
-        // We do the background keybundle fetch...
+        this._updateSignedInUser(accountData);
         this._log.info("Starting fetch for key bundle.");
         if (this.needsCustomization) {
           // If the user chose to "Customize sync options" when signing
           // up with Firefox Accounts, ask them to choose what to sync.
           const url = "chrome://browser/content/sync/customize.xul";
           const features = "centerscreen,chrome,modal,dialog,resizable=no";
           let win = Services.wm.getMostRecentWindow("navigator:browser");
 
@@ -155,16 +211,17 @@ this.BrowserIDManager.prototype = {
           }
         }
       }).then(() => {
         return this._fetchSyncKeyBundle();
       }).then(() => {
         this._shouldHaveSyncKeyBundle = true; // and we should actually have one...
         this.whenReadyToAuthenticate.resolve();
         this._log.info("Background fetch for key bundle done");
+        Weave.Status.login = LOGIN_SUCCEEDED;
         if (isInitialSync) {
           this._log.info("Doing initial sync actions");
           Svc.Prefs.set("firstSync", "resetClient");
           Services.obs.notifyObservers(null, "weave:service:setup-complete", null);
           Weave.Utils.nextTick(Weave.Service.sync, Weave.Service);
         }
       }).then(null, err => {
         this._shouldHaveSyncKeyBundle = true; // but we probably don't have one...
@@ -173,43 +230,60 @@ this.BrowserIDManager.prototype = {
         this._log.error("Background fetch for key bundle failed: " + err);
       });
       // and we are done - the fetch continues on in the background...
     }).then(null, err => {
       this._log.error("Processing logged in account: " + err);
     });
   },
 
+  _updateSignedInUser: function(userData) {
+    // This object should only ever be used for a single user.  It is an
+    // error to update the data if the user changes (but updates are still
+    // necessary, as each call may add more attributes to the user).
+    // We start with no user, so an initial update is always ok.
+    if (this._signedInUser && this._signedInUser.email != userData.email) {
+      throw new Error("Attempting to update to a different user.")
+    }
+    this._signedInUser = userData;
+  },
+
+  logout: function() {
+    // This will be called when sync fails (or when the account is being
+    // unlinked etc).  It may have failed because we got a 401 from a sync
+    // server, so we nuke the token.  Next time sync runs and wants an
+    // authentication header, we will notice the lack of the token and fetch a
+    // new one.
+    this._token = null;
+  },
+
   observe: function (subject, topic, data) {
     this._log.debug("observed " + topic);
     switch (topic) {
     case fxAccountsCommon.ONLOGIN_NOTIFICATION:
+      // This should only happen if we've been initialized without a current
+      // user - otherwise we'd have seen the LOGOUT notification and been
+      // thrown away.
+      // The exception is when we've initialized with a user that needs to
+      // reauth with the server - in that case we will also get here, but
+      // should have the same identity.
+      // initializeWithCurrentIdentity will throw and log if these contraints
+      // aren't met, so just go ahead and do the init.
       this.initializeWithCurrentIdentity(true);
       break;
 
     case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
-      Components.utils.import("resource://services-sync/main.js");
-      // Setting .username calls resetCredentials which drops the key bundle
-      // and resets _shouldHaveSyncKeyBundle.
-      this.username = "";
-      this._account = null;
-      Weave.Service.logout();
-      break;
-
-    case "weave:service:logout:finish":
-      // This signals an auth error with the storage server,
-      // or that the user unlinked her account from the browser.
-      // Either way, we clear our auth token. In the case of an
-      // auth error, this will force the fetch of a new one.
-      this._token = null;
+      Weave.Service.startOver();
+      // startOver will cause this instance to be thrown away, so there's
+      // nothing else to do.
       break;
     }
   },
 
-   /**
+  /**
    * Compute the sha256 of the message bytes.  Return bytes.
    */
   _sha256: function(message) {
     let hasher = Cc["@mozilla.org/security/hash;1"]
                     .createInstance(Ci.nsICryptoHash);
     hasher.init(hasher.SHA256);
     return CryptoUtils.digestBytes(message, hasher);
   },
@@ -229,33 +303,19 @@ this.BrowserIDManager.prototype = {
   _now: function() {
     return this._fxaService.now()
   },
 
   get _localtimeOffsetMsec() {
     return this._fxaService.localtimeOffsetMsec;
   },
 
-  get account() {
-    return this._account;
-  },
-
-  /**
-   * Sets the active account name.
-   *
-   * This should almost always be called in favor of setting username, as
-   * username is derived from account.
-   *
-   * Changing the account name has the side-effect of wiping out stored
-   * credentials.
-   *
-   * Set this value to null to clear out identity information.
-   */
-  set account(value) {
-    throw "account setter should be not used in BrowserIDManager";
+  usernameFromAccount: function(val) {
+    // we don't differentiate between "username" and "account"
+    return val;
   },
 
   /**
    * Obtains the HTTP Basic auth password.
    *
    * Returns a string if set or null if it is not set.
    */
   get basicPassword() {
@@ -303,18 +363,18 @@ this.BrowserIDManager.prototype = {
   get syncKeyBundle() {
     return this._syncKeyBundle;
   },
 
   /**
    * Resets/Drops all credentials we hold for the current user.
    */
   resetCredentials: function() {
-    // the only credentials we hold are the sync key.
     this.resetSyncKey();
+    this._token = null;
   },
 
   /**
    * Resets/Drops the sync key we hold for the current user.
    */
   resetSyncKey: function() {
     this._syncKey = null;
     this._syncKeyBundle = null;
@@ -324,16 +384,21 @@ this.BrowserIDManager.prototype = {
 
   /**
    * The current state of the auth credentials.
    *
    * This essentially validates that enough credentials are available to use
    * Sync.
    */
   get currentAuthState() {
+    if (this._authFailureReason) {
+      this._log.info("currentAuthState returning " + this._authFailureReason +
+                     " due to previous failure");
+      return this._authFailureReason;
+    }
     // TODO: need to revisit this. Currently this isn't ready to go until
     // both the username and syncKeyBundle are both configured and having no
     // username seems to make things fail fast so that's good.
     if (!this.username) {
       return LOGIN_FAILED_NO_USERNAME;
     }
 
     // No need to check this.syncKey as our getter for that attribute
@@ -342,171 +407,135 @@ this.BrowserIDManager.prototype = {
     if (this._shouldHaveSyncKeyBundle && !this.syncKeyBundle) {
       return LOGIN_FAILED_NO_PASSPHRASE;
     }
 
     return STATUS_OK;
   },
 
   /**
-   * Do we have a non-null, not yet expired token whose email field
-   * matches (when normalized) our account field?
+   * Do we have a non-null, not yet expired token for the user currently
+   * signed in?
    */
   hasValidToken: function() {
     if (!this._token) {
       return false;
     }
     if (this._token.expiration < this._now()) {
       return false;
     }
-    let signedInUser = this._getSignedInUser();
-    if (!signedInUser) {
-      return false;
-    }
-    // Does the signed in user match the user we retrieved the token for?
-    if (signedInUser.email !== this.account) {
-      return false;
-    }
     return true;
   },
 
-  /**
-   * Wrap and synchronize FxAccounts.getSignedInUser().
-   *
-   * @return credentials per wrapped.
-   */
-  _getSignedInUser: function() {
-    let userData;
-    let cb = Async.makeSpinningCallback();
-
-    this._fxaService.getSignedInUser().then(function (result) {
-        cb(null, result);
-    },
-    function (err) {
-        cb(err);
-    });
-
-    try {
-      userData = cb.wait();
-    } catch (err) {
-      this._log.error("FxAccounts.getSignedInUser() failed with: " + err);
-      return null;
-    }
-    return userData;
-  },
-
   _fetchSyncKeyBundle: function() {
     // Fetch a sync token for the logged in user from the token server.
     return this._fxaService.getKeys().then(userData => {
-      // Unlikely, but if the logged in user somehow changed between these
-      // calls we better fail. TODO: add tests for these
-      if (!userData) {
-        throw new AuthenticationError("No userData in _fetchSyncKeyBundle");
-      } else if (userData.email !== this.account) {
-        throw new AuthenticationError("Unexpected user change in _fetchSyncKeyBundle");
-      }
-      return this._fetchTokenForUser(userData).then(token => {
+      this._updateSignedInUser(userData); // throws if the user changed.
+      return this._fetchTokenForUser().then(token => {
         this._token = token;
-        // Set the username to be the uid returned by the token server.
-        this.username = this._token.uid.toString();
         // both Jelly and FxAccounts give us kA/kB as hex.
         let kB = Utils.hexToBytes(userData.kB);
         this._syncKeyBundle = deriveKeyBundle(kB);
         return;
       });
     });
   },
 
-  // Refresh the sync token for the specified Firefox Accounts user.
-  _fetchTokenForUser: function(userData) {
+  // Refresh the sync token for our user.
+  _fetchTokenForUser: function() {
     let tokenServerURI = Svc.Prefs.get("tokenServerURI");
     let log = this._log;
     let client = this._tokenServerClient;
     let fxa = this._fxaService;
+    let userData = this._signedInUser;
 
     // Both Jelly and FxAccounts give us kB as hex
     let kBbytes = CommonUtils.hexToBytes(userData.kB);
     let headers = {"X-Client-State": this._computeXClientState(kBbytes)};
     log.info("Fetching assertion and token from: " + tokenServerURI);
 
     function getToken(tokenServerURI, assertion) {
       log.debug("Getting a token");
       let deferred = Promise.defer();
       let cb = function (err, token) {
         if (err) {
-          log.info("TokenServerClient.getTokenFromBrowserIDAssertion() failed with: " + err.message);
-          return deferred.reject(new AuthenticationError(err));
+          log.info("TokenServerClient.getTokenFromBrowserIDAssertion() failed with: " + err);
+          if (err.response && err.response.status === 401) {
+            err = new AuthenticationError(err);
+          }
+          return deferred.reject(err);
         } else {
           log.debug("Successfully got a sync token");
           return deferred.resolve(token);
         }
       };
 
       client.getTokenFromBrowserIDAssertion(tokenServerURI, assertion, cb, headers);
       return deferred.promise;
     }
 
     function getAssertion() {
       log.debug("Getting an assertion");
       let audience = Services.io.newURI(tokenServerURI, null, null).prePath;
       return fxa.getAssertion(audience).then(null, err => {
+        log.error("fxa.getAssertion() failed with: " + err.code + " - " + err.message);
         if (err.code === 401) {
           throw new AuthenticationError("Unable to get assertion for user");
         } else {
           throw err;
         }
       });
     };
 
     // wait until the account email is verified and we know that
     // getAssertion() will return a real assertion (not null).
-    return fxa.whenVerified(userData)
+    return fxa.whenVerified(this._signedInUser)
       .then(() => getAssertion())
       .then(assertion => getToken(tokenServerURI, assertion))
       .then(token => {
         // TODO: Make it be only 80% of the duration, so refresh the token
         // before it actually expires. This is to avoid sync storage errors
         // otherwise, we get a nasty notification bar briefly. Bug 966568.
         token.expiration = this._now() + (token.duration * 1000) * 0.80;
         return token;
       })
       .then(null, err => {
         // TODO: write tests to make sure that different auth error cases are handled here
         // properly: auth error getting assertion, auth error getting token (invalid generation
         // and client-state error)
         if (err instanceof AuthenticationError) {
           this._log.error("Authentication error in _fetchTokenForUser: " + err);
-          // Drop the sync key bundle, but still expect to have one.
-          // This will arrange for us to be in the right 'currentAuthState'
-          // such that UI will show the right error.
-          this._shouldHaveSyncKeyBundle = true;
-          this._syncKeyBundle = null;
-          Weave.Status.login = this.currentAuthState;
-          Services.obs.notifyObservers(null, "weave:service:login:error", null);
+          // set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason.
+          this._authFailureReason = LOGIN_FAILED_LOGIN_REJECTED;
+        } else {
+          this._log.error("Non-authentication error in _fetchTokenForUser: " + err.message);
+          // for now assume it is just a transient network related problem.
+          this._authFailureReason = LOGIN_FAILED_NETWORK_ERROR;
         }
+        // Drop the sync key bundle, but still expect to have one.
+        // This will arrange for us to be in the right 'currentAuthState'
+        // such that UI will show the right error.
+        this._shouldHaveSyncKeyBundle = true;
+        this._syncKeyBundle = null;
+        Weave.Status.login = this._authFailureReason;
         throw err;
       });
   },
 
-  _fetchTokenForLoggedInUserSync: function() {
-    let cb = Async.makeSpinningCallback();
-
-    this._fxaService.getSignedInUser().then(userData => {
-      this._fetchTokenForUser(userData).then(token => {
-        cb(null, token);
-      }, err => {
-        cb(err);
-      });
-    });
-    try {
-      return cb.wait();
-    } catch (err) {
-      this._log.info("_fetchTokenForLoggedInUserSync: " + err.message);
-      return null;
+  // Returns a promise that is resolved when we have a valid token for the
+  // current user stored in this._token.  When resolved, this._token is valid.
+  _ensureValidToken: function() {
+    if (this.hasValidToken()) {
+      return Promise.resolve();
     }
+    return this._fetchTokenForUser().then(
+      token => {
+        this._token = token;
+      }
+    );
   },
 
   getResourceAuthenticator: function () {
     return this._getAuthenticationHeader.bind(this);
   },
 
   /**
    * Obtain a function to be used for adding auth to RESTRequest instances.
@@ -515,22 +544,26 @@ this.BrowserIDManager.prototype = {
     return this._addAuthenticationHeader.bind(this);
   },
 
   /**
    * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri
    * of a RESTRequest or AsyncResponse object.
    */
   _getAuthenticationHeader: function(httpObject, method) {
-    if (!this.hasValidToken()) {
-      // Refresh token for the currently logged in FxA user
-      this._token = this._fetchTokenForLoggedInUserSync();
-      if (!this._token) {
-        return null;
-      }
+    let cb = Async.makeSpinningCallback();
+    this._ensureValidToken().then(cb, cb);
+    try {
+      cb.wait();
+    } catch (ex) {
+      this._log.error("Failed to fetch a token for authentication: " + ex);
+      return null;
+    }
+    if (!this._token) {
+      return null;
     }
     let credentials = {algorithm: "sha256",
                        id: this._token.id,
                        key: this._token.key,
                       };
     method = method || httpObject.method;
 
     // Get the local clock offset from the Firefox Accounts server.  This should
@@ -566,39 +599,40 @@ this.BrowserIDManager.prototype = {
 function BrowserIDClusterManager(service) {
   ClusterManager.call(this, service);
 }
 
 BrowserIDClusterManager.prototype = {
   __proto__: ClusterManager.prototype,
 
   _findCluster: function() {
-    let fxa = this.identity._fxaService; // will be mocked for tests.
+    let endPointFromIdentityToken = function() {
+      let endpoint = this.identity._token.endpoint;
+      // For Sync 1.5 storage endpoints, we use the base endpoint verbatim.
+      // However, it should end in "/" because we will extend it with
+      // well known path components. So we add a "/" if it's missing.
+      if (!endpoint.endsWith("/")) {
+        endpoint += "/";
+      }
+      return endpoint;
+    }.bind(this);
+
+    // Spinningly ensure we are ready to authenticate and have a valid token.
     let promiseClusterURL = function() {
-      return fxa.getSignedInUser().then(userData => {
-        return this.identity._fetchTokenForUser(userData).then(token => {
-          let endpoint = token.endpoint;
-          // For Sync 1.5 storage endpoints, we use the base endpoint verbatim.
-          // However, it should end in "/" because we will extend it with
-          // well known path components. So we add a "/" if it's missing.
-          if (!endpoint.endsWith("/")) {
-            endpoint += "/";
-          }
-          return endpoint;
-        });
-      });
+      return this.identity.whenReadyToAuthenticate.promise.then(
+        () => this.identity._ensureValidToken()
+      ).then(
+        () => endPointFromIdentityToken()
+      );
     }.bind(this);
 
     let cb = Async.makeSpinningCallback();
     promiseClusterURL().then(function (clusterURL) {
-        cb(null, clusterURL);
-    },
-    function (err) {
-        cb(err);
-    });
+      cb(null, clusterURL);
+    }).then(null, cb);
     return cb.wait();
   },
 
   getUserBaseURL: function() {
     // Legacy Sync and FxA Sync construct the userBaseURL differently. Legacy
     // Sync appends path components onto an empty path, and in FxA Sync the
     // token server constructs this for us in an opaque manner. Since the
     // cluster manager already sets the clusterURL on Service and also has
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -49,21 +49,16 @@ MAXIMUM_BACKOFF_INTERVAL:              8
 // HMAC event handling timeout.
 // 10 minutes: a compromise between the multi-desktop sync interval
 // and the mobile sync interval.
 HMAC_EVENT_INTERVAL:                   600000,
 
 // How long to wait between sync attempts if the Master Password is locked.
 MASTER_PASSWORD_LOCKED_RETRY_INTERVAL: 15 * 60 * 1000,   // 15 minutes
 
-// How long to initially wait between sync attempts if the identity manager is
-// not ready.  As we expect this to become ready relatively quickly, we retry
-// in (IDENTITY_NOT_READY_RETRY_INTERVAL * num_failures) seconds.
-IDENTITY_NOT_READY_RETRY_INTERVAL: 5 * 1000,   // 5 seconds
-
 // Separate from the ID fetch batch size to allow tuning for mobile.
 MOBILE_BATCH_SIZE:                     50,
 
 // 50 is hardcoded here because of URL length restrictions.
 // (GUIDs can be up to 64 chars long.)
 // Individual engines can set different values for their limit if their
 // identifiers are shorter.
 DEFAULT_GUID_FETCH_BATCH_SIZE:         50,
--- a/services/sync/modules/identity.js
+++ b/services/sync/modules/identity.js
@@ -84,16 +84,37 @@ IdentityManager.prototype = {
   _syncKeyBundle: null,
 
   /**
    * Initialize the identity provider.  Returns a promise that is resolved
    * when initialization is complete and the provider can be queried for
    * its state
    */
   initialize: function() {
+    // Nothing to do for this identity provider.
+    return Promise.resolve();
+  },
+
+  finalize: function() {
+    // Nothing to do for this identity provider.
+    return Promise.resolve();
+  },
+
+  /**
+   * Called whenever Service.logout() is called.
+   */
+  logout: function() {
+    // nothing to do for this identity provider.
+  },
+
+  /**
+   * Ensure the user is logged in.  Returns a promise that resolves when
+   * the user is logged in, or is rejected if the login attempt has failed.
+   */
+  ensureLoggedIn: function() {
     // nothing to do for this identity provider
     return Promise.resolve();
   },
 
   /**
    * Indicates if the identity manager is still initializing
    */
   get readyToAuthenticate() {
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -25,18 +25,16 @@ SyncScheduler.prototype = {
   _log: Log.repository.getLogger("Sync.SyncScheduler"),
 
   _fatalLoginStatus: [LOGIN_FAILED_NO_USERNAME,
                       LOGIN_FAILED_NO_PASSWORD,
                       LOGIN_FAILED_NO_PASSPHRASE,
                       LOGIN_FAILED_INVALID_PASSPHRASE,
                       LOGIN_FAILED_LOGIN_REJECTED],
 
-  _loginNotReadyCounter: 0,
-
   /**
    * The nsITimer object that schedules the next sync. See scheduleNextSync().
    */
   syncTimer: null,
 
   setDefaults: function setDefaults() {
     this._log.trace("Setting SyncScheduler policy values to defaults.");
 
@@ -110,20 +108,16 @@ SyncScheduler.prototype = {
       case "weave:service:sync:start":
         // Clear out any potentially pending syncs now that we're syncing
         this.clearSyncTriggers();
 
         // reset backoff info, if the server tells us to continue backing off,
         // we'll handle that later
         Status.resetBackoff();
 
-        // Reset the loginNotReady counter, just in-case the user signs in
-        // as another user and re-hits the not-ready state.
-        this._loginNotReadyCounter = 0;
-
         this.globalScore = 0;
         break;
       case "weave:service:sync:finish":
         this.nextSync = 0;
         this.adjustSyncInterval();
 
         if (Status.service == SYNC_FAILED_PARTIAL && this.requiresBackoff) {
           this.requiresBackoff = false;
@@ -156,23 +150,16 @@ SyncScheduler.prototype = {
         this.clearSyncTriggers();
 
         if (Status.login == MASTER_PASSWORD_LOCKED) {
           // Try again later, just as if we threw an error... only without the
           // error count.
           this._log.debug("Couldn't log in: master password is locked.");
           this._log.trace("Scheduling a sync at MASTER_PASSWORD_LOCKED_RETRY_INTERVAL");
           this.scheduleAtInterval(MASTER_PASSWORD_LOCKED_RETRY_INTERVAL);
-        } else if (Status.login == LOGIN_FAILED_NOT_READY) {
-          this._loginNotReadyCounter++;
-          this._log.debug("Couldn't log in: identity not ready.");
-          this._log.trace("Scheduling a sync at IDENTITY_NOT_READY_RETRY_INTERVAL * " +
-                          this._loginNotReadyCounter);
-          this.scheduleAtInterval(IDENTITY_NOT_READY_RETRY_INTERVAL *
-                                  this._loginNotReadyCounter);
         } else if (this._fatalLoginStatus.indexOf(Status.login) == -1) {
           // Not a fatal login error, just an intermittent network or server
           // issue. Keep on syncin'.
           this.checkSyncStatus();
         }
         break;
       case "weave:service:logout:finish":
         // Start or cancel the sync timer depending on if
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -159,17 +159,17 @@ Sync11Service.prototype = {
     if (!this._clusterManager) {
       return null;
     }
     return this._clusterManager.getUserBaseURL();
   },
 
   _updateCachedURLs: function _updateCachedURLs() {
     // Nothing to cache yet if we don't have the building blocks
-    if (this.clusterURL == "" || this.identity.username == "")
+    if (!this.clusterURL || !this.identity.username)
       return;
 
     this._log.debug("Caching URLs under storage user base: " + this.userBaseURL);
 
     // Generate and cache various URLs under the storage API for this user
     this.infoURL = this.userBaseURL + "info/collections";
     this.storageURL = this.userBaseURL + "storage/";
     this.metaURL = this.storageURL + "meta/global";
@@ -847,39 +847,41 @@ Sync11Service.prototype = {
     })();
   },
 
   startOver: function startOver() {
     this._log.trace("Invoking Service.startOver.");
     Svc.Obs.notify("weave:engine:stop-tracking");
     this.status.resetSync();
 
-    // We want let UI consumers of the following notification know as soon as
-    // possible, so let's fake for the CLIENT_NOT_CONFIGURED status for now
-    // by emptying the passphrase (we still need the password).
-    this.identity.resetSyncKey();
-    this.status.login = LOGIN_FAILED_NO_PASSPHRASE;
-    this.logout();
-    Svc.Obs.notify("weave:service:start-over");
-
     // Deletion doesn't make sense if we aren't set up yet!
     if (this.clusterURL != "") {
       // Clear client-specific data from the server, including disabled engines.
       for each (let engine in [this.clientsEngine].concat(this.engineManager.getAll())) {
         try {
           engine.removeClientData();
         } catch(ex) {
           this._log.warn("Deleting client data for " + engine.name + " failed:"
                          + Utils.exceptionStr(ex));
         }
       }
+      this._log.debug("Finished deleting client data.");
     } else {
       this._log.debug("Skipping client data removal: no cluster URL.");
     }
 
+    // We want let UI consumers of the following notification know as soon as
+    // possible, so let's fake for the CLIENT_NOT_CONFIGURED status for now
+    // by emptying the passphrase (we still need the password).
+    this._log.info("Service.startOver dropping sync key and logging out.");
+    this.identity.resetSyncKey();
+    this.status.login = LOGIN_FAILED_NO_PASSPHRASE;
+    this.logout();
+    Svc.Obs.notify("weave:service:start-over");
+
     // Reset all engines and clear keys.
     this.resetClient();
     this.collectionKeys.clear();
     this.status.resetBackoff();
 
     // Reset Weave prefs.
     this._ignorePrefObserver = true;
     Svc.Prefs.resetBranch("");
@@ -895,31 +897,33 @@ Sync11Service.prototype = {
     try {
       keepIdentity = Services.prefs.getBoolPref("services.sync-testing.startOverKeepIdentity");
     } catch (_) { /* no such pref */ }
     if (keepIdentity) {
       Svc.Obs.notify("weave:service:start-over:finish");
       return;
     }
 
-    this.identity.username = "";
-    Services.prefs.clearUserPref("services.sync.fxaccounts.enabled");
-    this.status.__authManager = null;
-    this.identity = Status._authManager;
-    this._clusterManager = this.identity.createClusterManager(this);
-
-    // Tell the new identity manager to initialize itself
-    this.identity.initialize().then(() => {
-      Svc.Obs.notify("weave:service:start-over:finish");
-    }).then(null, err => {
-      this._log.error("startOver failed to re-initialize the identity manager: " + err);
-      // Still send the observer notification so the current state is
-      // reflected in the UI.
-      Svc.Obs.notify("weave:service:start-over:finish");
-    });
+    this.identity.finalize().then(
+      () => {
+        this.identity.username = "";
+        Services.prefs.clearUserPref("services.sync.fxaccounts.enabled");
+        this.status.__authManager = null;
+        this.identity = Status._authManager;
+        this._clusterManager = this.identity.createClusterManager(this);
+        Svc.Obs.notify("weave:service:start-over:finish");
+      }
+    ).then(null,
+      err => {
+        this._log.error("startOver failed to re-initialize the identity manager: " + err);
+        // Still send the observer notification so the current state is
+        // reflected in the UI.
+        Svc.Obs.notify("weave:service:start-over:finish");
+      }
+    );
   },
 
   persistLogin: function persistLogin() {
     try {
       this.identity.persistCredentials(true);
     } catch (ex) {
       this._log.info("Unable to persist credentials: " + ex);
     }
@@ -943,16 +947,23 @@ Sync11Service.prototype = {
       if (passphrase) {
         this.identity.syncKey = passphrase;
       }
 
       if (this._checkSetup() == CLIENT_NOT_CONFIGURED) {
         throw "Aborting login, client not configured.";
       }
 
+      // Ask the identity manager to explicitly login now.
+      let cb = Async.makeSpinningCallback();
+      this.identity.ensureLoggedIn().then(cb, cb);
+
+      // Just let any errors bubble up - they've more context than we do!
+      cb.wait();
+
       // Calling login() with parameters when the client was
       // previously not configured means setup was completed.
       if (initialStatus == CLIENT_NOT_CONFIGURED
           && (username || password || passphrase)) {
         Svc.Obs.notify("weave:service:setup-complete");
       }
 
       this._log.info("Logging in user " + this.identity.username);
@@ -973,16 +984,17 @@ Sync11Service.prototype = {
   },
 
   logout: function logout() {
     // No need to do anything if we're already logged out.
     if (!this._loggedIn)
       return;
 
     this._log.info("Logging out");
+    this.identity.logout();
     this._loggedIn = false;
 
     Svc.Obs.notify("weave:service:logout:finish");
   },
 
   checkAccount: function checkAccount(account) {
     let client = new UserAPI10Client(this.userAPIURI);
     let cb = Async.makeSpinningCallback();
--- a/services/sync/tests/unit/test_browserid_identity.js
+++ b/services/sync/tests/unit/test_browserid_identity.js
@@ -7,16 +7,19 @@ Cu.import("resource://services-sync/rest
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://services-crypto/utils.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 Cu.import("resource://services-common/hawkclient.js");
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 Cu.import("resource://gre/modules/FxAccountsClient.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-common/tokenserverclient.js");
+Cu.import("resource://services-sync/status.js");
+Cu.import("resource://services-sync/constants.js");
 
 const SECOND_MS = 1000;
 const MINUTE_MS = SECOND_MS * 60;
 const HOUR_MS = MINUTE_MS * 60;
 
 let identityConfig = makeIdentityConfig();
 let browseridManager = new BrowserIDManager();
 configureFxAccountIdentity(browseridManager, identityConfig);
@@ -52,41 +55,52 @@ function MockFxAccounts() {
     return Promise.resolve(this.cert.cert);
   };
   return fxa;
 }
 
 function run_test() {
   initTestLogging("Trace");
   Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace;
+  Log.repository.getLogger("Sync.BrowserIDManager").level = Log.Level.Trace;
   run_next_test();
 };
 
 add_test(function test_initial_state() {
     _("Verify initial state");
     do_check_false(!!browseridManager._token);
     do_check_false(browseridManager.hasValidToken());
     run_next_test();
   }
 );
 
+add_task(function test_initialializeWithCurrentIdentity() {
+    _("Verify start after initializeWithCurrentIdentity");
+    browseridManager.initializeWithCurrentIdentity();
+    yield browseridManager.whenReadyToAuthenticate.promise;
+    do_check_true(!!browseridManager._token);
+    do_check_true(browseridManager.hasValidToken());
+    do_check_eq(browseridManager.account, identityConfig.fxaccount.user.email);
+  }
+);
+
+
 add_test(function test_getResourceAuthenticator() {
     _("BrowserIDManager supplies a Resource Authenticator callback which returns a Hawk header.");
     let authenticator = browseridManager.getResourceAuthenticator();
     do_check_true(!!authenticator);
     let req = {uri: CommonUtils.makeURI(
       "https://example.net/somewhere/over/the/rainbow"),
                method: 'GET'};
     let output = authenticator(req, 'GET');
     do_check_true('headers' in output);
     do_check_true('authorization' in output.headers);
     do_check_true(output.headers.authorization.startsWith('Hawk'));
     _("Expected internal state after successful call.");
     do_check_eq(browseridManager._token.uid, identityConfig.fxaccount.token.uid);
-    do_check_eq(browseridManager.account, identityConfig.fxaccount.user.email);
     run_next_test();
   }
 );
 
 add_test(function test_getRESTRequestAuthenticator() {
     _("BrowserIDManager supplies a REST Request Authenticator callback which sets a Hawk header on a request object.");
     let request = new SyncStorageRequest(
       "https://example.net/somewhere/over/the/rainbow");
@@ -217,16 +231,54 @@ add_test(function test_RESTResourceAuthe
   // window.
   do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS);
   do_check_true(
       (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS);
 
   run_next_test();
 });
 
+add_task(function test_ensureLoggedIn() {
+  configureFxAccountIdentity(browseridManager);
+  yield browseridManager.initializeWithCurrentIdentity();
+  Assert.equal(Status.login, LOGIN_SUCCEEDED, "original initialize worked");
+  yield browseridManager.ensureLoggedIn();
+  Assert.equal(Status.login, LOGIN_SUCCEEDED, "original ensureLoggedIn worked");
+  Assert.ok(browseridManager._shouldHaveSyncKeyBundle,
+            "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes.");
+
+  // arrange for no logged in user.
+  let fxa = browseridManager._fxaService
+  let signedInUser = fxa.internal.currentAccountState.signedInUser;
+  fxa.internal.currentAccountState.signedInUser = null;
+  browseridManager.initializeWithCurrentIdentity();
+  Assert.ok(!browseridManager._shouldHaveSyncKeyBundle,
+            "_shouldHaveSyncKeyBundle should be false so we know we are testing what we think we are.");
+  Status.login = LOGIN_FAILED_NO_USERNAME;
+  try {
+    yield browseridManager.ensureLoggedIn();
+    Assert.ok(false, "promise should have been rejected.")
+  } catch(_) {
+  }
+  Assert.ok(browseridManager._shouldHaveSyncKeyBundle,
+            "_shouldHaveSyncKeyBundle should always be true after ensureLogin completes.");
+  fxa.internal.currentAccountState.signedInUser = signedInUser;
+  Status.login = LOGIN_FAILED_LOGIN_REJECTED;
+  try {
+    yield browseridManager.ensureLoggedIn();
+    Assert.ok(false, "LOGIN_FAILED_LOGIN_REJECTED should have caused immediate rejection");
+  } catch (_) {
+  }
+  Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED,
+               "status should remain LOGIN_FAILED_LOGIN_REJECTED");
+  Status.login = LOGIN_FAILED_NETWORK_ERROR;
+  yield browseridManager.ensureLoggedIn();
+  Assert.equal(Status.login, LOGIN_SUCCEEDED, "final ensureLoggedIn worked");
+});
+
 add_test(function test_tokenExpiration() {
     _("BrowserIDManager notices token expiration:");
     let bimExp = new BrowserIDManager();
     configureFxAccountIdentity(bimExp, identityConfig);
 
     let authenticator = bimExp.getResourceAuthenticator();
     do_check_true(!!authenticator);
     let req = {uri: CommonUtils.makeURI(
@@ -244,34 +296,16 @@ add_test(function test_tokenExpiration()
     });
     do_check_true(bimExp._token.expiration < bimExp._now());
     _("... means BrowserIDManager knows to re-fetch it on the next call.");
     do_check_false(bimExp.hasValidToken());
     run_next_test();
   }
 );
 
-add_test(function test_userChangeAndLogOut() {
-    _("BrowserIDManager notices when the FxAccounts.getSignedInUser().email changes.");
-    let bidUser = new BrowserIDManager();
-    configureFxAccountIdentity(bidUser, identityConfig);
-    let request = new SyncStorageRequest(
-      "https://example.net/somewhere/over/the/rainbow");
-    let authenticator = bidUser.getRESTRequestAuthenticator();
-    do_check_true(!!authenticator);
-    let output = authenticator(request, 'GET');
-    do_check_true(!!output);
-    do_check_eq(bidUser.account, identityConfig.fxaccount.user.email);
-    do_check_true(bidUser.hasValidToken());
-    identityConfig.fxaccount.user.email = "something@new";
-    do_check_false(bidUser.hasValidToken());
-    run_next_test();
-  }
-);
-
 add_test(function test_sha256() {
   // Test vectors from http://www.bichlmeier.info/sha256test.html
   let vectors = [
     ["",
      "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"],
     ["abc",
      "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"],
     ["message digest",
@@ -300,19 +334,153 @@ add_test(function test_computeXClientSta
 
   let bidUser = new BrowserIDManager();
   let header = bidUser._computeXClientState(kB);
 
   do_check_eq(header, "6ae94683571c7a7c54dab4700aa3995f");
   run_next_test();
 });
 
+add_task(function test_getTokenErrors() {
+  _("BrowserIDManager correctly handles various failures to get a token.");
+
+  _("Arrange for a 401 - Sync should reflect an auth error.");
+  yield initializeIdentityWithTokenServerFailure({
+    status: 401,
+    headers: {"content-type": "application/json"},
+    body: JSON.stringify({}),
+  });
+  Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected");
+
+  // XXX - other interesting responses to return?
+
+  // And for good measure, some totally "unexpected" errors - we generally
+  // assume these problems are going to magically go away at some point.
+  _("Arrange for an empty body with a 200 response - should reflect a network error.");
+  yield initializeIdentityWithTokenServerFailure({
+    status: 200,
+    headers: [],
+    body: "",
+  });
+  Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login state is LOGIN_FAILED_NETWORK_ERROR");
+});
+
+add_task(function test_getHAWKErrors() {
+  _("BrowserIDManager correctly handles various HAWK failures.");
+
+  _("Arrange for a 401 - Sync should reflect an auth error.");
+  yield initializeIdentityWithHAWKFailure({
+    status: 401,
+    headers: {"content-type": "application/json"},
+    body: JSON.stringify({}),
+  });
+  Assert.equal(Status.login, LOGIN_FAILED_LOGIN_REJECTED, "login was rejected");
+
+  // XXX - other interesting responses to return?
+
+  // And for good measure, some totally "unexpected" errors - we generally
+  // assume these problems are going to magically go away at some point.
+  _("Arrange for an empty body with a 200 response - should reflect a network error.");
+  yield initializeIdentityWithHAWKFailure({
+    status: 200,
+    headers: [],
+    body: "",
+  });
+  Assert.equal(Status.login, LOGIN_FAILED_NETWORK_ERROR, "login state is LOGIN_FAILED_NETWORK_ERROR");
+});
+
 // End of tests
 // Utility functions follow
 
+// Create a new browserid_identity object and initialize it with a
+// mocked TokenServerClient which always gets the specified response.
+function* initializeIdentityWithTokenServerFailure(response) {
+  // First create a mock "request" object that well' hack into the token server.
+  // A log for it
+  let requestLog = Log.repository.getLogger("testing.mock-rest");
+  if (!requestLog.appenders.length) { // might as well see what it says :)
+    requestLog.addAppender(new Log.DumpAppender());
+    requestLog.level = Log.Level.Trace;
+  }
+
+  // A mock request object.
+  function MockRESTRequest(url) {};
+  MockRESTRequest.prototype = {
+    _log: requestLog,
+    setHeader: function() {},
+    get: function(callback) {
+      this.response = response;
+      callback.call(this);
+    }
+  }
+  // The mocked TokenServer client which will get the response.
+  function MockTSC() { }
+  MockTSC.prototype = new TokenServerClient();
+  MockTSC.prototype.constructor = MockTSC;
+  MockTSC.prototype.newRESTRequest = function(url) {
+    return new MockRESTRequest(url);
+  }
+  // tie it all together.
+  let mockTSC = new MockTSC()
+  configureFxAccountIdentity(browseridManager);
+  browseridManager._tokenServerClient = mockTSC;
+
+  yield browseridManager.initializeWithCurrentIdentity();
+  try {
+    yield browseridManager.whenReadyToAuthenticate.promise;
+    Assert.ok(false, "expecting this promise to resolve with an error");
+  } catch (ex) {}
+}
+
+
+// Create a new browserid_identity object and initialize it with a
+// hawk mock that simulates a failure.
+// A token server mock will be used that doesn't hit a server, so we move
+// directly to a hawk request.
+function* initializeIdentityWithHAWKFailure(response) {
+  // A mock request object.
+  function MockRESTRequest() {};
+  MockRESTRequest.prototype = {
+    setHeader: function() {},
+    post: function(data, callback) {
+      this.response = response;
+      callback.call(this);
+    }
+  }
+
+  // The hawk client.
+  function MockedHawkClient() {}
+  MockedHawkClient.prototype = new HawkClient();
+  MockedHawkClient.prototype.constructor = MockedHawkClient;
+  MockedHawkClient.prototype.newHAWKAuthenticatedRESTRequest = function(uri, credentials, extra) {
+    return new MockRESTRequest();
+  }
+
+  // tie it all together - configureFxAccountIdentity isn't useful here :(
+  let fxaClient = new MockFxAccountsClient();
+  fxaClient.hawk = new MockedHawkClient();
+  let config = makeIdentityConfig();
+  let internal = {
+    fxAccountsClient: fxaClient,
+  }
+  let fxa = new FxAccounts(internal);
+  fxa.internal.currentAccountState.signedInUser = {
+      accountData: config.fxaccount.user,
+  };
+
+  browseridManager._fxaService = fxa;
+  browseridManager._signedInUser = null;
+  yield browseridManager.initializeWithCurrentIdentity();
+  try {
+    yield browseridManager.whenReadyToAuthenticate.promise;
+    Assert.ok(false, "expecting this promise to resolve with an error");
+  } catch (ex) {}
+}
+
+
 function getTimestamp(hawkAuthHeader) {
   return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS;
 }
 
 function getTimestampDelta(hawkAuthHeader, now=Date.now()) {
   return Math.abs(getTimestamp(hawkAuthHeader) - now);
 }
 
--- a/services/sync/tests/unit/test_errorhandler.js
+++ b/services/sync/tests/unit/test_errorhandler.js
@@ -121,23 +121,24 @@ function sync_httpd_setup() {
       upd("crypto", (new ServerWBO("keys")).handler()),
     "/maintenance/1.1/broken.wipe/storage": service_unavailable,
     "/maintenance/1.1/broken.wipe/storage/clients": upd("clients", clientsColl.handler()),
     "/maintenance/1.1/broken.wipe/storage/catapult": service_unavailable
   });
 }
 
 function setUp(server) {
-  let deferred = Promise.defer();
-  configureIdentity({username: "johndoe"}).then(() => {
-    deferred.resolve(generateAndUploadKeys());
-  });
-  Service.serverURL  = server.baseURI + "/";
-  Service.clusterURL = server.baseURI + "/";
-  return deferred.promise;
+  return configureIdentity({username: "johndoe"}).then(
+    () => {
+      Service.serverURL  = server.baseURI + "/";
+      Service.clusterURL = server.baseURI + "/";
+    }
+  ).then(
+    () => generateAndUploadKeys()
+  );
 }
 
 function generateAndUploadKeys() {
   generateNewKeys(Service.collectionKeys);
   let serverKeys = Service.collectionKeys.asWBO("crypto", "keys");
   serverKeys.encrypt(Service.identity.syncKeyBundle);
   return serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success;
 }
--- a/services/sync/tests/unit/test_fxa_startOver.js
+++ b/services/sync/tests/unit/test_fxa_startOver.js
@@ -27,35 +27,36 @@ add_task(function* test_startover() {
   // we expect the "legacy" provider (but can't instanceof that, as BrowserIDManager
   // extends it)
   do_check_false(Service.identity instanceof BrowserIDManager);
   Service.login();
   // We should have a cluster URL
   do_check_true(Service.clusterURL.length > 0);
 
   // remember some stuff so we can reset it after.
-  let oldIdentidy = Service.identity;
+  let oldIdentity = Service.identity;
   let oldClusterManager = Service._clusterManager;
   let deferred = Promise.defer();
   Services.obs.addObserver(function observeStartOverFinished() {
     Services.obs.removeObserver(observeStartOverFinished, "weave:service:start-over:finish");
     deferred.resolve();
   }, "weave:service:start-over:finish", false);
 
   Service.startOver();
-  yield deferred; // wait for the observer to fire.
+  yield deferred.promise; // wait for the observer to fire.
 
   // should have reset the pref that indicates if FxA is enabled.
   do_check_true(Services.prefs.getBoolPref("services.sync.fxaccounts.enabled"));
   // the xpcom service should agree FxA is enabled.
   do_check_true(xps.fxAccountsEnabled);
   // should have swapped identities.
   do_check_true(Service.identity instanceof BrowserIDManager);
   // should have clobbered the cluster URL
   do_check_eq(Service.clusterURL, "");
 
+  // we should have thrown away the old identity provider and cluster manager.
+  do_check_neq(oldIdentity, Service.identity);
+  do_check_neq(oldClusterManager, Service._clusterManager);
+
   // reset the world.
-  Service.identity = oldIdentity = Service.identity;
-  Service._clusterManager = Service._clusterManager;
   Services.prefs.setBoolPref("services.sync.fxaccounts.enabled", false);
-
   Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", oldValue);
 });
--- a/services/sync/tests/unit/test_service_wipeClient.js
+++ b/services/sync/tests/unit/test_service_wipeClient.js
@@ -77,19 +77,19 @@ add_test(function test_startOver_clears_
 
   run_next_test();
 });
 
 add_test(function test_credentials_preserved() {
   _("Ensure that credentials are preserved if client is wiped.");
 
   // Required for wipeClient().
-  Service.clusterURL = "http://dummy:9000/";
   Service.identity.account = "testaccount";
   Service.identity.basicPassword = "testpassword";
+  Service.clusterURL = "http://dummy:9000/";
   let key = Utils.generatePassphrase();
   Service.identity.syncKey = key;
   Service.identity.persistCredentials();
 
   // Simulate passwords engine wipe without all the overhead. To do this
   // properly would require extra test infrastructure.
   Services.logins.removeAllLogins();
   Service.wipeClient();
--- a/toolkit/content/license.html
+++ b/toolkit/content/license.html
@@ -104,16 +104,17 @@
       <li><a href="about:license#libyuv">libyuv License</a></li>
       <li><a href="about:license#hunspell-lt">Lithuanian Spellchecking Dictionary License</a></li>
       <li><a href="about:license#maattachedwindow">MAAttachedWindow License</a></li>
       <li><a href="about:license#msinttypes">msinttypes License</a></li>
       <li><a href="about:license#myspell">MySpell License</a></li>
       <li><a href="about:license#nicer">nICEr License</a></li>
       <li><a href="about:license#nrappkit">nrappkit License</a></li>
       <li><a href="about:license#openvision">OpenVision License</a></li>
+      <li><a href="about:license#pbkdf2-sha256">pbkdf2_sha256 License</a></li>
       <li><a href="about:license#praton">praton License</a></li>
       <li><a href="about:license#qcms">qcms License</a></li>
       <li><a href="about:license#xdg">Red Hat xdg_user_dir_lookup License</a></li>
       <li><a href="about:license#hunspell-ru">Russian Spellchecking Dictionary License</a></li>
       <li><a href="about:license#sctp">SCTP Licenses</a></li>
       <li><a href="about:license#skia">Skia License</a></li>
       <li><a href="about:license#snappy">Snappy License</a></li>
       <li><a href="about:license#sparkle">Sparkle License</a></li>
@@ -3457,16 +3458,52 @@ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQ
 WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
 OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 </pre>
 
 
     <hr>
 
+    <h1><a id="pbkdf2-sha256"></a>pbkdf2_sha256 License</h1>
+
+    <p>This license applies to the code
+    <span class="path">mozglue/android/pbkdf2_sha256.c</span> and
+    <span class="path">mozglue/android/pbkdf2_sha256.h</span>.
+    </p>
+
+<pre>
+Copyright 2005,2007,2009 Colin Percival
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+</pre>
+
+
+    <hr>
+
     <h1><a id="qcms"></a>qcms License</h1>
 
     <p>This license applies to certain files in the directory
       <span class="path">gfx/qcms/</span>.</p>
 <pre>
 Copyright (C) 2009 Mozilla Corporation
 Copyright (C) 1998-2007 Marti Maria
 
new file mode 100644
--- /dev/null
+++ b/toolkit/devtools/webconsole/test/unit/test_js_property_provider.js
@@ -0,0 +1,71 @@
+/* -*- js2-basic-offset: 2; indent-tabs-mode: nil; -*- */
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+"use strict";
+const { devtools } = Components.utils.import("resource://gre/modules/devtools/Loader.jsm", {});
+let JSPropertyProvider = devtools.require("devtools/toolkit/webconsole/utils").JSPropertyProvider;
+
+Components.utils.import("resource://gre/modules/jsdebugger.jsm");
+addDebuggerToGlobal(this);
+
+function run_test() {
+  const testArray = 'var testArray = [\
+    {propA: "A"},\
+    {\
+      propB: "B", \
+      propC: [\
+        {propD: "D"}\
+      ]\
+    },\
+    [\
+      {propE: "E"}\
+    ]\
+  ];'
+
+  const testObject = 'var testObject = {"propA": [{"propB": "B"}]}';
+
+  let sandbox = Components.utils.Sandbox("http://example.com");
+  let dbg = new Debugger;
+  let dbgObject = dbg.addDebuggee(sandbox);
+  Components.utils.evalInSandbox(testArray, sandbox);
+  Components.utils.evalInSandbox(testObject, sandbox);
+
+  let results = JSPropertyProvider(dbgObject, null, "testArray[0].");
+  do_print("Test that suggestions are given for 'foo[n]' where n is an integer.");
+  test_has_result(results, "propA");
+
+  do_print("Test that suggestions are given for multidimensional arrays.");
+  results = JSPropertyProvider(dbgObject, null, "testArray[2][0].");
+  test_has_result(results, "propE");
+
+  do_print("Test that suggestions are not given for index that's out of bounds.");
+  results = JSPropertyProvider(dbgObject, null, "testArray[10].");
+  do_check_null(results);
+
+  do_print("Test that no suggestions are given if an index is not numerical somewhere in the chain.");
+  results = JSPropertyProvider(dbgObject, null, "testArray[0]['propC'][0].");
+  do_check_null(results);
+
+  results = JSPropertyProvider(dbgObject, null, "testObject['propA'][0].");
+  do_check_null(results);
+
+  results = JSPropertyProvider(dbgObject, null, "testArray[0]['propC'].");
+  do_check_null(results);
+
+  results = JSPropertyProvider(dbgObject, null, "testArray[][1].");
+  do_check_null(results);
+}
+
+/**
+ * A helper that ensures (required) results were found.
+ * @param Object aResults
+ *        The results returned by JSPropertyProvider.
+ * @param String aRequiredSuggestion
+ *        A suggestion that must be found from the results.
+ */
+function test_has_result(aResults, aRequiredSuggestion) {
+  do_check_neq(aResults, null);
+  do_check_true(aResults.matches.length > 0);
+  do_check_true(aResults.matches.indexOf(aRequiredSuggestion) !== -1);
+}
--- a/toolkit/devtools/webconsole/test/unit/xpcshell.ini
+++ b/toolkit/devtools/webconsole/test/unit/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head =
 tail =
 support-files =
 
-[test_network_helper.js]
\ No newline at end of file
+[test_js_property_provider.js]
+[test_network_helper.js]
--- a/toolkit/devtools/webconsole/utils.js
+++ b/toolkit/devtools/webconsole/utils.js
@@ -833,32 +833,83 @@ function JSPropertyProvider(aDbgObject, 
   // We get the rest of the properties recursively starting from the Debugger.Object
   // that wraps the first property
   for (let prop of properties) {
     prop = prop.trim();
     if (!prop) {
       return null;
     }
 
-    obj = DevToolsUtils.getProperty(obj, prop);
+    if (/\[\d+\]$/.test(prop)) {
+      // The property to autocomplete is a member of array. For example
+      // list[i][j]..[n]. Traverse the array to get the actual element.
+      obj = getArrayMemberProperty(obj, prop);
+    }
+    else {
+      obj = DevToolsUtils.getProperty(obj, prop);
+    }
 
     if (!isObjectUsable(obj)) {
       return null;
     }
   }
 
   // If the final property is a primitive
   if (typeof obj != "object") {
     return getMatchedProps(obj, matchProp);
   }
 
   return getMatchedPropsInDbgObject(obj, matchProp);
 }
 
 /**
+ * Get the array member of aObj for the given aProp. For example, given
+ * aProp='list[0][1]' the element at [0][1] of aObj.list is returned.
+ *
+ * @param object aObj
+ *        The object to operate on.
+ * @param string aProp
+ *        The property to return.
+ * @return null or Object
+ *         Returns null if the property couldn't be located. Otherwise the array
+ *         member identified by aProp.
+ */
+function getArrayMemberProperty(aObj, aProp)
+{
+  // First get the array.
+  let obj = aObj;
+  let propWithoutIndices = aProp.substr(0, aProp.indexOf("["));
+  obj = DevToolsUtils.getProperty(obj, propWithoutIndices);
+  if (!isObjectUsable(obj)) {
+    return null;
+  }
+
+  // Then traverse the list of indices to get the actual element.
+  let result;
+  let arrayIndicesRegex = /\[[^\]]*\]/g;
+  while ((result = arrayIndicesRegex.exec(aProp)) !== null) {
+    let indexWithBrackets = result[0];
+    let indexAsText = indexWithBrackets.substr(1, indexWithBrackets.length - 2);
+    let index = parseInt(indexAsText);
+
+    if (isNaN(index)) {
+      return null;
+    }
+
+    obj = DevToolsUtils.getProperty(obj, index);
+
+    if (!isObjectUsable(obj)) {
+      return null;
+    }
+  }
+
+  return obj;
+}
+
+/**
  * Check if the given Debugger.Object can be used for autocomplete.
  *
  * @param Debugger.Object aObject
  *        The Debugger.Object to check.
  * @return boolean
  *         True if further inspection into the object is possible, or false
  *         otherwise.
  */
--- a/toolkit/themes/linux/global/jar.mn
+++ b/toolkit/themes/linux/global/jar.mn
@@ -47,9 +47,10 @@ toolkit.jar:
 +  skin/classic/global/icons/loading_16.png                    (icons/loading_16.png)
 +  skin/classic/global/icons/panelarrow-horizontal.svg         (icons/panelarrow-horizontal.svg)
 +  skin/classic/global/icons/panelarrow-vertical.svg           (icons/panelarrow-vertical.svg)
 +  skin/classic/global/icons/resizer.png                       (icons/resizer.png)
 +  skin/classic/global/icons/sslWarning.png                    (icons/sslWarning.png)
 +  skin/classic/global/icons/wrap.png                          (icons/wrap.png)
 +  skin/classic/global/icons/webapps-16.png                    (icons/webapps-16.png)
 +  skin/classic/global/icons/webapps-64.png                    (icons/webapps-64.png)
+   skin/classic/global/menu/menu-check.png                     (../../shared/menu-check.png)
 +  skin/classic/global/toolbar/spring.png                      (toolbar/spring.png)
--- a/toolkit/themes/osx/global/jar.mn
+++ b/toolkit/themes/osx/global/jar.mn
@@ -176,18 +176,18 @@ toolkit.jar:
   skin/classic/global/media/volume-empty.png                         (media/volume-empty.png)
   skin/classic/global/media/volume-empty@2x.png                      (media/volume-empty@2x.png)
   skin/classic/global/media/volume-full.png                          (media/volume-full.png)
   skin/classic/global/media/volume-full@2x.png                       (media/volume-full@2x.png)
   skin/classic/global/media/clicktoplay-bgtexture.png                (media/clicktoplay-bgtexture.png)
   skin/classic/global/media/videoClickToPlayButton.svg               (media/videoClickToPlayButton.svg)
   skin/classic/global/menu/menu-arrow.png                            (menu/menu-arrow.png)
   skin/classic/global/menu/menu-arrow@2x.png                         (menu/menu-arrow@2x.png)
-  skin/classic/global/menu/menu-check.png                            (menu/menu-check.png)
-  skin/classic/global/menu/menu-check@2x.png                         (menu/menu-check@2x.png)
+  skin/classic/global/menu/menu-check.png                            (../../shared/menu-check.png)
+  skin/classic/global/menu/menu-check@2x.png                         (../../shared/menu-check@2x.png)
   skin/classic/global/scale/scale-tray-horiz.gif                     (scale/scale-tray-horiz.gif)
   skin/classic/global/scale/scale-tray-vert.gif                      (scale/scale-tray-vert.gif)
   skin/classic/global/splitter/dimple.png                            (splitter/dimple.png)
   skin/classic/global/splitter/grip-bottom.gif                       (splitter/grip-bottom.gif)
   skin/classic/global/splitter/grip-top.gif                          (splitter/grip-top.gif)
   skin/classic/global/splitter/grip-left.gif                         (splitter/grip-left.gif)
   skin/classic/global/splitter/grip-right.gif                        (splitter/grip-right.gif)
   skin/classic/global/toolbar/spring.png                             (toolbar/spring.png)
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d82635a639a401185de2f31f3ea2ad8520fa1a99
GIT binary patch
literal 197
zc%17D@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`J)SO(Ar_~T6C{EZ6ikd*I-WBd
zid)V)+VI0v;6MALzfUAK@U4<;arU?>)4Qb7RL@n%VNI`V3|Gf}C)4IV3T>ww>=gd7
z9O27&G@H{(RwJ=*$qQ?N-;PIxIjm$)PKZ-Ey1#J;ONYHciPbE{SdX?p>e^QtyKY}J
uS+AykE_&gtc)_P7GHoW4<|MGOFfj1*<#K3rak2v4#o+1c=d#Wzp$Pyh#zO7@
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2b1f8361324008de1265f2a2ec6acf57ecfc0fc4
GIT binary patch
literal 377
zc$@)w0fzpGP)<h;3K|Lk000e1NJLTq001BW001Be1^@s6b9#F80003%Nkl<ZSi@sr
z7zM)}1cXL14y5I3Kzs&>wFbg+W*|NR#Q%Z#GZ077+X1yu%aQS9AZDkB<t9M<3yTBp
z0WlA)E$0N{YgjD*2gLUD@cjfVmV@vFdRXoZ^(ngLw}6<7)|Lwb@gppj{{dnvdiZ_`
zj-c-*J2**D8;ZknP-5gD$MRfQ!U19?0%|3J_$`j0HzUV#Kd5GyL92230#wce@pc@(
zZzacaT_FB~#jry_EQCdUCJxIl0Wljn4k*WAAS^x=kkqQ;a4e_<G^8Xox8iZcb09WC
zt7y=1JvFjaGag5L0^$`o<jw*yE4A`<9Uezu!#_c3h}wlq4G9jYqE!i3L4@TeLFs~)
z#cUZK%fAD$COyjfVjK=Are`y&0Ij+`Lerqf;fOq_BS3|a>OeKOvp^NrXs>4!Py+w}
X8K;zTOeY^*00000NkvXXu0mjf<Uy4=
--- a/toolkit/themes/windows/global/jar.mn
+++ b/toolkit/themes/windows/global/jar.mn
@@ -163,16 +163,17 @@ toolkit.jar:
         skin/classic/global/media/scrubberThumbWide.png          (media/scrubberThumbWide.png)
         skin/classic/global/media/throbber.png                   (media/throbber.png)
         skin/classic/global/media/stalled.png                    (media/stalled.png)
         skin/classic/global/media/volume-empty.png               (media/volume-empty.png)
         skin/classic/global/media/volume-full.png                (media/volume-full.png)
         skin/classic/global/media/error.png                      (media/error.png)
         skin/classic/global/media/clicktoplay-bgtexture.png      (media/clicktoplay-bgtexture.png)
         skin/classic/global/media/videoClickToPlayButton.svg     (media/videoClickToPlayButton.svg)
+        skin/classic/global/menu/menu-check.png                  (../../shared/menu-check.png)
         skin/classic/global/printpreview/arrow-left.png          (printpreview/arrow-left.png)
         skin/classic/global/printpreview/arrow-left-end.png      (printpreview/arrow-left-end.png)
         skin/classic/global/printpreview/arrow-right.png         (printpreview/arrow-right.png)
         skin/classic/global/printpreview/arrow-right-end.png     (printpreview/arrow-right-end.png)
         skin/classic/global/radio/radio-check.gif                (radio/radio-check.gif)
         skin/classic/global/radio/radio-check-dis.gif            (radio/radio-check-dis.gif)
         skin/classic/global/scrollbar/slider.gif                 (scrollbar/slider.gif)
         skin/classic/global/splitter/grip-bottom.gif             (splitter/grip-bottom.gif)
@@ -345,16 +346,17 @@ toolkit.jar:
         skin/classic/aero/global/media/scrubberThumbWide.png             (media/scrubberThumbWide.png)
         skin/classic/aero/global/media/throbber.png                      (media/throbber.png)
         skin/classic/aero/global/media/stalled.png                       (media/stalled.png)
         skin/classic/aero/global/media/volume-empty.png                  (media/volume-empty.png)
         skin/classic/aero/global/media/volume-full.png                   (media/volume-full.png)
         skin/classic/aero/global/media/error.png                         (media/error.png)
         skin/classic/aero/global/media/clicktoplay-bgtexture.png         (media/clicktoplay-bgtexture.png)
         skin/classic/aero/global/media/videoClickToPlayButton.svg        (media/videoClickToPlayButton.svg)
+        skin/classic/aero/global/menu/menu-check.png                     (../../shared/menu-check.png)
         skin/classic/aero/global/printpreview/arrow-left.png             (printpreview/arrow-left-aero.png)
         skin/classic/aero/global/printpreview/arrow-left-end.png         (printpreview/arrow-left-end-aero.png)
         skin/classic/aero/global/printpreview/arrow-right.png            (printpreview/arrow-right-aero.png)
         skin/classic/aero/global/printpreview/arrow-right-end.png        (printpreview/arrow-right-end-aero.png)
         skin/classic/aero/global/radio/radio-check.gif                   (radio/radio-check.gif)
         skin/classic/aero/global/radio/radio-check-dis.gif               (radio/radio-check-dis.gif)
         skin/classic/aero/global/scrollbar/slider.gif                    (scrollbar/slider.gif)
         skin/classic/aero/global/splitter/grip-bottom.gif                (splitter/grip-bottom.gif)