Bug 1618311 - Contextually suggest importing passwords as an autocomplete entry r=MattN,fluent-reviewers,flod
authorEd Lee <edilee@mozilla.com>
Tue, 28 Apr 2020 21:59:34 +0000
changeset 526580 5690715ef70efb9e6c835e0e0ae63371af1475f7
parent 526579 e98c8caadb972eacf87934257fdb909f7bcf4974
child 526581 bc7658646927718a3c7b7174cb0e6df7130afec3
push id37358
push useropoprus@mozilla.com
push dateWed, 29 Apr 2020 03:05:14 +0000
treeherdermozilla-central@6bb8423186c1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, fluent-reviewers, flod
bugs1618311
milestone77.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1618311 - Contextually suggest importing passwords as an autocomplete entry r=MattN,fluent-reviewers,flod Add importableLogins autocomplete items that show for a site when there's chromium-based logins, no saved logins, and appropriate experiment state. Default behavior is unchanged with default "" pref value, and new behavior can be turned on with "import" pref value. Differential Revision: https://phabricator.services.mozilla.com/D72096
browser/app/profile/firefox.js
browser/base/content/browser.css
browser/components/migration/ChromeMigrationUtils.jsm
browser/components/migration/content/migration.js
browser/themes/shared/autocomplete.inc.css
toolkit/actors/AutoCompleteParent.jsm
toolkit/components/passwordmgr/LoginAutoComplete.jsm
toolkit/components/passwordmgr/LoginHelper.jsm
toolkit/components/passwordmgr/LoginManagerChild.jsm
toolkit/components/passwordmgr/LoginManagerParent.jsm
toolkit/content/widgets/autocomplete-popup.js
toolkit/content/widgets/autocomplete-richlistitem.js
toolkit/locales/en-US/toolkit/main-window/autocomplete.ftl
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1735,16 +1735,17 @@ pref("signon.management.page.sort", "nam
 pref("signon.management.page.mobileAndroidURL", "https://app.adjust.com/6tteyjo?redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dmozilla.lockbox&utm_campaign=Desktop&utm_adgroup=InProduct&utm_creative=");
 pref("signon.management.page.mobileAppleURL", "https://app.adjust.com/6tteyjo?redirect=https%3A%2F%2Fitunes.apple.com%2Fapp%2Fid1314000270%3Fmt%3D8&utm_campaign=Desktop&utm_adgroup=InProduct&utm_creative=");
 pref("signon.management.page.breachAlertUrl",
      "https://monitor.firefox.com/breach-details/");
 pref("signon.management.page.hideMobileFooter", false);
 pref("signon.management.page.showPasswordSyncNotification", true);
 pref("signon.passwordEditCapture.enabled", true);
 pref("signon.showAutoCompleteFooter", true);
+pref("signon.showAutoCompleteImport", "");
 
 // Enable the "Simplify Page" feature in Print Preview. This feature
 // is disabled by default in toolkit.
 pref("print.use_simplify_page", true);
 
 // Space separated list of URLS that are allowed to send objects (instead of
 // only strings) through webchannels. This list is duplicated in mobile/android/app/mobile.js
 pref("webchannel.allowObject.urlWhitelist", "https://content.cdn.mozilla.net https://support.mozilla.org https://install.mozilla.org");
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -670,16 +670,20 @@ toolbar:not(#TabsToolbar) > #personal-bo
 #PopupAutoComplete[resultstyles~="insecureWarning"] {
   min-width: 17em;
 }
 
 #PopupAutoComplete[resultstyles~="generatedPassword"] {
   min-width: 22em;
 }
 
+#PopupAutoComplete[resultstyles~="importableLogins"] {
+  min-width: 24em;
+}
+
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] {
   height: auto;
 }
 
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="loginWithOrigin"] > .ac-site-icon,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] > .ac-site-icon {
   margin-inline-start: 0;
   display: initial;
--- a/browser/components/migration/ChromeMigrationUtils.jsm
+++ b/browser/components/migration/ChromeMigrationUtils.jsm
@@ -1,20 +1,25 @@
 /* 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";
 
 var EXPORTED_SYMBOLS = ["ChromeMigrationUtils"];
 
-const { AppConstants } = ChromeUtils.import(
-  "resource://gre/modules/AppConstants.jsm"
+const { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
 );
-const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
-const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AppConstants: "resource://gre/modules/AppConstants.jsm",
+  LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+  MigrationUtils: "resource:///modules/MigrationUtils.jsm",
+  OS: "resource://gre/modules/osfile.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+});
 
 const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
 const S100NS_PER_MS = 10;
 
 var ChromeMigrationUtils = {
   _extensionVersionDirectoryNames: {},
 
   // The cache for the locale strings.
@@ -350,9 +355,74 @@ var ChromeMigrationUtils = {
    * @param   aDate
    *          Date object or integer equivalent
    * @return  Chrome time
    * @note    For details on Chrome time, see chromeTimeToDate.
    */
   dateToChromeTime(aDate) {
     return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS;
   },
+
+  /**
+   * Returns an array of chromium browser ids that have importable logins.
+   */
+  _importableLoginsCache: null,
+  async getImportableLogins(formOrigin) {
+    // Lazily fill the cache with all importable login browsers.
+    if (!this._importableLoginsCache) {
+      this._importableLoginsCache = new Map();
+
+      // Just handle these chromium-based browsers for now.
+      for (const browserId of ["chrome", "chromium-edge", "chromium"]) {
+        // Skip if there's no profile data.
+        const migrator = await MigrationUtils.getMigrator(browserId);
+        if (!migrator) {
+          continue;
+        }
+
+        // Check each profile for logins.
+        const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists();
+        for (const profile of await migrator.getSourceProfiles()) {
+          const path = OS.Path.join(dataPath, profile.id, "Login Data");
+          // Skip if login data is missing.
+          if (!(await OS.File.exists(path))) {
+            Cu.reportError(`Missing file at ${path}`);
+            continue;
+          }
+
+          try {
+            for (const row of await MigrationUtils.getRowsFromDBWithoutLocks(
+              path,
+              `Importable ${browserId} logins`,
+              `SELECT origin_url
+               FROM logins
+               WHERE blacklisted_by_user = 0`
+            )) {
+              const url = row.getString(0);
+              try {
+                // Initialize an array if it doesn't exist for the origin yet.
+                const origin = LoginHelper.getLoginOrigin(url);
+                const entries = this._importableLoginsCache.get(origin) || [];
+                if (!entries.length) {
+                  this._importableLoginsCache.set(origin, entries);
+                }
+
+                // Add the browser if it doesn't exist yet.
+                if (!entries.includes(browserId)) {
+                  entries.push(browserId);
+                }
+              } catch (ex) {
+                Cu.reportError(
+                  `Failed to process importable url ${url} from ${browserId} ${ex}`
+                );
+              }
+            }
+          } catch (ex) {
+            Cu.reportError(
+              `Failed to get importable logins from ${browserId} ${ex}`
+            );
+          }
+        }
+      }
+    }
+    return this._importableLoginsCache.get(formOrigin);
+  },
 };
--- a/browser/components/migration/content/migration.js
+++ b/browser/components/migration/content/migration.js
@@ -34,17 +34,19 @@ var MigrationWizard = {
     let args = window.arguments;
     let entryPointId = args[0] || MigrationUtils.MIGRATION_ENTRYPOINT_UNKNOWN;
     Services.telemetry
       .getHistogramById("FX_MIGRATION_ENTRY_POINT")
       .add(entryPointId);
     this.isInitialMigration =
       entryPointId == MigrationUtils.MIGRATION_ENTRYPOINT_FIRSTRUN;
 
-    if (args.length > 1) {
+    if (args.length == 2) {
+      this._source = args[1];
+    } else if (args.length > 2) {
       this._source = args[1];
       this._migrator = args[2] instanceof kIMig ? args[2] : null;
       this._autoMigrate = args[3].QueryInterface(kIPStartup);
       this._skipImportSourcePage = args[4];
       if (this._migrator && args[5]) {
         let sourceProfiles = this.spinResolve(
           this._migrator.getSourceProfiles()
         );
--- a/browser/themes/shared/autocomplete.inc.css
+++ b/browser/themes/shared/autocomplete.inc.css
@@ -70,30 +70,48 @@
   padding-top: 2px !important;
   opacity: .6;
 }
 
 /* Login form autocompletion (with and without origin showing) and generated passwords */
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="generatedPassword"] > .two-line-wrapper > .ac-site-icon,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="loginWithOrigin"] > .two-line-wrapper > .ac-site-icon,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="login"] > .ac-site-icon {
-  display: initial;
+  fill: GrayText;
   list-style-image: url(chrome://browser/skin/login.svg);
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"] > .two-line-wrapper > .ac-site-icon {
+  fill: GrayText;
+  list-style-image: url(chrome://browser/skin/import.svg);
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"] > .two-line-wrapper > .ac-info-icon {
+  background: url(chrome://global/skin/icons/identity-icon.svg) center no-repeat;
   -moz-context-properties: fill;
+  cursor: pointer;
   fill: GrayText;
+  width: 20px;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"] > .two-line-wrapper > .ac-info-icon:hover {
+  fill: -moz-DialogText;
 }
 
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="generatedPassword"][selected] > .two-line-wrapper > .ac-site-icon,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"][selected] > .two-line-wrapper > .ac-site-icon,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"][selected] > .two-line-wrapper > .ac-info-icon,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="loginWithOrigin"][selected] > .two-line-wrapper > .ac-site-icon,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="login"] > .ac-site-icon[selected] {
   fill: HighlightText;
 }
 
 /* Login form autocompletion with origin showing and generated passwords */
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="generatedPassword"] > .two-line-wrapper,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="importableLogins"] > .two-line-wrapper,
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="loginWithOrigin"] > .two-line-wrapper {
   padding: 4px;
 }
 
 #PopupAutoComplete > richlistbox > richlistitem[originaltype="generatedPassword"]:not([collapsed="true"]) {
   /* Workaround bug 451997 and/or bug 492645 */
   display: block;
 }
--- a/toolkit/actors/AutoCompleteParent.jsm
+++ b/toolkit/actors/AutoCompleteParent.jsm
@@ -168,21 +168,26 @@ class AutoCompleteParent extends JSWindo
     switch (evt.type) {
       case "popupshowing": {
         this.sendAsyncMessage("FormAutoComplete:PopupOpened", {});
         break;
       }
 
       case "popuphidden": {
         let selectedIndex = this.openedPopup.selectedIndex;
+        let selectedRowComment =
+          selectedIndex != -1
+            ? AutoCompleteResultView.getCommentAt(selectedIndex)
+            : "";
         let selectedRowStyle =
           selectedIndex != -1
             ? AutoCompleteResultView.getStyleAt(selectedIndex)
             : "";
         this.sendAsyncMessage("FormAutoComplete:PopupClosed", {
+          selectedRowComment,
           selectedRowStyle,
         });
         AutoCompleteResultView.clearResults();
         // adjustHeight clears the height from the popup so that
         // we don't have a big shrink effect if we closed with a
         // large list, and then open on a small one.
         this.openedPopup.adjustHeight();
         this.openedPopup = null;
--- a/toolkit/components/passwordmgr/LoginAutoComplete.jsm
+++ b/toolkit/components/passwordmgr/LoginAutoComplete.jsm
@@ -222,16 +222,24 @@ class GeneratedPasswordAutocompleteItem 
     this.value = generatedPassword;
 
     XPCOMUtils.defineLazyGetter(this, "label", () => {
       return getLocalizedString("useASecurelyGeneratedPassword");
     });
   }
 }
 
+class ImportableLoginsAutocompleteItem extends AutocompleteItem {
+  constructor(browserId, hostname) {
+    super("importableLogins");
+    this.label = browserId;
+    this.comment = hostname;
+  }
+}
+
 class LoginsFooterAutocompleteItem extends AutocompleteItem {
   constructor(formHostname, telemetryEventData) {
     super("loginsFooter");
     XPCOMUtils.defineLazyGetter(this, "comment", () => {
       // The comment field of `loginsFooter` results have many additional pieces of
       // information for telemetry purposes. After bug 1555209, this information
       // can be passed to the parent process outside of nsIAutoCompleteResult APIs
       // so we won't need this hack.
@@ -250,39 +258,43 @@ class LoginsFooterAutocompleteItem exten
 // nsIAutoCompleteResult implementation
 function LoginAutoCompleteResult(
   aSearchString,
   matchingLogins,
   formOrigin,
   {
     generatedPassword,
     willAutoSaveGeneratedPassword,
+    importable,
     isSecure,
     actor,
     hasBeenTypePassword,
     hostname,
     telemetryEventData,
   }
 ) {
   let hidingFooterOnPWFieldAutoOpened = false;
+  const importableBrowsers =
+    importable?.state === "import" && importable?.browsers;
   function isFooterEnabled() {
     // We need to check LoginHelper.enabled here since the insecure warning should
     // appear even if pwmgr is disabled but the footer should never appear in that case.
     if (!LoginHelper.showAutoCompleteFooter || !LoginHelper.enabled) {
       return false;
     }
 
     // Don't show the footer on non-empty password fields as it's not providing
     // value and only adding noise since a password was already filled.
     if (hasBeenTypePassword && aSearchString && !generatedPassword) {
       log.debug("Hiding footer: non-empty password field");
       return false;
     }
 
     if (
+      !importableBrowsers &&
       !matchingLogins.length &&
       !generatedPassword &&
       hasBeenTypePassword &&
       formFillController.passwordPopupAutomaticallyOpened
     ) {
       hidingFooterOnPWFieldAutoOpened = true;
       log.debug(
         "Hiding footer: no logins and the popup was opened upon focus of the pw. field"
@@ -326,16 +338,26 @@ function LoginAutoCompleteResult(
     if (generatedPassword) {
       this._rows.push(
         new GeneratedPasswordAutocompleteItem(
           generatedPassword,
           willAutoSaveGeneratedPassword
         )
       );
     }
+
+    // Suggest importing logins if there are none found.
+    if (!logins.length && importableBrowsers) {
+      this._rows.push(
+        ...importableBrowsers.map(
+          browserId => new ImportableLoginsAutocompleteItem(browserId, hostname)
+        )
+      );
+    }
+
     this._rows.push(
       new LoginsFooterAutocompleteItem(hostname, telemetryEventData)
     );
   }
 
   // Determine the result code and default index.
   if (this.matchCount > 0) {
     this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
@@ -490,16 +512,17 @@ LoginAutoComplete.prototype = {
     let loginManagerActor = LoginManagerChild.forWindow(aElement.ownerGlobal);
 
     let completeSearch = async autoCompleteLookupPromise => {
       // Assign to the member synchronously before awaiting the Promise.
       this._autoCompleteLookupPromise = autoCompleteLookupPromise;
 
       let {
         generatedPassword,
+        importable,
         logins,
         willAutoSaveGeneratedPassword,
       } = await autoCompleteLookupPromise;
 
       // If the search was canceled before we got our
       // results, don't bother reporting them.
       // N.B. This check must occur after the `await` above for it to be
       // effective.
@@ -520,16 +543,17 @@ LoginAutoComplete.prototype = {
       this._autoCompleteLookupPromise = null;
       let results = new LoginAutoCompleteResult(
         aSearchString,
         logins,
         formOrigin,
         {
           generatedPassword,
           willAutoSaveGeneratedPassword,
+          importable,
           actor: loginManagerActor,
           isSecure,
           hasBeenTypePassword,
           hostname,
           telemetryEventData,
         }
       );
       aCallback.onSearchCompletion(results);
@@ -639,16 +663,17 @@ LoginAutoComplete.prototype = {
 
     let result = await loginManagerActor.sendQuery(
       "PasswordManager:autoCompleteLogins",
       messageData
     );
 
     return {
       generatedPassword: result.generatedPassword,
+      importable: result.importable,
       logins: LoginHelper.vanillaObjectsToLogins(result.logins),
       willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword,
     };
   },
 
   _isProbablyANewPasswordField(inputElement) {
     const threshold = LoginHelper.generationConfidenceThreshold;
     if (threshold == -1) {
@@ -686,17 +711,17 @@ let gAutoCompleteListener = {
     switch (messageName) {
       case "FormAutoComplete:PopupOpened": {
         let { chromeEventHandler } = target.docShell;
         chromeEventHandler.addEventListener("keydown", this, true);
         break;
       }
 
       case "FormAutoComplete:PopupClosed": {
-        this.onPopupClosed(data.selectedRowStyle, target);
+        this.onPopupClosed(data, target);
         let { chromeEventHandler } = target.docShell;
         chromeEventHandler.removeEventListener("keydown", this, true);
         break;
       }
     }
   },
 
   handleEvent(event) {
@@ -710,28 +735,34 @@ let gAutoCompleteListener = {
       focusedElement != event.target
     ) {
       this.keyDownEnterForInput = null;
       return;
     }
     this.keyDownEnterForInput = focusedElement;
   },
 
-  onPopupClosed(selectedRowStyle, window) {
+  onPopupClosed({ selectedRowComment, selectedRowStyle }, window) {
     let focusedElement = formFillController.focusedInput;
     let eventTarget = this.keyDownEnterForInput;
-    if (
-      !eventTarget ||
-      eventTarget !== focusedElement ||
-      selectedRowStyle != "loginsFooter"
-    ) {
-      this.keyDownEnterForInput = null;
+    this.keyDownEnterForInput = null;
+    if (!eventTarget || eventTarget !== focusedElement) {
       return;
     }
 
     let loginManager = window.windowGlobalChild.getActor("LoginManager");
-    let hostname = eventTarget.ownerDocument.documentURIObject.host;
-    loginManager.sendAsyncMessage("PasswordManager:OpenPreferences", {
-      hostname,
-      entryPoint: "autocomplete",
-    });
+    switch (selectedRowStyle) {
+      case "importableLogins":
+        loginManager.sendAsyncMessage(
+          "PasswordManager:OpenMigrationWizard",
+          selectedRowComment
+        );
+        break;
+      case "loginsFooter":
+        let hostname = eventTarget.ownerDocument.documentURIObject.host;
+        loginManager.sendAsyncMessage("PasswordManager:OpenPreferences", {
+          hostname,
+          entryPoint: "autocomplete",
+        });
+        break;
+    }
   },
 };
--- a/toolkit/components/passwordmgr/LoginHelper.jsm
+++ b/toolkit/components/passwordmgr/LoginHelper.jsm
@@ -32,16 +32,17 @@ this.LoginHelper = {
   generationAvailable: null,
   generationConfidenceThreshold: null,
   generationEnabled: null,
   includeOtherSubdomainsInLookup: null,
   insecureAutofill: null,
   privateBrowsingCaptureEnabled: null,
   schemeUpgrades: null,
   showAutoCompleteFooter: null,
+  showAutoCompleteImport: null,
   testOnlyUserHasInteractedWithDocument: null,
   userInputRequiredToCapture: null,
 
   init() {
     // Watch for pref changes to update cached pref values.
     Services.prefs.addObserver("signon.", () => this.updateSignonPrefs());
     this.updateSignonPrefs();
     Services.telemetry.setEventRecordingEnabled("pwmgr", true);
@@ -82,16 +83,20 @@ this.LoginHelper = {
     );
     this.privateBrowsingCaptureEnabled = Services.prefs.getBoolPref(
       "signon.privateBrowsingCapture.enabled"
     );
     this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
     this.showAutoCompleteFooter = Services.prefs.getBoolPref(
       "signon.showAutoCompleteFooter"
     );
+    this.showAutoCompleteImport = Services.prefs.getStringPref(
+      "signon.showAutoCompleteImport",
+      ""
+    );
     this.storeWhenAutocompleteOff = Services.prefs.getBoolPref(
       "signon.storeWhenAutocompleteOff"
     );
 
     if (
       Services.prefs.getBoolPref(
         "signon.testOnlyUserHasInteractedByPrefValue",
         false
--- a/toolkit/components/passwordmgr/LoginManagerChild.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerChild.jsm
@@ -617,16 +617,17 @@ this.LoginManagerChild = class LoginMana
 
     let resultPromise = this.sendQuery(
       "PasswordManager:findLogins",
       messageData
     );
     return resultPromise.then(result => {
       return {
         form,
+        importable: result.importable,
         loginsFound: LoginHelper.vanillaObjectsToLogins(result.logins),
         recipes: result.recipes,
       };
     });
   }
 
   setupProgressListener(window) {
     if (!LoginHelper.formlessCaptureEnabled) {
@@ -972,26 +973,26 @@ this.LoginManagerChild = class LoginMana
       inputElement,
       autofillForm: true,
       clobberUsername,
       clobberPassword: true,
       userTriggered: true,
     });
   }
 
-  loginsFound({ form, loginsFound, recipes }) {
+  loginsFound({ form, importable, loginsFound, recipes }) {
     let doc = form.ownerDocument;
     let autofillForm =
       LoginHelper.autofillForms &&
       !PrivateBrowsingUtils.isContentWindowPrivate(doc.defaultView);
 
     let formOrigin = LoginHelper.getLoginOrigin(doc.documentURI);
     LoginRecipesContent.cacheRecipes(formOrigin, doc.defaultView, recipes);
 
-    this._fillForm(form, loginsFound, recipes, { autofillForm });
+    this._fillForm(form, loginsFound, recipes, { autofillForm, importable });
   }
 
   /**
    * Focus event handler for username fields to decide whether to show autocomplete.
    * @param {FocusEvent} event
    */
   _onUsernameFocus(event) {
     let focusedField = event.target;
@@ -1947,16 +1948,17 @@ this.LoginManagerChild = class LoginMana
   // eslint-disable-next-line complexity
   _fillForm(
     form,
     foundLogins,
     recipes,
     {
       inputElement = null,
       autofillForm = false,
+      importable = null,
       clobberUsername = false,
       clobberPassword = false,
       userTriggered = false,
     } = {}
   ) {
     if (ChromeUtils.getClassName(form) === "HTMLFormElement") {
       throw new Error("_fillForm should only be called with LoginForm objects");
     }
@@ -1991,16 +1993,17 @@ this.LoginManagerChild = class LoginMana
     );
 
     try {
       // Nothing to do if we have no matching (excluding form action
       // checks) logins available, and there isn't a need to show
       // the insecure form warning.
       if (
         !foundLogins.length &&
+        !importable?.browsers &&
         (InsecurePasswordUtils.isFormSecure(form) ||
           !LoginHelper.showInsecureFieldWarning)
       ) {
         // We don't log() here since this is a very common case.
         autofillResult = AUTOFILL_RESULT.NO_SAVED_LOGINS;
         return;
       }
 
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -12,31 +12,23 @@ const { Services } = ChromeUtils.import(
 const LoginInfo = new Components.Constructor(
   "@mozilla.org/login-manager/loginInfo;1",
   Ci.nsILoginInfo,
   "init"
 );
 
 XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
 
-ChromeUtils.defineModuleGetter(
-  this,
-  "LoginHelper",
-  "resource://gre/modules/LoginHelper.jsm"
-);
-ChromeUtils.defineModuleGetter(
-  this,
-  "PasswordGenerator",
-  "resource://gre/modules/PasswordGenerator.jsm"
-);
-ChromeUtils.defineModuleGetter(
-  this,
-  "PrivateBrowsingUtils",
-  "resource://gre/modules/PrivateBrowsingUtils.jsm"
-);
+XPCOMUtils.defineLazyModuleGetters(this, {
+  ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.jsm",
+  LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+  MigrationUtils: "resource:///modules/MigrationUtils.jsm",
+  PasswordGenerator: "resource://gre/modules/PasswordGenerator.jsm",
+  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
+});
 
 XPCOMUtils.defineLazyServiceGetter(
   this,
   "prompterSvc",
   "@mozilla.org/login-manager/prompter;1",
   Ci.nsILoginManagerPrompter
 );
 
@@ -122,16 +114,34 @@ let gGeneratedPasswordObserver = {
   },
 };
 
 Services.ppmm.addMessageListener("PasswordManager:findRecipes", message => {
   let formHost = new URL(message.data.formOrigin).host;
   return gRecipeManager.getRecipesForHost(formHost);
 });
 
+/**
+ * Lazily create a Map of origins to array of browsers with importable logins.
+ *
+ * @param {origin} formOrigin
+ * @returns {Object?} containing array of migration browsers and experiment state.
+ */
+async function getImportableLogins(formOrigin) {
+  // Include the experiment state for data and UI decisions; otherwise skip
+  // importing if not supported or disabled.
+  const state = LoginHelper.showAutoCompleteImport;
+  return state
+    ? {
+        browsers: await ChromeMigrationUtils.getImportableLogins(formOrigin),
+        state,
+      }
+    : null;
+}
+
 class LoginManagerParent extends JSWindowActorParent {
   // This is used by tests to listen to form submission.
   static setListenerForTests(listener) {
     gListenerForTests = listener;
   }
 
   // Used by tests to clean up recipes only when they were actually used.
   static get _recipeManager() {
@@ -253,16 +263,26 @@ class LoginManagerParent extends JSWindo
       }
 
       case "PasswordManager:removeLogin": {
         let login = LoginHelper.vanillaObjectToLogin(data.login);
         Services.logins.removeLogin(login);
         break;
       }
 
+      case "PasswordManager:OpenMigrationWizard": {
+        // Open the migration wizard pre-selecting the appropriate browser.
+        let window = this.getRootBrowser().ownerGlobal;
+        MigrationUtils.showMigrationWizard(window, [
+          MigrationUtils.MIGRATION_ENTRYPOINT_PASSWORDS,
+          msg.data,
+        ]);
+        break;
+      }
+
       case "PasswordManager:OpenPreferences": {
         let window = this.getRootBrowser().ownerGlobal;
         LoginHelper.openPasswordManager(window, {
           filterString: msg.data.hostname,
           entryPoint: msg.data.entryPoint,
         });
         break;
       }
@@ -403,17 +423,21 @@ class LoginManagerParent extends JSWindo
         acceptDifferentSubdomains: LoginHelper.includeOtherSubdomainsInLookup,
       });
     }
 
     log("sendLoginDataToChild:", logins.length, "deduped logins");
     // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
     // doesn't support structured cloning.
     let jsLogins = LoginHelper.loginsToVanillaObjects(logins);
-    return { logins: jsLogins, recipes };
+    return {
+      importable: await getImportableLogins(formOrigin),
+      logins: jsLogins,
+      recipes,
+    };
   }
 
   async doAutocompleteSearch({
     formOrigin,
     actionOrigin,
     searchString,
     previousResult,
     forcePasswordGeneration,
@@ -503,16 +527,17 @@ class LoginManagerParent extends JSWindo
       );
     }
 
     // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
     // doesn't support structured cloning.
     let jsLogins = LoginHelper.loginsToVanillaObjects(matchingLogins);
     return {
       generatedPassword,
+      importable: await getImportableLogins(formOrigin),
       logins: jsLogins,
       willAutoSaveGeneratedPassword,
     };
   }
 
   /**
    * Expose `BrowsingContext` so we can stub it in tests.
    */
--- a/toolkit/content/widgets/autocomplete-popup.js
+++ b/toolkit/content/widgets/autocomplete-popup.js
@@ -389,16 +389,17 @@
           // The styles on the list which have different <content> structure and overrided
           // _adjustAcItem() are unreusable.
           const UNREUSEABLE_STYLES = [
             "autofill-profile",
             "autofill-footer",
             "autofill-clear-button",
             "autofill-insecureWarning",
             "generatedPassword",
+            "importableLogins",
             "insecureWarning",
             "loginsFooter",
             "loginWithOrigin",
           ];
           // Reuse the item when its style is exactly equal to the previous style or
           // neither of their style are in the UNREUSEABLE_STYLES.
           reusable =
             originalType === style ||
@@ -419,16 +420,19 @@
               options = { is: "autocomplete-profile-listitem-footer" };
               break;
             case "autofill-clear-button":
               options = { is: "autocomplete-profile-listitem-clear-button" };
               break;
             case "autofill-insecureWarning":
               options = { is: "autocomplete-creditcard-insecure-field" };
               break;
+            case "importableLogins":
+              options = { is: "autocomplete-importable-logins-richlistitem" };
+              break;
             case "generatedPassword":
               options = { is: "autocomplete-generated-password-richlistitem" };
               break;
             case "insecureWarning":
               options = { is: "autocomplete-richlistitem-insecure-warning" };
               break;
             case "loginsFooter":
               options = { is: "autocomplete-richlistitem-logins-footer" };
--- a/toolkit/content/widgets/autocomplete-richlistitem.js
+++ b/toolkit/content/widgets/autocomplete-richlistitem.js
@@ -730,16 +730,84 @@
       } else {
         this.line3.remove();
       }
 
       super._adjustAcItem();
     }
   }
 
+  class MozAutocompleteImportableLoginsRichlistitem extends MozAutocompleteTwoLineRichlistitem {
+    constructor() {
+      super();
+      MozXULElement.insertFTLIfNeeded("toolkit/main-window/autocomplete.ftl");
+
+      ChromeUtils.defineModuleGetter(
+        this,
+        "MigrationUtils",
+        "resource:///modules/MigrationUtils.jsm"
+      );
+
+      this.addEventListener("click", event => {
+        // Handle clicks on the info icon to show support article.
+        if (event.target.classList.contains("ac-info-icon")) {
+          window.openTrustedLinkIn(
+            Services.urlFormatter.formatURLPref("app.support.baseURL") +
+              "password-import",
+            "tab",
+            {
+              relatedToCurrent: true,
+            }
+          );
+          return;
+        }
+
+        if (event.button != 0) {
+          return;
+        }
+
+        // Open the migration wizard pre-selecting the appropriate browser.
+        this.MigrationUtils.showMigrationWizard(window, [
+          this.MigrationUtils.MIGRATION_ENTRYPOINT_PASSWORDS,
+          this.getAttribute("ac-value"),
+        ]);
+      });
+    }
+
+    static get markup() {
+      return `
+      <div xmlns="http://www.w3.org/1999/xhtml"
+           xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+           class="two-line-wrapper">
+        <xul:image class="ac-site-icon" />
+        <div class="labels-wrapper">
+          <div class="label-row line1-label" data-l10n-name="line1" />
+          <div class="label-row line2-label" data-l10n-name="line2" />
+        </div>
+        <xul:image class="ac-info-icon"
+                   data-l10n-id="autocomplete-import-logins-info" />
+      </div>
+    `;
+    }
+
+    _adjustAcItem() {
+      document.l10n.setAttributes(
+        this.querySelector(".labels-wrapper"),
+        "autocomplete-import-logins",
+        {
+          browser: this.MigrationUtils.getBrowserName(
+            this.getAttribute("ac-value")
+          ),
+          host: this.getAttribute("ac-label").replace(/^www\./, ""),
+        }
+      );
+      super._adjustAcItem();
+    }
+  }
+
   customElements.define(
     "autocomplete-richlistitem",
     MozElements.MozAutocompleteRichlistitem,
     {
       extends: "richlistitem",
     }
   );
 
@@ -777,9 +845,17 @@
 
   customElements.define(
     "autocomplete-generated-password-richlistitem",
     MozAutocompleteGeneratedPasswordRichlistitem,
     {
       extends: "richlistitem",
     }
   );
+
+  customElements.define(
+    "autocomplete-importable-logins-richlistitem",
+    MozAutocompleteImportableLoginsRichlistitem,
+    {
+      extends: "richlistitem",
+    }
+  );
 }
new file mode 100644
--- /dev/null
+++ b/toolkit/locales/en-US/toolkit/main-window/autocomplete.ftl
@@ -0,0 +1,15 @@
+# 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/.
+
+## Import Logins Autocomplete
+
+# Variables:
+#   $browser (String) - Browser name to import logins from.
+#   $host (String) - Host name of the current site.
+autocomplete-import-logins =
+    <div data-l10n-name="line1">Import your login from { $browser }</div>
+    <div data-l10n-name="line2">for { $host } and other sites</div>
+
+autocomplete-import-logins-info =
+    .tooltiptext = Learn more