Backed out changeset 1df2049352ff (bug 1205928) for bc7 test bustage on a CLOSED TREE
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Wed, 21 Oct 2015 12:01:38 +0200
changeset 268635 fd4bf5ebb3cde0183028e2d0bfdaa66aa262f1d1
parent 268634 7e8966f583f3a9563239347176e91ccc96b5e1fd
child 268696 a9f84167b319241510c87df253750bb634caa4e6
push id15788
push usercbook@mozilla.com
push dateWed, 21 Oct 2015 10:01:46 +0000
treeherderfx-team@fd4bf5ebb3cd [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1205928
milestone44.0a1
backs out1df2049352ff2ea1ab272e15f318da30aecd1e98
Backed out changeset 1df2049352ff (bug 1205928) for bc7 test bustage on a CLOSED TREE
browser/base/content/browser-fxaccounts.js
browser/components/preferences/in-content/sync.js
browser/components/preferences/in-content/sync.xul
browser/locales/en-US/chrome/browser/accounts.properties
services/sync/modules/FxaMigrator.jsm
services/sync/modules/healthreport.jsm
services/sync/modules/policies.js
services/sync/tests/unit/test_block_sync.js
services/sync/tests/unit/test_fxa_migration.js
services/sync/tests/unit/test_fxa_migration_sentinel.js
services/sync/tests/unit/test_healthreport_migration.js
services/sync/tests/unit/xpcshell.ini
--- a/browser/base/content/browser-fxaccounts.js
+++ b/browser/base/content/browser-fxaccounts.js
@@ -5,16 +5,21 @@
 var gFxAccounts = {
 
   PREF_SYNC_START_DOORHANGER: "services.sync.ui.showSyncStartDoorhanger",
   DOORHANGER_ACTIVATE_DELAY_MS: 5000,
   SYNC_MIGRATION_NOTIFICATION_TITLE: "fxa-migration",
 
   _initialized: false,
   _inCustomizationMode: false,
+  // _expectingNotifyClose is a hack that helps us determine if the
+  // migration notification was closed due to being "dismissed" vs closed
+  // due to one of the migration buttons being clicked.  It's ugly and somewhat
+  // fragile, so bug 1119020 exists to help us do this better.
+  _expectingNotifyClose: false,
 
   get weave() {
     delete this.weave;
     return this.weave = Cc["@mozilla.org/weave/service;1"]
                           .getService(Ci.nsISupports)
                           .wrappedJSObject;
   },
 
@@ -26,16 +31,17 @@ var gFxAccounts = {
       "weave:service:sync:start",
       "weave:service:login:error",
       "weave:service:setup-complete",
       "weave:ui:login:error",
       "fxa-migration:state-changed",
       this.FxAccountsCommon.ONLOGIN_NOTIFICATION,
       this.FxAccountsCommon.ONVERIFIED_NOTIFICATION,
       this.FxAccountsCommon.ONLOGOUT_NOTIFICATION,
+      "weave:notification:removed",
       this.FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION,
     ];
   },
 
   get panelUIFooter() {
     delete this.panelUIFooter;
     return this.panelUIFooter = document.getElementById("PanelUI-footer-fxa");
   },
@@ -96,16 +102,20 @@ var gFxAccounts = {
     for (let topic of this.topics) {
       Services.obs.addObserver(this, topic, false);
     }
 
     addEventListener("activate", this);
     gNavToolbox.addEventListener("customizationstarting", this);
     gNavToolbox.addEventListener("customizationending", this);
 
+    // Request the current Legacy-Sync-to-FxA migration status.  We'll be
+    // notified of fxa-migration:state-changed in response if necessary.
+    Services.obs.notifyObservers(null, "fxa-migration:state-request", null);
+
     EnsureFxAccountsWebChannel();
     this._initialized = true;
 
     this.updateUI();
   },
 
   uninit: function () {
     if (!this._initialized) {
@@ -125,16 +135,26 @@ var gFxAccounts = {
         Services.prefs.setBoolPref(this.PREF_SYNC_START_DOORHANGER, true);
         break;
       case "weave:service:sync:start":
         this.onSyncStart();
         break;
       case "fxa-migration:state-changed":
         this.onMigrationStateChanged(data, subject);
         break;
+      case "weave:notification:removed":
+        // this exists just so we can tell the difference between "box was
+        // closed due to button press" vs "was closed due to click on [x]"
+        let notif = subject.wrappedJSObject.object;
+        if (notif.title == this.SYNC_MIGRATION_NOTIFICATION_TITLE &&
+            !this._expectingNotifyClose) {
+          // it's an [x] on our notification, so record telemetry.
+          this.fxaMigrator.recordTelemetry(this.fxaMigrator.TELEMETRY_DECLINED);
+        }
+        break;
       case this.FxAccountsCommon.ONPROFILE_IMAGE_CHANGE_NOTIFICATION:
         this.updateUI();
         break;
       default:
         this.updateUI();
         break;
     }
   },
@@ -151,35 +171,22 @@ var gFxAccounts = {
     } catch (e) { /* The pref might not exist. */ }
 
     if (showDoorhanger) {
       Services.prefs.clearUserPref(this.PREF_SYNC_START_DOORHANGER);
       this.showSyncStartedDoorhanger();
     }
   },
 
-  onMigrationStateChanged: function () {
-    // Since we nuked most of the migration code, this notification will fire
-    // once after legacy Sync has been disconnected (and should never fire
-    // again)
-    let msg = this.strings.GetStringFromName("autoDisconnectDescription")
-    let signInLabel = this.strings.GetStringFromName("autoDisconnectSignIn.label");
-    let signInAccessKey = this.strings.GetStringFromName("autoDisconnectSignIn.accessKey");
-    let learnMoreLink = this.fxaMigrator.learnMoreLink;
-    let note = new Weave.Notification(
-          undefined, msg, undefined, Weave.Notifications.PRIORITY_WARNING, [
-            new Weave.NotificationButton(signInLabel, signInAccessKey, () => {
-              this.openPreferences();
-            }),
-          ], learnMoreLink
-        );
-    note.title = this.SYNC_MIGRATION_NOTIFICATION_TITLE;
-    Weave.Notifications.replaceTitle(note);
-    // ensure the hamburger menu reflects the newly disconnected state.
-    this.updateAppMenuItem();
+  onMigrationStateChanged: function (newState, email) {
+    this._migrationInfo = !newState ? null : {
+      state: newState,
+      email: email ? email.QueryInterface(Ci.nsISupportsString).data : null,
+    };
+    this.updateUI();
   },
 
   handleEvent: function (event) {
     if (event.type == "activate") {
       // Our window might have been in the background while we received the
       // sync:start notification. If still needed, show the doorhanger after
       // a short delay. Without this delay the doorhanger would not show up
       // or with a too small delay show up while we're still animating the
@@ -203,32 +210,40 @@ var gFxAccounts = {
     panel.openPopup(iconAnchor || anchor, "bottomcenter topright");
   },
 
   showSyncStartedDoorhanger: function () {
     this.showDoorhanger("sync-start-panel");
   },
 
   updateUI: function () {
-    // It's possible someone signed in to FxA after seeing our notification
-    // about "Legacy Sync migration" (which now is actually "Legacy Sync
-    // auto-disconnect") so kill that notification if it still exists.
-    Weave.Notifications.removeAll(this.SYNC_MIGRATION_NOTIFICATION_TITLE);
     this.updateAppMenuItem();
+    this.updateMigrationNotification();
   },
 
   // Note that updateAppMenuItem() returns a Promise that's only used by tests.
   updateAppMenuItem: function () {
+    if (this._migrationInfo) {
+      this.updateAppMenuItemForMigration();
+      return Promise.resolve();
+    }
+
     let profileInfoEnabled = false;
     try {
       profileInfoEnabled = Services.prefs.getBoolPref("identity.fxaccounts.profile_image.enabled");
     } catch (e) { }
 
     // Bail out if FxA is disabled.
     if (!this.weave.fxAccountsEnabled) {
+      // When migration transitions from needs-verification to the null state,
+      // fxAccountsEnabled is false because migration has not yet finished.  In
+      // that case, hide the button.  We'll get another notification with a null
+      // state once migration is complete.
+      this.panelUIFooter.hidden = true;
+      this.panelUIFooter.removeAttribute("fxastatus");
       return Promise.resolve();
     }
 
     this.panelUIFooter.hidden = false;
 
     // Make sure the button is disabled in customization mode.
     if (this._inCustomizationMode) {
       this.panelUIStatus.setAttribute("disabled", "true");
@@ -338,29 +353,119 @@ var gFxAccounts = {
       // The most likely scenario is a user logged out, so reflect that.
       // Bug 995134 calls for better errors so we could retry if we were
       // sure this was the failure reason.
       this.FxAccountsCommon.log.error("Error updating FxA account info", error);
       updateWithUserData(null);
     });
   },
 
+  updateAppMenuItemForMigration: Task.async(function* () {
+    let status = null;
+    let label = null;
+    switch (this._migrationInfo.state) {
+      case this.fxaMigrator.STATE_USER_FXA:
+        status = "migrate-signup";
+        label = this.strings.formatStringFromName("needUserShort",
+          [this.panelUILabel.getAttribute("fxabrandname")], 1);
+        break;
+      case this.fxaMigrator.STATE_USER_FXA_VERIFIED:
+        status = "migrate-verify";
+        label = this.strings.formatStringFromName("needVerifiedUserShort",
+                                                  [this._migrationInfo.email],
+                                                  1);
+        break;
+    }
+    this.panelUILabel.label = label;
+    this.panelUIFooter.setAttribute("fxastatus", status);
+  }),
+
+  updateMigrationNotification: Task.async(function* () {
+    if (!this._migrationInfo) {
+      this._expectingNotifyClose = true;
+      Weave.Notifications.removeAll(this.SYNC_MIGRATION_NOTIFICATION_TITLE);
+      // because this is called even when there is no such notification, we
+      // set _expectingNotifyClose back to false as we may yet create a new
+      // notification (but in general, once we've created a migration
+      // notification once in a session, we don't create one again)
+      this._expectingNotifyClose = false;
+      return;
+    }
+    let note = null;
+    switch (this._migrationInfo.state) {
+      case this.fxaMigrator.STATE_USER_FXA: {
+        // There are 2 cases here - no email address means it is an offer on
+        // the first device (so the user is prompted to create an account).
+        // If there is an email address it is the "join the party" flow, so the
+        // user is prompted to sign in with the address they previously used.
+        let msg, upgradeLabel, upgradeAccessKey, learnMoreLink;
+        if (this._migrationInfo.email) {
+          msg = this.strings.formatStringFromName("signInAfterUpgradeOnOtherDevice.description",
+                                                  [this._migrationInfo.email],
+                                                  1);
+          upgradeLabel = this.strings.GetStringFromName("signInAfterUpgradeOnOtherDevice.label");
+          upgradeAccessKey = this.strings.GetStringFromName("signInAfterUpgradeOnOtherDevice.accessKey");
+        } else {
+          msg = this.strings.GetStringFromName("needUserLong");
+          upgradeLabel = this.strings.GetStringFromName("upgradeToFxA.label");
+          upgradeAccessKey = this.strings.GetStringFromName("upgradeToFxA.accessKey");
+          learnMoreLink = this.fxaMigrator.learnMoreLink;
+        }
+        note = new Weave.Notification(
+          undefined, msg, undefined, Weave.Notifications.PRIORITY_WARNING, [
+            new Weave.NotificationButton(upgradeLabel, upgradeAccessKey, () => {
+              this._expectingNotifyClose = true;
+              this.fxaMigrator.createFxAccount(window);
+            }),
+          ], learnMoreLink
+        );
+        break;
+      }
+      case this.fxaMigrator.STATE_USER_FXA_VERIFIED: {
+        let msg =
+          this.strings.formatStringFromName("needVerifiedUserLong",
+                                            [this._migrationInfo.email], 1);
+        let resendLabel =
+          this.strings.GetStringFromName("resendVerificationEmail.label");
+        let resendAccessKey =
+          this.strings.GetStringFromName("resendVerificationEmail.accessKey");
+        note = new Weave.Notification(
+          undefined, msg, undefined, Weave.Notifications.PRIORITY_INFO, [
+            new Weave.NotificationButton(resendLabel, resendAccessKey, () => {
+              this._expectingNotifyClose = true;
+              this.fxaMigrator.resendVerificationMail();
+            }),
+          ]
+        );
+        break;
+      }
+    }
+    note.title = this.SYNC_MIGRATION_NOTIFICATION_TITLE;
+    Weave.Notifications.replaceTitle(note);
+  }),
+
   onMenuPanelCommand: function () {
 
     switch (this.panelUIFooter.getAttribute("fxastatus")) {
     case "signedin":
       this.openPreferences();
       break;
     case "error":
       if (this.panelUIFooter.getAttribute("unverified")) {
         this.openPreferences();
       } else {
         this.openSignInAgainPage("menupanel");
       }
       break;
+    case "migrate-signup":
+    case "migrate-verify":
+      // The migration flow calls for the menu item to open sync prefs rather
+      // than requesting migration start immediately.
+      this.openPreferences();
+      break;
     default:
       this.openPreferences();
       break;
     }
 
     PanelUI.hide();
   },
 
--- a/browser/components/preferences/in-content/sync.js
+++ b/browser/components/preferences/in-content/sync.js
@@ -7,16 +7,19 @@ Components.utils.import("resource://gre/
 
 XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
   return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {});
 });
 
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
   "resource://gre/modules/FxAccounts.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "fxaMigrator",
+  "resource://services-sync/FxaMigrator.jsm");
+
 const PAGE_NO_ACCOUNT = 0;
 const PAGE_HAS_ACCOUNT = 1;
 const PAGE_NEEDS_UPDATE = 2;
 const FXA_PAGE_LOGGED_OUT = 3;
 const FXA_PAGE_LOGGED_IN = 4;
 
 // Indexes into the "login status" deck.
 // We are in a successful verified state - everything should work!
@@ -114,27 +117,32 @@ var gSyncPane = {
                   "weave:service:login:finish",
                   "weave:service:start-over:finish",
                   "weave:service:setup-complete",
                   "weave:service:logout:finish",
                   FxAccountsCommon.ONVERIFIED_NOTIFICATION,
                   FxAccountsCommon.ONLOGIN_NOTIFICATION,
                   FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION,
                   ];
+    let migrateTopic = "fxa-migration:state-changed";
+
     // Add the observers now and remove them on unload
     //XXXzpao This should use Services.obs.* but Weave's Obs does nice handling
     //        of `this`. Fix in a followup. (bug 583347)
     topics.forEach(function (topic) {
       Weave.Svc.Obs.add(topic, this.updateWeavePrefs, this);
     }, this);
+    // The FxA migration observer is a special case.
+    Weave.Svc.Obs.add(migrateTopic, this.updateMigrationState, this);
 
     window.addEventListener("unload", function() {
       topics.forEach(function (topic) {
         Weave.Svc.Obs.remove(topic, this.updateWeavePrefs, this);
       }, gSyncPane);
+      Weave.Svc.Obs.remove(migrateTopic, gSyncPane.updateMigrationState, gSyncPane);
     }, false);
 
     XPCOMUtils.defineLazyGetter(this, '_stringBundle', () => {
       return Services.strings.createBundle("chrome://browser/locale/preferences/preferences.properties");
     }),
 
     XPCOMUtils.defineLazyGetter(this, '_accountsStringBundle', () => {
       return Services.strings.createBundle("chrome://browser/locale/accounts.properties");
@@ -181,16 +189,17 @@ var gSyncPane = {
   _closeSyncStatusMessageBox: function() {
     document.getElementById("syncStatusMessage").removeAttribute("message-type");
     document.getElementById("syncStatusMessageTitle").textContent = "";
     document.getElementById("syncStatusMessageDescription").textContent = "";
     let learnMoreLink = document.getElementById("learnMoreLink");
     if (learnMoreLink) {
       learnMoreLink.parentNode.removeChild(learnMoreLink);
     }
+    document.getElementById("sync-migration-buttons-deck").hidden = true;
   },
 
   _setupEventListeners: function() {
     function setEventListener(aId, aEventType, aCallback)
     {
       document.getElementById(aId)
               .addEventListener(aEventType, aCallback.bind(gSyncPane));
     }
@@ -278,16 +287,30 @@ var gSyncPane = {
     });
     setEventListener("rejectReSignIn", "command",
       gSyncPane.reSignIn);
     setEventListener("rejectUnlinkFxaAccount", "command", function () {
       gSyncPane.unlinkFirefoxAccount(true);
     });
     setEventListener("tosPP-small-ToS", "click", gSyncPane.openToS);
     setEventListener("tosPP-small-PP", "click", gSyncPane.openPrivacyPolicy);
+    setEventListener("sync-migrate-upgrade", "click", function () {
+      let win = Services.wm.getMostRecentWindow("navigator:browser");
+      fxaMigrator.createFxAccount(win);
+    });
+    setEventListener("sync-migrate-unlink", "click", function () {
+      gSyncPane.startOverMigration();
+    });
+    setEventListener("sync-migrate-forget", "click", function () {
+      fxaMigrator.forgetFxAccount();
+    });
+    setEventListener("sync-migrate-resend", "click", function () {
+      let win = Services.wm.getMostRecentWindow("navigator:browser");
+      fxaMigrator.resendVerificationMail(win);
+    });
     setEventListener("fxaSyncComputerName", "keypress", function (e) {
       if (e.keyCode == KeyEvent.DOM_VK_RETURN) {
         document.getElementById("fxaSaveChangeDeviceName").click();
       } else if (e.keyCode == KeyEvent.DOM_VK_ESCAPE) {
         document.getElementById("fxaCancelChangeDeviceName").click();
       }
     });
   },
@@ -296,16 +319,22 @@ var gSyncPane = {
     try {
       if (Services.prefs.getBoolPref("identity.fxaccounts.profile_image.enabled")) {
         document.getElementById("fxaProfileImage").hidden = false;
       }
     } catch (e) { }
   },
 
   updateWeavePrefs: function () {
+    // ask the migration module to broadcast its current state (and nothing will
+    // happen if it's not loaded - which is good, as that means no migration
+    // is pending/necessary) - we don't want to suck that module in just to
+    // find there's nothing to do.
+    Services.obs.notifyObservers(null, "fxa-migration:state-request", null);
+
     let service = Components.classes["@mozilla.org/weave/service;1"]
                   .getService(Components.interfaces.nsISupports)
                   .wrappedJSObject;
     // service.fxAccountsEnabled is false iff sync is already configured for
     // the legacy provider.
     if (service.fxAccountsEnabled) {
       let displayNameLabel = document.getElementById("fxaDisplayName");
       let fxaEmailAddress1Label = document.getElementById("fxaEmailAddress1");
@@ -414,16 +443,93 @@ var gSyncPane = {
     } else {
       this.page = PAGE_HAS_ACCOUNT;
       document.getElementById("accountName").textContent = Weave.Service.identity.account;
       document.getElementById("syncComputerName").value = Weave.Service.clientsEngine.localName;
       document.getElementById("tosPP-normal").hidden = this._usingCustomServer;
     }
   },
 
+  updateMigrationState: function(subject, state) {
+    this._closeSyncStatusMessageBox();
+    let selIndex;
+    let sb = this._accountsStringBundle;
+    switch (state) {
+      case fxaMigrator.STATE_USER_FXA: {
+        // There are 2 cases here - no email address means it is an offer on
+        // the first device (so the user is prompted to create an account).
+        // If there is an email address it is the "join the party" flow, so the
+        // user is prompted to sign in with the address they previously used.
+        let email = subject ? subject.QueryInterface(Components.interfaces.nsISupportsString).data : null;
+        let elt = document.getElementById("syncStatusMessageDescription");
+        elt.textContent = email ?
+                          sb.formatStringFromName("signInAfterUpgradeOnOtherDevice.description",
+                                                  [email], 1) :
+                          sb.GetStringFromName("needUserLong");
+
+        // The "Learn more" link.
+        if (!email) {
+          let learnMoreLink = document.createElement("label");
+          learnMoreLink.id = "learnMoreLink";
+          learnMoreLink.className = "text-link";
+          let { text, href } = fxaMigrator.learnMoreLink;
+          learnMoreLink.setAttribute("value", text);
+          learnMoreLink.href = href;
+          elt.parentNode.insertBefore(learnMoreLink, elt.nextSibling);
+        }
+
+        // The "upgrade" button.
+        let button = document.getElementById("sync-migrate-upgrade");
+        button.setAttribute("label",
+                            sb.GetStringFromName(email
+                                                 ? "signInAfterUpgradeOnOtherDevice.label"
+                                                 : "upgradeToFxA.label"));
+        button.setAttribute("accesskey",
+                            sb.GetStringFromName(email
+                                                 ? "signInAfterUpgradeOnOtherDevice.accessKey"
+                                                 : "upgradeToFxA.accessKey"));
+        // The "unlink" button - this is only shown for first migration
+        button = document.getElementById("sync-migrate-unlink");
+        if (email) {
+          button.hidden = true;
+        } else {
+          button.setAttribute("label", sb.GetStringFromName("unlinkMigration.label"));
+          button.setAttribute("accesskey", sb.GetStringFromName("unlinkMigration.accessKey"));
+        }
+        selIndex = 0;
+        break;
+      }
+      case fxaMigrator.STATE_USER_FXA_VERIFIED: {
+        let sb = this._accountsStringBundle;
+        let email = subject.QueryInterface(Components.interfaces.nsISupportsString).data;
+        let label = sb.formatStringFromName("needVerifiedUserLong", [email], 1);
+        let elt = document.getElementById("syncStatusMessageDescription");
+        elt.setAttribute("value", label);
+        // The "resend" button.
+        let button = document.getElementById("sync-migrate-resend");
+        button.setAttribute("label", sb.GetStringFromName("resendVerificationEmail.label"));
+        button.setAttribute("accesskey", sb.GetStringFromName("resendVerificationEmail.accessKey"));
+        // The "forget" button.
+        button = document.getElementById("sync-migrate-forget");
+        button.setAttribute("label", sb.GetStringFromName("forgetMigration.label"));
+        button.setAttribute("accesskey", sb.GetStringFromName("forgetMigration.accessKey"));
+        selIndex = 1;
+        break;
+      }
+      default:
+        if (state) { // |null| is expected, but everything else is not.
+          Cu.reportError("updateMigrationState has unknown state: " + state);
+        }
+        document.getElementById("sync-migration").hidden = true;
+        return;
+    }
+    document.getElementById("sync-migration-buttons-deck").selectedIndex = selIndex;
+    document.getElementById("syncStatusMessage").setAttribute("message-type", "migration");
+  },
+
   // Called whenever one of the sync engine preferences is changed.
   onPreferenceChanged: function() {
     let prefElts = document.querySelectorAll("#syncEnginePrefs > preference");
     let syncEnabled = false;
     for (let elt of prefElts) {
       if (elt.name.startsWith("services.sync.") && elt.value) {
         syncEnabled = true;
         break;
@@ -449,16 +555,40 @@ var gSyncPane = {
       if (buttonChoice == 1)
         return;
     }
 
     Weave.Service.startOver();
     this.updateWeavePrefs();
   },
 
+  // When the "Unlink" button in the migration header is selected we display
+  // a slightly different message.
+  startOverMigration: function () {
+    let flags = Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING +
+                Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL +
+                Services.prompt.BUTTON_POS_1_DEFAULT;
+    let sb = this._accountsStringBundle;
+    let buttonChoice =
+      Services.prompt.confirmEx(window,
+                                sb.GetStringFromName("unlinkVerificationTitle"),
+                                sb.GetStringFromName("unlinkVerificationDescription"),
+                                flags,
+                                sb.GetStringFromName("unlinkVerificationConfirm"),
+                                null, null, null, {});
+
+    // If the user selects cancel, just bail
+    if (buttonChoice == 1)
+      return;
+
+    fxaMigrator.recordTelemetry(fxaMigrator.TELEMETRY_UNLINKED);
+    Weave.Service.startOver();
+    this.updateWeavePrefs();
+  },
+
   updatePass: function () {
     if (Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED)
       gSyncUtils.changePassword();
     else
       gSyncUtils.updatePassphrase();
   },
 
   resetPass: function () {
--- a/browser/components/preferences/in-content/sync.xul
+++ b/browser/components/preferences/in-content/sync.xul
@@ -38,16 +38,28 @@
   <label class="header-name">&paneSync.title;</label>
 </hbox>
 
 <vbox id="syncStatusMessage-container" data-category="paneSync" hidden="true">
   <hbox id="syncStatusMessage">
     <vbox id="syncStatusMessageWrapper">
       <label id="syncStatusMessageTitle"></label>
       <description id="syncStatusMessageDescription"></description>
+      <deck id="sync-migration-buttons-deck">
+        <!-- When we are in the "need FxA user" state -->
+        <hbox>
+          <button id="sync-migrate-unlink"/>
+          <button id="sync-migrate-upgrade"/>
+        </hbox>
+        <!-- When we are in the "need the user to be verified" state -->
+        <hbox>
+          <button id="sync-migrate-forget"/>
+          <button id="sync-migrate-resend"/>
+        </hbox>
+      </deck>
     </vbox>
     <vbox>
       <button id="syncStatusMessageClose" class="close-icon"/>
     </vbox>
   </hbox>
 </vbox>
 
 <deck id="weavePrefsDeck" data-category="paneSync" hidden="true">
--- a/browser/locales/en-US/chrome/browser/accounts.properties
+++ b/browser/locales/en-US/chrome/browser/accounts.properties
@@ -1,17 +1,55 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
-# autoDisconnectDescription is shown in an info bar when we detect an old
-# Sync is being used.
-autoDisconnectDescription = We've rebuilt Sync to make it easier for everyone.
+# LOCALIZATION NOTE (needUserShort)
+# %S = Firefox Accounts brand name from syncBrand.dtd
+needUserShort = %S required for sync
+needUserLong = We've rebuilt Sync to make it easier for everyone. Please upgrade to a Firefox Account to continue syncing.
+
+upgradeToFxA.label = Upgrade
+upgradeToFxA.accessKey = U
 
-# autoDisconnectSignIn.label and .accessKey are for buttons when we auto-disconnect
-autoDisconnectSignIn.label = Sign in to Sync
-autoDisconnectSignIn.accessKey = S
+# LOCALIZATION NOTE (signInAfterUpgradeOnOtherDevice.description)
+# %S = Email address of user's Firefox Account
+signInAfterUpgradeOnOtherDevice.description = Sync was upgraded on another device by %S. Resume syncing?
+signInAfterUpgradeOnOtherDevice.label = Sign In
+signInAfterUpgradeOnOtherDevice.accessKey = S
+
+# LOCALIZATION NOTE (needVerifiedUserShort, needVerifiedUserLong)
+# %S = Email address of user's Firefox Account
+needVerifiedUserShort = %S not verified
+needVerifiedUserLong = Please click the verification link in the email sent to %S
+
+resendVerificationEmail.label = Resend
+resendVerificationEmail.accessKey = R
 
 # LOCALIZATION NOTE (reconnectDescription) - %S = Email address of user's Firefox Account
 reconnectDescription = Reconnect %S
 
 # LOCALIZATION NOTE (verifyDescription) - %S = Email address of user's Firefox Account
 verifyDescription = Verify %S
+
+forgetMigration.label = Forget
+forgetMigration.accessKey = F
+
+unlinkMigration.label = Unlink Sync
+unlinkMigration.accessKey = L
+
+unlinkVerificationTitle = Unlink old version of Sync?
+unlinkVerificationDescription = If you no longer want to be reminded about upgrading Sync, you can unlink your old Sync account to remove it.
+unlinkVerificationConfirm = Unlink
+
+# These strings are used in a dialog we display after the user requests we resend
+# a verification email.
+verificationSentTitle = Verification Sent
+# LOCALIZATION NOTE (verificationSentHeading) - %S = Email address of user's Firefox Account
+verificationSentHeading = A verification link has been sent to %S
+verificationSentDescription = Please check your email and click the link to begin syncing.
+# LOCALIZATION NOTE (verificationSentFull) - %S = Email address of user's Firefox Account
+verificationSentFull = A verification link has been sent to %S. Please check your email and click the link to begin syncing.
+
+verificationNotSentTitle = Unable to Send Verification
+verificationNotSentHeading = We are unable to send a verification mail at this time
+verificationNotSentDescription = Please try again later.
+verificationNotSentFull = We are unable to send a verification mail at this time, please try again later.
--- a/services/sync/modules/FxaMigrator.jsm
+++ b/services/sync/modules/FxaMigrator.jsm
@@ -1,86 +1,553 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict;"
 
-// Note that this module used to supervise the step-by-step migration from
-// a legacy Sync account to a FxA-based Sync account. In bug 1205928, this
-// changed to automatically disconnect the legacy Sync account.
-
 const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+  "resource://gre/modules/FxAccounts.jsm");
 
 XPCOMUtils.defineLazyGetter(this, "WeaveService", function() {
   return Cc["@mozilla.org/weave/service;1"]
          .getService(Components.interfaces.nsISupports)
          .wrappedJSObject;
 });
 
 XPCOMUtils.defineLazyModuleGetter(this, "Weave",
   "resource://services-sync/main.js");
 
-// We send this notification when we perform the disconnection. The browser
-// window will show a one-off notification bar.
+// FxAccountsCommon.js doesn't use a "namespace", so create one here.
+var fxAccountsCommon = {};
+Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
+
+// We send this notification whenever the "user" migration state changes.
 const OBSERVER_STATE_CHANGE_TOPIC = "fxa-migration:state-changed";
+// We also send the state notification when we *receive* this.  This allows
+// consumers to avoid loading this module until it receives a notification
+// from us (which may never happen if there's no migration to do)
+const OBSERVER_STATE_REQUEST_TOPIC = "fxa-migration:state-request";
+
+// We send this notification whenever the migration is paused waiting for
+// something internal to complete.
+const OBSERVER_INTERNAL_STATE_CHANGE_TOPIC = "fxa-migration:internal-state-changed";
+
+// We use this notification so Sync's healthreport module can record telemetry
+// (actually via "health report") for us.
+const OBSERVER_INTERNAL_TELEMETRY_TOPIC = "fxa-migration:internal-telemetry";
 
 const OBSERVER_TOPICS = [
   "xpcom-shutdown",
+  "weave:service:sync:start",
+  "weave:service:sync:finish",
+  "weave:service:sync:error",
   "weave:eol",
+  OBSERVER_STATE_REQUEST_TOPIC,
+  fxAccountsCommon.ONLOGIN_NOTIFICATION,
+  fxAccountsCommon.ONLOGOUT_NOTIFICATION,
+  fxAccountsCommon.ONVERIFIED_NOTIFICATION,
+];
+
+// A list of preference names we write to the migration sentinel.  We only
+// write ones that have a user-set value.
+const FXA_SENTINEL_PREFS = [
+  "identity.fxaccounts.auth.uri",
+  "identity.fxaccounts.remote.force_auth.uri",
+  "identity.fxaccounts.remote.signup.uri",
+  "identity.fxaccounts.remote.signin.uri",
+  "identity.fxaccounts.settings.uri",
+  // Note that "identity.sync.tokenserver.uri" and "services.sync.tokenServerURI"
+  // have special handing when writing/reading prefs.
 ];
 
 function Migrator() {
   // Leave the log-level as Debug - Sync will setup log appenders such that
   // these messages generally will not be seen unless other log related
   // prefs are set.
   this.log.level = Log.Level.Debug;
 
+  this._nextUserStatePromise = Promise.resolve();
+
   for (let topic of OBSERVER_TOPICS) {
     Services.obs.addObserver(this, topic, false);
   }
+  // ._state is an optimization so we avoid sending redundant observer
+  // notifications when the state hasn't actually changed.
+  this._state = null;
 }
 
 Migrator.prototype = {
   log: Log.repository.getLogger("Sync.SyncMigration"),
 
+  // What user action is necessary to push the migration forward?
+  // A |null| state means there is nothing to do.  Note that a null state implies
+  // either. (a) no migration is necessary or (b) that the migrator module is
+  // waiting for something outside of the user's control - eg, sync to complete,
+  // the migration sentinel to be uploaded, etc.  In most cases the wait will be
+  // short, but edge cases (eg, no network, sync bugs that prevent it stopping
+  // until shutdown) may require a significantly longer wait.
+  STATE_USER_FXA: "waiting for user to be signed in to FxA",
+  STATE_USER_FXA_VERIFIED: "waiting for a verified FxA user",
+
+  // What internal state are we at?  This is primarily used for FHR reporting so
+  // we can determine why exactly we might be stalled.
+  STATE_INTERNAL_WAITING_SYNC_COMPLETE: "waiting for sync to complete",
+  STATE_INTERNAL_WAITING_WRITE_SENTINEL: "waiting for sentinel to be written",
+  STATE_INTERNAL_WAITING_START_OVER: "waiting for sync to reset itself",
+  STATE_INTERNAL_COMPLETE: "migration complete",
+
+  // Flags for the telemetry we record.  The UI will call a helper to record
+  // the fact some UI was interacted with.
+  TELEMETRY_ACCEPTED: "accepted",
+  TELEMETRY_DECLINED: "declined",
+  TELEMETRY_UNLINKED: "unlinked",
+
   finalize() {
     for (let topic of OBSERVER_TOPICS) {
       Services.obs.removeObserver(this, topic);
     }
   },
 
   observe(subject, topic, data) {
     this.log.debug("observed " + topic);
     switch (topic) {
       case "xpcom-shutdown":
         this.finalize();
         break;
 
+      case OBSERVER_STATE_REQUEST_TOPIC:
+        // someone has requested the state - send it.
+        this._queueCurrentUserState(true);
+        break;
+
       default:
-        // this notification when configured with legacy Sync means we want to
-        // disconnect
-        if (!WeaveService.fxAccountsEnabled) {
-          this.log.info("Disconnecting from legacy Sync");
-          // Set up an observer for when the disconnection is complete.
-          let observe;
-          Services.obs.addObserver(observe = () => {
-            this.log.info("observed that startOver is complete");
-            Services.obs.removeObserver(observe, "weave:service:start-over:finish");
-            // Send the notification for the UI.
-            Services.obs.notifyObservers(null, OBSERVER_STATE_CHANGE_TOPIC, null);
-          }, "weave:service:start-over:finish", false);
+        // some other observer that may affect our state has fired, so update.
+        this._queueCurrentUserState().then(
+          () => this.log.debug("update state from observer " + topic + " complete")
+        ).catch(err => {
+          let msg = "Failed to handle topic " + topic + ": " + err;
+          Cu.reportError(msg);
+          this.log.error(msg);
+        });
+    }
+  },
+
+  // Try and move to a state where we are blocked on a user action.
+  // This needs to be restartable, and the states may, in edge-cases, end
+  // up going backwards (eg, user logs out while we are waiting to be told
+  // about verification)
+  // This is called by our observer notifications - so if there is already
+  // a promise in-flight, it's possible we will miss something important - so
+  // we wait for the in-flight one to complete then fire another (ie, this
+  // is effectively a queue of promises)
+  _queueCurrentUserState(forceObserver = false) {
+    return this._nextUserStatePromise = this._nextUserStatePromise.then(
+      () => this._promiseCurrentUserState(forceObserver),
+      err => {
+        let msg = "Failed to determine the current user state: " + err;
+        Cu.reportError(msg);
+        this.log.error(msg);
+        return this._promiseCurrentUserState(forceObserver)
+      }
+    );
+  },
+
+  _promiseCurrentUserState: Task.async(function* (forceObserver) {
+    this.log.trace("starting _promiseCurrentUserState");
+    let update = (newState, email=null) => {
+      this.log.info("Migration state: '${state}' => '${newState}'",
+                    {state: this._state, newState: newState});
+      if (forceObserver || newState !== this._state) {
+        this._state = newState;
+        let subject = Cc["@mozilla.org/supports-string;1"]
+                      .createInstance(Ci.nsISupportsString);
+        subject.data = email || "";
+        Services.obs.notifyObservers(subject, OBSERVER_STATE_CHANGE_TOPIC, newState);
+      }
+      return newState;
+    }
+
+    // If we have no sync user, or are already using an FxA account we must
+    // be done.
+    if (WeaveService.fxAccountsEnabled) {
+      // should not be necessary, but if we somehow ended up with FxA enabled
+      // and sync blocked it would be bad - so better safe than sorry.
+      this.log.debug("FxA enabled - there's nothing to do!")
+      this._unblockSync();
+      return update(null);
+    }
+
+    // so we need to migrate - let's see how far along we are.
+    // If sync isn't in EOL mode, then we are still waiting for the server
+    // to offer the migration process - so no user action necessary.
+    let isEOL = false;
+    try {
+      isEOL = !!Services.prefs.getCharPref("services.sync.errorhandler.alert.mode");
+    } catch (e) {}
+
+    if (!isEOL) {
+      return update(null);
+    }
+
+    // So we are in EOL mode - have we a user?
+    let fxauser = yield fxAccounts.getSignedInUser();
+    if (!fxauser) {
+      // See if there is a migration sentinel so we can send the email
+      // address that was used on a different device for this account (ie, if
+      // this is a "join the party" migration rather than the first)
+      let sentinel = yield this._getSyncMigrationSentinel();
+      return update(this.STATE_USER_FXA, sentinel && sentinel.email);
+    }
+    if (!fxauser.verified) {
+      return update(this.STATE_USER_FXA_VERIFIED, fxauser.email);
+    }
+
+    // So we just have housekeeping to do - we aren't blocked on a user, so
+    // reflect that.
+    this.log.info("No next user state - doing some housekeeping");
+    update(null);
+
+    // We need to disable sync from automatically starting,
+    // and if we are currently syncing wait for it to complete.
+    this._blockSync();
+
+    // Are we currently syncing?
+    if (Weave.Service._locked) {
+      // our observers will kick us further along when complete.
+      this.log.info("waiting for sync to complete")
+      Services.obs.notifyObservers(null, OBSERVER_INTERNAL_STATE_CHANGE_TOPIC,
+                                   this.STATE_INTERNAL_WAITING_SYNC_COMPLETE);
+      return null;
+    }
+
+    // Write the migration sentinel if necessary.
+    Services.obs.notifyObservers(null, OBSERVER_INTERNAL_STATE_CHANGE_TOPIC,
+                                 this.STATE_INTERNAL_WAITING_WRITE_SENTINEL);
+    yield this._setMigrationSentinelIfNecessary();
+
+    // Get the list of enabled engines to we can restore that state.
+    let enginePrefs = this._getEngineEnabledPrefs();
+
+    // Must be ready to perform the actual migration.
+    this.log.info("Performing final sync migration steps");
+    // Do the actual migration.  We setup one observer for when the new identity
+    // is about to be initialized so we can reset some key preferences - but
+    // there's no promise associated with this.
+    let observeStartOverIdentity;
+    Services.obs.addObserver(observeStartOverIdentity = () => {
+      this.log.info("observed that startOver is about to re-initialize the identity");
+      Services.obs.removeObserver(observeStartOverIdentity, "weave:service:start-over:init-identity");
+      // We've now reset all sync prefs - set the engine related prefs back to
+      // what they were.
+      for (let [prefName, prefType, prefVal] of enginePrefs) {
+        this.log.debug("Restoring pref ${prefName} (type=${prefType}) to ${prefVal}",
+                       {prefName, prefType, prefVal});
+        switch (prefType) {
+          case Services.prefs.PREF_BOOL:
+            Services.prefs.setBoolPref(prefName, prefVal);
+            break;
+          case Services.prefs.PREF_STRING:
+            Services.prefs.setCharPref(prefName, prefVal);
+            break;
+          default:
+            // _getEngineEnabledPrefs doesn't return any other type...
+            Cu.reportError("unknown engine pref type for " + prefName + ": " + prefType);
+        }
+      }
+    }, "weave:service:start-over:init-identity", false);
+
+    // And another observer for the startOver being fully complete - the only
+    // reason for this is so we can wait until everything is fully reset.
+    let startOverComplete = new Promise((resolve, reject) => {
+      let observe;
+      Services.obs.addObserver(observe = () => {
+        this.log.info("observed that startOver is complete");
+        Services.obs.removeObserver(observe, "weave:service:start-over:finish");
+        resolve();
+      }, "weave:service:start-over:finish", false);
+    });
+
+    Weave.Service.startOver();
+    // need to wait for an observer.
+    Services.obs.notifyObservers(null, OBSERVER_INTERNAL_STATE_CHANGE_TOPIC,
+                                 this.STATE_INTERNAL_WAITING_START_OVER);
+    yield startOverComplete;
+    // observer fired, now kick things off with the FxA user.
+    this.log.info("scheduling initial FxA sync.");
+    // Note we technically don't need to unblockSync as by now all sync prefs
+    // have been reset - but it doesn't hurt.
+    this._unblockSync();
+    Weave.Service.scheduler.scheduleNextSync(0);
+
+    // Tell the front end that migration is now complete -- Sync is now
+    // configured with an FxA user.
+    forceObserver = true;
+    this.log.info("Migration complete");
+    update(null);
+
+    Services.obs.notifyObservers(null, OBSERVER_INTERNAL_STATE_CHANGE_TOPIC,
+                                 this.STATE_INTERNAL_COMPLETE);
+    return null;
+  }),
+
+  /* Return an object with the preferences we care about */
+  _getSentinelPrefs() {
+    let result = {};
+    for (let pref of FXA_SENTINEL_PREFS) {
+      if (Services.prefs.prefHasUserValue(pref)) {
+        result[pref] = Services.prefs.getCharPref(pref);
+      }
+    }
+    // We used to use services.sync.tokenServerURI as the tokenServer pref but
+    // have since changed to identity.sync.tokenserver.uri. However, clients
+    // using this pref may not have updated - so always write whatever value
+    // is actually being used to the "old" name.
+    let tokenServerValue;
+    for (let pref of ["services.sync.tokenServerURI", "identity.sync.tokenserver.uri"]) {
+      if (Services.prefs.prefHasUserValue(pref)) {
+        tokenServerValue = Services.prefs.getCharPref(pref);
+        break;
+      }
+    }
+    if (tokenServerValue) {
+      result["services.sync.tokenServerURI"] = tokenServerValue;
+    }
+    return result;
+  },
+
+  /* Apply any preferences we've obtained from the sentinel */
+  _applySentinelPrefs(savedPrefs) {
+    for (let pref of FXA_SENTINEL_PREFS) {
+      if (savedPrefs[pref]) {
+        Services.prefs.setCharPref(pref, savedPrefs[pref]);
+      }
+    }
+    // And special handling for the tokenserver prefs.
+    let tokenServerValue = savedPrefs["services.sync.tokenServerURI"];
+    if (tokenServerValue) {
+      Services.prefs.setCharPref("identity.sync.tokenserver.uri", tokenServerValue);
+    }
+  },
 
-          // Do the disconnection.
-          Weave.Service.startOver();
-        }
+  /* Ask sync to upload the migration sentinel */
+  _setSyncMigrationSentinel: Task.async(function* () {
+    yield WeaveService.whenLoaded();
+    let signedInUser = yield fxAccounts.getSignedInUser();
+    let sentinel = {
+      email: signedInUser.email,
+      uid: signedInUser.uid,
+      verified: signedInUser.verified,
+      prefs: this._getSentinelPrefs(),
+    };
+    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();
+    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
+    // services.sync.account pref), otherwise return null.
+    // NOTE: Sync does all this synchronously via nested event loops, but we
+    // expose a promise to make future migration to an async-sync easier.
+    if (sentinel && sentinel.email) {
+      this.log.info("defaultAccountName found via sentinel: ${}", sentinel.email);
+      return sentinel.email;
+    }
+    // No previous migrations, so check the existing account name.
+    let account = Weave.Service.identity.account;
+    if (account && account.contains("@")) {
+      this.log.info("defaultAccountName found via legacy account name: {}", account);
+      return account;
+    }
+    this.log.info("defaultAccountName could not find an account");
+    return null;
+  }),
+
+  // Prevent sync from automatically starting
+  _blockSync() {
+    Weave.Service.scheduler.blockSync();
+  },
+
+  _unblockSync() {
+    Weave.Service.scheduler.unblockSync();
+  },
+
+  /* Return a list of [prefName, prefType, prefVal] for all engine related
+     preferences.
+  */
+  _getEngineEnabledPrefs() {
+    let result = [];
+    for (let engine of Weave.Service.engineManager.getAll()) {
+      let prefName = "services.sync.engine." + engine.prefName;
+      let prefVal;
+      try {
+        prefVal = Services.prefs.getBoolPref(prefName);
+        result.push([prefName, Services.prefs.PREF_BOOL, prefVal]);
+      } catch (ex) {} /* just skip this pref */
+    }
+    // and the declined list.
+    try {
+      let prefName = "services.sync.declinedEngines";
+      let prefVal = Services.prefs.getCharPref(prefName);
+      result.push([prefName, Services.prefs.PREF_STRING, prefVal]);
+    } catch (ex) {}
+    return result;
+  },
+
+  /* return true if all engines are enabled, false otherwise. */
+  _allEnginesEnabled() {
+    return Weave.Service.engineManager.getAll().every(e => e.enabled);
+  },
+
+  /*
+   * 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
+  // the creation we'll see an ONLOGIN_NOTIFICATION notification from FxA and
+  // we'll move to either the STATE_USER_FXA_VERIFIED state or we'll just
+  // complete the migration if they login as an already verified user.
+  createFxAccount: Task.async(function* (win) {
+    let {url, options} = yield this.getFxAccountCreationOptions();
+    win.switchToTabHavingURI(url, true, options);
+    // An FxA observer will fire when the user completes this, which will
+    // cause us to move to the next "user blocked" state and notify via our
+    // observer notification.
+  }),
+
+  // Returns an object with properties "url" and "options", suitable for
+  // opening FxAccounts to create/signin to FxA suitable for the migration
+  // state.  The caller of this is responsible for the actual opening of the
+  // page.
+  // This should only be called while we are in the STATE_USER_FXA state.  When
+  // the user completes the creation we'll see an ONLOGIN_NOTIFICATION
+  // notification from FxA and we'll move to either the STATE_USER_FXA_VERIFIED
+  // state or we'll just complete the migration if they login as an already
+  // verified user.
+  getFxAccountCreationOptions: Task.async(function* (win) {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA) {
+      this.log.warn("getFxAccountCreationOptions called in an unexpected state: ${}", this._state);
+    }
+    // We need to obtain the sentinel and apply any prefs that might be
+    // specified *before* attempting to setup FxA as the prefs might
+    // specify custom servers etc.
+    let sentinel = yield this._getSyncMigrationSentinel();
+    if (sentinel && sentinel.prefs) {
+      this._applySentinelPrefs(sentinel.prefs);
+    }
+    // If we already have a sentinel then we assume the user has previously
+    // created the specified account, so just ask to sign-in.
+    let action = sentinel ? "signin" : "signup";
+    // See if we can find a default account name to use.
+    let email = yield this._getDefaultAccountName(sentinel);
+    let tail = email ? "&email=" + encodeURIComponent(email) : "";
+    // A special flag so server-side metrics can tell this is part of migration.
+    tail += "&migration=sync11";
+    // We want to ask FxA to offer a "Customize Sync" checkbox iff any engines
+    // are disabled.
+    let customize = !this._allEnginesEnabled();
+    tail += "&customizeSync=" + customize;
+
+    // We assume the caller of this is going to actually use it, so record
+    // telemetry now.
+    this.recordTelemetry(this.TELEMETRY_ACCEPTED);
+    return {
+      url: "about:accounts?action=" + action + tail,
+      options: {ignoreFragment: true, replaceQueryString: true}
+    };
+  }),
+
+  // Ask the FxA servers to re-send a verification mail for the currently
+  // logged in user. This should only be called while we are in the
+  // STATE_USER_FXA_VERIFIED state.  When the user clicks on the link in
+  // the mail we should see an ONVERIFIED_NOTIFICATION which will cause us
+  // to complete the migration.
+  resendVerificationMail: Task.async(function * (win) {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA_VERIFIED) {
+      this.log.warn("resendVerificationMail called in an unexpected state: ${}", this._state);
+    }
+    let ok = true;
+    try {
+      yield fxAccounts.resendVerificationEmail();
+    } catch (ex) {
+      this.log.error("Failed to resend verification mail: ${}", ex);
+      ok = false;
+    }
+    this.recordTelemetry(this.TELEMETRY_ACCEPTED);
+    let fxauser = yield fxAccounts.getSignedInUser();
+    let sb = Services.strings.createBundle("chrome://browser/locale/accounts.properties");
+
+    let heading = ok ?
+                  sb.formatStringFromName("verificationSentHeading", [fxauser.email], 1) :
+                  sb.GetStringFromName("verificationNotSentHeading");
+    let title = sb.GetStringFromName(ok ? "verificationSentTitle" : "verificationNotSentTitle");
+    let description = sb.GetStringFromName(ok ? "verificationSentDescription"
+                                              : "verificationNotSentDescription");
+
+    let factory = Cc["@mozilla.org/prompter;1"]
+                    .getService(Ci.nsIPromptFactory);
+    let prompt = factory.getPrompt(win, Ci.nsIPrompt);
+    let bag = prompt.QueryInterface(Ci.nsIWritablePropertyBag2);
+    bag.setPropertyAsBool("allowTabModal", true);
+
+    prompt.alert(title, heading + "\n\n" + description);
+  }),
+
+  // "forget" about the current Firefox account. This should only be called
+  // while we are in the STATE_USER_FXA_VERIFIED state.  After this we will
+  // see an ONLOGOUT_NOTIFICATION, which will cause the migrator to return back
+  // to the STATE_USER_FXA state, from where they can choose a different account.
+  forgetFxAccount: Task.async(function * () {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA_VERIFIED) {
+      this.log.warn("forgetFxAccount called in an unexpected state: ${}", this._state);
+    }
+    return fxAccounts.signOut();
+  }),
+
+  recordTelemetry(flag) {
+    // Note the value is the telemetry field name - but this is an
+    // implementation detail which could be changed later.
+    switch (flag) {
+      case this.TELEMETRY_ACCEPTED:
+      case this.TELEMETRY_UNLINKED:
+      case this.TELEMETRY_DECLINED:
+        Services.obs.notifyObservers(null, OBSERVER_INTERNAL_TELEMETRY_TOPIC, flag);
+        break;
+      default:
+        throw new Error("Unexpected telemetry flag: " + flag);
     }
   },
 
   get learnMoreLink() {
     try {
       var url = Services.prefs.getCharPref("app.support.baseURL");
     } catch (err) {
       return null;
--- a/services/sync/modules/healthreport.jsm
+++ b/services/sync/modules/healthreport.jsm
@@ -58,33 +58,55 @@ SyncDevicesMeasurement1.prototype = Obje
     return true;
   },
 
   fieldType: function (name) {
     return Metrics.Storage.FIELD_DAILY_COUNTER;
   },
 });
 
+function SyncMigrationMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+SyncMigrationMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "migration",
+  version: 1,
+
+  fields: {
+    state: DAILY_LAST_TEXT_FIELD, // last "user" or "internal" state we saw for the day
+    accepted: DAILY_COUNTER_FIELD, // number of times user tried to start migration
+    declined: DAILY_COUNTER_FIELD, // number of times user closed nagging infobar
+    unlinked: DAILY_LAST_NUMERIC_FIELD, // did the user decline and unlink
+  },
+});
+
 this.SyncProvider = function () {
   Metrics.Provider.call(this);
 };
 SyncProvider.prototype = Object.freeze({
   __proto__: Metrics.Provider.prototype,
 
   name: "org.mozilla.sync",
 
   measurementTypes: [
     SyncDevicesMeasurement1,
     SyncMeasurement1,
+    SyncMigrationMeasurement1,
   ],
 
   _OBSERVERS: [
     "weave:service:sync:start",
     "weave:service:sync:finish",
     "weave:service:sync:error",
+    "fxa-migration:state-changed",
+    "fxa-migration:internal-state-changed",
+    "fxa-migration:internal-telemetry",
   ],
 
   postInit: function () {
     for (let o of this._OBSERVERS) {
       Services.obs.addObserver(this, o, false);
     }
 
     return Promise.resolve();
@@ -99,16 +121,21 @@ SyncProvider.prototype = Object.freeze({
   },
 
   observe: function (subject, topic, data) {
     switch (topic) {
       case "weave:service:sync:start":
       case "weave:service:sync:finish":
       case "weave:service:sync:error":
         return this._observeSync(subject, topic, data);
+
+      case "fxa-migration:state-changed":
+      case "fxa-migration:internal-state-changed":
+      case "fxa-migration:internal-telemetry":
+        return this._observeMigration(subject, topic, data);
     }
     Cu.reportError("unexpected topic in sync healthreport provider: " + topic);
   },
 
   _observeSync: function (subject, topic, data) {
     let field;
     switch (topic) {
       case "weave:service:sync:start":
@@ -130,16 +157,57 @@ SyncProvider.prototype = Object.freeze({
 
     let m = this.getMeasurement(SyncMeasurement1.prototype.name,
                                 SyncMeasurement1.prototype.version);
     return this.enqueueStorageOperation(function recordSyncEvent() {
       return m.incrementDailyCounter(field);
     });
   },
 
+  _observeMigration: function(subject, topic, data) {
+    switch (topic) {
+      case "fxa-migration:state-changed":
+      case "fxa-migration:internal-state-changed": {
+        // We record both "user" and "internal" states in the same field.  This
+        // works for us as user state is always null when there is an internal
+        // state.
+        if (!data) {
+          return; // we don't count the |null| state
+        }
+        let m = this.getMeasurement(SyncMigrationMeasurement1.prototype.name,
+                                    SyncMigrationMeasurement1.prototype.version);
+        return this.enqueueStorageOperation(function() {
+          return m.setDailyLastText("state", data);
+        });
+      }
+
+      case "fxa-migration:internal-telemetry": {
+        // |data| is our field name.
+        let m = this.getMeasurement(SyncMigrationMeasurement1.prototype.name,
+                                    SyncMigrationMeasurement1.prototype.version);
+        return this.enqueueStorageOperation(function() {
+          switch (data) {
+            case "accepted":
+            case "declined":
+              return m.incrementDailyCounter(data);
+            case "unlinked":
+              return m.setDailyLastNumeric(data, 1);
+            default:
+              Cu.reportError("Unexpected migration field in sync healthreport provider: " + data);
+              return Promise.resolve();
+          }
+        });
+      }
+
+      default:
+        Cu.reportError("unexpected migration topic in sync healthreport provider: " + topic);
+        return;
+    }
+  },
+
   collectDailyData: function () {
     return this.storage.enqueueTransaction(this._populateDailyData.bind(this));
   },
 
   _populateDailyData: function* () {
     let m = this.getMeasurement(SyncMeasurement1.prototype.name,
                                 SyncMeasurement1.prototype.version);
 
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -512,16 +512,55 @@ SyncScheduler.prototype = {
     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;
+  },
 };
 
 this.ErrorHandler = function ErrorHandler(service) {
   this.service = service;
   this.init();
 }
 ErrorHandler.prototype = {
   MINIMUM_ALERT_INTERVAL_MSEC: 604800000,   // One week.
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
@@ -1,14 +1,17 @@
 // We change this pref before anything else initializes
 Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost");
 
 // Test the FxAMigration module
 Cu.import("resource://services-sync/FxaMigrator.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-sync/browserid_identity.js");
 
 // Set our username pref early so sync initializes with the legacy provider.
 Services.prefs.setCharPref("services.sync.username", "foo");
 // And ensure all debug messages end up being printed.
 Services.prefs.setCharPref("services.sync.log.appender.dump", "Debug");
 
 // Now import sync
 Cu.import("resource://services-sync/service.js");
@@ -75,41 +78,237 @@ function configureLegacySync() {
   Service.identity.username = USER;
   Service._updateCachedURLs();
 
   Service.engineManager._engines[engine.name] = engine;
 
   return [engine, server];
 }
 
-add_task(function *testMigrationUnlinks() {
+add_task(function *testMigration() {
 
   // when we do a .startOver we want the new provider.
   let oldValue = Services.prefs.getBoolPref("services.sync-testing.startOverKeepIdentity");
   Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", false);
 
+  // disable the addons engine - this engine choice is arbitrary, but we
+  // want to check it remains disabled after migration.
+  Services.prefs.setBoolPref("services.sync.engine.addons", false);
+
   do_register_cleanup(() => {
     Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", oldValue)
+    Services.prefs.setBoolPref("services.sync.engine.addons", true);
   });
 
-  // Arrange for a legacy sync user.
+  // No sync user - that should report no user-action necessary.
+  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();
 
+  // Check our disabling of the "addons" engine worked, and for good measure,
+  // that the "passwords" engine is enabled.
+  Assert.ok(!Service.engineManager.get("addons").enabled, "addons is disabled");
+  Assert.ok(Service.engineManager.get("passwords").enabled, "passwords is enabled");
+
+  // monkey-patch the migration sentinel code so we know it was called.
+  let haveStartedSentinel = false;
+  let origSetFxAMigrationSentinel = Service.setFxAMigrationSentinel;
+  let promiseSentinelWritten = new Promise((resolve, reject) => {
+    Service.setFxAMigrationSentinel = function(arg) {
+      haveStartedSentinel = true;
+      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 promiseMigration = promiseOneObserver("fxa-migration:state-changed");
-  let promiseStartOver = promiseOneObserver("weave:service:start-over:finish");
+  let promise = promiseOneObserver("fxa-migration:state-changed");
   _("Starting sync");
   Service.sync();
   _("Finished sync");
 
-  yield promiseStartOver;
-  yield promiseMigration;
-  // We should have seen the observer and Sync should no longer be configured.
-  Assert.ok(!Services.prefs.prefHasUserValue("services.sync.username"));
+  // We should have seen the observer, so be waiting for an FxA user.
+  Assert.equal((yield promise).data, fxaMigrator.STATE_USER_FXA, "now waiting for FxA.")
+
+  // Re-calling our user-state promise should also reflect the same state.
+  Assert.equal((yield fxaMigrator._queueCurrentUserState()),
+               fxaMigrator.STATE_USER_FXA,
+               "still waiting for FxA.");
+
+  // arrange for an unverified FxA user.
+  let config = makeIdentityConfig({username: FXA_USERNAME});
+  let fxa = new FxAccounts({});
+  config.fxaccount.user.email = config.username;
+  delete config.fxaccount.user.verified;
+  // *sob* - shouldn't need this boilerplate
+  fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
+    this.cert = {
+      validUntil: fxa.internal.now() + CERT_LIFETIME,
+      cert: "certificate",
+    };
+    return Promise.resolve(this.cert.cert);
+  };
+
+  // As soon as we set the FxA user the observers should fire and magically
+  // transition.
+  promise = promiseOneObserver("fxa-migration:state-changed");
+  fxAccounts.setSignedInUser(config.fxaccount.user);
+
+  let observerInfo = yield promise;
+  Assert.equal(observerInfo.data,
+               fxaMigrator.STATE_USER_FXA_VERIFIED,
+               "now waiting for verification");
+  Assert.ok(observerInfo.subject instanceof Ci.nsISupportsString,
+            "email was passed to observer");
+  Assert.equal(observerInfo.subject.data,
+               FXA_USERNAME,
+               "email passed to observer is correct");
+
+  // should have seen the user set, so state should automatically update.
+  Assert.equal((yield fxaMigrator._queueCurrentUserState()),
+               fxaMigrator.STATE_USER_FXA_VERIFIED,
+               "now waiting for verification");
+
+  // Before we verify the user, fire off a sync that calls us back during
+  // the sync and before it completes - this way we can ensure we do the right
+  // thing in terms of blocking sync and waiting for it to complete.
+
+  let wasWaiting = false;
+  // This is a PITA as sync is pseudo-blocking.
+  engine._syncFinish = function () {
+    // We aren't in a generator here, so use a helper to block on promises.
+    function getState() {
+      let cb = Async.makeSpinningCallback();
+      fxaMigrator._queueCurrentUserState().then(state => cb(null, state));
+      return cb.wait();
+    }
+    // should still be waiting for verification.
+    Assert.equal(getState(), fxaMigrator.STATE_USER_FXA_VERIFIED,
+                 "still waiting for verification");
+
+    // arrange for the user to be verified.  The fxAccount's mock story is
+    // broken, so go behind its back.
+    config.fxaccount.user.verified = true;
+    fxAccounts.setSignedInUser(config.fxaccount.user);
+    Services.obs.notifyObservers(null, ONVERIFIED_NOTIFICATION, null);
+
+    // spinningly wait for the migrator to catch up - sync is running so
+    // we should be in a 'null' user-state as there is no user-action
+    // necessary.
+    let cb = Async.makeSpinningCallback();
+    promiseOneObserver("fxa-migration:state-changed").then(({ data: 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
+    Assert.ok(Service.scheduler.isBlocked, "sync is blocked.")
+
+    wasWaiting = true;
+    throw ex;
+  };
+
+  _("Starting sync");
+  Service.sync();
+  _("Finished sync");
+
+  // mock sync so we can ensure the final sync is scheduled with the FxA user.
+  // (letting a "normal" sync complete is a PITA without mocking huge amounts
+  // of FxA infra)
+  let promiseFinalSync = new Promise((resolve, reject) => {
+    let oldSync = Service.sync;
+    Service.sync = function() {
+      Service.sync = oldSync;
+      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"
+  Assert.ok(Service.scheduler.isBlocked, "sync is blocked.");
+
+  // We should see the migration sentinel written and it should return true.
+  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;
+  Assert.ok(WeaveService.fxAccountsEnabled, "FxA is enabled");
+  Assert.ok(Service.identity instanceof BrowserIDManager,
+            "sync is configured with the browserid_identity provider.");
+  Assert.equal(Service.identity.username, config.username, "correct user configured")
+  Assert.ok(!Service.scheduler.isBlocked, "sync is not blocked.")
+  // and the user state should remain null.
+  Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()),
+                   null,
+                   "still no user action necessary");
+  // and our engines should be in the same enabled/disabled state as before.
+  Assert.ok(!Service.engineManager.get("addons").enabled, "addons is still disabled");
+  Assert.ok(Service.engineManager.get("passwords").enabled, "passwords is still enabled");
+
+  // aaaand, we are done - clean up.
+  yield promiseStopServer(server);
+});
+
+// Test our tokenServer URL is set correctly given we've changed the prefname
+// it uses.
+add_task(function* testTokenServerOldPrefName() {
+  let value = "http://custom-token-server/";
+  // Set the pref we used in the past...
+  Services.prefs.setCharPref("services.sync.tokenServerURI", value);
+  // And make sure the new pref the value will be written to has a different
+  // value.
+  Assert.notEqual(Services.prefs.getCharPref("identity.sync.tokenserver.uri"), value);
+
+  let prefs = fxaMigrator._getSentinelPrefs();
+  Assert.equal(prefs["services.sync.tokenServerURI"], value);
+  // check it applies correctly.
+  Services.prefs.clearUserPref("services.sync.tokenServerURI");
+  Assert.ok(!Services.prefs.prefHasUserValue("services.sync.tokenServerURI"));
+  fxaMigrator._applySentinelPrefs(prefs);
+  // We should have written the pref value to the *new* pref name.
+  Assert.equal(Services.prefs.getCharPref("identity.sync.tokenserver.uri"), value);
+  // And the old pref name should remain untouched.
+  Assert.ok(!Services.prefs.prefHasUserValue("services.sync.tokenServerURI"));
+});
+
+add_task(function* testTokenServerNewPrefName() {
+  let value = "http://token-server/";
+  // Set the new pref name we now use.
+  Services.prefs.setCharPref("identity.sync.tokenserver.uri", value);
+
+  let prefs = fxaMigrator._getSentinelPrefs();
+  // It should be written to the sentinel with the *old* pref name.
+  Assert.equal(prefs["services.sync.tokenServerURI"], value);
+  // check it applies correctly.
+  Services.prefs.clearUserPref("services.sync.tokenServerURI");
+  Assert.ok(!Services.prefs.prefHasUserValue("services.sync.tokenServerURI"));
+  fxaMigrator._applySentinelPrefs(prefs);
+  // We should have written the pref value to the new pref name.
+  Assert.equal(Services.prefs.getCharPref("identity.sync.tokenserver.uri"), value);
+  // And the old pref name should remain untouched.
+  Assert.ok(!Services.prefs.prefHasUserValue("services.sync.tokenServerURI"));
 });
 
 function run_test() {
   initTestLogging();
   do_register_cleanup(() => {
     fxaMigrator.finalize();
     Svc.Prefs.resetBranch("");
   });
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);
+  });
+}
+
+var 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();
+}
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_healthreport_migration.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Metrics.jsm", this);
+Cu.import("resource://gre/modules/Preferences.jsm", this);
+Cu.import("resource://gre/modules/Promise.jsm", this);
+Cu.import("resource://services-sync/healthreport.jsm", this);
+Cu.import("resource://services-sync/FxaMigrator.jsm", this);
+Cu.import("resource://testing-common/services/common/logging.js", this);
+Cu.import("resource://testing-common/services/healthreport/utils.jsm", this);
+
+
+function run_test() {
+  initTestLogging();
+
+  run_next_test();
+}
+
+add_task(function* test_no_data() {
+  let storage = yield Metrics.Storage("collect");
+  let provider = new SyncProvider();
+  yield provider.init(storage);
+
+  try {
+    // Initially nothing should be configured.
+    let now = new Date();
+    yield provider.collectDailyData();
+
+    let m = provider.getMeasurement("migration", 1);
+    let values = yield m.getValues();
+    Assert.equal(values.days.size, 0);
+    Assert.ok(!values.days.hasDay(now));
+  } finally {
+    yield provider.shutdown();
+    yield storage.close();
+  }
+});
+
+function checkCorrectStateRecorded(provider, state) {
+  // Wait for storage to complete.
+  yield m.storage.enqueueOperation(() => {
+    return Promise.resolve();
+  });
+
+  let m = provider.getMeasurement("migration", 1);
+  let values = yield m.getValues();
+  Assert.equal(values.days.size, 1);
+  Assert.ok(values.days.hasDay(now));
+  let day = values.days.getDay(now);
+
+  Assert.ok(day.has("state"));
+  Assert.equal(day.get("state"), state);
+}
+
+add_task(function* test_state() {
+  let storage = yield Metrics.Storage("collect");
+  let provider = new SyncProvider();
+  yield provider.init(storage);
+
+  try {
+    // Initially nothing should be configured.
+    let now = new Date();
+
+    // We record both a "user" and "internal" state in the same field.
+    // So simulate a "user" state first.
+    Services.obs.notifyObservers(null, "fxa-migration:state-changed",
+                                 fxaMigrator.STATE_USER_FXA_VERIFIED);
+    checkCorrectStateRecorded(provider, fxaMigrator.STATE_USER_FXA_VERIFIED);
+
+    // And an internal state.
+    Services.obs.notifyObservers(null, "fxa-migration:internal-state-changed",
+                                 fxaMigrator.STATE_INTERNAL_WAITING_SYNC_COMPLETE);
+    checkCorrectStateRecorded(provider, fxaMigrator.STATE_INTERNAL_WAITING_SYNC_COMPLETE);
+  } finally {
+    yield provider.shutdown();
+    yield storage.close();
+  }
+});
+
+add_task(function* test_flags() {
+  let storage = yield Metrics.Storage("collect");
+  let provider = new SyncProvider();
+  yield provider.init(storage);
+
+  try {
+    // Initially nothing should be configured.
+    let now = new Date();
+
+    let m = provider.getMeasurement("migration", 1);
+
+    let record = function*(what) {
+      Services.obs.notifyObservers(null, "fxa-migration:internal-telemetry", what);
+      // Wait for storage to complete.
+      yield m.storage.enqueueOperation(Promise.resolve);
+      let values = yield m.getValues();
+      Assert.equal(values.days.size, 1);
+      return values.days.getDay(now);
+    }
+
+    let values = yield m.getValues();
+    Assert.equal(values.days.size, 1);
+    let day = values.days.getDay(now);
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_UNLINKED));
+
+    // let's send an unknown value to ensure our error mitigation works.
+    day = yield record("unknown");
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_UNLINKED));
+
+    // record an fxaMigrator.TELEMETRY_ACCEPTED state.
+    day = yield record(fxaMigrator.TELEMETRY_ACCEPTED);
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_UNLINKED));
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_ACCEPTED), 1);
+
+    // and again - it should get 2.
+    day = yield record(fxaMigrator.TELEMETRY_ACCEPTED);
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_ACCEPTED), 2);
+
+    // record fxaMigrator.TELEMETRY_DECLINED - also a counter.
+    day = yield record(fxaMigrator.TELEMETRY_DECLINED);
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_UNLINKED));
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_ACCEPTED), 2);
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_DECLINED), 1);
+
+    day = yield record(fxaMigrator.TELEMETRY_DECLINED);
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(!day.has(fxaMigrator.TELEMETRY_UNLINKED));
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_ACCEPTED), 2);
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_DECLINED), 2);
+
+    // and fxaMigrator.TELEMETRY_UNLINKED - this is conceptually a "daily bool".
+    // (ie, it's DAILY_LAST_NUMERIC_FIELD and only ever has |1| written to it)
+    day = yield record(fxaMigrator.TELEMETRY_UNLINKED);
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_ACCEPTED));
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_DECLINED));
+    Assert.ok(day.has(fxaMigrator.TELEMETRY_UNLINKED));
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_UNLINKED), 1);
+    // and doing it again still leaves us with |1|
+    day = yield record(fxaMigrator.TELEMETRY_UNLINKED);
+    Assert.equal(day.get(fxaMigrator.TELEMETRY_UNLINKED), 1);
+  } finally {
+    yield provider.shutdown();
+    yield storage.close();
+  }
+});
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -172,12 +172,17 @@ skip-if = debug
 [test_prefs_tracker.js]
 [test_tab_engine.js]
 [test_tab_store.js]
 [test_tab_tracker.js]
 
 [test_healthreport.js]
 skip-if = ! healthreport
 
+[test_healthreport_migration.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]