merge fx-team to mozilla-central a=merge
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Wed, 10 Dec 2014 11:55:54 +0100
changeset 218985 5b01216f97f863236eccee5227abbc18319d4ab1
parent 218955 551c3cd74dbdcacb83803ded241c657917da94a5 (current diff)
parent 218984 219d81afdd503110fd28def92d7a2076b715f78c (diff)
child 219001 be1f49e80d2da65d16134390ab8b8e8c36a883ab
push id27950
push usercbook@mozilla.com
push dateWed, 10 Dec 2014 10:58:50 +0000
treeherderautoland@5b01216f97f8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone37.0a1
first release with
nightly linux32
5b01216f97f8 / 37.0a1 / 20141210030207 / files
nightly linux64
5b01216f97f8 / 37.0a1 / 20141210030207 / files
nightly mac
5b01216f97f8 / 37.0a1 / 20141210030207 / files
nightly win32
5b01216f97f8 / 37.0a1 / 20141210030207 / files
nightly win64
5b01216f97f8 / 37.0a1 / 20141210030207 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
merge fx-team to mozilla-central a=merge
js/src/jscompartment.cpp
mobile/android/base/LocaleAware.java
mobile/android/config/proguard.cfg
--- a/browser/base/content/abouthome/aboutHome.xhtml
+++ b/browser/base/content/abouthome/aboutHome.xhtml
@@ -59,17 +59,17 @@
       </div>
     </div>
     <div class="spacer"/>
 
     <div id="launcher">
       <button class="launchButton" id="downloads">&abouthome.downloadsButton.label;</button>
       <button class="launchButton" id="bookmarks">&abouthome.bookmarksButton.label;</button>
       <button class="launchButton" id="history">&abouthome.historyButton.label;</button>
-      <button class="launchButton" id="apps" hidden="true">&abouthome.appsButton.label;</button>
+      <button class="launchButton" id="apps">&abouthome.appsButton.label;</button>
       <button class="launchButton" id="addons">&abouthome.addonsButton.label;</button>
       <button class="launchButton" id="sync">&abouthome.syncButton.label;</button>
 #ifdef XP_WIN
       <button class="launchButton" id="settings">&abouthome.preferencesButtonWin.label;</button>
 #else
       <button class="launchButton" id="settings">&abouthome.preferencesButtonUnix.label;</button>
 #endif
       <div id="restorePreviousSessionSeparator"/>
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -2545,34 +2545,34 @@ let gMenuButtonUpdateBadge = {
         PanelUI.menuButton.setAttribute("badge", "\u2605");
 
         let brandBundle = document.getElementById("bundle_brand");
         let brandShortName = brandBundle.getString("brandShortName");
         stringId = "appmenu.restartNeeded.description";
         updateButtonText = gNavigatorBundle.getFormattedString(stringId,
                                                                [brandShortName]);
 
-        updateButton.label = updateButtonText;
+        updateButton.setAttribute("label", updateButtonText);
+        updateButton.setAttribute("update-status", "succeeded");
         updateButton.hidden = false;
-        updateButton.setAttribute("update-status", "succeeded");
 
         PanelUI.panel.addEventListener("popupshowing", this, true);
 
         break;
       case STATE_FAILED:
         // Background update has failed, let's show the UI responsible for
         // prompting the user to update manually.
         PanelUI.menuButton.setAttribute("badge", "!");
 
         stringId = "appmenu.updateFailed.description";
         updateButtonText = gNavigatorBundle.getString(stringId);
 
-        updateButton.label = updateButtonText;
+        updateButton.setAttribute("label", updateButtonText);
+        updateButton.setAttribute("update-status", "failed");
         updateButton.hidden = false;
-        updateButton.setAttribute("update-status", "failed");
 
         PanelUI.panel.addEventListener("popupshowing", this, true);
 
         break;
       case STATE_DOWNLOADING:
         // We've fallen back to downloading the full update because the partial
         // update failed to get staged in the background. Therefore we need to keep
         // our observer.
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -322,22 +322,16 @@ let AboutHomeListener = {
     addMessageListener("AboutHome:FocusInput", this);
     addEventListener("click", this, true);
     addEventListener("pagehide", this, true);
 
     if (!Services.prefs.getBoolPref("browser.search.showOneOffButtons")) {
       doc.documentElement.setAttribute("searchUIConfiguration", "oldsearchui");
     }
 
-    // XXX bug 738646 - when Marketplace is launched, remove this statement and
-    // the hidden attribute set on the apps button in aboutHome.xhtml
-    if (Services.prefs.getPrefType("browser.aboutHome.apps") == Services.prefs.PREF_BOOL &&
-        Services.prefs.getBoolPref("browser.aboutHome.apps"))
-      doc.getElementById("apps").removeAttribute("hidden");
-
     sendAsyncMessage("AboutHome:RequestUpdate");
     doc.addEventListener("AboutHomeSearchEvent", this, true, true);
     doc.addEventListener("AboutHomeSearchPanel", this, true, true);
   },
 
   onClick: function(aEvent) {
     if (!aEvent.isTrusted || // Don't trust synthetic events
         aEvent.button == 2 || aEvent.target.localName != "button") {
--- a/browser/components/loop/content/js/contacts.js
+++ b/browser/components/loop/content/js/contacts.js
@@ -253,17 +253,20 @@ loop.contacts = (function(_, mozL10n) {
             : null
           
         )
       );
     }
   });
 
   const ContactsList = React.createClass({displayName: 'ContactsList',
-    mixins: [React.addons.LinkedStateMixin],
+    mixins: [
+      React.addons.LinkedStateMixin,
+      loop.shared.mixins.WindowCloseMixin
+    ],
 
     /**
      * Contacts collection object
      */
     contacts: null,
 
     /**
      * User profile
@@ -430,21 +433,23 @@ loop.contacts = (function(_, mozL10n) {
             if (err) {
               throw err;
             }
           });
           break;
         case "video-call":
           if (!contact.blocked) {
             navigator.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+            this.closeWindow();
           }
           break;
         case "audio-call":
           if (!contact.blocked) {
             navigator.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+            this.closeWindow();
           }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
--- a/browser/components/loop/content/js/contacts.jsx
+++ b/browser/components/loop/content/js/contacts.jsx
@@ -253,17 +253,20 @@ loop.contacts = (function(_, mozL10n) {
             : null
           }
         </li>
       );
     }
   });
 
   const ContactsList = React.createClass({
-    mixins: [React.addons.LinkedStateMixin],
+    mixins: [
+      React.addons.LinkedStateMixin,
+      loop.shared.mixins.WindowCloseMixin
+    ],
 
     /**
      * Contacts collection object
      */
     contacts: null,
 
     /**
      * User profile
@@ -430,21 +433,23 @@ loop.contacts = (function(_, mozL10n) {
             if (err) {
               throw err;
             }
           });
           break;
         case "video-call":
           if (!contact.blocked) {
             navigator.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_VIDEO);
+            this.closeWindow();
           }
           break;
         case "audio-call":
           if (!contact.blocked) {
             navigator.mozLoop.calls.startDirectCall(contact, CALL_TYPES.AUDIO_ONLY);
+            this.closeWindow();
           }
           break;
         default:
           console.error("Unrecognized action: " + actionName);
           break;
       }
     },
 
--- a/browser/components/loop/content/js/conversation.js
+++ b/browser/components/loop/content/js/conversation.js
@@ -218,17 +218,17 @@ loop.conversation = (function(mozL10n) {
 
   /**
    * This view manages the incoming conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({displayName: 'IncomingConversationView',
-    mixins: [sharedMixins.AudioMixin],
+    mixins: [sharedMixins.AudioMixin, sharedMixins.WindowCloseMixin],
 
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       conversationAppStore: React.PropTypes.instanceOf(
         loop.store.ConversationAppStore).isRequired,
@@ -310,17 +310,17 @@ loop.conversation = (function(mozL10n) {
           return (
             sharedViews.FeedbackView({
               feedbackStore: this.props.feedbackStore, 
               onAfterFeedbackReceived: this.closeWindow.bind(this)}
             )
           );
         }
         case "close": {
-          window.close();
+          this.closeWindow();
           return (React.DOM.div(null));
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
@@ -454,20 +454,16 @@ loop.conversation = (function(mozL10n) {
      */
     _abortIncomingCall: function() {
       this._websocket.close();
       // Having a timeout here lets the logging for the websocket complete and be
       // displayed on the console if both are on.
       setTimeout(this.closeWindow, 0);
     },
 
-    closeWindow: function() {
-      window.close();
-    },
-
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       navigator.mozLoop.stopAlerting();
       this._websocket.accept();
       this.props.conversation.accepted();
     },
@@ -536,17 +532,17 @@ loop.conversation = (function(mozL10n) {
     },
   });
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
   var AppControllerView = React.createClass({displayName: 'AppControllerView',
-    mixins: [Backbone.Events],
+    mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
@@ -570,20 +566,16 @@ loop.conversation = (function(mozL10n) {
         this.setState(this.props.conversationAppStore.getStoreState());
       }, this);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.conversationAppStore);
     },
 
-    closeWindow: function() {
-      window.close();
-    },
-
     render: function() {
       switch(this.state.windowType) {
         case "incoming": {
           return (IncomingConversationView({
             client: this.props.client, 
             conversation: this.props.conversation, 
             sdk: this.props.sdk, 
             conversationAppStore: this.props.conversationAppStore, 
--- a/browser/components/loop/content/js/conversation.jsx
+++ b/browser/components/loop/content/js/conversation.jsx
@@ -218,17 +218,17 @@ loop.conversation = (function(mozL10n) {
 
   /**
    * This view manages the incoming conversation views - from
    * call initiation through to the actual conversation and call end.
    *
    * At the moment, it does more than that, these parts need refactoring out.
    */
   var IncomingConversationView = React.createClass({
-    mixins: [sharedMixins.AudioMixin],
+    mixins: [sharedMixins.AudioMixin, sharedMixins.WindowCloseMixin],
 
     propTypes: {
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
       conversationAppStore: React.PropTypes.instanceOf(
         loop.store.ConversationAppStore).isRequired,
@@ -310,17 +310,17 @@ loop.conversation = (function(mozL10n) {
           return (
             <sharedViews.FeedbackView
               feedbackStore={this.props.feedbackStore}
               onAfterFeedbackReceived={this.closeWindow.bind(this)}
             />
           );
         }
         case "close": {
-          window.close();
+          this.closeWindow();
           return (<div/>);
         }
       }
     },
 
     /**
      * Notify the user that the connection was not possible
      * @param {{code: number, message: string}} error
@@ -454,20 +454,16 @@ loop.conversation = (function(mozL10n) {
      */
     _abortIncomingCall: function() {
       this._websocket.close();
       // Having a timeout here lets the logging for the websocket complete and be
       // displayed on the console if both are on.
       setTimeout(this.closeWindow, 0);
     },
 
-    closeWindow: function() {
-      window.close();
-    },
-
     /**
      * Accepts an incoming call.
      */
     accept: function() {
       navigator.mozLoop.stopAlerting();
       this._websocket.accept();
       this.props.conversation.accepted();
     },
@@ -536,17 +532,17 @@ loop.conversation = (function(mozL10n) {
     },
   });
 
   /**
    * Master controller view for handling if incoming or outgoing calls are
    * in progress, and hence, which view to display.
    */
   var AppControllerView = React.createClass({
-    mixins: [Backbone.Events],
+    mixins: [Backbone.Events, sharedMixins.WindowCloseMixin],
 
     propTypes: {
       // XXX Old types required for incoming call view.
       client: React.PropTypes.instanceOf(loop.Client).isRequired,
       conversation: React.PropTypes.instanceOf(sharedModels.ConversationModel)
                          .isRequired,
       sdk: React.PropTypes.object.isRequired,
 
@@ -570,20 +566,16 @@ loop.conversation = (function(mozL10n) {
         this.setState(this.props.conversationAppStore.getStoreState());
       }, this);
     },
 
     componentWillUnmount: function() {
       this.stopListening(this.props.conversationAppStore);
     },
 
-    closeWindow: function() {
-      window.close();
-    },
-
     render: function() {
       switch(this.state.windowType) {
         case "incoming": {
           return (<IncomingConversationView
             client={this.props.client}
             conversation={this.props.conversation}
             sdk={this.props.sdk}
             conversationAppStore={this.props.conversationAppStore}
--- a/browser/components/loop/content/js/conversationViews.js
+++ b/browser/components/loop/content/js/conversationViews.js
@@ -188,17 +188,21 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({displayName: 'CallFailedView',
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [
+      Backbone.Events,
+      sharedMixins.AudioMixin,
+      sharedMixins.WindowCloseMixin
+    ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired,
       contact: React.PropTypes.object.isRequired,
       // This is used by the UI showcase.
       emailLinkError: React.PropTypes.bool,
@@ -222,17 +226,17 @@ loop.conversationViews = (function(mozL1
     componentWillUnmount: function() {
       this.stopListening(this.props.store);
     },
 
     _onEmailLinkReceived: function() {
       var emailLink = this.props.store.getStoreState("emailLink");
       var contactEmail = _getPreferredEmail(this.props.contact).value;
       sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
-      window.close();
+      this.closeWindow();
     },
 
     _onEmailLinkError: function() {
       this.setState({
         emailLinkError: true,
         emailLinkButtonDisabled: false
       });
     },
--- a/browser/components/loop/content/js/conversationViews.jsx
+++ b/browser/components/loop/content/js/conversationViews.jsx
@@ -188,17 +188,21 @@ loop.conversationViews = (function(mozL1
       );
     }
   });
 
   /**
    * Call failed view. Displayed when a call fails.
    */
   var CallFailedView = React.createClass({
-    mixins: [Backbone.Events, sharedMixins.AudioMixin],
+    mixins: [
+      Backbone.Events,
+      sharedMixins.AudioMixin,
+      sharedMixins.WindowCloseMixin
+    ],
 
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       store: React.PropTypes.instanceOf(
         loop.store.ConversationStore).isRequired,
       contact: React.PropTypes.object.isRequired,
       // This is used by the UI showcase.
       emailLinkError: React.PropTypes.bool,
@@ -222,17 +226,17 @@ loop.conversationViews = (function(mozL1
     componentWillUnmount: function() {
       this.stopListening(this.props.store);
     },
 
     _onEmailLinkReceived: function() {
       var emailLink = this.props.store.getStoreState("emailLink");
       var contactEmail = _getPreferredEmail(this.props.contact).value;
       sharedUtils.composeCallUrlEmail(emailLink, contactEmail);
-      window.close();
+      this.closeWindow();
     },
 
     _onEmailLinkError: function() {
       this.setState({
         emailLinkError: true,
         emailLinkButtonDisabled: false
       });
     },
--- a/browser/components/loop/content/js/panel.js
+++ b/browser/components/loop/content/js/panel.js
@@ -590,30 +590,33 @@ loop.panel = (function(_, mozL10n) {
    * Room list entry.
    */
   var RoomEntry = React.createClass({displayName: 'RoomEntry',
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       room:       React.PropTypes.instanceOf(loop.store.Room).isRequired
     },
 
+    mixins: [loop.shared.mixins.WindowCloseMixin],
+
     getInitialState: function() {
       return { urlCopied: false };
     },
 
     shouldComponentUpdate: function(nextProps, nextState) {
       return (nextProps.room.ctime > this.props.room.ctime) ||
         (nextState.urlCopied !== this.state.urlCopied);
     },
 
     handleClickEntry: function(event) {
       event.preventDefault();
       this.props.dispatcher.dispatch(new sharedActions.OpenRoom({
         roomToken: this.props.room.roomToken
       }));
+      this.closeWindow();
     },
 
     handleCopyButtonClick: function(event) {
       event.stopPropagation();
       event.preventDefault();
       this.props.dispatcher.dispatch(new sharedActions.CopyRoomUrl({
         roomUrl: this.props.room.roomUrl
       }));
--- a/browser/components/loop/content/js/panel.jsx
+++ b/browser/components/loop/content/js/panel.jsx
@@ -590,30 +590,33 @@ loop.panel = (function(_, mozL10n) {
    * Room list entry.
    */
   var RoomEntry = React.createClass({
     propTypes: {
       dispatcher: React.PropTypes.instanceOf(loop.Dispatcher).isRequired,
       room:       React.PropTypes.instanceOf(loop.store.Room).isRequired
     },
 
+    mixins: [loop.shared.mixins.WindowCloseMixin],
+
     getInitialState: function() {
       return { urlCopied: false };
     },
 
     shouldComponentUpdate: function(nextProps, nextState) {
       return (nextProps.room.ctime > this.props.room.ctime) ||
         (nextState.urlCopied !== this.state.urlCopied);
     },
 
     handleClickEntry: function(event) {
       event.preventDefault();
       this.props.dispatcher.dispatch(new sharedActions.OpenRoom({
         roomToken: this.props.room.roomToken
       }));
+      this.closeWindow();
     },
 
     handleCopyButtonClick: function(event) {
       event.stopPropagation();
       event.preventDefault();
       this.props.dispatcher.dispatch(new sharedActions.CopyRoomUrl({
         roomUrl: this.props.room.roomUrl
       }));
--- a/browser/components/loop/content/shared/js/mixins.js
+++ b/browser/components/loop/content/shared/js/mixins.js
@@ -11,23 +11,25 @@ loop.shared.mixins = (function() {
 
   /**
    * Root object, by default set to window.
    * @type {DOMWindow|Object}
    */
   var rootObject = window;
 
   /**
-   * Sets a new root object. This is useful for testing native DOM events so we
-   * can fake them.
+   * Sets a new root object.  This is useful for testing native DOM events so we
+   * can fake them. In beforeEach(), loop.shared.mixins.setRootObject is used to
+   * substitute a fake window, and in afterEach(), the real window object is
+   * replaced.
    *
    * @param {Object}
    */
   function setRootObject(obj) {
-    console.info("loop.shared.mixins: rootObject set to " + obj);
+    console.log("loop.shared.mixins: rootObject set to " + obj);
     rootObject = obj;
   }
 
   /**
    * window.location mixin. Handles changes in the call url.
    * Forces a reload of the page to ensure proper state of the webapp
    *
    * @type {Object}
@@ -60,16 +62,31 @@ loop.shared.mixins = (function() {
    */
   var DocumentTitleMixin = {
     setTitle: function(newTitle) {
       rootObject.document.title = newTitle;
     }
   };
 
   /**
+   * Window close mixin, for more testable closing of windows.  Instead of
+   * calling window.close() directly, use this mixin and call
+   * this.closeWindow from your component.
+   *
+   * @type {Object}
+   *
+   * @see setRootObject for info on how to unit test code that uses this mixin
+   */
+  var WindowCloseMixin = {
+    closeWindow: function() {
+      rootObject.close();
+    }
+  };
+
+  /**
    * Dropdown menu mixin.
    * @type {Object}
    */
   var DropdownMenuMixin = {
     get documentBody() {
       return rootObject.document.body;
     },
 
@@ -286,11 +303,12 @@ loop.shared.mixins = (function() {
   return {
     AudioMixin: AudioMixin,
     RoomsAudioMixin: RoomsAudioMixin,
     setRootObject: setRootObject,
     DropdownMenuMixin: DropdownMenuMixin,
     DocumentVisibilityMixin: DocumentVisibilityMixin,
     DocumentLocationMixin: DocumentLocationMixin,
     DocumentTitleMixin: DocumentTitleMixin,
-    UrlHashChangeMixin: UrlHashChangeMixin
+    UrlHashChangeMixin: UrlHashChangeMixin,
+    WindowCloseMixin: WindowCloseMixin
   };
 })();
--- a/browser/components/loop/test/desktop-local/contacts_test.js
+++ b/browser/components/loop/test/desktop-local/contacts_test.js
@@ -9,37 +9,87 @@ var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 
 describe("loop.contacts", function() {
   "use strict";
 
   var fakeAddContactButtonText = "Fake Add Contact";
   var fakeEditContactButtonText = "Fake Edit Contact";
   var fakeDoneButtonText = "Fake Done";
+  var sandbox;
+  var fakeWindow;
 
   beforeEach(function(done) {
+    sandbox = sinon.sandbox.create();
     navigator.mozLoop = {
       getStrings: function(entityName) {
         var textContentValue = "fakeText";
         if (entityName == "add_contact_button") {
           textContentValue = fakeAddContactButtonText;
         } else if (entityName == "edit_contact_title") {
           textContentValue = fakeEditContactButtonText;
         } else if (entityName == "edit_contact_done_button") {
           textContentValue = fakeDoneButtonText;
         }
         return JSON.stringify({textContent: textContentValue});
       },
     };
 
+    fakeWindow = {
+      close: sandbox.stub(),
+    };
+    loop.shared.mixins.setRootObject(fakeWindow);
+
     document.mozL10n.initialize(navigator.mozLoop);
     // XXX prevent a race whenever mozL10n hasn't been initialized yet
     setTimeout(done, 0);
   });
 
+  afterEach(function() {
+    loop.shared.mixins.setRootObject(window);
+    sandbox.restore();
+  });
+
+
+  describe("ContactsList", function () {
+    var listView;
+
+    beforeEach(function() {
+      navigator.mozLoop.calls = {
+        startDirectCall: sandbox.stub(),
+        clearCallInProgress: sandbox.stub()
+      };
+      navigator.mozLoop.contacts = {getAll: sandbox.stub()};
+
+      listView = TestUtils.renderIntoDocument(loop.contacts.ContactsList());
+    });
+
+    afterEach(function() {
+      listView = null;
+      delete navigator.mozLoop.calls;
+      delete navigator.mozLoop.contacts;
+    });
+
+    describe("#handleContactAction", function() {
+      it("should call window.close when called with 'video-call' action",
+        function() {
+          listView.handleContactAction({}, "video-call");
+
+          sinon.assert.calledOnce(fakeWindow.close);
+      });
+
+      it("should call window.close when called with 'audio-call' action",
+        function() {
+          listView.handleContactAction({}, "audio-call");
+
+          sinon.assert.calledOnce(fakeWindow.close);
+        });
+    });
+  });
+
   describe("ContactDetailsForm", function() {
     describe("#render", function() {
       describe("add mode", function() {
         it("should render 'add' header", function() {
           var view = TestUtils.renderIntoDocument(
             loop.contacts.ContactDetailsForm({mode: "add"}));
 
           var header = view.getDOMNode().querySelector("header");
--- a/browser/components/loop/test/desktop-local/conversationViews_test.js
+++ b/browser/components/loop/test/desktop-local/conversationViews_test.js
@@ -3,17 +3,17 @@
 
 var expect = chai.expect;
 
 describe("loop.conversationViews", function () {
   "use strict";
 
   var sharedUtils = loop.shared.utils;
   var sandbox, oldTitle, view, dispatcher, contact, fakeAudioXHR;
-  var fakeMozLoop;
+  var fakeMozLoop, fakeWindow;
 
   var CALL_STATES = loop.store.CALL_STATES;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
 
     oldTitle = document.title;
     sandbox.stub(document.mozL10n, "get", function(x) {
@@ -53,19 +53,27 @@ describe("loop.conversationViews", funct
           channel: "test",
           platform: "test"
         };
       },
       getAudioBlob: sinon.spy(function(name, callback) {
         callback(null, new Blob([new ArrayBuffer(10)], {type: "audio/ogg"}));
       })
     };
+
+    fakeWindow = {
+      navigator: { mozLoop: fakeMozLoop },
+      close: sandbox.stub(),
+    };
+    loop.shared.mixins.setRootObject(fakeWindow);
+
   });
 
   afterEach(function() {
+    loop.shared.mixins.setRootObject(window);
     document.title = oldTitle;
     view = undefined;
     delete navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("CallIdentifierView", function() {
     function mountTestComponent(props) {
@@ -311,22 +319,21 @@ describe("loop.conversationViews", funct
 
       sinon.assert.calledOnce(composeCallUrlEmail);
       sinon.assert.calledWithExactly(composeCallUrlEmail,
         "http://fake.invalid/", "test@test.tld");
     });
 
     it("should close the conversation window once the email link is received",
       function() {
-        sandbox.stub(window, "close");
         view = mountTestComponent();
 
         store.setStoreState({emailLink: "http://fake.invalid/"});
 
-        sinon.assert.calledOnce(window.close);
+        sinon.assert.calledOnce(fakeWindow.close);
       });
 
     it("should display an error message in case email link retrieval failed",
       function() {
         view = mountTestComponent();
 
         store.trigger("error:emailLink");
 
--- a/browser/components/loop/test/desktop-local/conversation_test.js
+++ b/browser/components/loop/test/desktop-local/conversation_test.js
@@ -6,16 +6,17 @@
 
 var expect = chai.expect;
 
 describe("loop.conversation", function() {
   "use strict";
 
   var sharedModels = loop.shared.models,
       sharedView = loop.shared.views,
+      fakeWindow,
       sandbox;
 
   // XXX refactor to Just Work with "sandbox.stubComponent" or else
   // just pass in the sandbox and put somewhere generally usable
 
   function stubComponent(obj, component, mockTagName){
     var reactClass = React.createClass({
       render: function() {
@@ -63,25 +64,32 @@ describe("loop.conversation", function()
           platform: "test"
         };
       },
       getAudioBlob: sinon.spy(function(name, callback) {
         callback(null, new Blob([new ArrayBuffer(10)], {type: 'audio/ogg'}));
       })
     };
 
+    fakeWindow = {
+      navigator: { mozLoop: navigator.mozLoop },
+      close: sandbox.stub(),
+    };
+    loop.shared.mixins.setRootObject(fakeWindow);
+
     // XXX These stubs should be hoisted in a common file
     // Bug 1040968
     sandbox.stub(document.mozL10n, "get", function(x) {
       return x;
     });
     document.mozL10n.initialize(navigator.mozLoop);
   });
 
   afterEach(function() {
+    loop.shared.mixins.setRootObject(window);
     delete navigator.mozLoop;
     sandbox.restore();
   });
 
   describe("#init", function() {
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(document.mozL10n, "initialize");
@@ -403,17 +411,16 @@ describe("loop.conversation", function()
             // setup functions
             icView = mountTestComponent();
             promise = new Promise(function(resolve, reject) {
               resolve();
             });
 
             sandbox.stub(loop.CallConnectionWebSocket.prototype, "promiseConnect").returns(promise);
             sandbox.stub(loop.CallConnectionWebSocket.prototype, "close");
-            sandbox.stub(window, "close");
           });
 
           describe("progress - terminated (previousState = alerting)", function() {
             it("should stop alerting", function(done) {
               promise.then(function() {
                 icView._websocket.trigger("progress", {
                   state: "terminated",
                   reason: "timeout"
@@ -440,22 +447,23 @@ describe("loop.conversation", function()
               promise.then(function() {
                 icView._websocket.trigger("progress", {
                   state: "terminated",
                   reason: "answered-elsewhere"
                 }, "alerting");
 
                 sandbox.clock.tick(1);
 
-                sinon.assert.calledOnce(window.close);
+                sinon.assert.calledOnce(fakeWindow.close);
                 done();
               });
             });
           });
 
+
           describe("progress - terminated (previousState not init" +
                    " nor alerting)",
             function() {
               it("should set the state to end", function(done) {
                 promise.then(function() {
                   icView._websocket.trigger("progress", {
                     state: "terminated",
                     reason: "media-fail"
@@ -516,17 +524,16 @@ describe("loop.conversation", function()
           sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
         });
       });
 
       describe("#decline", function() {
         beforeEach(function() {
           icView = mountTestComponent();
 
-          sandbox.stub(window, "close");
           icView._websocket = {
             decline: sinon.stub(),
             close: sinon.stub()
           };
           conversation.set({
             windowId: "8699"
           });
           conversation.setIncomingSessionData({
@@ -534,17 +541,17 @@ describe("loop.conversation", function()
           });
         });
 
         it("should close the window", function() {
           icView.decline();
 
           sandbox.clock.tick(1);
 
-          sinon.assert.calledOnce(window.close);
+          sinon.assert.calledOnce(fakeWindow.close);
         });
 
         it("should stop alerting", function() {
           icView.decline();
 
           sinon.assert.calledOnce(navigator.mozLoop.stopAlerting);
         });
 
@@ -562,17 +569,16 @@ describe("loop.conversation", function()
 
         beforeEach(function() {
           icView = mountTestComponent();
 
           icView._websocket = {
             decline: sinon.spy(),
             close: sinon.stub()
           };
-          sandbox.stub(window, "close");
 
           mozLoop = {
             LOOP_SESSION_TYPE: {
               GUEST: 1,
               FXA: 2
             }
           };
 
@@ -621,17 +627,17 @@ describe("loop.conversation", function()
           sinon.assert.calledWithExactly(log, fakeError);
         });
 
         it("should close the window", function() {
           icView.declineAndBlock();
 
           sandbox.clock.tick(1);
 
-          sinon.assert.calledOnce(window.close);
+          sinon.assert.calledOnce(fakeWindow.close);
         });
       });
     });
 
     describe("Events", function() {
       var fakeSessionData;
 
       beforeEach(function() {
--- a/browser/components/loop/test/desktop-local/panel_test.js
+++ b/browser/components/loop/test/desktop-local/panel_test.js
@@ -8,26 +8,33 @@
 var expect = chai.expect;
 var TestUtils = React.addons.TestUtils;
 var sharedActions = loop.shared.actions;
 var sharedUtils = loop.shared.utils;
 
 describe("loop.panel", function() {
   "use strict";
 
-  var sandbox, notifications, fakeXHR, requests = [];
+  var sandbox, notifications, fakeXHR, fakeWindow, requests = [];
 
   beforeEach(function(done) {
     sandbox = sinon.sandbox.create();
     fakeXHR = sandbox.useFakeXMLHttpRequest();
     requests = [];
     // https://github.com/cjohansen/Sinon.JS/issues/393
     fakeXHR.xhr.onCreate = function (xhr) {
       requests.push(xhr);
+    }
+
+    fakeWindow = {
+      close: sandbox.stub(),
+      document: { addEventListener: function(){} }
     };
+    loop.shared.mixins.setRootObject(fakeWindow);
+
     notifications = new loop.shared.models.NotificationCollection();
 
     navigator.mozLoop = {
       doNotDisturb: true,
       fxAEnabled: true,
       getStrings: function() {
         return JSON.stringify({textContent: "fakeText"});
       },
@@ -60,16 +67,17 @@ describe("loop.panel", function() {
 
     document.mozL10n.initialize(navigator.mozLoop);
     // XXX prevent a race whenever mozL10n hasn't been initialized yet
     setTimeout(done, 0);
   });
 
   afterEach(function() {
     delete navigator.mozLoop;
+    loop.shared.mixins.setRootObject(window);
     sandbox.restore();
   });
 
   describe("#init", function() {
     beforeEach(function() {
       sandbox.stub(React, "renderComponent");
       sandbox.stub(document.mozL10n, "initialize");
       sandbox.stub(document.mozL10n, "get").returns("Fake title");
@@ -835,32 +843,42 @@ describe("loop.panel", function() {
         TestUtils.Simulate.click(deleteButton);
 
         sinon.assert.calledOnce(navigator.mozLoop.confirm);
         sinon.assert.notCalled(dispatcher.dispatch);
       });
     });
 
     describe("Room URL click", function() {
-      var roomEntry;
+
+      var roomEntry, urlLink;
 
-      it("should dispatch an OpenRoom action", function() {
+      beforeEach(function() {
         sandbox.stub(dispatcher, "dispatch");
+
         roomEntry = mountRoomEntry({
           dispatcher: dispatcher,
           room: new loop.store.Room(roomData)
         });
-        var urlLink = roomEntry.getDOMNode().querySelector("p > a");
+        urlLink = roomEntry.getDOMNode().querySelector("p > a");
+      });
 
+      it("should dispatch an OpenRoom action", function() {
         TestUtils.Simulate.click(urlLink);
 
         sinon.assert.calledOnce(dispatcher.dispatch);
         sinon.assert.calledWithExactly(dispatcher.dispatch,
           new sharedActions.OpenRoom({roomToken: roomData.roomToken}));
       });
+
+      it("should call window.close", function() {
+        TestUtils.Simulate.click(urlLink);
+
+        sinon.assert.calledOnce(fakeWindow.close);
+      });
     });
 
     describe("Room name updated", function() {
       it("should update room name", function() {
         var roomEntry = mountRoomEntry({
           dispatcher: dispatcher,
           room: new loop.store.Room(roomData)
         });
--- a/browser/components/loop/test/shared/mixins_test.js
+++ b/browser/components/loop/test/shared/mixins_test.js
@@ -112,16 +112,44 @@ describe("loop.shared.mixins", function(
       var comp = TestUtils.renderIntoDocument(TestComp());
 
       comp.setTitle("It's a Fake!");
 
       expect(rootObject.document.title).eql("It's a Fake!");
     });
   });
 
+
+  describe("loop.shared.mixins.WindowCloseMixin", function() {
+    var TestComp, rootObject;
+
+    beforeEach(function() {
+      rootObject = {
+        close: sandbox.stub()
+      };
+      sharedMixins.setRootObject(rootObject);
+
+      TestComp = React.createClass({
+        mixins: [loop.shared.mixins.WindowCloseMixin],
+        render: function() {
+          return React.DOM.div();
+        }
+      });
+    });
+
+    it("should call window.close", function() {
+      var comp = TestUtils.renderIntoDocument(TestComp());
+
+      comp.closeWindow();
+
+      sinon.assert.calledOnce(rootObject.close);
+      sinon.assert.calledWithExactly(rootObject.close);
+    });
+  });
+
   describe("loop.shared.mixins.DocumentVisibilityMixin", function() {
     var comp, TestComp, onDocumentVisibleStub, onDocumentHiddenStub;
 
     beforeEach(function() {
       onDocumentVisibleStub = sandbox.stub();
       onDocumentHiddenStub = sandbox.stub();
 
       TestComp = React.createClass({
--- a/browser/modules/AboutHome.jsm
+++ b/browser/modules/AboutHome.jsm
@@ -138,17 +138,17 @@ let AboutHome = {
         window.PlacesCommandHook.showPlacesOrganizer("AllBookmarks");
         break;
 
       case "AboutHome:History":
         window.PlacesCommandHook.showPlacesOrganizer("History");
         break;
 
       case "AboutHome:Apps":
-        window.openUILinkIn("https://marketplace.mozilla.org/", "tab");
+        window.BrowserOpenApps();
         break;
 
       case "AboutHome:Addons":
         window.BrowserOpenAddonsMgr();
         break;
 
       case "AboutHome:Sync":
         let weave = Cc["@mozilla.org/weave/service;1"]
--- a/browser/modules/UITour.jsm
+++ b/browser/modules/UITour.jsm
@@ -116,25 +116,29 @@ this.UITour = {
     ["home",        {query: "#home-button"}],
     ["forget", {
       query: "#panic-button",
       widgetName: "panic-button",
       allowAdd: true,
     }],
     ["loop",        {query: "#loop-button"}],
     ["loop-newRoom", {
+      infoPanelPosition: "leftcenter topright",
       query: (aDocument) => {
         let loopBrowser = aDocument.querySelector("#loop-notification-panel > #loop");
         if (!loopBrowser) {
           return null;
         }
-        return loopBrowser.contentDocument.querySelector(".new-room-button");
+        // Use the parentElement full-width container of the button so our arrow
+        // doesn't overlap the panel contents much.
+        return loopBrowser.contentDocument.querySelector(".new-room-button").parentElement;
       },
     }],
     ["loop-roomList", {
+      infoPanelPosition: "leftcenter topright",
       query: (aDocument) => {
         let loopBrowser = aDocument.querySelector("#loop-notification-panel > #loop");
         if (!loopBrowser) {
           return null;
         }
         return loopBrowser.contentDocument.querySelector(".room-list");
       },
     }],
@@ -145,16 +149,17 @@ this.UITour = {
           return null;
         }
         return loopBrowser.contentDocument.querySelector(".signin-link");
       },
     }],
     ["privateWindow",  {query: "#privatebrowsing-button"}],
     ["quit",        {query: "#PanelUI-quit"}],
     ["search",      {
+      infoPanelPosition: "after_start",
       query: "#searchbar",
       widgetName: "search-container",
     }],
     ["searchProvider", {
       query: (aDocument) => {
         let searchbar = aDocument.getElementById("searchbar");
         if (searchbar.hasAttribute("oneoffui")) {
           return null;
@@ -872,16 +877,17 @@ this.UITour = {
           node = null;
         }
       } else {
         node = aWindow.document.querySelector(targetQuery);
       }
 
       deferred.resolve({
         addTargetListener: targetObject.addTargetListener,
+        infoPanelPosition: targetObject.infoPanelPosition,
         node: node,
         removeTargetListener: targetObject.removeTargetListener,
         targetName: aTargetName,
         widgetName: targetObject.widgetName,
         allowAdd: targetObject.allowAdd,
       });
     }).catch(log.error);
     return deferred.promise;
@@ -1204,20 +1210,23 @@ this.UITour = {
         tooltipClose.removeEventListener("command", closeButtonCallback);
         if (aOptions.targetCallbackID && aAnchor.removeTargetListener) {
           aAnchor.removeTargetListener(document, targetCallback);
         }
       });
 
       tooltip.setAttribute("targetName", aAnchor.targetName);
       tooltip.hidden = false;
+      let alignment = "bottomcenter topright";
+      if (aAnchor.infoPanelPosition) {
+        alignment = aAnchor.infoPanelPosition;
+      }
+
       let xOffset = 0, yOffset = 0;
-      let alignment = "bottomcenter topright";
       if (aAnchor.targetName == "search") {
-        alignment = "after_start";
         xOffset = 18;
       }
       this._addAnnotationPanelMutationObserver(tooltip);
       tooltip.openPopup(aAnchorEl, alignment, xOffset, yOffset);
       if (tooltip.state == "closed") {
         document.defaultView.addEventListener("endmodalstate", function endModalStateHandler() {
           document.defaultView.removeEventListener("endmodalstate", endModalStateHandler);
           tooltip.openPopup(aAnchorEl, alignment);
--- a/browser/modules/test/browser_UITour_loop.js
+++ b/browser/modules/test/browser_UITour_loop.js
@@ -95,18 +95,38 @@ let tests = [
           ok(false, "No more notifications should have arrived");
         });
         done();
       });
       document.querySelector("#pinnedchats > chatbox").close();
     });
     LoopRooms.open("fakeTourRoom");
   },
+  taskify(function* test_arrow_panel_position() {
+    ise(loopButton.open, false, "Menu should initially be closed");
+    let popup = document.getElementById("UITourTooltip");
+
+    yield showMenuPromise("loop");
+
+    let currentTarget = "loop-newRoom";
+    yield showInfoPromise(currentTarget, "This is " + currentTarget, "My arrow should be on the side");
+    is(popup.popupBoxObject.alignmentPosition, "start_before", "Check " + currentTarget + " position");
+
+    currentTarget = "loop-roomList";
+    yield showInfoPromise(currentTarget, "This is " + currentTarget, "My arrow should be on the side");
+    is(popup.popupBoxObject.alignmentPosition, "start_before", "Check " + currentTarget + " position");
+
+    currentTarget = "loop-signInUpLink";
+    yield showInfoPromise(currentTarget, "This is " + currentTarget, "My arrow should be underneath");
+    is(popup.popupBoxObject.alignmentPosition, "after_end", "Check " + currentTarget + " position");
+  }),
 ];
 
+// End tests
+
 function checkLoopPanelIsHidden() {
   ok(!loopPanel.hasAttribute("noautohide"), "@noautohide on the loop panel should have been cleaned up");
   ok(!loopPanel.hasAttribute("panelopen"), "The panel shouldn't have @panelopen");
   isnot(loopPanel.state, "open", "The panel shouldn't be open");
   is(loopButton.hasAttribute("open"), false, "Loop button should know that the panel is closed");
 }
 
 if (Services.prefs.getBoolPref("loop.enabled")) {
--- a/browser/modules/test/head.js
+++ b/browser/modules/test/head.js
@@ -130,16 +130,22 @@ function hideInfoPromise(...args) {
 }
 
 function showInfoPromise(...args) {
   let popup = document.getElementById("UITourTooltip");
   gContentAPI.showInfo.apply(gContentAPI, args);
   return promisePanelElementShown(window, popup);
 }
 
+function showMenuPromise(name) {
+  return new Promise(resolve => {
+    gContentAPI.showMenu(name, () => resolve());
+  });
+}
+
 function waitForCallbackResultPromise() {
   return waitForConditionPromise(() => {
     return gContentWindow.callbackResult;
   }, "callback should be called");
 }
 
 function addPinnedTabPromise() {
   gContentAPI.addPinnedTab();
--- a/browser/themes/shared/devtools/dark-theme.css
+++ b/browser/themes/shared/devtools/dark-theme.css
@@ -379,25 +379,16 @@ div.CodeMirror span.eval-text {
   color: white;
   border-bottom: 1px solid #434850;
 }
 
 .theme-tooltip-panel .devtools-tooltip-simple-text:last-child {
   border-bottom: 0;
 }
 
-.devtools-horizontal-splitter {
-  border-bottom: 1px solid var(--theme-splitter-color);
-}
-
-.devtools-side-splitter {
-  -moz-border-end: 1px solid var(--theme-splitter-color);
-  border-color: var(--theme-splitter-color); /* Needed for responsive container at low width. */
-}
-
 .devtools-textinput,
 .devtools-searchinput {
   background-color: rgba(24, 29, 32, 1);
   color: rgba(184, 200, 217, 1);
 }
 
 .CodeMirror-Tern-fname {
   color: #f7f7f7;
--- a/browser/themes/shared/devtools/debugger.inc.css
+++ b/browser/themes/shared/devtools/debugger.inc.css
@@ -99,21 +99,21 @@
     -moz-image-region: rect(0px,64px,32px,32px);
   }
 }
 
 #sources .black-boxed {
   color: rgba(128,128,128,0.4);
 }
 
-#sources .selected > .black-boxed {
+#sources .selected .black-boxed {
   color: rgba(255,255,255,0.4);
 }
 
-#sources .black-boxed > .dbg-breakpoint {
+#sources .black-boxed ~ .dbg-breakpoint {
   display: none;
 }
 
 /* Debugger unblackbox button */
 
 #black-boxed-message-button > .button-box > .button-icon {
   width: 16px;
   height: 16px;
--- a/browser/themes/shared/devtools/light-theme.css
+++ b/browser/themes/shared/devtools/light-theme.css
@@ -388,25 +388,16 @@ div.CodeMirror span.eval-text {
   color: black;
   border-bottom: 1px solid #d9e1e8;
 }
 
 .theme-tooltip-panel .devtools-tooltip-simple-text:last-child {
   border-bottom: 0;
 }
 
-.devtools-horizontal-splitter {
-  border-bottom: 1px solid var(--theme-splitter-color);
-}
-
-.devtools-side-splitter {
-  -moz-border-end: 1px solid var(--theme-splitter-color);
-  border-color: var(--theme-splitter-color); /* Needed for responsive container at low width. */
-}
-
 .CodeMirror-hints,
 .CodeMirror-Tern-tooltip {
   box-shadow: 0 0 4px rgba(128, 128, 128, .5);
   background-color: var(--theme-sidebar-background);
 }
 
 
 %include toolbars.inc.css
--- a/browser/themes/shared/devtools/timeline.inc.css
+++ b/browser/themes/shared/devtools/timeline.inc.css
@@ -170,17 +170,17 @@
   color: #f5f7fa; /* Light foreground text */
 }
 
 .waterfall-marker-container.selected .waterfall-marker-bullet,
 .waterfall-marker-container.selected .waterfall-marker-bar {
   border-color: initial!important;
 }
 
-#waterfall-details {
+#timeline-waterfall-details {
   -moz-padding-start: 8px;
   -moz-padding-end: 8px;
   padding-top: 8vh;
   overflow: auto;
 }
 
 .marker-details-bullet {
   width: 8px;
--- a/browser/themes/shared/devtools/toolbars.inc.css
+++ b/browser/themes/shared/devtools/toolbars.inc.css
@@ -642,17 +642,17 @@
     background-image: url("chrome://browser/skin/devtools/command-eyedropper@2x.png");
   }
 }
 
 /* Tabs */
 
 .devtools-tabbar {
   -moz-appearance: none;
-  min-height: 28px;
+  min-height: 24px;
   border: 0px solid;
   border-bottom-width: 1px;
   padding: 0;
   background: var(--theme-tab-toolbar-background);
   border-bottom-color: var(--theme-splitter-color);
 }
 
 .theme-light .devtools-tabbar {
@@ -667,17 +667,17 @@
   margin: 0;
 }
 
 .devtools-tab {
   -moz-appearance: none;
   -moz-binding: url("chrome://global/content/bindings/general.xml#control-item");
   -moz-box-align: center;
   min-width: 32px;
-  min-height: 28px;
+  min-height: 24px;
   max-width: 127px;
   margin: 0;
   padding: 0;
   border-style: solid;
   border-width: 0;
   -moz-border-start-width: 1px;
   -moz-box-align: center;
 }
@@ -848,8 +848,18 @@
 .hidden-labels-box:not(.visible) > label,
 .hidden-labels-box.visible ~ .hidden-labels-box > label:last-child {
   display: none;
 }
 
 .devtools-invisible-splitter {
   border-color: transparent;
 }
+
+.devtools-horizontal-splitter {
+  border-bottom: 1px solid var(--theme-splitter-color);
+}
+
+.devtools-side-splitter {
+  -moz-border-end: 1px solid var(--theme-splitter-color);
+  border-color: var(--theme-splitter-color); /* Needed for responsive container at low width. */
+}
+
--- a/browser/themes/shared/devtools/widgets.inc.css
+++ b/browser/themes/shared/devtools/widgets.inc.css
@@ -45,23 +45,30 @@
 }
 
 @media (max-width: 700px) {
   .devtools-responsive-container {
     -moz-box-orient: vertical;
   }
 
   .devtools-responsive-container > .devtools-side-splitter {
-    border-width: 0;
-    border-top-width: 1px;
-    border-top-style: solid;
-    margin: 0;
+    /* This is a normally vertical splitter, but we have turned it horizontal
+       due to the smaller resolution */
     min-height: 3px;
     height: 3px;
-    margin-bottom: -3px;
+    margin-top: -3px;
+
+    /* Reset the vertical splitter styles */
+    border-width: 0;
+    border-bottom-width: 1px;
+    border-bottom-style: solid;
+    -moz-margin-start: 0;
+    width: auto;
+    min-width: 0;
+
     /* In some edge case the cursor is not changed to n-resize */
     cursor: n-resize;
   }
 
   .devtools-responsive-container > .devtools-sidebar-tabs {
     min-height: 35vh;
     max-height: 75vh;
   }
--- a/browser/themes/shared/incontentprefs/preferences.inc.css
+++ b/browser/themes/shared/incontentprefs/preferences.inc.css
@@ -94,16 +94,22 @@ treecol {
     -moz-image-region: rect(0, 288px, 48px, 240px);
   }
 
   #category-advanced > .category-icon {
     -moz-image-region: rect(0, 336px, 48px, 288px);
   }
 }
 
+@media (max-width: 800px) {
+  .category-name {
+    display: none;
+  }
+}
+
 /* header */
 
 #header-advanced {
   border-bottom: none;
   padding-bottom: 0;
 }
 
 /* General Pane */
--- a/layout/base/nsDocumentViewer.cpp
+++ b/layout/base/nsDocumentViewer.cpp
@@ -1083,21 +1083,16 @@ nsDocumentViewer::PermitUnloadInternal(b
   static bool sBeforeUnloadPrefCached = false;
 
   if (!sBeforeUnloadPrefCached ) {
     sBeforeUnloadPrefCached = true;
     Preferences::AddBoolVarCache(&sIsBeforeUnloadDisabled,
                                  BEFOREUNLOAD_DISABLED_PREFNAME);
   }
 
-  // If the user has turned off onbeforeunload warnings, no need to check.
-  if (sIsBeforeUnloadDisabled) {
-    return NS_OK;
-  }
-
   // First, get the script global object from the document...
   nsPIDOMWindow *window = mDocument->GetWindow();
 
   if (!window) {
     // This is odd, but not fatal
     NS_WARNING("window not set for document!");
     return NS_OK;
   }
@@ -1142,18 +1137,20 @@ nsDocumentViewer::PermitUnloadInternal(b
     if (dialogsWereEnabled) {
       utils->EnableDialogs();
     }
   }
 
   nsCOMPtr<nsIDocShell> docShell(mContainer);
   nsAutoString text;
   beforeUnload->GetReturnValue(text);
-  if (*aShouldPrompt && (event->GetInternalNSEvent()->mFlags.mDefaultPrevented ||
-                         !text.IsEmpty())) {
+
+  if (!sIsBeforeUnloadDisabled && *aShouldPrompt &&
+      (event->GetInternalNSEvent()->mFlags.mDefaultPrevented ||
+       !text.IsEmpty())) {
     // Ask the user if it's ok to unload the current page
 
     nsCOMPtr<nsIPrompt> prompt = do_GetInterface(docShell);
 
     if (prompt) {
       nsCOMPtr<nsIWritablePropertyBag2> promptBag = do_QueryInterface(prompt);
       if (promptBag) {
         bool isTabModalPromptAllowed;
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -2192,17 +2192,17 @@ public class BrowserApp extends GeckoApp
                     @Override
                     public void run() {
                         final LocaleManager localeManager = BrowserLocaleManager.getInstance();
                         final Locale locale = localeManager.getCurrentLocale(getApplicationContext());
                         Log.d(LOGTAG, "Read persisted locale " + locale);
                         if (locale == null) {
                             return;
                         }
-                        onLocaleChanged(BrowserLocaleManager.getLanguageTag(locale));
+                        onLocaleChanged(Locales.getLanguageTag(locale));
                     }
                 });
                 break;
             default:
                 super.onActivityResult(requestCode, resultCode, data);
         }
     }
 
@@ -2925,17 +2925,17 @@ public class BrowserApp extends GeckoApp
             // we might need to redisplay based on a locale change.
             startActivityForResult(intent, ACTIVITY_REQUEST_PREFERENCES);
             return true;
         }
 
         if (itemId == R.id.help) {
             final String VERSION = AppConstants.MOZ_APP_VERSION;
             final String OS = AppConstants.OS_TARGET;
-            final String LOCALE = BrowserLocaleManager.getLanguageTag(Locale.getDefault());
+            final String LOCALE = Locales.getLanguageTag(Locale.getDefault());
 
             final String URL = getResources().getString(R.string.help_link, VERSION, OS, LOCALE);
             Tabs.getInstance().loadUrlInTab(URL);
             return true;
         }
 
         if (itemId == R.id.addons) {
             Tabs.getInstance().loadUrlInTab(AboutPages.ADDONS);
--- a/mobile/android/base/BrowserLocaleManager.java
+++ b/mobile/android/base/BrowserLocaleManager.java
@@ -76,74 +76,16 @@ public class BrowserLocaleManager implem
     }
 
     @Override
     public boolean isEnabled() {
         return AppConstants.MOZ_LOCALE_SWITCHER;
     }
 
     /**
-     * Sometimes we want just the language for a locale, not the entire
-     * language tag. But Java's .getLanguage method is wrong.
-     *
-     * This method is equivalent to the first part of {@link #getLanguageTag(Locale)}.
-     *
-     * @return a language string, such as "he" for the Hebrew locales.
-     */
-    public static String getLanguage(final Locale locale) {
-        final String language = locale.getLanguage();  // Can, but should never be, an empty string.
-        // Modernize certain language codes.
-        if (language.equals("iw")) {
-            return "he";
-        }
-
-        if (language.equals("in")) {
-            return "id";
-        }
-
-        if (language.equals("ji")) {
-            return "yi";
-        }
-
-        return language;
-    }
-
-    /**
-     * Gecko uses locale codes like "es-ES", whereas a Java {@link Locale}
-     * stringifies as "es_ES".
-     *
-     * This method approximates the Java 7 method <code>Locale#toLanguageTag()</code>.
-     *
-     * @return a locale string suitable for passing to Gecko.
-     */
-    public static String getLanguageTag(final Locale locale) {
-        // If this were Java 7:
-        // return locale.toLanguageTag();
-
-        final String language = getLanguage(locale);
-        final String country = locale.getCountry();    // Can be an empty string.
-        if (country.equals("")) {
-            return language;
-        }
-        return language + "-" + country;
-    }
-
-    public static Locale parseLocaleCode(final String localeCode) {
-        int index;
-        if ((index = localeCode.indexOf('-')) != -1 ||
-            (index = localeCode.indexOf('_')) != -1) {
-            final String langCode = localeCode.substring(0, index);
-            final String countryCode = localeCode.substring(index + 1);
-            return new Locale(langCode, countryCode);
-        } else {
-            return new Locale(localeCode);
-        }
-    }
-
-    /**
      * Ensure that you call this early in your application startup,
      * and with a context that's sufficiently long-lived (typically
      * the application context).
      *
      * Calling multiple times is harmless.
      */
     @Override
     public void initialize(final Context context) {
@@ -270,17 +212,17 @@ public class BrowserLocaleManager implem
             return;
         }
 
         // Store the Java-native form.
         prefs.edit().putString("osLocale", osLocaleString).apply();
 
         // The value we send to Gecko should be a language tag, not
         // a Java locale string.
-        final String osLanguageTag = BrowserLocaleManager.getLanguageTag(osLocale);
+        final String osLanguageTag = Locales.getLanguageTag(osLocale);
         final GeckoEvent localeOSEvent = GeckoEvent.createBroadcastEvent("Locale:OS", osLanguageTag);
         GeckoAppShell.sendEventToGecko(localeOSEvent);
     }
 
     @Override
     public String getAndApplyPersistedLocale(Context context) {
         initialize(context);
 
@@ -316,17 +258,17 @@ public class BrowserLocaleManager implem
         // We always persist and notify Gecko, even if nothing seemed to
         // change. This might happen if you're picking a locale that's the same
         // as the current OS locale. The OS locale might change next time we
         // launch, and we need the Gecko pref and persisted locale to have been
         // set by the time that happens.
         persistLocale(context, localeCode);
 
         // Tell Gecko.
-        GeckoEvent ev = GeckoEvent.createBroadcastEvent(EVENT_LOCALE_CHANGED, BrowserLocaleManager.getLanguageTag(getCurrentLocale(context)));
+        GeckoEvent ev = GeckoEvent.createBroadcastEvent(EVENT_LOCALE_CHANGED, Locales.getLanguageTag(getCurrentLocale(context)));
         GeckoAppShell.sendEventToGecko(ev);
 
         return resultant;
     }
 
     @Override
     public void resetToSystemLocale(Context context) {
         // Wipe the pref.
@@ -384,17 +326,17 @@ public class BrowserLocaleManager implem
         if (currentLocale != null) {
             return currentLocale;
         }
 
         final String current = getPersistedLocale(context);
         if (current == null) {
             return null;
         }
-        return currentLocale = parseLocaleCode(current);
+        return currentLocale = Locales.parseLocaleCode(current);
     }
 
     /**
      * Updates the Java locale and the Android configuration.
      *
      * Returns the persisted locale if it differed.
      *
      * Does not notify Gecko.
@@ -404,17 +346,17 @@ public class BrowserLocaleManager implem
      */
     private String updateLocale(Context context, String localeCode) {
         // Fast path.
         final Locale defaultLocale = Locale.getDefault();
         if (defaultLocale.toString().equals(localeCode)) {
             return null;
         }
 
-        final Locale locale = parseLocaleCode(localeCode);
+        final Locale locale = Locales.parseLocaleCode(localeCode);
 
         return updateLocale(context, locale);
     }
 
     /**
      * @return the Java locale string: e.g., "en_US".
      */
     private String updateLocale(Context context, final Locale locale) {
@@ -490,12 +432,13 @@ public class BrowserLocaleManager implem
             return null;
         }
     }
 
     /**
      * @return the single default locale baked into this application.
      *         Applicable when there is no multilocale.json present.
      */
-    public static String getFallbackLocaleTag() {
+    @SuppressWarnings("static-method")
+    public String getFallbackLocaleTag() {
         return FALLBACK_LOCALE_TAG;
     }
 }
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -1378,17 +1378,17 @@ public abstract class GeckoApp
      * response to device changes.
      */
     @Override
     public void onLocaleReady(final String locale) {
         if (!ThreadUtils.isOnUiThread()) {
             throw new RuntimeException("onLocaleReady must always be called from the UI thread.");
         }
 
-        final Locale loc = BrowserLocaleManager.parseLocaleCode(locale);
+        final Locale loc = Locales.parseLocaleCode(locale);
         if (loc.equals(mLastLocale)) {
             Log.d(LOGTAG, "New locale same as old; onLocaleReady has nothing to do.");
         }
 
         // The URL bar hint needs to be populated.
         TextView urlBar = (TextView) findViewById(R.id.url_bar_title);
         if (urlBar != null) {
             final String hint = getResources().getString(R.string.url_bar_default_text);
@@ -2114,17 +2114,17 @@ public abstract class GeckoApp
 
     @Override
     public void onConfigurationChanged(Configuration newConfig) {
         Log.d(LOGTAG, "onConfigurationChanged: " + newConfig.locale);
 
         final LocaleManager localeManager = BrowserLocaleManager.getInstance();
         final Locale changed = localeManager.onSystemConfigurationChanged(this, getResources(), newConfig, mLastLocale);
         if (changed != null) {
-            onLocaleChanged(BrowserLocaleManager.getLanguageTag(changed));
+            onLocaleChanged(Locales.getLanguageTag(changed));
         }
 
         // onConfigurationChanged is not called for 180 degree orientation changes,
         // we will miss such rotations and the screen orientation will not be
         // updated.
         if (GeckoScreenOrientation.getInstance().update(newConfig.orientation)) {
             if (mFormAssistPopup != null)
                 mFormAssistPopup.hide();
--- a/mobile/android/base/LocaleManager.java
+++ b/mobile/android/base/LocaleManager.java
@@ -33,9 +33,10 @@ public interface LocaleManager {
 
     /**
      * Call this in your onConfigurationChanged handler. This method is expected
      * to do the appropriate thing: if the user has selected a locale, it
      * corrects the incoming configuration; if not, it signals the new locale to
      * use.
      */
     Locale onSystemConfigurationChanged(Context context, Resources resources, Configuration configuration, Locale currentActivityLocale);
+    String getFallbackLocaleTag();
 }
rename from mobile/android/base/LocaleAware.java
rename to mobile/android/base/Locales.java
--- a/mobile/android/base/LocaleAware.java
+++ b/mobile/android/base/Locales.java
@@ -1,52 +1,116 @@
 /* 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;
 
+import java.util.Locale;
+
 import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.LocaleManager;
 
 import android.app.Activity;
 import android.content.Context;
 import android.os.Bundle;
 import android.os.StrictMode;
 import android.support.v4.app.FragmentActivity;
 
 /**
- * This is a helper class to do typical locale switching operations
- * without hitting StrictMode errors or adding boilerplate to common
- * activity subclasses.
+ * This is a helper class to do typical locale switching operations without
+ * hitting StrictMode errors or adding boilerplate to common activity
+ * subclasses.
  *
- * Either call {@link LocaleAware#initializeLocale(Context)} in your
- * <code>onCreate</code> method, or inherit from <code>LocaleAwareFragmentActivity</code>
- * or <code>LocaleAwareActivity</code>.
+ * Either call {@link Locales#initializeLocale(Context)} in your
+ * <code>onCreate</code> method, or inherit from
+ * <code>LocaleAwareFragmentActivity</code> or <code>LocaleAwareActivity</code>.
  */
-public class LocaleAware {
-  public static void initializeLocale(Context context) {
-    final LocaleManager localeManager = BrowserLocaleManager.getInstance();
-    final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
-    StrictMode.allowThreadDiskWrites();
-    try {
-      localeManager.getAndApplyPersistedLocale(context);
-    } finally {
-      StrictMode.setThreadPolicy(savedPolicy);
+public class Locales {
+    public static void initializeLocale(Context context) {
+        final LocaleManager localeManager = BrowserLocaleManager.getInstance();
+        final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
+        StrictMode.allowThreadDiskWrites();
+        try {
+            localeManager.getAndApplyPersistedLocale(context);
+        } finally {
+            StrictMode.setThreadPolicy(savedPolicy);
+        }
+    }
+
+    public static class LocaleAwareFragmentActivity extends FragmentActivity {
+        @Override
+        protected void onCreate(Bundle savedInstanceState) {
+            Locales.initializeLocale(getApplicationContext());
+            super.onCreate(savedInstanceState);
+        }
+    }
+
+    public static class LocaleAwareActivity extends Activity {
+        @Override
+        protected void onCreate(Bundle savedInstanceState) {
+            Locales.initializeLocale(getApplicationContext());
+            super.onCreate(savedInstanceState);
+        }
     }
-  }
+
+    /**
+     * Sometimes we want just the language for a locale, not the entire language
+     * tag. But Java's .getLanguage method is wrong.
+     *
+     * This method is equivalent to the first part of
+     * {@link Locales#getLanguageTag(Locale)}.
+     *
+     * @return a language string, such as "he" for the Hebrew locales.
+     */
+    public static String getLanguage(final Locale locale) {
+        // Can, but should never be, an empty string.
+        final String language = locale.getLanguage();
 
-  public static class LocaleAwareFragmentActivity extends FragmentActivity {
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-      LocaleAware.initializeLocale(getApplicationContext());
-      super.onCreate(savedInstanceState);
+        // Modernize certain language codes.
+        if (language.equals("iw")) {
+            return "he";
+        }
+
+        if (language.equals("in")) {
+            return "id";
+        }
+
+        if (language.equals("ji")) {
+            return "yi";
+        }
+
+        return language;
     }
-  }
+
+    /**
+     * Gecko uses locale codes like "es-ES", whereas a Java {@link Locale}
+     * stringifies as "es_ES".
+     *
+     * This method approximates the Java 7 method
+     * <code>Locale#toLanguageTag()</code>.
+     *
+     * @return a locale string suitable for passing to Gecko.
+     */
+    public static String getLanguageTag(final Locale locale) {
+        // If this were Java 7:
+        // return locale.toLanguageTag();
 
-  public static class LocaleAwareActivity extends Activity {
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-      LocaleAware.initializeLocale(getApplicationContext());
-      super.onCreate(savedInstanceState);
+        final String language = getLanguage(locale);
+        final String country = locale.getCountry(); // Can be an empty string.
+        if (country.equals("")) {
+            return language;
+        }
+        return language + "-" + country;
     }
-  }
+
+    public static Locale parseLocaleCode(final String localeCode) {
+        int index;
+        if ((index = localeCode.indexOf('-')) != -1 ||
+            (index = localeCode.indexOf('_')) != -1) {
+            final String langCode = localeCode.substring(0, index);
+            final String countryCode = localeCode.substring(index + 1);
+            return new Locale(langCode, countryCode);
+        }
+
+        return new Locale(localeCode);
+    }
 }
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -55,34 +55,58 @@ GARBAGE += \
   javah.out \
   jni-stubs.inc \
   GeneratedJNIWrappers.cpp \
   GeneratedJNIWrappers.h \
   $(NULL)
 
 GARBAGE_DIRS += classes db jars res sync services generated
 
-JAVA_BOOTCLASSPATH = \
+# The bootclasspath is functionally identical to the classpath, but allows the
+# classes given to redefine classes in core packages, such as java.lang.
+# android.jar is here as it provides Android's definition of the Java Standard
+# Library. The compatability lib here tweaks a few of the core classes to paint
+# over changes in behaviour between versions.
+JAVA_BOOTCLASSPATH := \
     $(ANDROID_SDK)/android.jar \
     $(ANDROID_COMPAT_LIB) \
     $(NULL)
 
 JAVA_BOOTCLASSPATH := $(subst $(NULL) ,:,$(strip $(JAVA_BOOTCLASSPATH)))
 
-# If native devices are enabled, add Google Play Services and some of the v7 compat libraries
+# If native devices are enabled, add Google Play Services and some of the v7
+# compat libraries.
 ifdef MOZ_NATIVE_DEVICES
     JAVA_CLASSPATH += \
         $(GOOGLE_PLAY_SERVICES_LIB) \
         $(ANDROID_MEDIAROUTER_LIB) \
         $(ANDROID_APPCOMPAT_LIB) \
         $(NULL)
 endif
 
 JAVA_CLASSPATH := $(subst $(NULL) ,:,$(strip $(JAVA_CLASSPATH)))
 
+# Library jars that we're bundling: these are subject to Proguard before inclusion
+# into classes.dex.
+java_bundled_libs := \
+    $(ANDROID_COMPAT_LIB) \
+    $(NULL)
+
+ifdef MOZ_NATIVE_DEVICES
+    java_bundled_libs += \
+        $(GOOGLE_PLAY_SERVICES_LIB) \
+        $(ANDROID_MEDIAROUTER_LIB) \
+        $(ANDROID_APPCOMPAT_LIB) \
+        $(NULL)
+endif
+
+java_bundled_libs := $(subst $(NULL) ,:,$(strip $(java_bundled_libs)))
+
+# All the jars we're compiling from source. (not to be confused with
+# java_bundled_libs, which holds the jars which we're including as binaries).
 ALL_JARS = \
   constants.jar \
   gecko-R.jar \
   gecko-browser.jar \
   gecko-mozglue.jar \
   gecko-thirdparty.jar \
   gecko-util.jar \
   sync-thirdparty.jar \
@@ -97,75 +121,95 @@ ALL_JARS += search-activity.jar
 endif
 
 ifdef MOZ_ANDROID_MLS_STUMBLER
 extra_packages += org.mozilla.mozstumbler
 ALL_JARS += ../stumbler/stumbler.jar
 generated/org/mozilla/mozstumbler/R.java: .aapt.deps ;
 endif
 
+# The list of jars in Java classpath notation (colon-separated).
+all_jars_classpath := $(subst $(NULL) ,:,$(strip $(ALL_JARS)))
+
 include $(topsrcdir)/config/config.mk
 
-# Note that we're going to set up a dependency directly between embed_android.dex and the java files
-# Instead of on the .class files, since more than one .class file might be produced per .java file
-# Sync dependencies are provided in a single jar. Sync classes themselves are delivered as source,
-# because Android resource classes must be compiled together in order to avoid overlapping resource
-# indices.
-
-library_jars = \
-    $(JAVA_CLASSPATH) \
-    $(JAVA_BOOTCLASSPATH) \
+library_jars := \
+    $(ANDROID_SDK)/android.jar \
     $(NULL)
 
 library_jars := $(subst $(NULL) ,:,$(strip $(library_jars)))
 
 classes.dex: .proguard.deps
 	$(REPORT_BUILD)
-	$(DX) --dex --output=classes.dex jars-proguarded $(subst :, ,$(ANDROID_COMPAT_LIB):$(JAVA_CLASSPATH))
+	$(DX) --dex --output=classes.dex jars-proguarded
 
 ifdef MOZ_DISABLE_PROGUARD
   PROGUARD_PASSES=0
 else
   ifdef MOZ_DEBUG
     PROGUARD_PASSES=1
   else
     ifndef MOZILLA_OFFICIAL
       PROGUARD_PASSES=1
     else
       PROGUARD_PASSES=6
     endif
   endif
 endif
 
+proguard_config_dir=$(topsrcdir)/mobile/android/config/proguard
+
 # This stanza ensures that the set of GeckoView classes does not depend on too
 # much of Fennec, where "too much" is defined as the set of potentially
 # non-GeckoView classes that GeckoView already depended on at a certain point in
 # time.  The idea is to set a high-water mark that is not to be crossed.
 classycle_jar := $(topsrcdir)/mobile/android/build/classycle/classycle-1.4.1.jar
 .geckoview.deps: geckoview.ddf $(classycle_jar) $(ALL_JARS)
 	java -cp $(classycle_jar) \
 		classycle.dependency.DependencyChecker \
 		-mergeInnerClasses \
 		-dependencies=@$< \
 		$(ALL_JARS)
 	@$(TOUCH) $@
 
-# We touch the target file before invoking Proguard so that Proguard's
-# outputs are fresher than the target, preventing a subsequent
-# invocation from thinking Proguard's outputs are stale.  This is safe
-# because Make removes the target file if any recipe command fails.
-.proguard.deps: .geckoview.deps $(ALL_JARS) $(topsrcdir)/mobile/android/config/proguard.cfg
+# First, we delete debugging information from libraries. Having line-number
+# information for libraries for which we lack the source isn't useful, so this
+# saves us a bit of space. Importantly, Proguard has a bug causing it to
+# sometimes corrupt this information if present (which it does for some of the
+# included libraries). This corruption prevents dex from completing, so we need
+# to get rid of it.  This prevents us from seeing line numbers in stack traces
+# for stack frames inside libraries.
+#
+# This step can occur much earlier than the main Proguard pass: it needs only
+# gecko-R.jar to have been compiled (as that's where the library R.java files
+# end up), but it does block the main Proguard pass.
+.bundled.proguard.deps: gecko-R.jar $(proguard_config_dir)/strip-libs.cfg
 	$(REPORT_BUILD)
 	@$(TOUCH) $@
 	java \
 		-Xmx512m -Xms128m \
 		-jar $(ANDROID_SDK_ROOT)/tools/proguard/lib/proguard.jar \
-		@$(topsrcdir)/mobile/android/config/proguard.cfg \
+		@$(proguard_config_dir)/strip-libs.cfg \
+		-injars $(subst ::,:,$(java_bundled_libs))\
+		-outjars bundled-jars-nodebug \
+		-libraryjars $(library_jars):gecko-R.jar
+
+# We touch the target file before invoking Proguard so that Proguard's
+# outputs are fresher than the target, preventing a subsequent
+# invocation from thinking Proguard's outputs are stale.  This is safe
+# because Make removes the target file if any recipe command fails.
+.proguard.deps: .geckoview.deps .bundled.proguard.deps $(ALL_JARS) $(proguard_config_dir)/proguard.cfg
+	$(REPORT_BUILD)
+	@$(TOUCH) $@
+	java \
+		-Xmx512m -Xms128m \
+		-jar $(ANDROID_SDK_ROOT)/tools/proguard/lib/proguard.jar \
+		@$(proguard_config_dir)/proguard.cfg \
 		-optimizationpasses $(PROGUARD_PASSES) \
-		-injars $(subst ::,:,$(subst $(NULL) ,:,$(strip $(ALL_JARS)))) \
+		-injars $(subst ::,:,$(all_jars_classpath)):bundled-jars-nodebug \
 		-outjars jars-proguarded \
 		-libraryjars $(library_jars)
 
 CLASSES_WITH_JNI= \
     org.mozilla.gecko.ANRReporter \
     org.mozilla.gecko.GeckoAppShell \
     org.mozilla.gecko.GeckoJavaSampler \
     org.mozilla.gecko.gfx.NativePanZoomController \
--- a/mobile/android/base/db/SuggestedSites.java
+++ b/mobile/android/base/db/SuggestedSites.java
@@ -29,20 +29,19 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Scanner;
 import java.util.Set;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
-
-import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.db.BrowserContract;
 import org.mozilla.gecko.mozglue.RobocopTarget;
 import org.mozilla.gecko.preferences.GeckoPreferences;
 import org.mozilla.gecko.util.RawResource;
 import org.mozilla.gecko.util.ThreadUtils;
 
@@ -329,17 +328,17 @@ public class SuggestedSites {
      * current locale or with the fallback locale (en-US).
      *
      * It's assumed that the given distribution instance is ready to be
      * used and exists.
      */
     static Map<String, Site> loadFromDistribution(Distribution dist) {
         for (Locale locale : getAcceptableLocales()) {
             try {
-                final String languageTag = BrowserLocaleManager.getLanguageTag(locale);
+                final String languageTag = Locales.getLanguageTag(locale);
                 final String path = String.format("suggestedsites/locales/%s/%s",
                                                   languageTag, FILENAME);
 
                 final File f = dist.getDistributionFile(path);
                 if (f == null) {
                     Log.d(LOGTAG, "No suggested sites for locale: " + languageTag);
                     continue;
                 }
--- a/mobile/android/base/fxa/activities/FxAccountAbstractActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountAbstractActivity.java
@@ -4,17 +4,17 @@
 
 package org.mozilla.gecko.fxa.activities;
 
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountAgeLockoutHelper;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
-import org.mozilla.gecko.LocaleAware.LocaleAwareActivity;
+import org.mozilla.gecko.Locales.LocaleAwareActivity;
 
 import android.accounts.Account;
 import android.app.Activity;
 import android.content.Intent;
 import android.os.SystemClock;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.widget.TextView;
--- a/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountGetStartedActivity.java
@@ -8,17 +8,17 @@ import java.util.Locale;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.fxa.FxAccountAgeLockoutHelper;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.sync.setup.activities.ActivityUtils;
-import org.mozilla.gecko.LocaleAware;
+import org.mozilla.gecko.Locales;
 
 import android.accounts.AccountAuthenticatorActivity;
 import android.content.Intent;
 import android.os.Bundle;
 import android.os.SystemClock;
 import android.view.View;
 import android.view.View.OnClickListener;
 import android.widget.TextView;
@@ -34,17 +34,17 @@ public class FxAccountGetStartedActivity
   /**
    * {@inheritDoc}
    */
   @Override
   public void onCreate(Bundle icicle) {
     Logger.setThreadLogTag(FxAccountConstants.GLOBAL_LOG_TAG);
     Logger.debug(LOG_TAG, "onCreate(" + icicle + ")");
 
-    LocaleAware.initializeLocale(getApplicationContext());
+    Locales.initializeLocale(getApplicationContext());
 
     super.onCreate(icicle);
 
     setContentView(R.layout.fxaccount_get_started);
 
     linkifyOldFirefoxLink();
 
     View button = findViewById(R.id.get_started_button);
--- a/mobile/android/base/fxa/activities/FxAccountStatusActivity.java
+++ b/mobile/android/base/fxa/activities/FxAccountStatusActivity.java
@@ -1,16 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.fxa.activities;
 
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.LocaleAware.LocaleAwareFragmentActivity;
+import org.mozilla.gecko.Locales.LocaleAwareFragmentActivity;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.sync.Utils;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
--- a/mobile/android/base/home/TopSitesPanel.java
+++ b/mobile/android/base/home/TopSitesPanel.java
@@ -10,18 +10,18 @@ import static org.mozilla.gecko.db.URLMe
 
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
-import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tab;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.BrowserContract.Thumbnails;
 import org.mozilla.gecko.db.BrowserContract.TopSites;
 import org.mozilla.gecko.db.BrowserDB;
@@ -233,17 +233,17 @@ public class TopSitesPanel extends HomeF
                             method = TelemetryContract.Method.GRID_ITEM;
                         }
                         Telemetry.sendUIEvent(TelemetryContract.Event.LOAD_URL, method, Integer.toString(position));
 
                         // Record tile click events on non-private tabs.
                         final Tab tab = Tabs.getInstance().getSelectedTab();
                         if (!tab.isPrivate()) {
                             final Locale locale = Locale.getDefault();
-                            final String localeTag = BrowserLocaleManager.getLanguageTag(locale);
+                            final String localeTag = Locales.getLanguageTag(locale);
                             mTilesRecorder.recordAction(tab, TilesRecorder.ACTION_CLICK, position, getTilesSnapshot(), localeTag);
                         }
 
                         mUrlOpenListener.onUrlOpen(url, EnumSet.noneOf(OnUrlOpenListener.Flags.class));
                     }
                 } else {
                     if (mEditPinnedSiteListener != null) {
                         mEditPinnedSiteListener.onEditPinnedSite(position, "");
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -321,18 +321,18 @@ gbjar.sources += [
     'home/TopSitesGridView.java',
     'home/TopSitesPanel.java',
     'home/TopSitesThumbnailView.java',
     'home/TransitionAwareCursorLoaderCallbacks.java',
     'home/TwoLinePageRow.java',
     'InputMethods.java',
     'IntentHelper.java',
     'JavaAddonManager.java',
-    'LocaleAware.java',
     'LocaleManager.java',
+    'Locales.java',
     'lwt/LightweightTheme.java',
     'lwt/LightweightThemeDrawable.java',
     'MediaCastingBar.java',
     'MemoryMonitor.java',
     'menu/GeckoMenu.java',
     'menu/GeckoMenuInflater.java',
     'menu/GeckoMenuItem.java',
     'menu/GeckoSubMenu.java',
--- a/mobile/android/base/newtablet/res/layout-large-v11/tab_strip.xml
+++ b/mobile/android/base/newtablet/res/layout-large-v11/tab_strip.xml
@@ -5,17 +5,17 @@
 
 <merge xmlns:android="http://schemas.android.com/apk/res/android">
 
     <org.mozilla.gecko.tabs.TabStripView
         android:id="@+id/tab_strip"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:layout_weight="1"
-        android:paddingTop="4dp"/>
+        android:paddingTop="8dp"/>
 
     <!-- The right margin creates a "dead area" on the right side of the button
          which we compensate for with a touch delegate. See TabStrip -->
     <ImageButton
         android:id="@+id/add_tab"
         style="@style/UrlBar.ImageButton"
         android:layout_width="@dimen/new_tablet_tab_strip_height"
         android:src="@drawable/tab_new_level"
--- a/mobile/android/base/overlays/ui/ShareDialog.java
+++ b/mobile/android/base/overlays/ui/ShareDialog.java
@@ -5,17 +5,17 @@
 
 package org.mozilla.gecko.overlays.ui;
 
 import java.net.URISyntaxException;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.Assert;
 import org.mozilla.gecko.GeckoProfile;
-import org.mozilla.gecko.LocaleAware;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.LocalBrowserDB;
 import org.mozilla.gecko.overlays.OverlayConstants;
 import org.mozilla.gecko.overlays.service.OverlayActionService;
 import org.mozilla.gecko.overlays.service.sharemethods.ParcelableClientRecord;
 import org.mozilla.gecko.overlays.service.sharemethods.SendTab;
@@ -45,17 +45,17 @@ import android.view.animation.AnimationU
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
 /**
  * A transparent activity that displays the share overlay.
  */
-public class ShareDialog extends LocaleAware.LocaleAwareActivity implements SendTabTargetSelectedListener {
+public class ShareDialog extends Locales.LocaleAwareActivity implements SendTabTargetSelectedListener {
     private static final String LOGTAG = "GeckoShareDialog";
 
     private String url;
     private String title;
 
     // The override intent specified by SendTab (if any). See SendTab.java.
     private Intent sendTabOverrideIntent;
 
--- a/mobile/android/base/preferences/GeckoPreferences.java
+++ b/mobile/android/base/preferences/GeckoPreferences.java
@@ -7,31 +7,33 @@ package org.mozilla.gecko.preferences;
 
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
 import android.os.Build;
+
 import org.json.JSONObject;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.BrowserApp;
 import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.DataReportingNotification;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoActivityStatus;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoApplication;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.GuestSession;
 import org.mozilla.gecko.LocaleManager;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.NewTabletUI;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.RestrictedProfiles;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.TelemetryContract.Method;
 import org.mozilla.gecko.background.common.GlobalConstants;
@@ -1009,17 +1011,17 @@ OnSharedPreferenceChangeListener
      *
      * Note that this listener is not always registered: we use it only on
      * tablets, Honeycomb and up, where we'll have a multi-pane view and prefs
      * changing multiple times.
      */
     @Override
     public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
         if (PREFS_BROWSER_LOCALE.equals(key)) {
-            onLocaleSelected(BrowserLocaleManager.getLanguageTag(lastLocale),
+            onLocaleSelected(Locales.getLanguageTag(lastLocale),
                              sharedPreferences.getString(key, null));
         } else if (PREFS_SUGGESTED_SITES.equals(key)) {
             refreshSuggestedSites();
         } else if (PREFS_NEW_TABLET_UI.equals(key)) {
             Toast.makeText(this, R.string.new_tablet_restart, Toast.LENGTH_SHORT).show();
         }
     }
 
@@ -1049,17 +1051,17 @@ OnSharedPreferenceChangeListener
             // We don't want the "use master password" pref to change until the
             // user has gone through the dialog.
             return false;
         }
 
         if (PREFS_BROWSER_LOCALE.equals(prefName)) {
             // Even though this is a list preference, we don't want to handle it
             // below, so we return here.
-            return onLocaleSelected(BrowserLocaleManager.getLanguageTag(lastLocale), (String) newValue);
+            return onLocaleSelected(Locales.getLanguageTag(lastLocale), (String) newValue);
         }
 
         if (PREFS_MENU_CHAR_ENCODING.equals(prefName)) {
             setCharEncodingState(((String) newValue).equals("true"));
         } else if (PREFS_UPDATER_AUTODOWNLOAD.equals(prefName)) {
             UpdateServiceHelper.registerForUpdates(this, (String) newValue);
         } else if (PREFS_HEALTHREPORT_UPLOAD_ENABLED.equals(prefName)) {
             // The healthreport pref only lives in Android, so we do not persist
--- a/mobile/android/base/preferences/LocaleListPreference.java
+++ b/mobile/android/base/preferences/LocaleListPreference.java
@@ -9,16 +9,17 @@ import java.text.Collator;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Locale;
 import java.util.Set;
 
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.preference.ListPreference;
 import android.text.TextUtils;
@@ -112,17 +113,17 @@ public class LocaleListPreference extend
     private static final class LocaleDescriptor implements Comparable<LocaleDescriptor> {
         // We use Locale.US here to ensure a stable ordering of entries.
         private static final Collator COLLATOR = Collator.getInstance(Locale.US);
 
         public final String tag;
         private final String nativeName;
 
         public LocaleDescriptor(String tag) {
-            this(BrowserLocaleManager.parseLocaleCode(tag), tag);
+            this(Locales.parseLocaleCode(tag), tag);
         }
 
         public LocaleDescriptor(Locale locale, String tag) {
             this.tag = tag;
 
             final String displayName = locale.getDisplayName(locale);
             if (TextUtils.isEmpty(displayName)) {
                 // There's nothing sane we can do.
@@ -216,17 +217,17 @@ public class LocaleListPreference extend
      *
      * This method filters down the list before generating the descriptor array.
      */
     private LocaleDescriptor[] getUsableLocales() {
         Collection<String> shippingLocales = BrowserLocaleManager.getPackagedLocaleTags(getContext());
 
         // Future: single-locale builds should be specified, too.
         if (shippingLocales == null) {
-            final String fallbackTag = BrowserLocaleManager.getFallbackLocaleTag();
+            final String fallbackTag = BrowserLocaleManager.getInstance().getFallbackLocaleTag();
             return new LocaleDescriptor[] { new LocaleDescriptor(fallbackTag) };
         }
 
         final int initialCount = shippingLocales.size();
         final Set<LocaleDescriptor> locales = new HashSet<LocaleDescriptor>(initialCount);
         for (String tag : shippingLocales) {
             final LocaleDescriptor descriptor = new LocaleDescriptor(tag);
 
@@ -257,17 +258,17 @@ public class LocaleListPreference extend
         BrowserLocaleManager.getInstance().updateConfiguration(context, selectedLocale);
     }
 
     private Locale getSelectedLocale() {
         final String tag = getValue();
         if (tag == null || tag.equals("")) {
             return Locale.getDefault();
         }
-        return BrowserLocaleManager.parseLocaleCode(tag);
+        return Locales.parseLocaleCode(tag);
     }
 
     @Override
     public CharSequence getSummary() {
         final String value = getValue();
 
         if (TextUtils.isEmpty(value)) {
             return getContext().getString(R.string.locale_system_default);
--- a/mobile/android/base/resources/values/dimens.xml
+++ b/mobile/android/base/resources/values/dimens.xml
@@ -17,17 +17,17 @@
     <dimen name="browser_toolbar_favicon_size">21.33dip</dimen>
     <dimen name="browser_toolbar_shadow_size">2dp</dimen>
 
     <!-- If you update one of these values, update the others. -->
     <dimen name="new_tablet_nav_button_width">42dp</dimen>
     <dimen name="new_tablet_nav_button_width_half">21dp</dimen>
     <dimen name="new_tablet_nav_button_width_plus_half">63dp</dimen>
 
-    <dimen name="new_tablet_tab_strip_height">44dp</dimen>
+    <dimen name="new_tablet_tab_strip_height">48dp</dimen>
     <dimen name="new_tablet_tab_strip_item_width">208dp</dimen>
     <dimen name="new_tablet_tab_strip_item_margin">-28dp</dimen>
     <dimen name="new_tablet_tab_strip_favicon_size">16dp</dimen>
     <dimen name="new_tablet_tab_strip_fading_edge_size">15dp</dimen>
     <dimen name="new_tablet_site_security_height">60dp</dimen>
     <dimen name="new_tablet_site_security_width">34dp</dimen>
     <!-- We primarily use padding (instead of margins) to increase the hit area. -->
     <dimen name="new_tablet_site_security_padding_vertical">21dp</dimen>
@@ -133,16 +133,17 @@
     <dimen name="url_bar_offset_left">32dp</dimen>
     <dimen name="history_tab_indicator_height">50dp</dimen>
 
 
     <dimen name="new_tablet_tab_thumbnail_width">168dp</dimen>
     <dimen name="new_tablet_tab_thumbnail_height">140dp</dimen>
     <dimen name="new_tablet_tab_panel_column_width">178dp</dimen>
     <dimen name="new_tablet_tab_panel_grid_padding">19dp</dimen>
+    <dimen name="new_tablet_tab_panel_grid_vspacing">21dp</dimen>
     <dimen name="new_tablet_tab_panel_grid_padding_top">24dp</dimen>
 
     <dimen name="new_tablet_tab_highlight_stroke_width">5dp</dimen>
 
     <!-- PageActionButtons dimensions -->
     <dimen name="page_action_button_width">32dp</dimen>
 
     <!-- Banner -->
--- a/mobile/android/base/resources/values/styles.xml
+++ b/mobile/android/base/resources/values/styles.xml
@@ -197,17 +197,17 @@
     <style name="Widget.TabsGridLayout" parent="Widget.GridView">
         <item name="android:layout_width">match_parent</item>
         <item name="android:layout_height">match_parent</item>
         <item name="android:paddingTop">0dp</item>
         <item name="android:stretchMode">columnWidth</item>
         <item name="android:numColumns">auto_fit</item>
         <item name="android:columnWidth">@dimen/tabs_grid_view_column_width</item>
         <item name="android:horizontalSpacing">2dp</item>
-        <item name="android:verticalSpacing">21dp</item>
+        <item name="android:verticalSpacing">@dimen/new_tablet_tab_panel_grid_vspacing</item>
         <item name="android:drawSelectorOnTop">true</item>
         <item name="android:clipToPadding">false</item>
     </style>
 
     <style name="Widget.BookmarkItemView" parent="Widget.TwoLinePageRow"/>
 
     <style name="Widget.BookmarksListView" parent="Widget.HomeListView"/>
 
--- a/mobile/android/base/sync/setup/activities/SendTabActivity.java
+++ b/mobile/android/base/sync/setup/activities/SendTabActivity.java
@@ -22,17 +22,17 @@ import org.mozilla.gecko.sync.CommandPro
 import org.mozilla.gecko.sync.CommandRunner;
 import org.mozilla.gecko.sync.GlobalSession;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.SyncConstants;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
 import org.mozilla.gecko.sync.setup.SyncAccounts;
-import org.mozilla.gecko.LocaleAware.LocaleAwareActivity;
+import org.mozilla.gecko.Locales.LocaleAwareActivity;
 import org.mozilla.gecko.sync.syncadapter.SyncAdapter;
 
 import android.accounts.Account;
 import android.accounts.AccountManager;
 import android.app.Activity;
 import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
--- a/mobile/android/base/tabs/PrivateTabsPanel.java
+++ b/mobile/android/base/tabs/PrivateTabsPanel.java
@@ -2,17 +2,17 @@
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.tabs;
 
 import java.util.Locale;
 
-import org.mozilla.gecko.BrowserLocaleManager;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.NewTabletUI;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Tabs;
 import org.mozilla.gecko.tabs.TabsPanel.CloseAllPanelView;
 import org.mozilla.gecko.tabs.TabsPanel.TabsLayout;
 
 import android.content.Context;
 import android.content.res.Resources;
@@ -53,17 +53,17 @@ class PrivateTabsPanel extends FrameLayo
 
         emptyTabsFrame = (LinearLayout) findViewById(R.id.private_tabs_empty);
         tabsLayout.setEmptyView(emptyTabsFrame);
 
         final View learnMore = findViewById(R.id.private_tabs_learn_more);
         learnMore.setOnClickListener(new OnClickListener() {
             @Override
             public void onClick(View v) {
-                final String locale = BrowserLocaleManager.getLanguageTag(Locale.getDefault());
+                final String locale = Locales.getLanguageTag(Locale.getDefault());
                 final String url =
                         getResources().getString(R.string.private_tabs_panel_learn_more_link, locale);
                 Tabs.getInstance().loadUrlInTab(url);
                 if (tabsPanel != null) {
                     tabsPanel.autoHidePanel();
                 }
             }
         });
--- a/mobile/android/base/tabs/TabsGridLayout.java
+++ b/mobile/android/base/tabs/TabsGridLayout.java
@@ -159,17 +159,18 @@ class TabsGridLayout extends GridView
         final boolean lastChildVisible = (lastPosition - childCount == firstPosition - 1);
         final boolean oneItemOnLastRow = (lastPosition % numberOfColumns == 0);
         if (firstChildOffScreen && lastChildVisible && oneItemOnLastRow) {
             // We need to set the view's bottom padding to prevent a sudden jump as the
             // last item in the row is being removed. We then need to remove the padding
             // via a sweet animation
 
             final int removedHeight = getChildAt(0).getMeasuredHeight();
-            final int verticalSpacing = getVerticalSpacing();
+            final int verticalSpacing =
+                    getResources().getDimensionPixelOffset(R.dimen.new_tablet_tab_panel_grid_vspacing);
 
             ValueAnimator paddingAnimator = ValueAnimator.ofInt(getPaddingBottom() + removedHeight + verticalSpacing, getPaddingBottom());
             paddingAnimator.setDuration(ANIM_TIME_MS * 2);
 
             paddingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
 
                 @Override
                 public void onAnimationUpdate(ValueAnimator animation) {
--- a/mobile/android/base/tests/testOSLocale.java
+++ b/mobile/android/base/tests/testOSLocale.java
@@ -5,16 +5,17 @@
 package org.mozilla.gecko.tests;
 
 import java.util.Locale;
 
 import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.PrefsHelper;
 
 import android.content.SharedPreferences;
 
 
 public class testOSLocale extends BaseTest {
     @Override
     public void setUp() throws Exception {
@@ -85,17 +86,17 @@ public class testOSLocale extends BaseTe
         // If we cleared the pref above prior to BrowserApp's delayed init, or our Gecko
         // profile has been used before, then we're already going to be set up for en-US.
         //
         // If we cleared the pref after the initial broadcast, and our Android-side profile
         // has been used before but the Gecko profile is clean, then the Gecko prefs won't
         // have been set.
         //
         // Instead, we always send a new locale code, and see what we get.
-        final Locale fr = BrowserLocaleManager.parseLocaleCode("fr");
+        final Locale fr = Locales.parseLocaleCode("fr");
         BrowserLocaleManager.storeAndNotifyOSLocale(prefs, fr);
 
         state.fetch();
 
         mAsserter.is(state.osLocale, "fr", "We're in fr.");
 
         // Now we can see what the expected Accept-Languages header should be.
         // The OS locale is 'fr', so we have our app locale (en-US),
@@ -120,17 +121,17 @@ public class testOSLocale extends BaseTe
         // Expected, from es-ES's intl.properties:
         final String EXPECTED = SELECTED_LOCALES +
                                 (isMultiLocaleBuild ? "es,en-us,en" :  // Expected, from es-ES's intl.properties.
                                                       "en-us,en");     // Expected, from en-US (the default).
 
         mAsserter.is(state.acceptLanguages, EXPECTED, "We have the right es-ES+fr Accept-Languages for this build.");
 
         // And back to en-US.
-        final Locale en_US = BrowserLocaleManager.parseLocaleCode("en-US");
+        final Locale en_US = Locales.parseLocaleCode("en-US");
         BrowserLocaleManager.storeAndNotifyOSLocale(prefs, en_US);
         BrowserLocaleManager.getInstance().resetToSystemLocale(getActivity());
 
         state.fetch();
 
         mAsserter.is(state.osLocale, "en-US", "We're in en-US.");
         mAsserter.is(state.acceptLanguages, "en-us,en", "We have the default processed en-US Accept-Languages.");
     }
--- a/mobile/android/base/widget/ResizablePathDrawable.java
+++ b/mobile/android/base/widget/ResizablePathDrawable.java
@@ -30,16 +30,17 @@ public class ResizablePathDrawable exten
         this.colorStateList = colorStateList;
         updateColor(getState());
     }
 
     private boolean updateColor(int[] stateSet) {
         int newColor = colorStateList.getColorForState(stateSet, Color.WHITE);
         if (newColor != currentColor) {
             currentColor = newColor;
+            alpha = Color.alpha(currentColor);
             invalidateSelf();
             return true;
         }
 
         return false;
     }
 
     public Path getPath() {
@@ -51,21 +52,25 @@ public class ResizablePathDrawable exten
     public boolean isStateful() {
         return true;
     }
 
     @Override
     protected void onDraw(Shape shape, Canvas canvas, Paint paint) {
         paint.setColor(currentColor);
         // setAlpha overrides the alpha value in set color. Since we just set the color,
-        // the alpha value is reset: override the alpha value with the old value.
+        // the alpha value is reset: override the alpha value with the old value. We don't
+        // set alpha if the color is transparent.
         //
         // Note: We *should* be able to call Shape.setAlpha, rather than Paint.setAlpha, but
         // then the opacity doesn't change - dunno why but probably not worth the time.
-        paint.setAlpha(alpha);
+        if (currentColor != Color.TRANSPARENT) {
+            paint.setAlpha(alpha);
+        }
+
         super.onDraw(shape, canvas, paint);
     }
 
     @Override
     public void setAlpha(final int alpha) {
         super.setAlpha(alpha);
         this.alpha = alpha;
     }
--- a/mobile/android/chrome/content/aboutAddons.js
+++ b/mobile/android/chrome/content/aboutAddons.js
@@ -239,17 +239,17 @@ var Addons = {
   _getElementForAddon: function(aKey) {
     let list = document.getElementById("addons-list");
     let element = list.querySelector("div[addonID=\"" + CSS.escape(aKey) + "\"]");
     return element;
   },
 
   init: function init() {
     let self = this;
-    AddonManager.getAddonsByTypes(["extension", "theme", "locale"], function(aAddons) {
+    AddonManager.getAllAddons(function(aAddons) {
       // Clear all content before filling the addons
       let list = document.getElementById("addons-list");
       list.innerHTML = "";
 
       aAddons.sort(function(a,b) {
         return a.name.localeCompare(b.name);
       });
       for (let i=0; i<aAddons.length; i++) {
@@ -343,25 +343,21 @@ var Addons = {
               }
               box.appendChild(setting);
             }
             // Send an event so add-ons can prepopulate any non-preference based
             // settings
             let event = document.createEvent("Events");
             event.initEvent("AddonOptionsLoad", true, false);
             window.dispatchEvent(event);
-  
-            // Also send a notification to match the behavior of desktop Firefox
-            let id = aListItem.getAttribute("addonID");
-            Services.obs.notifyObservers(document, AddonManager.OPTIONS_NOTIFICATION_DISPLAYED, id);
-          } else {
-            // No options, so hide the header and reset the list item
-            detailItem.setAttribute("optionsURL", "");
-            aListItem.setAttribute("optionsURL", "");
           }
+
+          // Also send a notification to match the behavior of desktop Firefox
+          let id = aListItem.getAttribute("addonID");
+          Services.obs.notifyObservers(document, AddonManager.OPTIONS_NOTIFICATION_DISPLAYED, id);
         }
       }
       xhr.send(null);
     } catch (e) { }
 
     let list = document.querySelector("#addons-list");
     list.style.display = "none";
     let details = document.querySelector("#addons-details");
new file mode 100644
--- /dev/null
+++ b/mobile/android/config/proguard/play-services-keeps.cfg
@@ -0,0 +1,19 @@
+# Rules to prevent Google Play Services from exploding
+# (From http://developer.android.com/google/play-services/setup.html#Proguard
+# With the reference to "Object" changed so it'll actually *work*...)
+-keep class * extends java.util.ListResourceBundle {
+    protected java.lang.Object[][] getContents();
+}
+
+-keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable {
+    public static final *** NULL;
+}
+
+-keepnames @com.google.android.gms.common.annotation.KeepName class *
+-keepclassmembernames class * {
+    @com.google.android.gms.common.annotation.KeepName *;
+}
+
+-keepnames class * implements android.os.Parcelable {
+    public static final ** CREATOR;
+}
rename from mobile/android/config/proguard.cfg
rename to mobile/android/config/proguard/proguard.cfg
--- a/mobile/android/config/proguard.cfg
+++ b/mobile/android/config/proguard/proguard.cfg
@@ -105,16 +105,21 @@
 
 #
 # Mozilla-specific rules
 #
 # Merging classes can generate dex warnings about anonymous inner classes.
 -optimizations !class/merging/horizontal
 -optimizations !class/merging/vertical
 
+# This optimisation causes corrupt bytecode if we run more than two passes.
+# Testing shows that running the extra passes of everything else saves us
+# more than this optimisation does, so bye bye!
+-optimizations !code/allocation/variable
+
 # Keep miscellaneous targets.
 
 # Keep the annotation.
 -keep @interface org.mozilla.gecko.mozglue.JNITarget
 
 # Keep classes tagged with the annotation.
 -keep @org.mozilla.gecko.mozglue.JNITarget class *
 
@@ -202,8 +207,14 @@
     *;
 }
 
 # Disable obfuscation because it makes exception stack traces more difficult to read.
 -dontobfuscate
 
 # Suppress warnings about missing descriptor classes.
 #-dontnote **,!ch.boye.**,!org.mozilla.gecko.sync.**
+
+-include "play-services-keeps.cfg"
+
+# Don't print spurious warnings from the support library.
+# See: http://stackoverflow.com/questions/22441366/note-android-support-v4-text-icucompatics-cant-find-dynamically-referenced-cl
+-dontnote android.support.**
new file mode 100644
--- /dev/null
+++ b/mobile/android/config/proguard/strip-libs.cfg
@@ -0,0 +1,40 @@
+# Proguard step for stripping debug information.
+#
+# This is useful to work around a bug in the way Proguard handles debug information: it
+# sometimes corrupts it. Classes with corrupt debug information cannot be dexed, but
+# classes with *no* debug information can be. There's no way to configure Proguard to
+# delete debug information on a per-class basis, so we need this special extra step for
+# stripping debug information only from those classes for which the Proguard bug is
+# encountered.
+#
+# Currently, this pass is applied to all bundled library jars for which we are not
+# compiling the source. This is slightly more than is strictly necessary to work around
+# the Proguard bug, but such debug information is of negligible value and stripping it
+# too slightly simplifies the makefile and saves us a handful of kilobytes of binary size.
+#
+# Configuring Proguard to do nothing except strip metadata is done by having it run only
+# the obfuscation pass, but with a configuration that prevents it from renaming any classes.
+# It then attempts to delete class metadata, so we further configure it not to do so for
+# anything except the problematic debug information.
+
+# Run only the obfuscator.
+-dontoptimize
+-dontshrink
+-dontpreverify
+-verbose
+
+# Don't rename anything.
+-keeppackagenames
+
+# Seriously, don't rename anything.
+-keep class *
+-keepclassmembers class * {
+    *;
+}
+
+# Don't delete other useful metadata.
+-keepattributes Exceptions,InnerClasses,Signature,Deprecated,*Annotation*,EnclosingMethod
+
+# Don't print spurious warnings from the support library.
+# See: http://stackoverflow.com/questions/22441366/note-android-support-v4-text-icucompatics-cant-find-dynamically-referenced-cl
+-dontnote android.support.**
--- a/mobile/android/search/java/org/mozilla/search/SearchActivity.java
+++ b/mobile/android/search/java/org/mozilla/search/SearchActivity.java
@@ -1,16 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.search;
 
 import org.mozilla.gecko.GeckoAppShell;
-import org.mozilla.gecko.LocaleAware;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.BrowserContract.SearchHistory;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.gecko.health.BrowserHealthRecorder;
 import org.mozilla.search.autocomplete.SearchBar;
 import org.mozilla.search.autocomplete.SuggestionsFragment;
@@ -34,17 +34,17 @@ import com.nineoldandroids.animation.Ani
 import com.nineoldandroids.animation.ObjectAnimator;
 
 /**
  * The main entrance for the Android search intent.
  * <p/>
  * State management is delegated to child fragments. Fragments communicate
  * with each other by passing messages through this activity.
  */
-public class SearchActivity extends LocaleAware.LocaleAwareFragmentActivity
+public class SearchActivity extends Locales.LocaleAwareFragmentActivity
         implements AcceptsSearchQuery, SearchEngineCallback {
 
     private static final String LOGTAG = "GeckoSearchActivity";
 
     private static final String KEY_SEARCH_STATE = "search_state";
     private static final String KEY_EDIT_STATE = "edit_state";
     private static final String KEY_QUERY = "query";
 
--- a/mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java
+++ b/mobile/android/search/java/org/mozilla/search/SearchPreferenceActivity.java
@@ -1,16 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.search;
 
 import org.mozilla.gecko.GeckoSharedPrefs;
-import org.mozilla.gecko.LocaleAware;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.TelemetryContract;
 import org.mozilla.gecko.db.BrowserContract;
 
 import android.app.AlertDialog;
 import android.content.DialogInterface;
 import android.os.AsyncTask;
@@ -36,17 +36,17 @@ public class SearchPreferenceActivity ex
 
     private static final String LOG_TAG = "SearchPreferenceActivity";
 
     public static final String PREF_CLEAR_HISTORY_KEY = "search.not_a_preference.clear_history";
 
     @Override
     @SuppressWarnings("deprecation")
     protected void onCreate(Bundle savedInstanceState) {
-        LocaleAware.initializeLocale(getApplicationContext());
+        Locales.initializeLocale(getApplicationContext());
         super.onCreate(savedInstanceState);
 
         getPreferenceManager().setSharedPreferencesName(GeckoSharedPrefs.APP_PREFS_NAME);
 
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
             if (getActionBar() != null) {
                 getActionBar().setDisplayHomeAsUpEnabled(true);
             }
--- a/mobile/android/search/java/org/mozilla/search/providers/SearchEngineManager.java
+++ b/mobile/android/search/java/org/mozilla/search/providers/SearchEngineManager.java
@@ -7,19 +7,19 @@ package org.mozilla.search.providers;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.text.TextUtils;
 import android.util.Log;
 
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.AppConstants;
-import org.mozilla.gecko.BrowserLocaleManager;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoSharedPrefs;
+import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.FileUtils;
 import org.mozilla.gecko.util.GeckoJarReader;
 import org.mozilla.gecko.util.RawResource;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.distribution.Distribution;
 import org.mozilla.search.Constants;
 import org.xmlpull.v1.XmlPullParserException;
@@ -183,17 +183,17 @@ public class SearchEngineManager impleme
         if (prefFile == null) {
             return null;
         }
 
         try {
             final JSONObject all = new JSONObject(FileUtils.getFileContents(prefFile));
 
             // First, check to see if there's a locale-specific override.
-            final String languageTag = BrowserLocaleManager.getLanguageTag(Locale.getDefault());
+            final String languageTag = Locales.getLanguageTag(Locale.getDefault());
             final String overridesKey = "LocalizablePreferences." + languageTag;
             if (all.has(overridesKey)) {
                 final JSONObject overridePrefs = all.getJSONObject(overridesKey);
                 if (overridePrefs.has(PREF_GECKO_DEFAULT_ENGINE)) {
                     Log.d(LOG_TAG, "Found default engine name in distribution LocalizablePreferences override.");
                     return overridePrefs.getString(PREF_GECKO_DEFAULT_ENGINE);
                 }
             }
@@ -416,26 +416,26 @@ public class SearchEngineManager impleme
      *
      * @param fileName name of the file to read.
      * @return InputStream for file.
      */
     private InputStream getInputStreamFromSearchPluginsJar(String fileName) {
         final Locale locale = Locale.getDefault();
 
         // First, try a file path for the full locale.
-        final String languageTag = BrowserLocaleManager.getLanguageTag(locale);
+        final String languageTag = Locales.getLanguageTag(locale);
         String url = getSearchPluginsJarURL(languageTag, fileName);
 
         InputStream in = GeckoJarReader.getStream(url);
         if (in != null) {
             return in;
         }
 
         // If that doesn't work, try a file path for just the language.
-        final String language = BrowserLocaleManager.getLanguage(locale);
+        final String language = Locales.getLanguage(locale);
         if (!languageTag.equals(language)) {
             url = getSearchPluginsJarURL(language, fileName);
             in = GeckoJarReader.getStream(url);
             if (in != null) {
                 return in;
             }
         }
 
--- a/mobile/android/tests/browser/junit3/src/TestSuggestedSites.java
+++ b/mobile/android/tests/browser/junit3/src/TestSuggestedSites.java
@@ -91,17 +91,17 @@ public class TestSuggestedSites extends 
         public TestDistribution(Context context) {
             super(context);
             this.filesPerLocale = new HashMap<Locale, File>();
         }
 
         @Override
         public File getDistributionFile(String name) {
             for (Locale locale : filesPerLocale.keySet()) {
-                if (name.startsWith("suggestedsites/locales/" + BrowserLocaleManager.getLanguageTag(locale))) {
+                if (name.startsWith("suggestedsites/locales/" + Locales.getLanguageTag(locale))) {
                     return filesPerLocale.get(locale);
                 }
             }
 
             return null;
         }
 
         @Override
--- a/mobile/android/themes/core/aboutAddons.css
+++ b/mobile/android/themes/core/aboutAddons.css
@@ -34,20 +34,18 @@
 
 .options-header {
   font-weight: bold;
   text-transform: uppercase;
   margin-top: 1em;
 }
 
 .addon-item[isDisabled="true"] .options-header,
-.addon-item:not([optionsURL]) .options-header,
 .addon-item[optionsURL=""] .options-header,
 .addon-item[isDisabled="true"] .options-box,
-.addon-item:not([optionsURL]) .options-box,
 .addon-item[optionsURL=""] .options-box {
   display: none;
 }
 
 #addons-details > .list-item:active {
   background-color: #fff;
 }
 
--- a/services/sync/modules/FxaMigrator.jsm
+++ b/services/sync/modules/FxaMigrator.jsm
@@ -244,41 +244,33 @@ Migrator.prototype = {
     yield WeaveService.whenLoaded();
     let signedInUser = yield fxAccounts.getSignedInUser();
     let sentinel = {
       email: signedInUser.email,
       uid: signedInUser.uid,
       verified: signedInUser.verified,
       prefs: this._getSentinelPrefs(),
     };
-    if (Weave.Service.setFxaMigrationSentinel) {
-      yield Weave.Service.setFxaMigrationSentinel(sentinel);
-    } else {
-      this.log.warn("Waiting on bug 1017433; no sync sentinel");
-    }
+    yield Weave.Service.setFxAMigrationSentinel(sentinel);
   }),
 
   /* Ask sync to upload the migration sentinal if we (or any other linked device)
      haven't previously written one.
    */
   _setMigrationSentinelIfNecessary: Task.async(function* () {
     if (!(yield this._getSyncMigrationSentinel())) {
       this.log.info("writing the migration sentinel");
       yield this._setSyncMigrationSentinel();
     }
   }),
 
   /* Ask sync to return a migration sentinel if one exists, otherwise return null */
   _getSyncMigrationSentinel: Task.async(function* () {
     yield WeaveService.whenLoaded();
-    if (!Weave.Service.getFxaMigrationSentinel) {
-      this.log.warn("Waiting on bug 1017433; no sync sentinel");
-      return null;
-    }
-    let sentinel = yield Weave.Service.getFxaMigrationSentinel();
+    let sentinel = yield Weave.Service.getFxAMigrationSentinel();
     this.log.debug("got migration sentinel ${}", sentinel);
     return sentinel;
   }),
 
   _getDefaultAccountName: Task.async(function* (sentinel) {
     // Requires looking to see if other devices have written a migration
     // sentinel (eg, see _haveSynchedMigrationSentinel), and if not, see if
     // the legacy account name appears to be a valid email address (via the
@@ -296,29 +288,21 @@ Migrator.prototype = {
       return account;
     }
     this.log.info("defaultAccountName could not find an account");
     return null;
   }),
 
   // Prevent sync from automatically starting
   _blockSync() {
-    if (Weave.Service.scheduler.blockSync) {
-      Weave.Service.scheduler.blockSync();
-    } else {
-      this.log.warn("Waiting on bug 1019408; sync not blocked");
-    }
+    Weave.Service.scheduler.blockSync();
   },
 
   _unblockSync() {
-    if (Weave.Service.scheduler.unblockSync) {
-      Weave.Service.scheduler.unblockSync();
-    } else {
-      this.log.warn("Waiting on bug 1019408; sync not unblocked");
-    }
+    Weave.Service.scheduler.unblockSync();
   },
 
   /*
    * Some helpers for the UI to try and move to the next state.
    */
 
   // Open a UI for the user to create a Firefox Account.  This should only be
   // called while we are in the STATE_USER_FXA state.  When the user completes
--- a/services/sync/modules/browserid_identity.js
+++ b/services/sync/modules/browserid_identity.js
@@ -395,16 +395,23 @@ this.BrowserIDManager.prototype = {
   resetSyncKey: function() {
     this._syncKey = null;
     this._syncKeyBundle = null;
     this._syncKeyUpdated = true;
     this._shouldHaveSyncKeyBundle = false;
   },
 
   /**
+    * Return credentials hosts for this identity only.
+    */
+  _getSyncCredentialsHosts: function() {
+    return Utils.getSyncCredentialsHostsFxA();
+  },
+
+  /**
    * The current state of the auth credentials.
    *
    * This essentially validates that enough credentials are available to use
    * Sync, although it effectively ignores the state of the master-password -
    * if that's locked and that's the only problem we can see, say everything
    * is OK - unlockAndVerifyAuthState will be used to perform the unlock
    * and re-verification if necessary.
    */
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -49,16 +49,19 @@ 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
 
+// The default for how long we "block" sync from running when doing a migration.
+DEFAULT_BLOCK_PERIOD:                  2 * 24 * 60 * 60 * 1000, // 2 days
+
 // 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
@@ -480,20 +480,27 @@ IdentityManager.prototype = {
     let loginInfo = new Components.Constructor(
       "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
     let login = new loginInfo(PWDMGR_HOST, null, realm, username,
                                 password, "", "");
     Services.logins.addLogin(login);
   },
 
   /**
+    * Return credentials hosts for this identity only.
+    */
+  _getSyncCredentialsHosts: function() {
+    return Utils.getSyncCredentialsHostsLegacy();
+  },
+
+  /**
    * Deletes Sync credentials from the password manager.
    */
   deleteSyncCredentials: function deleteSyncCredentials() {
-    for (let host of Utils.getSyncCredentialsHosts()) {
+    for (let host of this._getSyncCredentialsHosts()) {
       let logins = Services.logins.findLogins({}, host, "", "");
       for each (let login in logins) {
         Services.logins.removeLogin(login);
       }
     }
 
     // Wait until after store is updated in case it fails.
     this._basicPassword = null;
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -492,16 +492,56 @@ SyncScheduler.prototype = {
   clearSyncTriggers: function clearSyncTriggers() {
     this._log.debug("Clearing sync triggers and the global score.");
     this.globalScore = this.nextSync = 0;
 
     // Clear out any scheduled syncs
     if (this.syncTimer)
       this.syncTimer.clear();
   },
+
+  /**
+   * Prevent new syncs from starting.  This is used by the FxA migration code
+   * where we can't afford to have a sync start partway through the migration.
+   * To handle the edge-case of a sync starting and not stopping, we store
+   * this state in a pref, so on the next startup we remain blocked (and thus
+   * sync will never start) so the migration can complete.
+   *
+   * As a safety measure, we only block for some period of time, and after
+   * that it will automatically unblock.  This ensures that if things go
+   * really pear-shaped and we never end up calling unblockSync() we haven't
+   * completely broken the world.
+   */
+  blockSync: function(until = null) {
+    if (!until) {
+      until = Date.now() + DEFAULT_BLOCK_PERIOD;
+    }
+    // until is specified in ms, but Prefs can't hold that much
+    Svc.Prefs.set("scheduler.blocked-until", Math.floor(until / 1000));
+  },
+
+  unblockSync: function() {
+    Svc.Prefs.reset("scheduler.blocked-until");
+    // the migration code should be ready to roll, so resume normal operations.
+    this.checkSyncStatus();
+  },
+
+  get isBlocked() {
+    let until = Svc.Prefs.get("scheduler.blocked-until");
+    if (until === undefined) {
+      return false;
+    }
+    if (until <= Math.floor(Date.now() / 1000)) {
+      // we were previously blocked but the time has expired.
+      Svc.Prefs.reset("scheduler.blocked-until");
+      return false;
+    }
+    // we remain blocked.
+    return true;
+  },
 };
 
 const LOG_PREFIX_SUCCESS = "success-";
 const LOG_PREFIX_ERROR   = "error-";
 
 this.ErrorHandler = function ErrorHandler(service) {
   this.service = service;
   this.init();
--- a/services/sync/modules/record.js
+++ b/services/sync/modules/record.js
@@ -100,78 +100,16 @@ WBORecord.prototype = {
       "ttl: "      + this.ttl       + "  " +
       "payload: "  + JSON.stringify(this.payload) +
       " }";
   }
 };
 
 Utils.deferGetSet(WBORecord, "data", ["id", "modified", "sortindex", "payload"]);
 
-/**
- * An interface and caching layer for records.
- */
-this.RecordManager = function RecordManager(service) {
-  this.service = service;
-
-  this._log = Log.repository.getLogger(this._logName);
-  this._records = {};
-}
-RecordManager.prototype = {
-  _recordType: WBORecord,
-  _logName: "Sync.RecordManager",
-
-  import: function RecordMgr_import(url) {
-    this._log.trace("Importing record: " + (url.spec ? url.spec : url));
-    try {
-      // Clear out the last response with empty object if GET fails
-      this.response = {};
-      this.response = this.service.resource(url).get();
-
-      // Don't parse and save the record on failure
-      if (!this.response.success)
-        return null;
-
-      let record = new this._recordType(url);
-      record.deserialize(this.response);
-
-      return this.set(url, record);
-    } catch(ex) {
-      this._log.debug("Failed to import record: " + Utils.exceptionStr(ex));
-      return null;
-    }
-  },
-
-  get: function RecordMgr_get(url) {
-    // Use a url string as the key to the hash
-    let spec = url.spec ? url.spec : url;
-    if (spec in this._records)
-      return this._records[spec];
-    return this.import(url);
-  },
-
-  set: function RecordMgr_set(url, record) {
-    let spec = url.spec ? url.spec : url;
-    return this._records[spec] = record;
-  },
-
-  contains: function RecordMgr_contains(url) {
-    if ((url.spec || url) in this._records)
-      return true;
-    return false;
-  },
-
-  clearCache: function recordMgr_clearCache() {
-    this._records = {};
-  },
-
-  del: function RecordMgr_del(url) {
-    delete this._records[url];
-  }
-};
-
 this.CryptoWrapper = function CryptoWrapper(collection, id) {
   this.cleartext = {};
   WBORecord.call(this, collection, id);
   this.ciphertext = null;
   this.id = id;
 }
 CryptoWrapper.prototype = {
   __proto__: WBORecord.prototype,
@@ -264,16 +202,77 @@ CryptoWrapper.prototype = {
     WBORecord.prototype.__lookupSetter__("id").call(this, val);
     return this.cleartext.id = val;
   },
 };
 
 Utils.deferGetSet(CryptoWrapper, "payload", ["ciphertext", "IV", "hmac"]);
 Utils.deferGetSet(CryptoWrapper, "cleartext", "deleted");
 
+/**
+ * An interface and caching layer for records.
+ */
+this.RecordManager = function RecordManager(service) {
+  this.service = service;
+
+  this._log = Log.repository.getLogger(this._logName);
+  this._records = {};
+}
+RecordManager.prototype = {
+  _recordType: CryptoWrapper,
+  _logName: "Sync.RecordManager",
+
+  import: function RecordMgr_import(url) {
+    this._log.trace("Importing record: " + (url.spec ? url.spec : url));
+    try {
+      // Clear out the last response with empty object if GET fails
+      this.response = {};
+      this.response = this.service.resource(url).get();
+
+      // Don't parse and save the record on failure
+      if (!this.response.success)
+        return null;
+
+      let record = new this._recordType(url);
+      record.deserialize(this.response);
+
+      return this.set(url, record);
+    } catch(ex) {
+      this._log.debug("Failed to import record: " + Utils.exceptionStr(ex));
+      return null;
+    }
+  },
+
+  get: function RecordMgr_get(url) {
+    // Use a url string as the key to the hash
+    let spec = url.spec ? url.spec : url;
+    if (spec in this._records)
+      return this._records[spec];
+    return this.import(url);
+  },
+
+  set: function RecordMgr_set(url, record) {
+    let spec = url.spec ? url.spec : url;
+    return this._records[spec] = record;
+  },
+
+  contains: function RecordMgr_contains(url) {
+    if ((url.spec || url) in this._records)
+      return true;
+    return false;
+  },
+
+  clearCache: function recordMgr_clearCache() {
+    this._records = {};
+  },
+
+  del: function RecordMgr_del(url) {
+    delete this._records[url];
+  }
+};
 
 /**
  * Keeps track of mappings between collection names ('tabs') and KeyBundles.
  *
  * You can update this thing simply by giving it /info/collections. It'll
  * use the last modified time to bring itself up to date.
  */
 this.CollectionKeyManager = function CollectionKeyManager() {
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -1275,17 +1275,26 @@ Sync11Service.prototype = {
       synchronizer.sync();
       // wait() throws if the first argument is truthy, which is exactly what
       // we want.
       let result = cb.wait();
 
       histogram = Services.telemetry.getHistogramById("WEAVE_COMPLETE_SUCCESS_COUNT");
       histogram.add(1);
 
-      // We successfully synchronized. Now let's update our declined engines.
+      // We successfully synchronized.
+      // Try and fetch the migration sentinel - it will end up in the recordManager
+      // cache, so a sync migration doesn't need a server round-trip.
+      // If we have no clusterURL, we are probably doing a node reassignment
+      // do don't attempt to get the credentials.
+      if (this.clusterURL) {
+        this.recordManager.get(this.storageURL + "meta/fxa_credentials");
+      }
+
+      // Now let's update our declined engines.
       let meta = this.recordManager.get(this.metaURL);
       if (!meta) {
         this._log.warn("No meta/global; can't update declined state.");
         return;
       }
 
       let declinedEngines = new DeclinedEngines(this);
       let didChange = declinedEngines.updateDeclined(meta, this.engineManager);
@@ -1312,16 +1321,102 @@ Sync11Service.prototype = {
     let response = res.put(meta);
     if (!response.success) {
       throw response;
     }
     this.recordManager.set(this.metaURL, meta);
   },
 
   /**
+   * Get a migration sentinel for the Firefox Accounts migration.
+   * Returns a JSON blob - it is up to callers of this to make sense of the
+   * data.
+   *
+   * Returns a promise that resolves with the sentinel, or null.
+   */
+  getFxAMigrationSentinel: function() {
+    if (this._shouldLogin()) {
+      this._log.debug("In getFxAMigrationSentinel: should login.");
+      if (!this.login()) {
+        this._log.debug("Can't get migration sentinel: login returned false.");
+        return Promise.resolve(null);
+      }
+    }
+    if (!this.identity.syncKeyBundle) {
+      this._log.error("Can't get migration sentinel: no syncKeyBundle.");
+      return Promise.resolve(null);
+    }
+    try {
+      let collectionURL = this.storageURL + "meta/fxa_credentials";
+      let cryptoWrapper = this.recordManager.get(collectionURL);
+      if (!cryptoWrapper.payload) {
+        // nothing to decrypt - .decrypt is noisy in that case, so just bail
+        // now.
+        return Promise.resolve(null);
+      }
+      // If the payload has a sentinel it means we must have put back the
+      // decrypted version last time we were called.
+      if (cryptoWrapper.payload.sentinel) {
+        return Promise.resolve(cryptoWrapper.payload.sentinel);
+      }
+      // If decryption fails it almost certainly means the key is wrong - but
+      // it's not clear if we need to take special action for that case?
+      let payload = cryptoWrapper.decrypt(this.identity.syncKeyBundle);
+      // After decrypting the ciphertext is lost, so we just stash the
+      // decrypted payload back into the wrapper.
+      cryptoWrapper.payload = payload;
+      return Promise.resolve(payload.sentinel);
+    } catch (ex) {
+      this._log.error("Failed to fetch the migration sentinel: ${}", ex);
+      return Promise.resolve(null);
+    }
+  },
+
+  /**
+   * Set a migration sentinel for the Firefox Accounts migration.
+   * Accepts a JSON blob - it is up to callers of this to make sense of the
+   * data.
+   *
+   * Returns a promise that resolves with a boolean which indicates if the
+   * sentinel was successfully written.
+   */
+  setFxAMigrationSentinel: function(sentinel) {
+    if (this._shouldLogin()) {
+      this._log.debug("In setFxAMigrationSentinel: should login.");
+      if (!this.login()) {
+        this._log.debug("Can't set migration sentinel: login returned false.");
+        return Promise.resolve(false);
+      }
+    }
+    if (!this.identity.syncKeyBundle) {
+      this._log.error("Can't set migration sentinel: no syncKeyBundle.");
+      return Promise.resolve(false);
+    }
+    try {
+      let collectionURL = this.storageURL + "meta/fxa_credentials";
+      let cryptoWrapper = new CryptoWrapper("meta", "fxa_credentials");
+      cryptoWrapper.cleartext.sentinel = sentinel;
+
+      cryptoWrapper.encrypt(this.identity.syncKeyBundle);
+
+      let res = this.resource(collectionURL);
+      let response = res.put(cryptoWrapper.toJSON());
+
+      if (!response.success) {
+        throw response;
+      }
+      this.recordManager.set(collectionURL, cryptoWrapper);
+    } catch (ex) {
+      this._log.error("Failed to set the migration sentinel: ${}", ex);
+      return Promise.resolve(false);
+    }
+    return Promise.resolve(true);
+  },
+
+  /**
    * If we have a passphrase, rather than a 25-alphadigit sync key,
    * use the provided sync ID to bootstrap it using PBKDF2.
    *
    * Store the new 'passphrase' back into the identity manager.
    *
    * We can check this as often as we want, because once it's done the
    * check will no longer succeed. It only matters that it happens after
    * we decide to bump the server storage version.
--- a/services/sync/modules/util.js
+++ b/services/sync/modules/util.js
@@ -591,23 +591,40 @@ this.Utils = {
   /**
    * Return a set of hostnames (including the protocol) which may have
    * credentials for sync itself stored in the login manager.
    *
    * In general, these hosts will not have their passwords synced, will be
    * reset when we drop sync credentials, etc.
    */
   getSyncCredentialsHosts: function() {
+    let result = new Set(this.getSyncCredentialsHostsLegacy());
+    for (let host of this.getSyncCredentialsHostsFxA()) {
+      result.add(host);
+    }
+    return result;
+  },
+
+  /*
+   * Get the "legacy" identity hosts.
+   */
+  getSyncCredentialsHostsLegacy: function() {
+    // the legacy sync host
+    return new Set([PWDMGR_HOST]);
+  },
+
+  /*
+   * Get the FxA identity hosts.
+   */
+  getSyncCredentialsHostsFxA: function() {
     // This is somewhat expensive and the result static, so we cache the result.
-    if (this._syncCredentialsHosts) {
-      return this._syncCredentialsHosts;
+    if (this._syncCredentialsHostsFxA) {
+      return this._syncCredentialsHostsFxA;
     }
     let result = new Set();
-    // the legacy sync host
-    result.add(PWDMGR_HOST);
     // the FxA host
     result.add(FxAccountsCommon.FXA_PWDMGR_HOST);
     //
     // The FxA hosts - these almost certainly all have the same hostname, but
     // better safe than sorry...
     for (let prefName of ["identity.fxaccounts.remote.force_auth.uri",
                           "identity.fxaccounts.remote.signup.uri",
                           "identity.fxaccounts.remote.signin.uri",
@@ -616,17 +633,17 @@ this.Utils = {
       try {
         prefVal = Services.prefs.getCharPref(prefName);
       } catch (_) {
         continue;
       }
       let uri = Services.io.newURI(prefVal, null, null);
       result.add(uri.prePath);
     }
-    return this._syncCredentialsHosts = result;
+    return this._syncCredentialsHostsFxA = result;
   },
 };
 
 XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
   let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                     .createInstance(Ci.nsIScriptableUnicodeConverter);
   converter.charset = "UTF-8";
   return converter;
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_block_sync.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+Cu.import("resource://services-sync/main.js");
+Cu.import("resource://services-sync/util.js");
+
+// Simple test for block/unblock.
+add_task(function *() {
+  Assert.ok(!Weave.Service.scheduler.isBlocked, "sync is not blocked.")
+  Assert.ok(!Svc.Prefs.has("scheduler.blocked-until"), "have no blocked pref");
+  Weave.Service.scheduler.blockSync();
+
+  Assert.ok(Weave.Service.scheduler.isBlocked, "sync is blocked.")
+  Assert.ok(Svc.Prefs.has("scheduler.blocked-until"), "have the blocked pref");
+
+  Weave.Service.scheduler.unblockSync();
+  Assert.ok(!Weave.Service.scheduler.isBlocked, "sync is not blocked.")
+  Assert.ok(!Svc.Prefs.has("scheduler.blocked-until"), "have no blocked pref");
+
+  // now check the "until" functionality.
+  let until = Date.now() + 1000;
+  Weave.Service.scheduler.blockSync(until);
+  Assert.ok(Weave.Service.scheduler.isBlocked, "sync is blocked.")
+  Assert.ok(Svc.Prefs.has("scheduler.blocked-until"), "have the blocked pref");
+
+  // wait for 'until' to pass.
+  yield new Promise((resolve, reject) => {
+    CommonUtils.namedTimer(resolve, 1000, {}, "timer");
+  });
+
+  // should have automagically unblocked and removed the pref.
+  Assert.ok(!Weave.Service.scheduler.isBlocked, "sync is not blocked.")
+  Assert.ok(!Svc.Prefs.has("scheduler.blocked-until"), "have no blocked pref");
+});
+
+function run_test() {
+  run_next_test();
+}
--- a/services/sync/tests/unit/test_fxa_migration.js
+++ b/services/sync/tests/unit/test_fxa_migration.js
@@ -98,30 +98,28 @@ add_task(function *testMigration() {
   Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()), null,
                    "no user state when complete");
 
   // Arrange for a legacy sync user and manually bump the migrator
   let [engine, server] = configureLegacySync();
 
   // monkey-patch the migration sentinel code so we know it was called.
   let haveStartedSentinel = false;
-// (This is waiting on bug 1017433)
-/**
-  let origSetFxaMigrationSentinel = Service.setFxaMigrationSentinel;
+  let origSetFxAMigrationSentinel = Service.setFxAMigrationSentinel;
   let promiseSentinelWritten = new Promise((resolve, reject) => {
-    Service.setFxaMigrationSentinel = function(arg) {
+    Service.setFxAMigrationSentinel = function(arg) {
       haveStartedSentinel = true;
-      return origSetFxaMigrationSentinel.call(Service, arg).then(result => {
-        Service.setFxaMigrationSentinel = origSetFxaMigrationSentinel;
+      return origSetFxAMigrationSentinel.call(Service, arg).then(result => {
+        Service.setFxAMigrationSentinel = origSetFxAMigrationSentinel;
         resolve(result);
         return result;
       });
     }
   });
-**/
+
   // We are now configured for legacy sync, but we aren't in an EOL state yet,
   // so should still be not waiting for a user.
   Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()), null,
                    "no user state before server EOL");
 
   // Start a sync - this will cause an EOL notification which the migrator's
   // observer will notice.
   let promise = promiseOneObserver("fxa-migration:state-changed");
@@ -194,20 +192,17 @@ add_task(function *testMigration() {
     let cb = Async.makeSpinningCallback();
     promiseOneObserver("fxa-migration:state-changed").then(state => cb(null, state));
     Assert.equal(cb.wait(), null, "no user action necessary while sync completes.");
 
     // We must not have started writing the sentinel yet.
     Assert.ok(!haveStartedSentinel, "haven't written a sentinel yet");
 
     // sync should be blocked from continuing
-// (This is waiting on bug 1019408)
-/**
     Assert.ok(Service.scheduler.isBlocked, "sync is blocked.")
-**/
 
     wasWaiting = true;
     throw ex;
   };
 
   _("Starting sync");
   Service.sync();
   _("Finished sync");
@@ -222,26 +217,20 @@ add_task(function *testMigration() {
       resolve();
     }
   });
 
   Assert.ok(wasWaiting, "everything was good while sync was running.")
 
   // The migration is now going to run to completion.
   // sync should still be "blocked"
-// (This is waiting on bug 1019408)
-/**
   Assert.ok(Service.scheduler.isBlocked, "sync is blocked.");
-**/
 
   // We should see the migration sentinel written and it should return true.
-// (This is waiting on bug 1017433)
-/**
   Assert.ok((yield promiseSentinelWritten), "wrote the sentinel");
-**/
 
   // And we should see a new sync start
   yield promiseFinalSync;
 
   // and we should be configured for FxA
   let WeaveService = Cc["@mozilla.org/weave/service;1"]
          .getService(Components.interfaces.nsISupports)
          .wrappedJSObject;
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_fxa_migration_sentinel.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test the reading and writing of the sync migration sentinel.
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+Cu.import("resource://testing-common/services/sync/utils.js");
+Cu.import("resource://testing-common/services/common/logging.js");
+
+Cu.import("resource://services-sync/record.js");
+
+// Set our username pref early so sync initializes with the legacy provider.
+Services.prefs.setCharPref("services.sync.username", "foo");
+
+// Now import sync
+Cu.import("resource://services-sync/service.js");
+
+const USER = "foo";
+const PASSPHRASE = "abcdeabcdeabcdeabcdeabcdea";
+
+function promiseStopServer(server) {
+  return new Promise((resolve, reject) => {
+    server.stop(resolve);
+  });
+}
+
+let numServerRequests = 0;
+
+// Helpers
+function configureLegacySync() {
+  let contents = {
+    meta: {global: {}},
+    crypto: {},
+  };
+
+  setBasicCredentials(USER, "password", PASSPHRASE);
+
+  numServerRequests = 0;
+  let server = new SyncServer({
+    onRequest: () => {
+      ++numServerRequests
+    }
+  });
+  server.registerUser(USER, "password");
+  server.createContents(USER, contents);
+  server.start();
+
+  Service.serverURL = server.baseURI;
+  Service.clusterURL = server.baseURI;
+  Service.identity.username = USER;
+  Service._updateCachedURLs();
+
+  return server;
+}
+
+// Test a simple round-trip of the get/set functions.
+add_task(function *() {
+  // Arrange for a legacy sync user.
+  let server = configureLegacySync();
+
+  Assert.equal((yield Service.getFxAMigrationSentinel()), null, "no sentinel to start");
+
+  let sentinel = {foo: "bar"};
+  yield Service.setFxAMigrationSentinel(sentinel);
+
+  Assert.deepEqual((yield Service.getFxAMigrationSentinel()), sentinel, "got the sentinel back");
+
+  yield promiseStopServer(server);
+});
+
+// Test the records are cached by the record manager.
+add_task(function *() {
+  // Arrange for a legacy sync user.
+  let server = configureLegacySync();
+  Service.login();
+
+  // Reset the request count here as the login would have made some.
+  numServerRequests = 0;
+
+  Assert.equal((yield Service.getFxAMigrationSentinel()), null, "no sentinel to start");
+  Assert.equal(numServerRequests, 1, "first fetch should hit the server");
+
+  let sentinel = {foo: "bar"};
+  yield Service.setFxAMigrationSentinel(sentinel);
+  Assert.equal(numServerRequests, 2, "setting sentinel should hit the server");
+
+  Assert.deepEqual((yield Service.getFxAMigrationSentinel()), sentinel, "got the sentinel back");
+  Assert.equal(numServerRequests, 2, "second fetch should not should hit the server");
+
+  // Clobber the caches and ensure we still get the correct value back when we
+  // do hit the server.
+  Service.recordManager.clearCache();
+  Assert.deepEqual((yield Service.getFxAMigrationSentinel()), sentinel, "got the sentinel back");
+  Assert.equal(numServerRequests, 3, "should have re-hit the server with empty caches");
+
+  yield promiseStopServer(server);
+});
+
+// Test the records are cached by a sync.
+add_task(function* () {
+  let server = configureLegacySync();
+
+  // A first sync clobbers meta/global due to it being empty, so we first
+  // do a sync which forces a good set of data on the server.
+  Service.sync();
+
+  // Now create a sentinel exists on the server.  It's encrypted, so we need to
+  // put an encrypted version.
+  let cryptoWrapper = new CryptoWrapper("meta", "fxa_credentials");
+  let sentinel = {foo: "bar"};
+  cryptoWrapper.cleartext = {
+    id: "fxa_credentials",
+    sentinel: sentinel,
+    deleted: false,
+  }
+  cryptoWrapper.encrypt(Service.identity.syncKeyBundle);
+  let payload = {
+    ciphertext: cryptoWrapper.ciphertext,
+    IV:         cryptoWrapper.IV,
+    hmac:       cryptoWrapper.hmac,
+  };
+
+  server.createContents(USER, {
+    meta: {fxa_credentials: payload},
+    crypto: {},
+  });
+
+  // Another sync - this will cause the encrypted record to be fetched.
+  Service.sync();
+  // Reset the request count here as the sync will have made many!
+  numServerRequests = 0;
+
+  // Asking for the sentinel should use the copy cached in the record manager.
+  Assert.deepEqual((yield Service.getFxAMigrationSentinel()), sentinel, "got it");
+  Assert.equal(numServerRequests, 0, "should not have hit the server");
+
+  // And asking for it again should work (we have to work around the fact the
+  // ciphertext is clobbered on first decrypt...)
+  Assert.deepEqual((yield Service.getFxAMigrationSentinel()), sentinel, "got it again");
+  Assert.equal(numServerRequests, 0, "should not have hit the server");
+
+  yield promiseStopServer(server);
+});
+
+function run_test() {
+  initTestLogging();
+  run_next_test();
+}
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -169,9 +169,11 @@ skip-if = debug
 [test_tab_tracker.js]
 
 [test_healthreport.js]
 skip-if = ! healthreport
 
 [test_warn_on_truncated_response.js]
 
 # FxA migration
+[test_block_sync.js]
 [test_fxa_migration.js]
+[test_fxa_migration_sentinel.js]
--- a/toolkit/devtools/server/actors/styles.js
+++ b/toolkit/devtools/server/actors/styles.js
@@ -670,18 +670,18 @@ var PageStyleActor = protocol.ActorClass
     this.cssLogic.highlight(node.rawNode);
 
     let layout = {};
 
     // First, we update the first part of the layout view, with
     // the size of the element.
 
     let clientRect = node.rawNode.getBoundingClientRect();
-    layout.width = Math.round(clientRect.width);
-    layout.height = Math.round(clientRect.height);
+    layout.width = Math.ceil(clientRect.width);
+    layout.height = Math.ceil(clientRect.height);
 
     // We compute and update the values of margins & co.
     let style = CssLogic.getComputedStyle(node.rawNode);
     for (let prop of [
       "position",
       "margin-top",
       "margin-right",
       "margin-bottom",