summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/default
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/formautofill/default
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/default')
-rw-r--r--toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs677
-rw-r--r--toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs274
2 files changed, 951 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs
new file mode 100644
index 0000000000..31975cd968
--- /dev/null
+++ b/toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs
@@ -0,0 +1,677 @@
+/* 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/. */
+
+/*
+ * Implements doorhanger singleton that wraps up the PopupNotifications and handles
+ * the doorhager UI for formautofill related features.
+ */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const { AutofillTelemetry } = ChromeUtils.import(
+ "resource://autofill/AutofillTelemetry.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillPrompter")
+);
+
+const { ENABLED_AUTOFILL_CREDITCARDS_PREF } = FormAutofill;
+
+const GetStringFromName = FormAutofillUtils.stringBundle.GetStringFromName;
+const formatStringFromName =
+ FormAutofillUtils.stringBundle.formatStringFromName;
+const brandShortName =
+ FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+let changeAutofillOptsKey = "changeAutofillOptions";
+let autofillOptsKey = "autofillOptionsLink";
+if (AppConstants.platform == "macosx") {
+ changeAutofillOptsKey += "OSX";
+ autofillOptsKey += "OSX";
+}
+
+const CONTENT = {
+ addFirstTimeUse: {
+ notificationId: "autofill-address",
+ message: formatStringFromName("saveAddressesMessage", [brandShortName]),
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: GetStringFromName("openAutofillMessagePanel"),
+ },
+ mainAction: {
+ label: GetStringFromName(changeAutofillOptsKey),
+ accessKey: GetStringFromName("changeAutofillOptionsAccessKey"),
+ callbackState: "open-pref",
+ },
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-address-save.svg",
+ checkbox: {
+ get checked() {
+ return Services.prefs.getBoolPref("services.sync.engine.addresses");
+ },
+ get label() {
+ // If sync account is not set, return null label to hide checkbox
+ return Services.prefs.prefHasUserValue("services.sync.username")
+ ? GetStringFromName("addressesSyncCheckbox")
+ : null;
+ },
+ callback(event) {
+ let checked = event.target.checked;
+ Services.prefs.setBoolPref("services.sync.engine.addresses", checked);
+ lazy.log.debug("Set addresses sync to", checked);
+ },
+ },
+ hideClose: true,
+ },
+ },
+ addAddress: {
+ notificationId: "autofill-address",
+ message: formatStringFromName("saveAddressesMessage", [brandShortName]),
+ descriptionLabel: GetStringFromName("saveAddressDescriptionLabel"),
+ descriptionIcon: true,
+ linkMessage: GetStringFromName(autofillOptsKey),
+ spotlightURL: "about:preferences#privacy-address-autofill",
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: GetStringFromName("openAutofillMessagePanel"),
+ },
+ mainAction: {
+ label: GetStringFromName("saveAddressLabel"),
+ accessKey: GetStringFromName("saveAddressAccessKey"),
+ callbackState: "create",
+ },
+ secondaryActions: [
+ {
+ label: GetStringFromName("cancelAddressLabel"),
+ accessKey: GetStringFromName("cancelAddressAccessKey"),
+ callbackState: "cancel",
+ },
+ ],
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-address-update.svg",
+ hideClose: true,
+ },
+ },
+ updateAddress: {
+ notificationId: "autofill-address",
+ message: GetStringFromName("updateAddressMessage"),
+ descriptionLabel: GetStringFromName("updateAddressNewDescriptionLabel"),
+ additionalDescriptionLabel: GetStringFromName(
+ "updateAddressOldDescriptionLabel"
+ ),
+ descriptionIcon: false,
+ linkMessage: GetStringFromName(autofillOptsKey),
+ spotlightURL: "about:preferences#privacy-address-autofill",
+ anchor: {
+ id: "autofill-address-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: GetStringFromName("openAutofillMessagePanel"),
+ },
+ mainAction: {
+ label: GetStringFromName("updateAddressLabel"),
+ accessKey: GetStringFromName("updateAddressAccessKey"),
+ callbackState: "update",
+ },
+ secondaryActions: [
+ {
+ label: GetStringFromName("createAddressLabel"),
+ accessKey: GetStringFromName("createAddressAccessKey"),
+ callbackState: "create",
+ },
+ ],
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-address-update.svg",
+ hideClose: true,
+ },
+ },
+ addCreditCard: {
+ notificationId: "autofill-credit-card",
+ message: formatStringFromName("saveCreditCardMessage", [brandShortName]),
+ descriptionLabel: GetStringFromName("saveCreditCardDescriptionLabel"),
+ descriptionIcon: true,
+ linkMessage: GetStringFromName(autofillOptsKey),
+ spotlightURL: "about:preferences#privacy-credit-card-autofill",
+ anchor: {
+ id: "autofill-credit-card-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: GetStringFromName("openAutofillMessagePanel"),
+ },
+ mainAction: {
+ label: GetStringFromName("saveCreditCardLabel"),
+ accessKey: GetStringFromName("saveCreditCardAccessKey"),
+ callbackState: "save",
+ },
+ secondaryActions: [
+ {
+ label: GetStringFromName("cancelCreditCardLabel"),
+ accessKey: GetStringFromName("cancelCreditCardAccessKey"),
+ callbackState: "cancel",
+ },
+ {
+ label: GetStringFromName("neverSaveCreditCardLabel"),
+ accessKey: GetStringFromName("neverSaveCreditCardAccessKey"),
+ callbackState: "disable",
+ },
+ ],
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
+ hideClose: true,
+ checkbox: {
+ get checked() {
+ return Services.prefs.getBoolPref("services.sync.engine.creditcards");
+ },
+ get label() {
+ // Only set the label when the fallowing conditions existed:
+ // - sync account is set
+ // - credit card sync is disabled
+ // - credit card sync is available
+ // otherwise return null label to hide checkbox.
+ return Services.prefs.prefHasUserValue("services.sync.username") &&
+ !Services.prefs.getBoolPref("services.sync.engine.creditcards") &&
+ Services.prefs.getBoolPref(
+ "services.sync.engine.creditcards.available"
+ )
+ ? GetStringFromName("creditCardsSyncCheckbox")
+ : null;
+ },
+ callback(event) {
+ let { secondaryButton, menubutton } =
+ event.target.closest("popupnotification");
+ let checked = event.target.checked;
+ Services.prefs.setBoolPref(
+ "services.sync.engine.creditcards",
+ checked
+ );
+ secondaryButton.disabled = checked;
+ menubutton.disabled = checked;
+ lazy.log.debug("Set creditCard sync to", checked);
+ },
+ },
+ },
+ },
+ updateCreditCard: {
+ notificationId: "autofill-credit-card",
+ message: GetStringFromName("updateCreditCardMessage"),
+ descriptionLabel: GetStringFromName("updateCreditCardDescriptionLabel"),
+ descriptionIcon: true,
+ linkMessage: GetStringFromName(autofillOptsKey),
+ spotlightURL: "about:preferences#privacy-credit-card-autofill",
+ anchor: {
+ id: "autofill-credit-card-notification-icon",
+ URL: "chrome://formautofill/content/formfill-anchor.svg",
+ tooltiptext: GetStringFromName("openAutofillMessagePanel"),
+ },
+ mainAction: {
+ label: GetStringFromName("updateCreditCardLabel"),
+ accessKey: GetStringFromName("updateCreditCardAccessKey"),
+ callbackState: "update",
+ },
+ secondaryActions: [
+ {
+ label: GetStringFromName("createCreditCardLabel"),
+ accessKey: GetStringFromName("createCreditCardAccessKey"),
+ callbackState: "create",
+ },
+ ],
+ options: {
+ persistWhileVisible: true,
+ popupIconURL: "chrome://formautofill/content/icon-credit-card.svg",
+ hideClose: true,
+ },
+ },
+};
+
+export let FormAutofillPrompter = {
+ /**
+ * Generate the main action and secondary actions from content parameters and
+ * promise resolve.
+ *
+ * @private
+ * @param {object} mainActionParams
+ * Parameters for main action.
+ * @param {Array<object>} secondaryActionParams
+ * Array of the parameters for secondary actions.
+ * @param {Function} resolve Should be called in action callback.
+ * @returns {Array<object>}
+ Return the mainAction and secondary actions in an array for showing doorhanger
+ */
+ _createActions(mainActionParams, secondaryActionParams, resolve) {
+ if (!mainActionParams) {
+ return [null, null];
+ }
+
+ let { label, accessKey, callbackState } = mainActionParams;
+ let callback = resolve.bind(null, callbackState);
+ let mainAction = { label, accessKey, callback };
+
+ if (!secondaryActionParams) {
+ return [mainAction, null];
+ }
+
+ let secondaryActions = [];
+ for (let params of secondaryActionParams) {
+ let cb = resolve.bind(null, params.callbackState);
+ secondaryActions.push({
+ label: params.label,
+ accessKey: params.accessKey,
+ callback: cb,
+ });
+ }
+
+ return [mainAction, secondaryActions];
+ },
+ _getNotificationElm(browser, id) {
+ let notificationId = id + "-notification";
+ let chromeDoc = browser.ownerDocument;
+ return chromeDoc.getElementById(notificationId);
+ },
+ /**
+ * Append the link label element to the popupnotificationcontent.
+ *
+ * @param {XULElement} content
+ * popupnotificationcontent
+ * @param {string} message
+ * The localized string for link title.
+ * @param {string} link
+ * Makes it possible to open and highlight a section in preferences
+ */
+ _appendPrivacyPanelLink(content, message, link) {
+ let chromeDoc = content.ownerDocument;
+ let privacyLinkElement = chromeDoc.createXULElement("label", {
+ is: "text-link",
+ });
+ privacyLinkElement.setAttribute("useoriginprincipal", true);
+ privacyLinkElement.setAttribute(
+ "href",
+ link || "about:preferences#privacy-form-autofill"
+ );
+ privacyLinkElement.setAttribute("value", message);
+ content.appendChild(privacyLinkElement);
+ },
+
+ /**
+ * Append the description section to the popupnotificationcontent.
+ *
+ * @param {XULElement} content
+ * popupnotificationcontent
+ * @param {string} descriptionLabel
+ * The label showing above description.
+ * @param {string} descriptionIcon
+ * The src of description icon.
+ * @param {string} descriptionId
+ * The id of description
+ */
+ _appendDescription(
+ content,
+ descriptionLabel,
+ descriptionIcon,
+ descriptionId
+ ) {
+ let chromeDoc = content.ownerDocument;
+ let docFragment = chromeDoc.createDocumentFragment();
+
+ let descriptionLabelElement = chromeDoc.createXULElement("label");
+ descriptionLabelElement.setAttribute("value", descriptionLabel);
+ docFragment.appendChild(descriptionLabelElement);
+
+ let descriptionWrapper = chromeDoc.createXULElement("hbox");
+ descriptionWrapper.className = "desc-message-box";
+
+ if (descriptionIcon) {
+ let descriptionIconElement = chromeDoc.createXULElement("image");
+ if (
+ typeof descriptionIcon == "string" &&
+ (descriptionIcon.includes("cc-logo") ||
+ descriptionIcon.includes("icon-credit"))
+ ) {
+ descriptionIconElement.setAttribute("src", descriptionIcon);
+ }
+ descriptionWrapper.appendChild(descriptionIconElement);
+ }
+
+ let descriptionElement = chromeDoc.createXULElement(descriptionId);
+ descriptionWrapper.appendChild(descriptionElement);
+ docFragment.appendChild(descriptionWrapper);
+
+ content.appendChild(docFragment);
+ },
+
+ _updateDescription(content, descriptionId, description) {
+ let element = content.querySelector(descriptionId);
+ element.textContent = description;
+ },
+
+ /**
+ * Create an image element for notification anchor if it doesn't already exist.
+ *
+ * @param {XULElement} browser
+ * Target browser element for showing doorhanger.
+ * @param {object} anchor
+ * Anchor options for setting the anchor element.
+ * @param {string} anchor.id
+ * ID of the anchor element.
+ * @param {string} anchor.URL
+ * Path of the icon asset.
+ * @param {string} anchor.tooltiptext
+ * Tooltip string for the anchor.
+ */
+ _setAnchor(browser, anchor) {
+ let chromeDoc = browser.ownerDocument;
+ let { id, URL, tooltiptext } = anchor;
+ let anchorEt = chromeDoc.getElementById(id);
+ if (!anchorEt) {
+ let notificationPopupBox = chromeDoc.getElementById(
+ "notification-popup-box"
+ );
+ // Icon shown on URL bar
+ let anchorElement = chromeDoc.createXULElement("image");
+ anchorElement.id = id;
+ anchorElement.setAttribute("src", URL);
+ anchorElement.classList.add("notification-anchor-icon");
+ anchorElement.setAttribute("role", "button");
+ anchorElement.setAttribute("tooltiptext", tooltiptext);
+ notificationPopupBox.appendChild(anchorElement);
+ }
+ },
+ _addCheckboxListener(browser, { notificationId, options }) {
+ if (!options.checkbox) {
+ return;
+ }
+ let { checkbox } = this._getNotificationElm(browser, notificationId);
+
+ if (checkbox && !checkbox.hidden) {
+ checkbox.addEventListener("command", options.checkbox.callback);
+ }
+ },
+
+ _removeCheckboxListener(browser, { notificationId, options }) {
+ if (!options.checkbox) {
+ return;
+ }
+ let { checkbox } = this._getNotificationElm(browser, notificationId);
+
+ if (checkbox && !checkbox.hidden) {
+ checkbox.removeEventListener("command", options.checkbox.callback);
+ }
+ },
+
+ /**
+ * Show save or update address doorhanger
+ *
+ * @param {Element<browser>} browser Browser to show the save/update address prompt
+ * @param {object} storage Address storage
+ * @param {object} newRecord Address record to save
+ * @param {string} flowId Unique GUID to record a series of the same user action
+ * @param {object} options
+ * @param {object} [options.mergeableRecord] Record to be merged
+ * @param {Array} [options.mergeableFields] List of field name that can be merged
+ */
+ async promptToSaveAddress(
+ browser,
+ storage,
+ newRecord,
+ flowId,
+ { mergeableRecord, mergeableFields }
+ ) {
+ // Overwrite the guid if there is a duplicate
+ let doorhangerType;
+ if (mergeableRecord) {
+ doorhangerType = "updateAddress";
+ } else if (FormAutofill.isAutofillAddressesCaptureV2Enabled) {
+ doorhangerType = "addAddress";
+ } else {
+ doorhangerType = "addFirstTimeUse";
+ this._updateStorageAfterInteractWithPrompt("save", storage, newRecord);
+
+ // Show first time use doorhanger
+ if (FormAutofill.isAutofillAddressesFirstTimeUse) {
+ Services.prefs.setBoolPref(
+ FormAutofill.ADDRESSES_FIRST_TIME_USE_PREF,
+ false
+ );
+ } else {
+ return;
+ }
+ }
+
+ const description = FormAutofillUtils.getAddressLabel(newRecord);
+ const additionalDescription = mergeableRecord
+ ? FormAutofillUtils.getAddressLabel(mergeableRecord)
+ : null;
+
+ const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger(
+ browser,
+ doorhangerType,
+ description,
+ flowId,
+ { additionalDescription }
+ );
+
+ if (state == "cancel") {
+ return;
+ } else if (state == "open-pref") {
+ browser.ownerGlobal.openPreferences("privacy-address-autofill");
+ return;
+ }
+
+ this._updateStorageAfterInteractWithPrompt(
+ state,
+ storage,
+ newRecord,
+ mergeableRecord?.guid
+ );
+ },
+
+ async promptToSaveCreditCard(browser, storage, record, flowId) {
+ // Overwrite the guid if there is a duplicate
+ let doorhangerType;
+ const duplicateRecord = (await storage.getDuplicateRecords(record).next())
+ .value;
+ if (duplicateRecord) {
+ doorhangerType = "updateCreditCard";
+ } else {
+ doorhangerType = "addCreditCard";
+ }
+
+ const number = record["cc-number"] || record["cc-number-decrypted"];
+ const name = record["cc-name"];
+ const network = lazy.CreditCard.getType(number);
+ const maskedNumber = lazy.CreditCard.getMaskedNumber(number);
+ const description = `${maskedNumber}` + (name ? `, ${name}` : ``);
+ const descriptionIcon = lazy.CreditCard.getCreditCardLogo(network);
+
+ const state = await FormAutofillPrompter._showCCorAddressCaptureDoorhanger(
+ browser,
+ doorhangerType,
+ description,
+ flowId,
+ { descriptionIcon }
+ );
+
+ if (state == "cancel") {
+ return;
+ } else if (state == "disable") {
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, false);
+ return;
+ }
+
+ if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
+ lazy.log.warn("User canceled encryption login");
+ return;
+ }
+
+ this._updateStorageAfterInteractWithPrompt(
+ state,
+ storage,
+ record,
+ duplicateRecord?.guid
+ );
+ },
+
+ async _updateStorageAfterInteractWithPrompt(
+ state,
+ storage,
+ record,
+ guid = null
+ ) {
+ let changedGUID = null;
+ if (state == "create" || state == "save") {
+ changedGUID = await storage.add(record);
+ } else if (state == "update") {
+ await storage.update(guid, record, true);
+ changedGUID = guid;
+ }
+ storage.notifyUsed(changedGUID);
+ },
+
+ _getUpdatedCCIcon(network) {
+ return FormAutofillUtils.getCreditCardLogo(network);
+ },
+
+ /**
+ * Show different types of doorhanger by leveraging PopupNotifications.
+ *
+ * @param {XULElement} browser Target browser element for showing doorhanger.
+ * @param {string} type The type of the doorhanger. There will have first time use/update/credit card.
+ * @param {string} description The message that provides more information on doorhanger.
+ * @param {string} flowId guid used to correlate events relating to the same form
+ * @param {object} [options = {}] a list of options for this method
+ * @param {string} options.descriptionIcon The icon for descriotion
+ * @param {string} options.additionalDescription The message that provides more information on doorhanger.
+ * @returns {Promise} Resolved with action type when action callback is triggered.
+ */
+ async _showCCorAddressCaptureDoorhanger(
+ browser,
+ type,
+ description,
+ flowId,
+ { descriptionIcon = null, additionalDescription = null }
+ ) {
+ const telemetryType = type.endsWith("CreditCard")
+ ? AutofillTelemetry.CREDIT_CARD
+ : AutofillTelemetry.ADDRESS;
+ const isCapture = type.startsWith("add");
+
+ AutofillTelemetry.recordDoorhangerShown(telemetryType, flowId, isCapture);
+
+ lazy.log.debug("show doorhanger with type:", type);
+ return new Promise(resolve => {
+ let {
+ notificationId,
+ message,
+ descriptionLabel,
+ additionalDescriptionLabel,
+ linkMessage,
+ spotlightURL,
+ anchor,
+ mainAction,
+ secondaryActions,
+ options,
+ } = CONTENT[type];
+ descriptionIcon = descriptionIcon ?? CONTENT[type].descriptionIcon;
+
+ const { ownerGlobal: chromeWin, ownerDocument: chromeDoc } = browser;
+ options.eventCallback = topic => {
+ lazy.log.debug("eventCallback:", topic);
+
+ if (topic == "removed" || topic == "dismissed") {
+ this._removeCheckboxListener(browser, { notificationId, options });
+ return;
+ }
+
+ // The doorhanger is customizable only when notification box is shown
+ if (topic != "shown") {
+ return;
+ }
+ this._addCheckboxListener(browser, { notificationId, options });
+
+ // There's no preferences link or other customization in first time use doorhanger.
+ if (type == "addFirstTimeUse") {
+ return;
+ }
+
+ const DESCRIPTION_ID = "description";
+ const ADDITIONAL_DESCRIPTION_ID = "additional-description";
+ const NOTIFICATION_ID = notificationId + "-notification";
+
+ const notification = chromeDoc.getElementById(NOTIFICATION_ID);
+ const notificationContent =
+ notification.querySelector("popupnotificationcontent") ||
+ chromeDoc.createXULElement("popupnotificationcontent");
+ if (!notification.contains(notificationContent)) {
+ notificationContent.setAttribute("orient", "vertical");
+
+ this._appendDescription(
+ notificationContent,
+ descriptionLabel,
+ descriptionIcon,
+ DESCRIPTION_ID
+ );
+
+ if (additionalDescription) {
+ this._appendDescription(
+ notificationContent,
+ additionalDescriptionLabel,
+ descriptionIcon,
+ ADDITIONAL_DESCRIPTION_ID
+ );
+ }
+
+ this._appendPrivacyPanelLink(
+ notificationContent,
+ linkMessage,
+ spotlightURL
+ );
+
+ notification.appendNotificationContent(notificationContent);
+ }
+
+ this._updateDescription(
+ notificationContent,
+ DESCRIPTION_ID,
+ description
+ );
+ if (additionalDescription) {
+ this._updateDescription(
+ notificationContent,
+ ADDITIONAL_DESCRIPTION_ID,
+ additionalDescription
+ );
+ }
+ };
+ this._setAnchor(browser, anchor);
+ chromeWin.PopupNotifications.show(
+ browser,
+ notificationId,
+ message,
+ anchor.id,
+ ...this._createActions(mainAction, secondaryActions, resolve),
+ options
+ );
+ }).then(state => {
+ AutofillTelemetry.recordDoorhangerClicked(
+ telemetryType,
+ state,
+ flowId,
+ isCapture
+ );
+ return state;
+ });
+ },
+};
diff --git a/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs
new file mode 100644
index 0000000000..c45186453d
--- /dev/null
+++ b/toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs
@@ -0,0 +1,274 @@
+/* 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/. */
+
+/*
+ * Implements an interface of the storage of Form Autofill.
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+import {
+ AddressesBase,
+ CreditCardsBase,
+ FormAutofillStorageBase,
+} from "resource://autofill/FormAutofillStorageBase.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+const PROFILE_JSON_FILE_NAME = "autofill-profiles.json";
+
+class Addresses extends AddressesBase {
+ /**
+ * Merge new address into the specified address if mergeable.
+ *
+ * @param {string} guid
+ * Indicates which address to merge.
+ * @param {object} address
+ * The new address used to merge into the old one.
+ * @param {boolean} strict
+ * In strict merge mode, we'll treat the subset record with empty field
+ * as unable to be merged, but mergeable if in non-strict mode.
+ * @returns {Promise<boolean>}
+ * Return true if address is merged into target with specific guid or false if not.
+ */
+ async mergeIfPossible(guid, address, strict) {
+ this.log.debug(`mergeIfPossible: ${guid}`);
+
+ let addressFound = this._findByGUID(guid);
+ if (!addressFound) {
+ throw new Error("No matching address.");
+ }
+
+ let addressToMerge = this._clone(address);
+ this._normalizeRecord(addressToMerge, strict);
+ let hasMatchingField = false;
+
+ let country =
+ addressFound.country ||
+ addressToMerge.country ||
+ FormAutofill.DEFAULT_REGION;
+ let collators = lazy.FormAutofillUtils.getSearchCollators(country);
+ for (let field of this.VALID_FIELDS) {
+ let existingField = addressFound[field];
+ let incomingField = addressToMerge[field];
+ if (incomingField !== undefined && existingField !== undefined) {
+ if (incomingField != existingField) {
+ // Treat "street-address" as mergeable if their single-line versions
+ // match each other.
+ if (
+ field == "street-address" &&
+ lazy.FormAutofillUtils.compareStreetAddress(
+ existingField,
+ incomingField,
+ collators
+ )
+ ) {
+ // Keep the street-address in storage if its amount of lines is greater than
+ // or equal to the incoming one.
+ if (
+ existingField.split("\n").length >=
+ incomingField.split("\n").length
+ ) {
+ // Replace the incoming field with the one in storage so it will
+ // be further merged back to storage.
+ addressToMerge[field] = existingField;
+ }
+ } else if (
+ field != "street-address" &&
+ lazy.FormAutofillUtils.strCompare(
+ existingField,
+ incomingField,
+ collators
+ )
+ ) {
+ addressToMerge[field] = existingField;
+ } else {
+ this.log.debug("Conflicts: field", field, "has different value.");
+ return false;
+ }
+ }
+ hasMatchingField = true;
+ }
+ }
+
+ // We merge the address only when at least one field has the same value.
+ if (!hasMatchingField) {
+ this.log.debug("Unable to merge because no field has the same value");
+ return false;
+ }
+
+ // Early return if the data is the same or subset.
+ let noNeedToUpdate = this.VALID_FIELDS.every(field => {
+ // When addressFound doesn't contain a field, it's unnecessary to update
+ // if the same field in addressToMerge is omitted or an empty string.
+ if (addressFound[field] === undefined) {
+ return !addressToMerge[field];
+ }
+
+ // When addressFound contains a field, it's unnecessary to update if
+ // the same field in addressToMerge is omitted or a duplicate.
+ return (
+ addressToMerge[field] === undefined ||
+ addressFound[field] === addressToMerge[field]
+ );
+ });
+ if (noNeedToUpdate) {
+ return true;
+ }
+
+ await this.update(guid, addressToMerge, true);
+ return true;
+ }
+}
+
+class CreditCards extends CreditCardsBase {
+ constructor(store) {
+ super(store);
+ }
+
+ async _encryptNumber(creditCard) {
+ if (!("cc-number-encrypted" in creditCard)) {
+ if ("cc-number" in creditCard) {
+ let ccNumber = creditCard["cc-number"];
+ if (lazy.CreditCard.isValidNumber(ccNumber)) {
+ creditCard["cc-number"] =
+ lazy.CreditCard.getLongMaskedNumber(ccNumber);
+ } else {
+ // Credit card numbers can be entered on versions of Firefox that don't validate
+ // the number and then synced to this version of Firefox. Therefore, mask the
+ // full number if the number is invalid on this version.
+ creditCard["cc-number"] = "*".repeat(ccNumber.length);
+ }
+ creditCard["cc-number-encrypted"] = await lazy.OSKeyStore.encrypt(
+ ccNumber
+ );
+ } else {
+ creditCard["cc-number-encrypted"] = "";
+ }
+ }
+ }
+
+ /**
+ * Merge new credit card into the specified record if cc-number is identical.
+ * (Note that credit card records always do non-strict merge.)
+ *
+ * @param {string} guid
+ * Indicates which credit card to merge.
+ * @param {object} creditCard
+ * The new credit card used to merge into the old one.
+ * @returns {boolean}
+ * Return true if credit card is merged into target with specific guid or false if not.
+ */
+ async mergeIfPossible(guid, creditCard) {
+ this.log.debug(`mergeIfPossible: ${guid}`);
+
+ // Credit card number is required since it also must match.
+ if (!creditCard["cc-number"]) {
+ return false;
+ }
+
+ // Query raw data for comparing the decrypted credit card number
+ let creditCardFound = await this.get(guid, { rawData: true });
+ if (!creditCardFound) {
+ throw new Error("No matching credit card.");
+ }
+
+ let creditCardToMerge = this._clone(creditCard);
+ this._normalizeRecord(creditCardToMerge);
+
+ for (let field of this.VALID_FIELDS) {
+ let existingField = creditCardFound[field];
+
+ // Make sure credit card field is existed and have value
+ if (
+ field == "cc-number" &&
+ (!existingField || !creditCardToMerge[field])
+ ) {
+ return false;
+ }
+
+ if (!creditCardToMerge[field] && typeof existingField != "undefined") {
+ creditCardToMerge[field] = existingField;
+ }
+
+ let incomingField = creditCardToMerge[field];
+ if (incomingField && existingField) {
+ if (incomingField != existingField) {
+ this.log.debug("Conflicts: field", field, "has different value.");
+ return false;
+ }
+ }
+ }
+
+ // Early return if the data is the same.
+ let exactlyMatch = this.VALID_FIELDS.every(
+ field => creditCardFound[field] === creditCardToMerge[field]
+ );
+ if (exactlyMatch) {
+ return true;
+ }
+
+ await this.update(guid, creditCardToMerge, true);
+ return true;
+ }
+}
+
+export class FormAutofillStorage extends FormAutofillStorageBase {
+ constructor(path) {
+ super(path);
+ }
+
+ getAddresses() {
+ if (!this._addresses) {
+ this._store.ensureDataReady();
+ this._addresses = new Addresses(this._store);
+ }
+ return this._addresses;
+ }
+
+ getCreditCards() {
+ if (!this._creditCards) {
+ this._store.ensureDataReady();
+ this._creditCards = new CreditCards(this._store);
+ }
+ return this._creditCards;
+ }
+
+ /**
+ * Loads the profile data from file to memory.
+ *
+ * @returns {JSONFile}
+ * The JSONFile store.
+ */
+ _initializeStore() {
+ return new lazy.JSONFile({
+ path: this._path,
+ dataPostProcessor: this._dataPostProcessor.bind(this),
+ });
+ }
+
+ _dataPostProcessor(data) {
+ data.version = this.version;
+ if (!data.addresses) {
+ data.addresses = [];
+ }
+ if (!data.creditCards) {
+ data.creditCards = [];
+ }
+ return data;
+ }
+}
+
+// The singleton exposed by this module.
+export const formAutofillStorage = new FormAutofillStorage(
+ PathUtils.join(PathUtils.profileDir, PROFILE_JSON_FILE_NAME)
+);