summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/formautofill
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill')
-rw-r--r--toolkit/components/formautofill/.eslintrc.js67
-rw-r--r--toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs417
-rw-r--r--toolkit/components/formautofill/AutofillTelemetry.sys.mjs557
-rw-r--r--toolkit/components/formautofill/Constants.ios.mjs102
-rw-r--r--toolkit/components/formautofill/FormAutofill.ios.sys.mjs17
-rw-r--r--toolkit/components/formautofill/FormAutofill.sys.mjs269
-rw-r--r--toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs65
-rw-r--r--toolkit/components/formautofill/FormAutofillChild.sys.mjs195
-rw-r--r--toolkit/components/formautofill/FormAutofillContent.sys.mjs418
-rw-r--r--toolkit/components/formautofill/FormAutofillNative.cpp1483
-rw-r--r--toolkit/components/formautofill/FormAutofillNative.h24
-rw-r--r--toolkit/components/formautofill/FormAutofillParent.sys.mjs607
-rw-r--r--toolkit/components/formautofill/FormAutofillPreferences.sys.mjs389
-rw-r--r--toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs2219
-rw-r--r--toolkit/components/formautofill/FormAutofillSync.sys.mjs386
-rw-r--r--toolkit/components/formautofill/Helpers.ios.mjs166
-rw-r--r--toolkit/components/formautofill/Overrides.ios.js22
-rw-r--r--toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs485
-rw-r--r--toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs70
-rw-r--r--toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs277
-rw-r--r--toolkit/components/formautofill/default/FormAutofillPrompter.sys.mjs677
-rw-r--r--toolkit/components/formautofill/default/FormAutofillStorage.sys.mjs274
-rw-r--r--toolkit/components/formautofill/jar.mn17
-rw-r--r--toolkit/components/formautofill/moz.build33
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs474
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs291
-rw-r--r--toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs67
-rw-r--r--toolkit/components/formautofill/shared/AddressComponent.sys.mjs1090
-rw-r--r--toolkit/components/formautofill/shared/AddressParser.sys.mjs281
-rw-r--r--toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs1212
-rw-r--r--toolkit/components/formautofill/shared/FieldScanner.sys.mjs211
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs400
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs1168
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs406
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs1353
-rw-r--r--toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs1253
-rw-r--r--toolkit/components/formautofill/shared/FormStateManager.sys.mjs154
-rw-r--r--toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs620
-rw-r--r--toolkit/components/formautofill/shared/LabelUtils.sys.mjs120
39 files changed, 18336 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/.eslintrc.js b/toolkit/components/formautofill/.eslintrc.js
new file mode 100644
index 0000000000..5a84c2c3e0
--- /dev/null
+++ b/toolkit/components/formautofill/.eslintrc.js
@@ -0,0 +1,67 @@
+/* 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";
+
+module.exports = {
+ rules: {
+ // Rules from the mozilla plugin
+ "mozilla/balanced-listeners": "error",
+ "mozilla/no-aArgs": "error",
+ "mozilla/var-only-at-top-level": "error",
+
+ // No expressions where a statement is expected
+ "no-unused-expressions": "error",
+
+ // No declaring variables that are never used
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "all",
+ },
+ ],
+
+ // No using variables before defined
+ "no-use-before-define": "error",
+
+ // Disallow using variables outside the blocks they are defined (especially
+ // since only let and const are used, see "no-var").
+ "block-scoped-var": "error",
+
+ // Warn about cyclomatic complexity in functions.
+ complexity: ["error", { max: 26 }],
+
+ // Maximum depth callbacks can be nested.
+ "max-nested-callbacks": ["error", 4],
+
+ // Disallow using the console API.
+ "no-console": ["error", { allow: ["error"] }],
+
+ // Disallow fallthrough of case statements, except if there is a comment.
+ "no-fallthrough": "error",
+
+ // Disallow use of multiline strings (use template strings instead).
+ "no-multi-str": "error",
+
+ // Disallow usage of __proto__ property.
+ "no-proto": "error",
+
+ // Disallow use of assignment in return statement. It is preferable for a
+ // single line of code to have only one easily predictable effect.
+ "no-return-assign": "error",
+
+ // Require use of the second argument for parseInt().
+ radix: "error",
+
+ // Require "use strict" to be defined globally in the script.
+ strict: ["error", "global"],
+
+ // Disallow Yoda conditions (where literal value comes first).
+ yoda: "error",
+
+ // Disallow function or variable declarations in nested blocks
+ "no-inner-declarations": "error",
+ },
+};
diff --git a/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs
new file mode 100644
index 0000000000..a8955b5fc2
--- /dev/null
+++ b/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs
@@ -0,0 +1,417 @@
+/* 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/. */
+
+/**
+ * Form Autofill content process module.
+ */
+
+/* eslint-disable no-use-before-define */
+
+const Cm = Components.manager;
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
+ ComponentUtils: "resource://gre/modules/ComponentUtils.sys.mjs",
+ CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
+ InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
+});
+
+const autocompleteController = Cc[
+ "@mozilla.org/autocomplete/controller;1"
+].getService(Ci.nsIAutoCompleteController);
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "ADDRESSES_COLLECTION_NAME",
+ () => lazy.FormAutofillUtils.ADDRESSES_COLLECTION_NAME
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "CREDITCARDS_COLLECTION_NAME",
+ () => lazy.FormAutofillUtils.CREDITCARDS_COLLECTION_NAME
+);
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "FIELD_STATES",
+ () => lazy.FormAutofillUtils.FIELD_STATES
+);
+
+function getActorFromWindow(contentWindow, name = "FormAutofill") {
+ // In unit tests, contentWindow isn't a real window.
+ if (!contentWindow) {
+ return null;
+ }
+
+ return contentWindow.windowGlobalChild
+ ? contentWindow.windowGlobalChild.getActor(name)
+ : null;
+}
+
+// Register/unregister a constructor as a factory.
+function AutocompleteFactory() {}
+AutocompleteFactory.prototype = {
+ register(targetConstructor) {
+ let proto = targetConstructor.prototype;
+ this._classID = proto.classID;
+
+ let factory =
+ lazy.ComponentUtils.generateSingletonFactory(targetConstructor);
+ this._factory = factory;
+
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.registerFactory(
+ proto.classID,
+ proto.classDescription,
+ proto.contractID,
+ factory
+ );
+
+ if (proto.classID2) {
+ this._classID2 = proto.classID2;
+ registrar.registerFactory(
+ proto.classID2,
+ proto.classDescription,
+ proto.contractID2,
+ factory
+ );
+ }
+ },
+
+ unregister() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.unregisterFactory(this._classID, this._factory);
+ if (this._classID2) {
+ registrar.unregisterFactory(this._classID2, this._factory);
+ }
+ this._factory = null;
+ },
+};
+
+/**
+ * @class
+ *
+ * @implements {nsIAutoCompleteSearch}
+ */
+function AutofillProfileAutoCompleteSearch() {
+ this.log = lazy.FormAutofill.defineLogGetter(
+ this,
+ "AutofillProfileAutoCompleteSearch"
+ );
+}
+AutofillProfileAutoCompleteSearch.prototype = {
+ classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"),
+ contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles",
+ classDescription: "AutofillProfileAutoCompleteSearch",
+ QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
+
+ // Begin nsIAutoCompleteSearch implementation
+
+ /**
+ * Searches for a given string and notifies a listener (either synchronously
+ * or asynchronously) of the result
+ *
+ * @param {string} searchString the string to search for
+ * @param {string} searchParam
+ * @param {object} previousResult a previous result to use for faster searchinig
+ * @param {object} listener the listener to notify when the search is complete
+ */
+ startSearch(searchString, searchParam, previousResult, listener) {
+ let {
+ activeInput,
+ activeSection,
+ activeFieldDetail,
+ activeHandler,
+ savedFieldNames,
+ } = lazy.FormAutofillContent;
+ this.forceStop = false;
+
+ let isAddressField = lazy.FormAutofillUtils.isAddressField(
+ activeFieldDetail.fieldName
+ );
+ const isCreditCardField = lazy.FormAutofillUtils.isCreditCardField(
+ activeFieldDetail.fieldName
+ );
+ let isInputAutofilled =
+ activeHandler.getFilledStateByElement(activeInput) ==
+ lazy.FIELD_STATES.AUTO_FILLED;
+ let allFieldNames = activeSection.allFieldNames;
+ let filledRecordGUID = activeSection.filledRecordGUID;
+
+ let searchPermitted = isAddressField
+ ? lazy.FormAutofill.isAutofillAddressesEnabled
+ : lazy.FormAutofill.isAutofillCreditCardsEnabled;
+ let AutocompleteResult = isAddressField
+ ? lazy.AddressResult
+ : lazy.CreditCardResult;
+ let isFormAutofillSearch = true;
+ let pendingSearchResult = null;
+
+ ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput;
+ // Fallback to form-history if ...
+ // - specified autofill feature is pref off.
+ // - no profile can fill the currently-focused input.
+ // - the current form has already been populated and the field is not
+ // an empty credit card field.
+ // - (address only) less than 3 inputs are covered by all saved fields in the storage.
+ if (
+ !searchPermitted ||
+ !savedFieldNames.has(activeFieldDetail.fieldName) ||
+ (!isInputAutofilled &&
+ filledRecordGUID &&
+ !(isCreditCardField && activeInput.value === "")) ||
+ (isAddressField &&
+ allFieldNames.filter(field => savedFieldNames.has(field)).length <
+ lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD)
+ ) {
+ isFormAutofillSearch = false;
+ if (activeInput.autocomplete == "off") {
+ // Create a dummy result as an empty search result.
+ pendingSearchResult = new AutocompleteResult("", "", [], [], {});
+ } else {
+ pendingSearchResult = new Promise(resolve => {
+ let formHistory = Cc[
+ "@mozilla.org/autocomplete/search;1?name=form-history"
+ ].createInstance(Ci.nsIAutoCompleteSearch);
+ formHistory.startSearch(searchString, searchParam, previousResult, {
+ onSearchResult: (_, result) => resolve(result),
+ });
+ });
+ }
+ } else if (isInputAutofilled) {
+ pendingSearchResult = new AutocompleteResult(searchString, "", [], [], {
+ isInputAutofilled,
+ });
+ } else {
+ let infoWithoutElement = { ...activeFieldDetail };
+ delete infoWithoutElement.elementWeakRef;
+
+ let data = {
+ collectionName: isAddressField
+ ? lazy.ADDRESSES_COLLECTION_NAME
+ : lazy.CREDITCARDS_COLLECTION_NAME,
+ info: infoWithoutElement,
+ searchString,
+ };
+
+ pendingSearchResult = this._getRecords(activeInput, data).then(
+ records => {
+ if (this.forceStop) {
+ return null;
+ }
+ // Sort addresses by timeLastUsed for showing the lastest used address at top.
+ records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
+
+ let adaptedRecords = activeSection.getAdaptedProfiles(records);
+ let handler = lazy.FormAutofillContent.activeHandler;
+ let isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
+
+ return new AutocompleteResult(
+ searchString,
+ activeFieldDetail.fieldName,
+ allFieldNames,
+ adaptedRecords,
+ { isSecure, isInputAutofilled }
+ );
+ }
+ );
+ }
+
+ Promise.resolve(pendingSearchResult).then(result => {
+ if (this.forceStop) {
+ // If we notify the listener the search result when the search is already
+ // cancelled, it corrupts the internal state of the listener. So we only
+ // reset the controller's state in this case.
+ if (isFormAutofillSearch) {
+ autocompleteController.resetInternalState();
+ }
+ return;
+ }
+
+ listener.onSearchResult(this, result);
+ // Don't save cache results or reset state when returning non-autofill results such as the
+ // form history fallback above.
+ if (isFormAutofillSearch) {
+ ProfileAutocomplete.lastProfileAutoCompleteResult = result;
+ // Reset AutoCompleteController's state at the end of startSearch to ensure that
+ // none of form autofill result will be cached in other places and make the
+ // result out of sync.
+ autocompleteController.resetInternalState();
+ } else {
+ // Clear the cache so that we don't try to autofill from it after falling
+ // back to form history.
+ ProfileAutocomplete.lastProfileAutoCompleteResult = null;
+ }
+ });
+ },
+
+ /**
+ * Stops an asynchronous search that is in progress
+ */
+ stopSearch() {
+ ProfileAutocomplete.lastProfileAutoCompleteResult = null;
+ this.forceStop = true;
+ },
+
+ /**
+ * Get the records from parent process for AutoComplete result.
+ *
+ * @private
+ * @param {object} input
+ * Input element for autocomplete.
+ * @param {object} data
+ * Parameters for querying the corresponding result.
+ * @param {string} data.collectionName
+ * The name used to specify which collection to retrieve records.
+ * @param {string} data.searchString
+ * The typed string for filtering out the matched records.
+ * @param {string} data.info
+ * The input autocomplete property's information.
+ * @returns {Promise}
+ * Promise that resolves when addresses returned from parent process.
+ */
+ _getRecords(input, data) {
+ if (!input) {
+ return [];
+ }
+
+ let actor = getActorFromWindow(input.ownerGlobal);
+ return actor.sendQuery("FormAutofill:GetRecords", data);
+ },
+};
+
+export const ProfileAutocomplete = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ lastProfileAutoCompleteResult: null,
+ lastProfileAutoCompleteFocusedInput: null,
+ _registered: false,
+ _factory: null,
+
+ ensureRegistered() {
+ if (this._registered) {
+ return;
+ }
+
+ this.log = lazy.FormAutofill.defineLogGetter(this, "ProfileAutocomplete");
+ this.debug("ensureRegistered");
+ this._factory = new AutocompleteFactory();
+ this._factory.register(AutofillProfileAutoCompleteSearch);
+ this._registered = true;
+
+ Services.obs.addObserver(this, "autocomplete-will-enter-text");
+
+ this.debug(
+ "ensureRegistered. Finished with _registered:",
+ this._registered
+ );
+ },
+
+ ensureUnregistered() {
+ if (!this._registered) {
+ return;
+ }
+
+ this.debug("ensureUnregistered");
+ this._factory.unregister();
+ this._factory = null;
+ this._registered = false;
+ this._lastAutoCompleteResult = null;
+
+ Services.obs.removeObserver(this, "autocomplete-will-enter-text");
+ },
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "autocomplete-will-enter-text": {
+ if (!lazy.FormAutofillContent.activeInput) {
+ // The observer notification is for autocomplete in a different process.
+ break;
+ }
+ lazy.FormAutofillContent.autofillPending = true;
+ Services.obs.notifyObservers(null, "autofill-fill-starting");
+ await this._fillFromAutocompleteRow(
+ lazy.FormAutofillContent.activeInput
+ );
+ Services.obs.notifyObservers(null, "autofill-fill-complete");
+ lazy.FormAutofillContent.autofillPending = false;
+ break;
+ }
+ }
+ },
+
+ _getSelectedIndex(contentWindow) {
+ let actor = getActorFromWindow(contentWindow, "AutoComplete");
+ if (!actor) {
+ throw new Error("Invalid autocomplete selectedIndex");
+ }
+
+ return actor.selectedIndex;
+ },
+
+ async _fillFromAutocompleteRow(focusedInput) {
+ this.debug("_fillFromAutocompleteRow:", focusedInput);
+ let formDetails = lazy.FormAutofillContent.activeFormDetails;
+ if (!formDetails) {
+ // The observer notification is for a different frame.
+ return;
+ }
+
+ let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal);
+ if (
+ selectedIndex == -1 ||
+ !this.lastProfileAutoCompleteResult ||
+ this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) !=
+ "autofill-profile"
+ ) {
+ return;
+ }
+
+ let profile = JSON.parse(
+ this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
+ );
+
+ await lazy.FormAutofillContent.activeHandler.autofillFormFields(profile);
+ },
+
+ _clearProfilePreview() {
+ if (
+ !this.lastProfileAutoCompleteFocusedInput ||
+ !lazy.FormAutofillContent.activeSection
+ ) {
+ return;
+ }
+
+ lazy.FormAutofillContent.activeSection.clearPreviewedFormFields();
+ },
+
+ _previewSelectedProfile(selectedIndex) {
+ if (
+ !lazy.FormAutofillContent.activeInput ||
+ !lazy.FormAutofillContent.activeFormDetails
+ ) {
+ // The observer notification is for a different process/frame.
+ return;
+ }
+
+ if (
+ !this.lastProfileAutoCompleteResult ||
+ this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) !=
+ "autofill-profile"
+ ) {
+ return;
+ }
+
+ let profile = JSON.parse(
+ this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
+ );
+ lazy.FormAutofillContent.activeSection.previewFormFields(profile);
+ },
+};
diff --git a/toolkit/components/formautofill/AutofillTelemetry.sys.mjs b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs
new file mode 100644
index 0000000000..1d8dea44b8
--- /dev/null
+++ b/toolkit/components/formautofill/AutofillTelemetry.sys.mjs
@@ -0,0 +1,557 @@
+/* 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 { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormAutofillCreditCardSection } from "resource://gre/modules/shared/FormAutofillSection.sys.mjs";
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+class AutofillTelemetryBase {
+ SUPPORTED_FIELDS = {};
+
+ EVENT_CATEGORY = null;
+ EVENT_OBJECT_FORM_INTERACTION = null;
+
+ SCALAR_DETECTED_SECTION_COUNT = null;
+ SCALAR_SUBMITTED_SECTION_COUNT = null;
+ SCALAR_AUTOFILL_PROFILE_COUNT = null;
+
+ HISTOGRAM_NUM_USES = null;
+ HISTOGRAM_PROFILE_NUM_USES = null;
+ HISTOGRAM_PROFILE_NUM_USES_KEY = null;
+
+ #initFormEventExtra(value) {
+ let extra = {};
+ for (const field of Object.values(this.SUPPORTED_FIELDS)) {
+ extra[field] = value;
+ }
+ return extra;
+ }
+
+ #setFormEventExtra(extra, key, value) {
+ if (!this.SUPPORTED_FIELDS[key]) {
+ return;
+ }
+
+ extra[this.SUPPORTED_FIELDS[key]] = value;
+ }
+
+ recordFormDetected(section) {
+ let extra = this.#initFormEventExtra("false");
+ let identified = new Set();
+ section.fieldDetails.forEach(detail => {
+ identified.add(detail.fieldName);
+
+ if (detail.reason == "autocomplete") {
+ this.#setFormEventExtra(extra, detail.fieldName, "true");
+ } else {
+ // confidence exists only when a field is identified by fathom.
+ let confidence =
+ detail.confidence > 0 ? Math.floor(100 * detail.confidence) / 100 : 0;
+ this.#setFormEventExtra(extra, detail.fieldName, confidence.toString());
+ }
+ });
+
+ this.recordFormEvent("detected", section.flowId, extra);
+ }
+
+ recordPopupShown(section, fieldName) {
+ const extra = { field_name: fieldName };
+ this.recordFormEvent("popup_shown", section.flowId, extra);
+ }
+
+ recordFormFilled(section, profile) {
+ // Calculate values for telemetry
+ let extra = this.#initFormEventExtra("unavailable");
+
+ for (let fieldDetail of section.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled";
+ if (
+ section.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.NORMAL &&
+ (HTMLSelectElement.isInstance(element) ||
+ (HTMLInputElement.isInstance(element) && element.value.length))
+ ) {
+ state = "user_filled";
+ }
+ this.#setFormEventExtra(extra, fieldDetail.fieldName, state);
+ }
+
+ this.recordFormEvent("filled", section.flowId, extra);
+ }
+
+ recordFilledModified(section, fieldName) {
+ const extra = { field_name: fieldName };
+ this.recordFormEvent("filled_modified", section.flowId, extra);
+ }
+
+ recordFormSubmitted(section, record, form) {
+ let extra = this.#initFormEventExtra("unavailable");
+
+ if (record.guid !== null) {
+ // If the `guid` is not null, it means we're editing an existing record.
+ // In that case, all fields in the record are autofilled, and fields in
+ // `untouchedFields` are unmodified.
+ for (const [fieldName, value] of Object.entries(record.record)) {
+ if (record.untouchedFields?.includes(fieldName)) {
+ this.#setFormEventExtra(extra, fieldName, "autofilled");
+ } else if (value) {
+ this.#setFormEventExtra(extra, fieldName, "user_filled");
+ } else {
+ this.#setFormEventExtra(extra, fieldName, "not_filled");
+ }
+ }
+ } else {
+ Object.keys(record.record).forEach(fieldName =>
+ this.#setFormEventExtra(extra, fieldName, "user_filled")
+ );
+ }
+
+ this.recordFormEvent("submitted", section.flowId, extra);
+ }
+
+ recordFormCleared(section, fieldName) {
+ const extra = { field_name: fieldName };
+
+ // Note that when a form is cleared, we also record `filled_modified` events
+ // for all the fields that have been cleared.
+ this.recordFormEvent("cleared", section.flowId, extra);
+ }
+
+ recordFormEvent(method, flowId, extra) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION,
+ flowId,
+ extra
+ );
+ }
+
+ recordFormInteractionEvent(
+ method,
+ section,
+ { fieldName, profile, record, form } = {}
+ ) {
+ if (!this.EVENT_OBJECT_FORM_INTERACTION) {
+ return undefined;
+ }
+ switch (method) {
+ case "detected":
+ return this.recordFormDetected(section);
+ case "popup_shown":
+ return this.recordPopupShown(section, fieldName);
+ case "filled":
+ return this.recordFormFilled(section, profile);
+ case "filled_modified":
+ return this.recordFilledModified(section, fieldName);
+ case "submitted":
+ return this.recordFormSubmitted(section, record, form);
+ case "cleared":
+ return this.recordFormCleared(section, fieldName);
+ }
+ return undefined;
+ }
+
+ recordDoorhangerEvent(method, flowId, isCapture) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ isCapture ? "capture_doorhanger" : "update_doorhanger",
+ flowId
+ );
+ }
+
+ recordManageEvent(method, object) {
+ Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, object);
+ }
+
+ recordAutofillProfileCount(count) {
+ if (!this.SCALAR_AUTOFILL_PROFILE_COUNT) {
+ return;
+ }
+
+ Services.telemetry.scalarSet(this.SCALAR_AUTOFILL_PROFILE_COUNT, count);
+ }
+
+ recordDetectedSectionCount() {
+ if (!this.SCALAR_DETECTED_SECTION_COUNT) {
+ return;
+ }
+
+ Services.telemetry.scalarAdd(this.SCALAR_DETECTED_SECTION_COUNT, 1);
+ }
+
+ recordSubmittedSectionCount(count) {
+ if (!this.SCALAR_SUBMITTED_SECTION_COUNT || !count) {
+ return;
+ }
+
+ Services.telemetry.scalarAdd(this.SCALAR_SUBMITTED_SECTION_COUNT, count);
+ }
+
+ recordNumberOfUse(records) {
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ this.HISTOGRAM_PROFILE_NUM_USES
+ );
+ histogram.clear();
+
+ for (let record of records) {
+ histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed);
+ }
+ }
+}
+
+export class AddressTelemetry extends AutofillTelemetryBase {
+ EVENT_CATEGORY = "address";
+ EVENT_OBJECT_FORM_INTERACTION = "address_form";
+ EVENT_OBJECT_FORM_INTERACTION_EXT = "address_form_ext";
+
+ SCALAR_DETECTED_SECTION_COUNT =
+ "formautofill.addresses.detected_sections_count";
+ SCALAR_SUBMITTED_SECTION_COUNT =
+ "formautofill.addresses.submitted_sections_count";
+ SCALAR_AUTOFILL_PROFILE_COUNT =
+ "formautofill.addresses.autofill_profiles_count";
+
+ HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES_KEY = "address";
+
+ // Fields that are record in `address_form` and `address_form_ext` telemetry
+ SUPPORTED_FIELDS = {
+ "street-address": "street_address",
+ "address-line1": "address_line1",
+ "address-line2": "address_line2",
+ "address-line3": "address_line3",
+ "address-level1": "address_level1",
+ "address-level2": "address_level2",
+ "postal-code": "postal_code",
+ country: "country",
+ name: "name",
+ "given-name": "given_name",
+ "additional-name": "additional_name",
+ "family-name": "family_name",
+ email: "email",
+ organization: "organization",
+ tel: "tel",
+ };
+
+ // Fields that are record in `address_form` event telemetry extra_keys
+ static SUPPORTED_FIELDS_IN_FORM = [
+ "street_address",
+ "address_line1",
+ "address_line2",
+ "address_line3",
+ "address_level2",
+ "address_level1",
+ "postal_code",
+ "country",
+ ];
+
+ // Fields that are record in `address_form_ext` event telemetry extra_keys
+ static SUPPORTED_FIELDS_IN_FORM_EXT = [
+ "name",
+ "given_name",
+ "additional_name",
+ "family_name",
+ "email",
+ "organization",
+ "tel",
+ ];
+
+ recordFormEvent(method, flowId, extra) {
+ let extExtra = {};
+ if (["detected", "filled", "submitted"].includes(method)) {
+ for (const [key, value] of Object.entries(extra)) {
+ if (AddressTelemetry.SUPPORTED_FIELDS_IN_FORM_EXT.includes(key)) {
+ extExtra[key] = value;
+ delete extra[key];
+ }
+ }
+ }
+
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION,
+ flowId,
+ extra
+ );
+
+ if (Object.keys(extExtra).length) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ this.EVENT_OBJECT_FORM_INTERACTION_EXT,
+ flowId,
+ extExtra
+ );
+ }
+ }
+}
+
+class CreditCardTelemetry extends AutofillTelemetryBase {
+ EVENT_CATEGORY = "creditcard";
+ EVENT_OBJECT_FORM_INTERACTION = "cc_form_v2";
+
+ SCALAR_DETECTED_SECTION_COUNT =
+ "formautofill.creditCards.detected_sections_count";
+ SCALAR_SUBMITTED_SECTION_COUNT =
+ "formautofill.creditCards.submitted_sections_count";
+ SCALAR_AUTOFILL_PROFILE_COUNT =
+ "formautofill.creditCards.autofill_profiles_count";
+
+ HISTOGRAM_NUM_USES = "CREDITCARD_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
+ HISTOGRAM_PROFILE_NUM_USES_KEY = "credit_card";
+
+ // Mapping of field name used in formautofill code to the field name
+ // used in the telemetry.
+ SUPPORTED_FIELDS = {
+ "cc-name": "cc_name",
+ "cc-number": "cc_number",
+ "cc-type": "cc_type",
+ "cc-exp": "cc_exp",
+ "cc-exp-month": "cc_exp_month",
+ "cc-exp-year": "cc_exp_year",
+ };
+
+ recordLegacyFormEvent(method, flowId, extra = null) {
+ Services.telemetry.recordEvent(
+ this.EVENT_CATEGORY,
+ method,
+ "cc_form",
+ flowId,
+ extra
+ );
+ }
+
+ recordFormDetected(section) {
+ super.recordFormDetected(section);
+
+ let identified = new Set();
+ section.fieldDetails.forEach(detail => {
+ identified.add(detail.fieldName);
+ });
+ let extra = {
+ cc_name_found: identified.has("cc-name") ? "true" : "false",
+ cc_number_found: identified.has("cc-number") ? "true" : "false",
+ cc_exp_found:
+ identified.has("cc-exp") ||
+ (identified.has("cc-exp-month") && identified.has("cc-exp-year"))
+ ? "true"
+ : "false",
+ };
+
+ this.recordLegacyFormEvent("detected", section.flowId, extra);
+ }
+
+ recordPopupShown(section, fieldName) {
+ super.recordPopupShown(section, fieldName);
+
+ this.recordLegacyFormEvent("popup_shown", section.flowId);
+ }
+
+ recordFormFilled(section, profile) {
+ super.recordFormFilled(section, profile);
+ // Calculate values for telemetry
+ let extra = {
+ cc_name: "unavailable",
+ cc_number: "unavailable",
+ cc_exp: "unavailable",
+ };
+
+ for (let fieldDetail of section.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ let state = profile[fieldDetail.fieldName] ? "filled" : "not_filled";
+ if (
+ section.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.NORMAL &&
+ (HTMLSelectElement.isInstance(element) ||
+ (HTMLInputElement.isInstance(element) && element.value.length))
+ ) {
+ state = "user_filled";
+ }
+ switch (fieldDetail.fieldName) {
+ case "cc-name":
+ extra.cc_name = state;
+ break;
+ case "cc-number":
+ extra.cc_number = state;
+ break;
+ case "cc-exp":
+ case "cc-exp-month":
+ case "cc-exp-year":
+ extra.cc_exp = state;
+ break;
+ }
+ }
+
+ this.recordLegacyFormEvent("filled", section.flowId, extra);
+ }
+
+ recordFilledModified(section, fieldName) {
+ super.recordFilledModified(section, fieldName);
+
+ let extra = { field_name: fieldName };
+ this.recordLegacyFormEvent("filled_modified", section.flowId, extra);
+ }
+
+ /**
+ * Called when a credit card form is submitted
+ *
+ * @param {object} section Section that produces this record
+ * @param {object} record Credit card record filled in the form.
+ * @param {Array<HTMLForm>} form Form that contains the section
+ */
+ recordFormSubmitted(section, record, form) {
+ super.recordFormSubmitted(section, record, form);
+
+ // For legacy cc_form event telemetry
+ let extra = {
+ fields_not_auto: "0",
+ fields_auto: "0",
+ fields_modified: "0",
+ };
+
+ if (record.guid !== null) {
+ let totalCount = form.elements.length;
+ let autofilledCount = Object.keys(record.record).length;
+ let unmodifiedCount = record.untouchedFields.length;
+
+ extra.fields_not_auto = (totalCount - autofilledCount).toString();
+ extra.fields_auto = autofilledCount.toString();
+ extra.fields_modified = (autofilledCount - unmodifiedCount).toString();
+ } else {
+ // If the `guid` is null, we're filling a new form.
+ // In that case, all not-null fields are manually filled.
+ extra.fields_not_auto = Array.from(form.elements)
+ .filter(element => !!element.value?.trim().length)
+ .length.toString();
+ }
+
+ this.recordLegacyFormEvent("submitted", section.flowId, extra);
+ }
+
+ recordNumberOfUse(records) {
+ super.recordNumberOfUse(records);
+
+ if (!this.HISTOGRAM_NUM_USES) {
+ return;
+ }
+
+ let histogram = Services.telemetry.getHistogramById(
+ this.HISTOGRAM_NUM_USES
+ );
+ histogram.clear();
+
+ for (let record of records) {
+ histogram.add(record.timesUsed);
+ }
+ }
+}
+
+export class AutofillTelemetry {
+ static #creditCardTelemetry = new CreditCardTelemetry();
+ static #addressTelemetry = new AddressTelemetry();
+
+ // const for `type` parameter used in the utility functions
+ static ADDRESS = "address";
+ static CREDIT_CARD = "creditcard";
+
+ static #getTelemetryBySection(section) {
+ return section instanceof FormAutofillCreditCardSection
+ ? this.#creditCardTelemetry
+ : this.#addressTelemetry;
+ }
+
+ static #getTelemetryByType(type) {
+ return type == AutofillTelemetry.CREDIT_CARD
+ ? this.#creditCardTelemetry
+ : this.#addressTelemetry;
+ }
+ /**
+ * Utility functions for `doorhanger` event (defined in Events.yaml)
+ *
+ * Category: address or creditcard
+ * Event name: doorhanger
+ */
+
+ static recordDoorhangerShown(type, flowId, isCapture) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordDoorhangerEvent("show", flowId, isCapture);
+ }
+
+ static recordDoorhangerClicked(type, method, flowId, isCapture) {
+ const telemetry = this.#getTelemetryByType(type);
+
+ // We don't have `create` method in telemetry, we treat `create` as `save`
+ switch (method) {
+ case "create":
+ method = "save";
+ break;
+ case "open-pref":
+ method = "pref";
+ break;
+ }
+
+ telemetry.recordDoorhangerEvent(method, flowId, isCapture);
+ }
+
+ /**
+ * Utility functions for form event (defined in Events.yaml)
+ *
+ * Category: address or creditcard
+ * Event name: cc_form, cc_form_v2, or address_form
+ */
+
+ static recordFormInteractionEvent(
+ method,
+ section,
+ { fieldName, profile, record, form } = {}
+ ) {
+ const telemetry = this.#getTelemetryBySection(section);
+ telemetry.recordFormInteractionEvent(method, section, {
+ fieldName,
+ profile,
+ record,
+ form,
+ });
+ }
+
+ /**
+ * Utility functions for submitted section count scalar (defined in Scalars.yaml)
+ *
+ * Category: formautofill.creditCards or formautofill.addresses
+ * Scalar name: submitted_sections_count
+ */
+ static recordDetectedSectionCount(section) {
+ const telemetry = this.#getTelemetryBySection(section);
+ telemetry.recordDetectedSectionCount();
+ }
+
+ static recordSubmittedSectionCount(type, count) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordSubmittedSectionCount(count);
+ }
+
+ static recordManageEvent(type, method) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordManageEvent(method, "manage");
+ }
+
+ static recordAutofillProfileCount(type, count) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordAutofillProfileCount(count);
+ }
+
+ /**
+ * Utility functions for address/credit card number of use
+ */
+ static recordNumberOfUse(type, records) {
+ const telemetry = this.#getTelemetryByType(type);
+ telemetry.recordNumberOfUse(records);
+ }
+}
diff --git a/toolkit/components/formautofill/Constants.ios.mjs b/toolkit/components/formautofill/Constants.ios.mjs
new file mode 100644
index 0000000000..619c1b9aad
--- /dev/null
+++ b/toolkit/components/formautofill/Constants.ios.mjs
@@ -0,0 +1,102 @@
+/* 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/. */
+
+const IOS_DEFAULT_PREFERENCES = {
+ "extensions.formautofill.creditCards.heuristics.mode": 1,
+ "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold": 0.5,
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold": 0.95,
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence": 0,
+ "extensions.formautofill.creditCards.heuristics.fathom.types":
+ "cc-number,cc-name",
+ "extensions.formautofill.loglevel": "Warn",
+ "extensions.formautofill.firstTimeUse": true,
+ "extensions.formautofill.addresses.supported": "off",
+ "extensions.formautofill.creditCards.supported": "detect",
+ "browser.search.region": "US",
+ "extensions.formautofill.creditCards.supportedCountries": "US,CA,GB,FR,DE",
+ "extensions.formautofill.addresses.enabled": false,
+ "extensions.formautofill.addresses.capture.enabled": false,
+ "extensions.formautofill.addresses.capture.v2.enabled": false,
+ "extensions.formautofill.addresses.supportedCountries": "",
+ "extensions.formautofill.creditCards.enabled": true,
+ "extensions.formautofill.reauth.enabled": true,
+ "extensions.formautofill.creditCards.hideui": false,
+ "extensions.formautofill.supportRTL": false,
+ "extensions.formautofill.creditCards.ignoreAutocompleteOff": true,
+ "extensions.formautofill.addresses.ignoreAutocompleteOff": true,
+ "extensions.formautofill.heuristics.enabled": true,
+ "extensions.formautofill.section.enabled": true,
+ // WebKit doesn't support the checkVisibility API, setting the threshold value to 0 to esnure
+ // `IsFieldVisible` function doesn't use it
+ "extensions.formautofill.heuristics.visibilityCheckThreshold": 0,
+ "extensions.formautofill.focusOnAutofill": false,
+};
+
+// Used Mimic the behavior of .getAutocompleteInfo()
+// List from: https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149
+// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
+const VALID_AUTOCOMPLETE_FIELDS = [
+ "off",
+ "on",
+ "name",
+ "honorific-prefix",
+ "given-name",
+ "additional-name",
+ "family-name",
+ "honorific-suffix",
+ "nickname",
+ "email",
+ "username",
+ "new-password",
+ "current-password",
+ "one-time-code",
+ "organization-title",
+ "organization",
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ "address-level4",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "country",
+ "country-name",
+ "postal-code",
+ "cc-name",
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ "cc-number",
+ "cc-exp",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-csc",
+ "cc-type",
+ "transaction-currency",
+ "transaction-amount",
+ "language",
+ "bday",
+ "bday-day",
+ "bday-month",
+ "bday-year",
+ "sex",
+ "tel",
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-extension",
+ "impp",
+ "url",
+ "photo",
+];
+
+export const IOSAppConstants = Object.freeze({
+ platform: "ios",
+ prefs: IOS_DEFAULT_PREFERENCES,
+ validAutocompleteFields: VALID_AUTOCOMPLETE_FIELDS,
+});
+
+export default IOSAppConstants;
diff --git a/toolkit/components/formautofill/FormAutofill.ios.sys.mjs b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs
new file mode 100644
index 0000000000..8e205c16c6
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofill.ios.sys.mjs
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+FormAutofill.defineLogGetter = (scope, logPrefix) => ({
+ // TODO: Bug 1828405. Explore how logging should be handled.
+ // Maybe it makes more sense to do it on swift side and have JS just send messages.
+ info: () => {},
+ error: () => {},
+ warn: () => {},
+ debug: () => {},
+});
+
+export { FormAutofill };
+export default FormAutofill;
diff --git a/toolkit/components/formautofill/FormAutofill.sys.mjs b/toolkit/components/formautofill/FormAutofill.sys.mjs
new file mode 100644
index 0000000000..49c594a345
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofill.sys.mjs
@@ -0,0 +1,269 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { Region } from "resource://gre/modules/Region.sys.mjs";
+
+const ADDRESSES_FIRST_TIME_USE_PREF = "extensions.formautofill.firstTimeUse";
+const AUTOFILL_ADDRESSES_AVAILABLE_PREF =
+ "extensions.formautofill.addresses.supported";
+// This pref should be refactored after the migration of the old bool pref
+const AUTOFILL_CREDITCARDS_AVAILABLE_PREF =
+ "extensions.formautofill.creditCards.supported";
+const BROWSER_SEARCH_REGION_PREF = "browser.search.region";
+const CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF =
+ "extensions.formautofill.creditCards.supportedCountries";
+const ENABLED_AUTOFILL_ADDRESSES_PREF =
+ "extensions.formautofill.addresses.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF =
+ "extensions.formautofill.addresses.capture.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF =
+ "extensions.formautofill.addresses.capture.v2.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF =
+ "extensions.formautofill.addresses.supportedCountries";
+const ENABLED_AUTOFILL_CREDITCARDS_PREF =
+ "extensions.formautofill.creditCards.enabled";
+const ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF =
+ "extensions.formautofill.reauth.enabled";
+const AUTOFILL_CREDITCARDS_HIDE_UI_PREF =
+ "extensions.formautofill.creditCards.hideui";
+const FORM_AUTOFILL_SUPPORT_RTL_PREF = "extensions.formautofill.supportRTL";
+const AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF =
+ "extensions.formautofill.creditCards.ignoreAutocompleteOff";
+const AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF =
+ "extensions.formautofill.addresses.ignoreAutocompleteOff";
+
+export const FormAutofill = {
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF,
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+ ADDRESSES_FIRST_TIME_USE_PREF,
+ AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+
+ get DEFAULT_REGION() {
+ return Region.home || "US";
+ },
+ /**
+ * Determines if an autofill feature should be enabled based on the "available"
+ * and "supportedCountries" parameters.
+ *
+ * @param {string} available Available can be one of the following: "on", "detect", "off".
+ * "on" forces the particular Form Autofill feature on, while "detect" utilizes the supported countries
+ * to see if the feature should be available.
+ * @param {string[]} supportedCountries
+ * @returns {boolean} `true` if autofill feature is supported in the current browser search region
+ */
+ _isSupportedRegion(available, supportedCountries) {
+ if (available == "on") {
+ return true;
+ } else if (available == "detect") {
+ if (!FormAutofill.supportRTL && Services.locale.isAppLocaleRTL) {
+ return false;
+ }
+
+ return supportedCountries.includes(FormAutofill.browserSearchRegion);
+ }
+ return false;
+ },
+ isAutofillAddressesAvailableInCountry(country) {
+ return FormAutofill._addressAutofillSupportedCountries.includes(country);
+ },
+ get isAutofillEnabled() {
+ return this.isAutofillAddressesEnabled || this.isAutofillCreditCardsEnabled;
+ },
+ /**
+ * Determines if the credit card autofill feature is available to use in the browser.
+ * If the feature is not available, then there are no user facing ways to enable it.
+ *
+ * @returns {boolean} `true` if credit card autofill is available
+ */
+ get isAutofillCreditCardsAvailable() {
+ return this._isSupportedRegion(
+ FormAutofill._isAutofillCreditCardsAvailable,
+ FormAutofill._creditCardAutofillSupportedCountries
+ );
+ },
+ /**
+ * Determines if the address autofill feature is available to use in the browser.
+ * If the feature is not available, then there are no user facing ways to enable it.
+ *
+ * @returns {boolean} `true` if address autofill is available
+ */
+ get isAutofillAddressesAvailable() {
+ return this._isSupportedRegion(
+ FormAutofill._isAutofillAddressesAvailable,
+ FormAutofill._addressAutofillSupportedCountries
+ );
+ },
+ /**
+ * Determines if the user has enabled or disabled credit card autofill.
+ *
+ * @returns {boolean} `true` if credit card autofill is enabled
+ */
+ get isAutofillCreditCardsEnabled() {
+ return (
+ this.isAutofillCreditCardsAvailable &&
+ FormAutofill._isAutofillCreditCardsEnabled
+ );
+ },
+ /**
+ * Determines if credit card autofill is locked by policy.
+ *
+ * @returns {boolean} `true` if credit card autofill is locked
+ */
+ get isAutofillCreditCardsLocked() {
+ return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+ },
+ /**
+ * Determines if the user has enabled or disabled address autofill.
+ *
+ * @returns {boolean} `true` if address autofill is enabled
+ */
+ get isAutofillAddressesEnabled() {
+ return (
+ this.isAutofillAddressesAvailable &&
+ FormAutofill._isAutofillAddressesEnabled
+ );
+ },
+ /**
+ * Determines if address autofill is locked by policy.
+ *
+ * @returns {boolean} `true` if address autofill is locked
+ */
+ get isAutofillAddressesLocked() {
+ return Services.prefs.prefIsLocked(ENABLED_AUTOFILL_ADDRESSES_PREF);
+ },
+
+ defineLogGetter(scope, logPrefix) {
+ // A logging helper for debug logging to avoid creating Console objects
+ // or triggering expensive JS -> C++ calls when debug logging is not
+ // enabled.
+ //
+ // Console objects, even natively-implemented ones, can consume a lot of
+ // memory, and since this code may run in every content process, that
+ // memory can add up quickly. And, even when debug-level messages are
+ // being ignored, console.debug() calls can be expensive.
+ //
+ // This helper avoids both of those problems by never touching the
+ // console object unless debug logging is enabled.
+ scope.debug = function debug() {
+ if (FormAutofill.logLevel.toLowerCase() == "debug") {
+ this.log.debug(...arguments);
+ }
+ };
+
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "extensions.formautofill.loglevel",
+ prefix: logPrefix,
+ });
+ },
+};
+
+// TODO: Bug 1747284. Use Region.home instead of reading "browser.serach.region"
+// by default. However, Region.home doesn't observe preference change at this point,
+// we should also fix that issue.
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "browserSearchRegion",
+ BROWSER_SEARCH_REGION_PREF,
+ FormAutofill.DEFAULT_REGION
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "logLevel",
+ "extensions.formautofill.loglevel",
+ "Warn"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillAddressesAvailable",
+ AUTOFILL_ADDRESSES_AVAILABLE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillAddressesEnabled",
+ ENABLED_AUTOFILL_ADDRESSES_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillAddressesCaptureEnabled",
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillAddressesCaptureV2Enabled",
+ ENABLED_AUTOFILL_ADDRESSES_CAPTURE_V2_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillCreditCardsAvailable",
+ AUTOFILL_CREDITCARDS_AVAILABLE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_isAutofillCreditCardsEnabled",
+ ENABLED_AUTOFILL_CREDITCARDS_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillCreditCardsHideUI",
+ AUTOFILL_CREDITCARDS_HIDE_UI_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "isAutofillAddressesFirstTimeUse",
+ ADDRESSES_FIRST_TIME_USE_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_addressAutofillSupportedCountries",
+ ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF,
+ null,
+ val => val.split(",")
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "_creditCardAutofillSupportedCountries",
+ CREDITCARDS_AUTOFILL_SUPPORTED_COUNTRIES_PREF,
+ null,
+ null,
+ val => val.split(",")
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "supportRTL",
+ FORM_AUTOFILL_SUPPORT_RTL_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "creditCardsAutocompleteOff",
+ AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofill,
+ "addressesAutocompleteOff",
+ AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF
+);
+
+// XXX: This should be invalidated on intl:app-locales-changed.
+XPCOMUtils.defineLazyGetter(FormAutofill, "countries", () => {
+ let availableRegionCodes =
+ Services.intl.getAvailableLocaleDisplayNames("region");
+ let displayNames = Services.intl.getRegionDisplayNames(
+ undefined,
+ availableRegionCodes
+ );
+ let result = new Map();
+ for (let i = 0; i < availableRegionCodes.length; i++) {
+ result.set(availableRegionCodes[i].toUpperCase(), displayNames[i]);
+ }
+ return result;
+});
diff --git a/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs
new file mode 100644
index 0000000000..9c2be17778
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillChild.ios.sys.mjs
@@ -0,0 +1,65 @@
+/* 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/. */
+
+/* eslint-disable no-undef,mozilla/balanced-listeners */
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormStateManager } from "resource://gre/modules/shared/FormStateManager.sys.mjs";
+
+export class FormAutofillChild {
+ constructor(onSubmitCallback, onAutofillCallback) {
+ this.onFocusIn = this.onFocusIn.bind(this);
+ this.onSubmit = this.onSubmit.bind(this);
+
+ this.onSubmitCallback = onSubmitCallback;
+ this.onAutofillCallback = onAutofillCallback;
+
+ this.fieldDetailsManager = new FormStateManager();
+
+ document.addEventListener("focusin", this.onFocusIn);
+ document.addEventListener("submit", this.onSubmit);
+ }
+
+ _doIdentifyAutofillFields(element) {
+ this.fieldDetailsManager.updateActiveInput(element);
+ const validDetails =
+ this.fieldDetailsManager.identifyAutofillFields(element);
+
+ // Only ping swift if current field is a cc field
+ if (validDetails?.find(field => field.elementWeakRef.get() === element)) {
+ const fieldNamesWithValues = validDetails?.reduce(
+ (acc, field) => ({
+ ...acc,
+ [field.fieldName]: field.elementWeakRef.get().value,
+ }),
+ {}
+ );
+ this.onAutofillCallback(fieldNamesWithValues);
+ }
+ }
+
+ onFocusIn(evt) {
+ const element = evt.target;
+ this.fieldDetailsManager.updateActiveInput(element);
+ if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
+ return;
+ }
+ this._doIdentifyAutofillFields(element);
+ }
+
+ onSubmit(evt) {
+ this.fieldDetailsManager.activeHandler.onFormSubmitted();
+ const records = this.fieldDetailsManager.activeHandler.createRecords();
+ if (records.creditCard) {
+ this.onSubmitCallback(records.creditCard.map(entry => entry.record));
+ }
+ }
+
+ fillFormFields(payload) {
+ this.fieldDetailsManager.activeHandler.autofillFormFields(
+ JSON.parse(payload)
+ );
+ }
+}
+
+export default FormAutofillChild;
diff --git a/toolkit/components/formautofill/FormAutofillChild.sys.mjs b/toolkit/components/formautofill/FormAutofillChild.sys.mjs
new file mode 100644
index 0000000000..acefdcdc87
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillChild.sys.mjs
@@ -0,0 +1,195 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AutoCompleteChild: "resource://gre/actors/AutoCompleteChild.sys.mjs",
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+/**
+ * Handles content's interactions for the frame.
+ */
+export class FormAutofillChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ this._nextHandleElement = null;
+ this._alreadyDOMContentLoaded = false;
+ this._hasDOMContentLoadedHandler = false;
+ this._hasPendingTask = false;
+ this.testListener = null;
+
+ lazy.AutoCompleteChild.addPopupStateListener(this);
+ }
+
+ didDestroy() {
+ lazy.AutoCompleteChild.removePopupStateListener(this);
+ }
+
+ popupStateChanged(messageName, data, target) {
+ let docShell;
+ try {
+ docShell = this.docShell;
+ } catch (ex) {
+ lazy.AutoCompleteChild.removePopupStateListener(this);
+ return;
+ }
+
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ return;
+ }
+
+ const { chromeEventHandler } = docShell;
+
+ switch (messageName) {
+ case "FormAutoComplete:PopupClosed": {
+ lazy.FormAutofillContent.onPopupClosed(data.selectedRowStyle);
+ Services.tm.dispatchToMainThread(() => {
+ chromeEventHandler.removeEventListener(
+ "keydown",
+ lazy.FormAutofillContent._onKeyDown,
+ true
+ );
+ });
+
+ break;
+ }
+ case "FormAutoComplete:PopupOpened": {
+ lazy.FormAutofillContent.onPopupOpened();
+ chromeEventHandler.addEventListener(
+ "keydown",
+ lazy.FormAutofillContent._onKeyDown,
+ true
+ );
+ break;
+ }
+ }
+ }
+
+ _doIdentifyAutofillFields() {
+ if (this._hasPendingTask) {
+ return;
+ }
+ this._hasPendingTask = true;
+
+ lazy.setTimeout(() => {
+ lazy.FormAutofillContent.identifyAutofillFields(this._nextHandleElement);
+ this._hasPendingTask = false;
+ this._nextHandleElement = null;
+ // This is for testing purpose only which sends a notification to indicate that the
+ // form has been identified, and ready to open popup.
+ this.sendAsyncMessage("FormAutofill:FieldsIdentified");
+ lazy.FormAutofillContent.updateActiveInput();
+ });
+ }
+
+ shouldIgnoreFormAutofillEvent(event) {
+ let nodePrincipal = event.target.nodePrincipal;
+ return (
+ nodePrincipal.isSystemPrincipal ||
+ nodePrincipal.isNullPrincipal ||
+ nodePrincipal.schemeIs("about")
+ );
+ }
+
+ handleEvent(evt) {
+ if (!evt.isTrusted) {
+ return;
+ }
+
+ if (this.shouldIgnoreFormAutofillEvent(evt)) {
+ return;
+ }
+
+ switch (evt.type) {
+ case "focusin": {
+ if (lazy.FormAutofill.isAutofillEnabled) {
+ this.onFocusIn(evt);
+ }
+ break;
+ }
+ case "DOMFormBeforeSubmit": {
+ if (lazy.FormAutofill.isAutofillEnabled) {
+ this.onDOMFormBeforeSubmit(evt);
+ }
+ break;
+ }
+
+ default: {
+ throw new Error("Unexpected event type");
+ }
+ }
+ }
+
+ onFocusIn(evt) {
+ lazy.FormAutofillContent.updateActiveInput();
+
+ let element = evt.target;
+ if (!lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)) {
+ return;
+ }
+ this._nextHandleElement = element;
+
+ if (!this._alreadyDOMContentLoaded) {
+ let doc = element.ownerDocument;
+ if (doc.readyState === "loading") {
+ if (!this._hasDOMContentLoadedHandler) {
+ this._hasDOMContentLoadedHandler = true;
+ doc.addEventListener(
+ "DOMContentLoaded",
+ () => this._doIdentifyAutofillFields(),
+ { once: true }
+ );
+ }
+ return;
+ }
+ this._alreadyDOMContentLoaded = true;
+ }
+
+ this._doIdentifyAutofillFields();
+ }
+
+ /**
+ * Handle the DOMFormBeforeSubmit event.
+ *
+ * @param {Event} evt
+ */
+ onDOMFormBeforeSubmit(evt) {
+ let formElement = evt.target;
+
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ return;
+ }
+
+ lazy.FormAutofillContent.formSubmitted(formElement);
+ }
+
+ receiveMessage(message) {
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ return;
+ }
+
+ const doc = this.document;
+
+ switch (message.name) {
+ case "FormAutofill:PreviewProfile": {
+ lazy.FormAutofillContent.previewProfile(doc);
+ break;
+ }
+ case "FormAutofill:ClearForm": {
+ lazy.FormAutofillContent.clearForm();
+ break;
+ }
+ case "FormAutofill:FillForm": {
+ lazy.FormAutofillContent.activeHandler.autofillFormFields(message.data);
+ break;
+ }
+ }
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillContent.sys.mjs b/toolkit/components/formautofill/FormAutofillContent.sys.mjs
new file mode 100644
index 0000000000..a7a1031b34
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillContent.sys.mjs
@@ -0,0 +1,418 @@
+/* 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/. */
+
+/**
+ * Form Autofill content process module.
+ */
+
+/* eslint-disable no-use-before-define */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs",
+ ProfileAutocomplete:
+ "resource://autofill/AutofillProfileAutoComplete.sys.mjs",
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DELEGATE_AUTOCOMPLETE",
+ "toolkit.autocomplete.delegate",
+ false
+);
+
+const formFillController = Cc[
+ "@mozilla.org/satchel/form-fill-controller;1"
+].getService(Ci.nsIFormFillController);
+
+function getActorFromWindow(contentWindow, name = "FormAutofill") {
+ // In unit tests, contentWindow isn't a real window.
+ if (!contentWindow) {
+ return null;
+ }
+
+ return contentWindow.windowGlobalChild
+ ? contentWindow.windowGlobalChild.getActor(name)
+ : null;
+}
+
+/**
+ * Handles content's interactions for the process.
+ *
+ */
+export var FormAutofillContent = {
+ /**
+ * @type {Set} Set of the fields with usable values in any saved profile.
+ */
+ get savedFieldNames() {
+ return Services.cpmm.sharedData.get("FormAutofill:savedFieldNames");
+ },
+
+ /**
+ * @type {boolean} Flag indicating whether a focus action requiring
+ * the popup to be active is pending.
+ */
+ _popupPending: false,
+
+ /**
+ * @type {boolean} Flag indicating whether the form is waiting to be
+ * filled by Autofill.
+ */
+ _autofillPending: false,
+
+ init() {
+ this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillContent");
+ this.debug("init");
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ Services.cpmm.sharedData.addEventListener("change", this);
+
+ let autofillEnabled = Services.cpmm.sharedData.get("FormAutofill:enabled");
+ // If storage hasn't be initialized yet autofillEnabled is undefined but we need to ensure
+ // autocomplete is registered before the focusin so register it in this case as long as the
+ // pref is true.
+ let shouldEnableAutofill =
+ autofillEnabled === undefined &&
+ (lazy.FormAutofill.isAutofillAddressesEnabled ||
+ lazy.FormAutofill.isAutofillCreditCardsEnabled);
+ if (autofillEnabled || shouldEnableAutofill) {
+ lazy.ProfileAutocomplete.ensureRegistered();
+ }
+
+ /**
+ * @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
+ */
+ this._fieldDetailsManager = new lazy.FormStateManager(
+ this.formSubmitted.bind(this),
+ this._showPopup.bind(this)
+ );
+ },
+
+ get activeFieldDetail() {
+ return this._fieldDetailsManager.activeFieldDetail;
+ },
+
+ get activeFormDetails() {
+ return this._fieldDetailsManager.activeFormDetails;
+ },
+
+ get activeInput() {
+ return this._fieldDetailsManager.activeInput;
+ },
+
+ get activeHandler() {
+ return this._fieldDetailsManager.activeHandler;
+ },
+
+ get activeSection() {
+ return this._fieldDetailsManager.activeSection;
+ },
+
+ /**
+ * Send the profile to parent for doorhanger and storage saving/updating.
+ *
+ * @param {object} profile Submitted form's address/creditcard guid and record.
+ * @param {object} domWin Current content window.
+ */
+ _onFormSubmit(profile, domWin) {
+ let actor = getActorFromWindow(domWin);
+ actor.sendAsyncMessage("FormAutofill:OnFormSubmit", profile);
+ },
+
+ /**
+ * Handle a form submission and early return when:
+ * 1. In private browsing mode.
+ * 2. Could not map any autofill handler by form element.
+ * 3. Number of filled fields is less than autofill threshold
+ *
+ * @param {HTMLElement} formElement Root element which receives submit event.
+ * @param {Window} domWin Content window; passed for unit tests and when
+ * invoked by the FormAutofillSection
+ * @param {object} handler FormAutofillHander, if known by caller
+ */
+ formSubmitted(
+ formElement,
+ domWin = formElement.ownerGlobal,
+ handler = undefined
+ ) {
+ this.debug("Handling form submission");
+
+ if (!lazy.FormAutofill.isAutofillEnabled) {
+ this.debug("Form Autofill is disabled");
+ return;
+ }
+
+ // The `domWin` truthiness test is used by unit tests to bypass this check.
+ if (domWin && lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
+ this.debug("Ignoring submission in a private window");
+ return;
+ }
+
+ handler = handler || this._fieldDetailsManager._getFormHandler(formElement);
+ const records = this._fieldDetailsManager.getRecords(formElement, handler);
+
+ if (!records || !handler) {
+ this.debug("Form element could not map to an existing handler");
+ return;
+ }
+
+ [records.address, records.creditCard].forEach((rs, idx) => {
+ lazy.AutofillTelemetry.recordSubmittedSectionCount(
+ idx == 0
+ ? lazy.AutofillTelemetry.ADDRESS
+ : lazy.AutofillTelemetry.CREDIT_CARD,
+ rs?.length
+ );
+
+ rs?.forEach(r => {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "submitted",
+ r.section,
+ {
+ record: r,
+ form: handler.form,
+ }
+ );
+ delete r.section;
+ });
+ });
+
+ this._onFormSubmit(records, domWin);
+ },
+
+ _showPopup() {
+ formFillController.showPopup();
+ },
+
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "change": {
+ if (!evt.changedKeys.includes("FormAutofill:enabled")) {
+ return;
+ }
+ if (Services.cpmm.sharedData.get("FormAutofill:enabled")) {
+ lazy.ProfileAutocomplete.ensureRegistered();
+ if (this._popupPending) {
+ this._popupPending = false;
+ this.debug("handleEvent: Opening deferred popup");
+ this._showPopup();
+ }
+ } else {
+ lazy.ProfileAutocomplete.ensureUnregistered();
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * All active items should be updated according the active element of
+ * `formFillController.focusedInput`. All of them including element,
+ * handler, section, and field detail, can be retrieved by their own getters.
+ *
+ * @param {HTMLElement|null} element The active item should be updated based
+ * on this or `formFillController.focusedInput` will be taken.
+ */
+ updateActiveInput(element) {
+ element = element || formFillController.focusedInput;
+ if (!element) {
+ this.debug("updateActiveElement: no element selected");
+ return;
+ }
+ this._fieldDetailsManager.updateActiveInput(element);
+ this.debug("updateActiveElement: checking for popup-on-focus");
+ // We know this element just received focus. If it's a credit card field,
+ // open its popup.
+ if (this._autofillPending) {
+ this.debug("updateActiveElement: skipping check; autofill is imminent");
+ } else if (element.value?.length !== 0) {
+ this.debug(
+ `updateActiveElement: Not opening popup because field is not empty.`
+ );
+ } else {
+ this.debug(
+ "updateActiveElement: checking if empty field is cc-*: ",
+ this.activeFieldDetail?.fieldName
+ );
+
+ if (
+ this.activeFieldDetail?.fieldName?.startsWith("cc-") ||
+ AppConstants.platform === "android"
+ ) {
+ if (Services.cpmm.sharedData.get("FormAutofill:enabled")) {
+ this.debug("updateActiveElement: opening pop up");
+ this._showPopup();
+ } else {
+ this.debug(
+ "updateActiveElement: Deferring pop-up until Autofill is ready"
+ );
+ this._popupPending = true;
+ }
+ }
+ }
+ },
+
+ set autofillPending(flag) {
+ this.debug("Setting autofillPending to", flag);
+ this._autofillPending = flag;
+ },
+
+ identifyAutofillFields(element) {
+ this.debug(
+ `identifyAutofillFields: ${element.ownerDocument.location?.hostname}`
+ );
+
+ if (lazy.DELEGATE_AUTOCOMPLETE || !this.savedFieldNames) {
+ this.debug("identifyAutofillFields: savedFieldNames are not known yet");
+ let actor = getActorFromWindow(element.ownerGlobal);
+ if (actor) {
+ actor.sendAsyncMessage("FormAutofill:InitStorage");
+ }
+ }
+
+ const validDetails =
+ this._fieldDetailsManager.identifyAutofillFields(element);
+
+ validDetails?.forEach(detail =>
+ this._markAsAutofillField(detail.elementWeakRef.get())
+ );
+ },
+
+ clearForm() {
+ let focusedInput =
+ this.activeInput ||
+ lazy.ProfileAutocomplete._lastAutoCompleteFocusedInput;
+ if (!focusedInput) {
+ return;
+ }
+
+ this.activeSection.clearPopulatedForm();
+
+ let fieldName = FormAutofillContent.activeFieldDetail?.fieldName;
+ if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "cleared",
+ this.activeSection,
+ { fieldName }
+ );
+ }
+ },
+
+ previewProfile(doc) {
+ let docWin = doc.ownerGlobal;
+ let selectedIndex = lazy.ProfileAutocomplete._getSelectedIndex(docWin);
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = this.activeInput;
+ let actor = getActorFromWindow(docWin);
+
+ if (
+ selectedIndex === -1 ||
+ !focusedInput ||
+ !lastAutoCompleteResult ||
+ lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile"
+ ) {
+ actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {});
+
+ lazy.ProfileAutocomplete._clearProfilePreview();
+ } else {
+ let focusedInputDetails = this.activeFieldDetail;
+ let profile = JSON.parse(
+ lastAutoCompleteResult.getCommentAt(selectedIndex)
+ );
+ let allFieldNames = FormAutofillContent.activeSection.allFieldNames;
+ let profileFields = allFieldNames.filter(
+ fieldName => !!profile[fieldName]
+ );
+
+ let focusedCategory = lazy.FormAutofillUtils.getCategoryFromFieldName(
+ focusedInputDetails.fieldName
+ );
+ let categories =
+ lazy.FormAutofillUtils.getCategoriesFromFieldNames(profileFields);
+ actor.sendAsyncMessage("FormAutofill:UpdateWarningMessage", {
+ focusedCategory,
+ categories,
+ });
+
+ lazy.ProfileAutocomplete._previewSelectedProfile(selectedIndex);
+ }
+ },
+
+ onPopupClosed(selectedRowStyle) {
+ this.debug("Popup has closed.");
+ lazy.ProfileAutocomplete._clearProfilePreview();
+
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = FormAutofillContent.activeInput;
+ if (
+ lastAutoCompleteResult &&
+ FormAutofillContent._keyDownEnterForInput &&
+ focusedInput === FormAutofillContent._keyDownEnterForInput &&
+ focusedInput ===
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput
+ ) {
+ if (selectedRowStyle == "autofill-footer") {
+ let actor = getActorFromWindow(focusedInput.ownerGlobal);
+ actor.sendAsyncMessage("FormAutofill:OpenPreferences");
+ } else if (selectedRowStyle == "autofill-clear-button") {
+ FormAutofillContent.clearForm();
+ }
+ }
+ },
+
+ onPopupOpened() {
+ this.debug(
+ "Popup has opened, automatic =",
+ formFillController.passwordPopupAutomaticallyOpened
+ );
+
+ let fieldName = FormAutofillContent.activeFieldDetail?.fieldName;
+ if (fieldName && this.activeSection) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "popup_shown",
+ this.activeSection,
+ { fieldName }
+ );
+ }
+ },
+
+ _markAsAutofillField(field) {
+ // Since Form Autofill popup is only for input element, any non-Input
+ // element should be excluded here.
+ if (!HTMLInputElement.isInstance(field)) {
+ return;
+ }
+
+ formFillController.markAsAutofillField(field);
+ },
+
+ _onKeyDown(e) {
+ delete FormAutofillContent._keyDownEnterForInput;
+ let lastAutoCompleteResult =
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteResult;
+ let focusedInput = FormAutofillContent.activeInput;
+ if (
+ e.keyCode != e.DOM_VK_RETURN ||
+ !lastAutoCompleteResult ||
+ !focusedInput ||
+ focusedInput !=
+ lazy.ProfileAutocomplete.lastProfileAutoCompleteFocusedInput
+ ) {
+ return;
+ }
+ FormAutofillContent._keyDownEnterForInput = focusedInput;
+ },
+};
+
+FormAutofillContent.init();
diff --git a/toolkit/components/formautofill/FormAutofillNative.cpp b/toolkit/components/formautofill/FormAutofillNative.cpp
new file mode 100644
index 0000000000..f86dc695b7
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillNative.cpp
@@ -0,0 +1,1483 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* 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/. */
+
+#include "FormAutofillNative.h"
+
+#include <math.h>
+
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/ComputedStyle.h"
+#include "mozilla/dom/AutocompleteInfoBinding.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/HTMLInputElement.h"
+#include "mozilla/dom/HTMLLabelElement.h"
+#include "mozilla/dom/HTMLOptionElement.h"
+#include "mozilla/dom/HTMLSelectElement.h"
+#include "mozilla/HashTable.h"
+#include "mozilla/RustRegex.h"
+#include "nsContentUtils.h"
+#include "nsIFrame.h"
+#include "nsIFrameInlines.h"
+#include "nsLayoutUtils.h"
+#include "nsTStringHasher.h"
+#include "mozilla/StaticPtr.h"
+
+namespace mozilla::dom {
+
+static const char kWhitespace[] = "\b\t\r\n ";
+
+enum class RegexKey : uint8_t {
+ CC_NAME,
+ CC_NUMBER,
+ CC_EXP,
+ CC_EXP_MONTH,
+ CC_EXP_YEAR,
+ CC_TYPE,
+ MM_MONTH,
+ YY_OR_YYYY,
+ MONTH,
+ YEAR,
+ MMYY,
+ VISA_CHECKOUT,
+ CREDIT_CARD_NETWORK,
+ CREDIT_CARD_NETWORK_EXACT_MATCH,
+ CREDIT_CARD_NETWORK_LONG,
+ TWO_OR_FOUR_DIGIT_YEAR,
+ DWFRM,
+ BML,
+ TEMPLATED_VALUE,
+ FIRST,
+ LAST,
+ GIFT,
+ SUBSCRIPTION,
+ VALIDATION,
+
+ Count
+};
+
+// We don't follow the coding style (naming start with capital letter) here and
+// the following CCXXX enum class because we want to sync the rule naming with
+// the JS implementation.
+enum class CCNumberParams : uint8_t {
+ idOrNameMatchNumberRegExp,
+ labelsMatchNumberRegExp,
+ closestLabelMatchesNumberRegExp,
+ placeholderMatchesNumberRegExp,
+ ariaLabelMatchesNumberRegExp,
+ idOrNameMatchGift,
+ labelsMatchGift,
+ placeholderMatchesGift,
+ ariaLabelMatchesGift,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ inputTypeNotNumbery,
+
+ Count,
+};
+
+enum class CCNameParams : uint8_t {
+ idOrNameMatchNameRegExp,
+ labelsMatchNameRegExp,
+ closestLabelMatchesNameRegExp,
+ placeholderMatchesNameRegExp,
+ ariaLabelMatchesNameRegExp,
+ idOrNameMatchFirst,
+ labelsMatchFirst,
+ placeholderMatchesFirst,
+ ariaLabelMatchesFirst,
+ idOrNameMatchLast,
+ labelsMatchLast,
+ placeholderMatchesLast,
+ ariaLabelMatchesLast,
+ idOrNameMatchFirstAndLast,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCTypeParams : uint8_t {
+ idOrNameMatchTypeRegExp,
+ labelsMatchTypeRegExp,
+ closestLabelMatchesTypeRegExp,
+ idOrNameMatchVisaCheckout,
+ ariaLabelMatchesVisaCheckout,
+ isSelectWithCreditCardOptions,
+ isRadioWithCreditCardText,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCExpParams : uint8_t {
+ labelsMatchExpRegExp,
+ closestLabelMatchesExpRegExp,
+ placeholderMatchesExpRegExp,
+ labelsMatchExpWith2Or4DigitYear,
+ placeholderMatchesExpWith2Or4DigitYear,
+ labelsMatchMMYY,
+ placeholderMatchesMMYY,
+ maxLengthIs7,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ isExpirationMonthLikely,
+ isExpirationYearLikely,
+ idOrNameMatchMonth,
+ idOrNameMatchYear,
+ idOrNameMatchExpMonthRegExp,
+ idOrNameMatchExpYearRegExp,
+ idOrNameMatchValidation,
+ Count,
+};
+
+enum class CCExpMonthParams : uint8_t {
+ idOrNameMatchExpMonthRegExp,
+ labelsMatchExpMonthRegExp,
+ closestLabelMatchesExpMonthRegExp,
+ placeholderMatchesExpMonthRegExp,
+ ariaLabelMatchesExpMonthRegExp,
+ idOrNameMatchMonth,
+ labelsMatchMonth,
+ placeholderMatchesMonth,
+ ariaLabelMatchesMonth,
+ nextFieldIdOrNameMatchExpYearRegExp,
+ nextFieldLabelsMatchExpYearRegExp,
+ nextFieldPlaceholderMatchExpYearRegExp,
+ nextFieldAriaLabelMatchExpYearRegExp,
+ nextFieldIdOrNameMatchYear,
+ nextFieldLabelsMatchYear,
+ nextFieldPlaceholderMatchesYear,
+ nextFieldAriaLabelMatchesYear,
+ nextFieldMatchesExpYearAutocomplete,
+ isExpirationMonthLikely,
+ nextFieldIsExpirationYearLikely,
+ maxLengthIs2,
+ placeholderMatchesMM,
+ roleIsMenu,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+enum class CCExpYearParams : uint8_t {
+ idOrNameMatchExpYearRegExp,
+ labelsMatchExpYearRegExp,
+ closestLabelMatchesExpYearRegExp,
+ placeholderMatchesExpYearRegExp,
+ ariaLabelMatchesExpYearRegExp,
+ idOrNameMatchYear,
+ labelsMatchYear,
+ placeholderMatchesYear,
+ ariaLabelMatchesYear,
+ previousFieldIdOrNameMatchExpMonthRegExp,
+ previousFieldLabelsMatchExpMonthRegExp,
+ previousFieldPlaceholderMatchExpMonthRegExp,
+ previousFieldAriaLabelMatchExpMonthRegExp,
+ previousFieldIdOrNameMatchMonth,
+ previousFieldLabelsMatchMonth,
+ previousFieldPlaceholderMatchesMonth,
+ previousFieldAriaLabelMatchesMonth,
+ previousFieldMatchesExpMonthAutocomplete,
+ isExpirationYearLikely,
+ previousFieldIsExpirationMonthLikely,
+ placeholderMatchesYYOrYYYY,
+ roleIsMenu,
+ idOrNameMatchSubscription,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+
+ Count,
+};
+
+struct AutofillParams {
+ EnumeratedArray<CCNumberParams, CCNumberParams::Count, double>
+ mCCNumberParams;
+ EnumeratedArray<CCNameParams, CCNameParams::Count, double> mCCNameParams;
+ EnumeratedArray<CCTypeParams, CCTypeParams::Count, double> mCCTypeParams;
+ EnumeratedArray<CCExpParams, CCExpParams::Count, double> mCCExpParams;
+ EnumeratedArray<CCExpMonthParams, CCExpMonthParams::Count, double>
+ mCCExpMonthParams;
+ EnumeratedArray<CCExpYearParams, CCExpYearParams::Count, double>
+ mCCExpYearParams;
+};
+
+// clang-format off
+constexpr AutofillParams kCoefficents{
+ .mCCNumberParams = {
+ /* idOrNameMatchNumberRegExp */ 7.679469585418701,
+ /* labelsMatchNumberRegExp */ 5.122580051422119,
+ /* closestLabelMatchesNumberRegExp */ 2.1256935596466064,
+ /* placeholderMatchesNumberRegExp */ 9.471800804138184,
+ /* ariaLabelMatchesNumberRegExp */ 6.067715644836426,
+ /* idOrNameMatchGift */ -22.946273803710938,
+ /* labelsMatchGift */ -7.852959632873535,
+ /* placeholderMatchesGift */ -2.355496406555176,
+ /* ariaLabelMatchesGift */ -2.940307855606079,
+ /* idOrNameMatchSubscription */ 0.11255314946174622,
+ /* idOrNameMatchDwfrmAndBml */ -0.0006645023822784424,
+ /* hasTemplatedValue */ -0.11370040476322174,
+ /* inputTypeNotNumbery */ -3.750155210494995
+ },
+ .mCCNameParams = {
+ /* idOrNameMatchNameRegExp */ 7.496212959289551,
+ /* labelsMatchNameRegExp */ 6.081472873687744,
+ /* closestLabelMatchesNameRegExp */ 2.600574254989624,
+ /* placeholderMatchesNameRegExp */ 5.750874042510986,
+ /* ariaLabelMatchesNameRegExp */ 5.162227153778076,
+ /* idOrNameMatchFirst */ -6.742659091949463,
+ /* labelsMatchFirst */ -0.5234538912773132,
+ /* placeholderMatchesFirst */ -3.4615235328674316,
+ /* ariaLabelMatchesFirst */ -1.3145145177841187,
+ /* idOrNameMatchLast */ -12.561869621276855,
+ /* labelsMatchLast */ -0.27417105436325073,
+ /* placeholderMatchesLast */ -1.434966802597046,
+ /* ariaLabelMatchesLast */ -2.9319725036621094,
+ /* idOrNameMatchFirstAndLast */ 24.123435974121094,
+ /* idOrNameMatchSubscription */ 0.08349418640136719,
+ /* idOrNameMatchDwfrmAndBml */ 0.01882520318031311,
+ /* hasTemplatedValue */ 0.182317852973938
+ },
+ .mCCTypeParams = {
+ /* idOrNameMatchTypeRegExp */ 2.0581533908843994,
+ /* labelsMatchTypeRegExp */ 1.0784518718719482,
+ /* closestLabelMatchesTypeRegExp */ 0.6995877623558044,
+ /* idOrNameMatchVisaCheckout */ -3.320356845855713,
+ /* ariaLabelMatchesVisaCheckout */ -3.4196767807006836,
+ /* isSelectWithCreditCardOptions */ 10.337477684020996,
+ /* isRadioWithCreditCardText */ 4.530318737030029,
+ /* idOrNameMatchSubscription */ -3.7206356525421143,
+ /* idOrNameMatchDwfrmAndBml */ -0.08782318234443665,
+ /* hasTemplatedValue */ 0.1772511601448059
+ },
+ .mCCExpParams = {
+ /* labelsMatchExpRegExp */ 7.588159561157227,
+ /* closestLabelMatchesExpRegExp */ 1.41484534740448,
+ /* placeholderMatchesExpRegExp */ 8.759064674377441,
+ /* labelsMatchExpWith2Or4DigitYear */ -3.876218795776367,
+ /* placeholderMatchesExpWith2Or4DigitYear */ 2.8364884853363037,
+ /* labelsMatchMMYY */ 8.836017608642578,
+ /* placeholderMatchesMMYY */ -0.5231751799583435,
+ /* maxLengthIs7 */ 1.3565447330474854,
+ /* idOrNameMatchSubscription */ 0.1779913753271103,
+ /* idOrNameMatchDwfrmAndBml */ 0.21037884056568146,
+ /* hasTemplatedValue */ 0.14900512993335724,
+ /* isExpirationMonthLikely */ -3.223409652709961,
+ /* isExpirationYearLikely */ -2.536919593811035,
+ /* idOrNameMatchMonth */ -3.6893014907836914,
+ /* idOrNameMatchYear */ -3.108184337615967,
+ /* idOrNameMatchExpMonthRegExp */ -2.264357089996338,
+ /* idOrNameMatchExpYearRegExp */ -2.7957723140716553,
+ /* idOrNameMatchValidation */ -2.29402756690979
+ },
+ .mCCExpMonthParams = {
+ /* idOrNameMatchExpMonthRegExp */ 0.2787344455718994,
+ /* labelsMatchExpMonthRegExp */ 1.298413634300232,
+ /* closestLabelMatchesExpMonthRegExp */ -11.206244468688965,
+ /* placeholderMatchesExpMonthRegExp */ 1.2605619430541992,
+ /* ariaLabelMatchesExpMonthRegExp */ 1.1330018043518066,
+ /* idOrNameMatchMonth */ 6.1464314460754395,
+ /* labelsMatchMonth */ 0.7051732540130615,
+ /* placeholderMatchesMonth */ 0.7463492751121521,
+ /* ariaLabelMatchesMonth */ 1.8244760036468506,
+ /* nextFieldIdOrNameMatchExpYearRegExp */ 0.06347066164016724,
+ /* nextFieldLabelsMatchExpYearRegExp */ -0.1692247837781906,
+ /* nextFieldPlaceholderMatchExpYearRegExp */ 1.0434566736221313,
+ /* nextFieldAriaLabelMatchExpYearRegExp */ 1.751156210899353,
+ /* nextFieldIdOrNameMatchYear */ -0.532447338104248,
+ /* nextFieldLabelsMatchYear */ 1.3248541355133057,
+ /* nextFieldPlaceholderMatchesYear */ 0.604235827922821,
+ /* nextFieldAriaLabelMatchesYear */ 1.5364223718643188,
+ /* nextFieldMatchesExpYearAutocomplete */ 6.285938262939453,
+ /* isExpirationMonthLikely */ 13.117807388305664,
+ /* nextFieldIsExpirationYearLikely */ 7.182341575622559,
+ /* maxLengthIs2 */ 4.477289199829102,
+ /* placeholderMatchesMM */ 14.403288841247559,
+ /* roleIsMenu */ 5.770959854125977,
+ /* idOrNameMatchSubscription */ -0.043085768818855286,
+ /* idOrNameMatchDwfrmAndBml */ 0.02823038399219513,
+ /* hasTemplatedValue */ 0.07234494388103485
+ },
+ .mCCExpYearParams = {
+ /* idOrNameMatchExpYearRegExp */ 5.426016807556152,
+ /* labelsMatchExpYearRegExp */ 1.3240209817886353,
+ /* closestLabelMatchesExpYearRegExp */ -8.702284812927246,
+ /* placeholderMatchesExpYearRegExp */ 0.9059725999832153,
+ /* ariaLabelMatchesExpYearRegExp */ 0.5550334453582764,
+ /* idOrNameMatchYear */ 5.362994194030762,
+ /* labelsMatchYear */ 2.7185044288635254,
+ /* placeholderMatchesYear */ 0.7883157134056091,
+ /* ariaLabelMatchesYear */ 0.311492383480072,
+ /* previousFieldIdOrNameMatchExpMonthRegExp */ 1.8155208826065063,
+ /* previousFieldLabelsMatchExpMonthRegExp */ -0.46133187413215637,
+ /* previousFieldPlaceholderMatchExpMonthRegExp */ 1.0374903678894043,
+ /* previousFieldAriaLabelMatchExpMonthRegExp */ -0.5901495814323425,
+ /* previousFieldIdOrNameMatchMonth */ -5.960310935974121,
+ /* previousFieldLabelsMatchMonth */ 0.6495584845542908,
+ /* previousFieldPlaceholderMatchesMonth */ 0.7198042273521423,
+ /* previousFieldAriaLabelMatchesMonth */ 3.4590985774993896,
+ /* previousFieldMatchesExpMonthAutocomplete */ 2.986003875732422,
+ /* isExpirationYearLikely */ 4.021566390991211,
+ /* previousFieldIsExpirationMonthLikely */ 9.298635482788086,
+ /* placeholderMatchesYYOrYYYY */ 10.457176208496094,
+ /* roleIsMenu */ 1.1051956415176392,
+ /* idOrNameMatchSubscription */ 0.000688597559928894,
+ /* idOrNameMatchDwfrmAndBml */ 0.15687309205532074,
+ /* hasTemplatedValue */ -0.19141331315040588
+ }
+};
+// clang-format off
+
+constexpr float kCCNumberBias = -4.948795795440674;
+constexpr float kCCNameBias = -5.3578081130981445;
+// Comment out code that are not used right now
+/*
+constexpr float kCCTypeBias = -5.979659557342529;
+constexpr float kCCExpBias = -5.849575996398926;
+constexpr float kCCExpMonthBias = -8.844199180603027;
+constexpr float kCCExpYearBias = -6.499860763549805;
+*/
+
+struct Rule {
+ RegexKey key;
+ const char* pattern;
+};
+
+const Rule kFirefoxRules[] = {
+ {RegexKey::MM_MONTH, "^mm$|\\(mm\\)"},
+ {RegexKey::YY_OR_YYYY, "^(yy|yyyy)$|\\(yy\\)|\\(yyyy\\)"},
+ {RegexKey::MONTH, "month"},
+ {RegexKey::YEAR, "year"},
+ {RegexKey::MMYY, "mm\\s*(/|\\\\)\\s*yy"},
+ {RegexKey::VISA_CHECKOUT, "visa(-|\\s)checkout"},
+ // This should be a union of NETWORK_NAMES in CreditCard.sys.mjs
+ {RegexKey::CREDIT_CARD_NETWORK_LONG,
+ "american express|master card|union pay"},
+ // Please also update CREDIT_CARD_NETWORK_EXACT_MATCH while updating
+ // CREDIT_CARD_NETWORK
+ {RegexKey::CREDIT_CARD_NETWORK,
+ "amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|visa"},
+ {RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH,
+ "^\\s*(?:amex|cartebancaire|diners|discover|jcb|mastercard|mir|unionpay|"
+ "visa)\\s*$"},
+ {RegexKey::TWO_OR_FOUR_DIGIT_YEAR,
+ "(?:exp.*date[^y\\\\n\\\\r]*|mm\\\\s*[-/]?\\\\s*)yy(?:yy)?(?:[^y]|$)"},
+ {RegexKey::DWFRM, "^dwfrm"},
+ {RegexKey::BML, "BML"},
+ {RegexKey::TEMPLATED_VALUE, "^\\{\\{.*\\}\\}$"},
+ {RegexKey::FIRST, "first"},
+ {RegexKey::LAST, "last"},
+ {RegexKey::GIFT, "gift"},
+ {RegexKey::SUBSCRIPTION, "subscription"},
+ {RegexKey::VALIDATION, "validate|validation"},
+};
+
+// These are the rules used by Bitwarden [0], converted into RegExp form.
+// [0]
+// https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436
+const Rule kCreditCardRules[] = {
+ /* eslint-disable */
+ // Let us keep our consistent wrapping.
+ {RegexKey::CC_NAME,
+ // Firefox-specific rules
+ "account.*holder.*name"
+ // de-DE
+ "|^(kredit)?(karten|konto)inhaber"
+ "|^(name).*karte"
+ // fr-FR
+ "|nom.*(titulaire|détenteur)"
+ "|(titulaire|détenteur).*(carte)"
+ // it-IT
+ "|titolare.*carta"
+ // pl-PL
+ "|posiadacz.*karty"
+ // Rules from Bitwarden
+ "|cc-?name"
+ "|card-?name"
+ "|cardholder-?name"
+ "|(^nom$)"
+ // Rules are from Chromium source codes
+ "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card"
+ "|(?:card|cc).?name|cc.?full.?name"
+ "|(?:card|cc).?owner"
+ "|nombre.*tarjeta" // es
+ "|nom.*carte" // fr-FR
+ "|nome.*cart" // it-IT
+ "|名前" // ja-JP
+ "|Имя.*карты" // ru
+ "|信用卡开户名|开户名|持卡人姓名" // zh-CN
+ "|持卡人姓名"}, // zh-TW
+ /* eslint-enable */
+
+ {RegexKey::CC_NUMBER,
+ // Firefox-specific rules
+ // de-DE
+ "(cc|kk)nr"
+ "|(kredit)?(karten)(nummer|nr)"
+ // it-IT
+ "|numero.*carta"
+ // fr-FR
+ "|(numero|número|numéro).*(carte)"
+ // pl-PL
+ "|numer.*karty"
+ // Rules from Bitwarden
+ "|cc-?number"
+ "|cc-?num"
+ "|card-?number"
+ "|card-?num"
+ "|cc-?no"
+ "|card-?no"
+ "|numero-?carte"
+ "|num-?carte"
+ "|cb-?num"
+ // Rules are from Chromium source codes
+ "|(add)?(?:card|cc|acct).?(?:number|#|no|num)"
+ "|カード番号" // ja-JP
+ "|Номер.*карты" // ru
+ "|信用卡号|信用卡号码" // zh-CN
+ "|信用卡卡號" // zh-TW
+ "|카드"}, // ko-KR
+
+ {RegexKey::CC_EXP,
+ // Firefox-specific rules
+ "mm\\s*(/|\\|-)\\s*(yy|jj|aa)"
+ "|(month|mois)\\s*(/|\\|-|et)\\s*(year|année)"
+ // de-DE
+ // fr-FR
+ // Rules from Bitwarden
+ "|(^cc-?exp$)"
+ "|(^card-?exp$)"
+ "|(^cc-?expiration$)"
+ "|(^card-?expiration$)"
+ "|(^cc-?ex$)"
+ "|(^card-?ex$)"
+ "|(^card-?expire$)"
+ "|(^card-?expiry$)"
+ "|(^validite$)"
+ "|(^expiration$)"
+ "|(^expiry$)"
+ "|mm-?yy"
+ "|mm-?yyyy"
+ "|yy-?mm"
+ "|yyyy-?mm"
+ "|expiration-?date"
+ "|payment-?card-?expiration"
+ "|(^payment-?cc-?date$)"
+ // Rules are from Chromium source codes
+ "|expir|exp.*date|^expfield$"
+ "|ablaufdatum|gueltig|gültig" // de-DE
+ "|fecha" // es
+ "|date.*exp" // fr-FR
+ "|scadenza" // it-IT
+ "|有効期限" // ja-JP
+ "|validade" // pt-BR, pt-PT
+ "|Срок действия карты"}, // ru
+
+ {RegexKey::CC_EXP_MONTH,
+ // Firefox-specific rules
+ "(cc|kk)month" // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?month$)"
+ "|(^cc-?exp-?month$)"
+ "|(^cc-?month$)"
+ "|(^card-?month$)"
+ "|(^cc-?mo$)"
+ "|(^card-?mo$)"
+ "|(^exp-?mo$)"
+ "|(^card-?exp-?mo$)"
+ "|(^cc-?exp-?mo$)"
+ "|(^card-?expiration-?month$)"
+ "|(^expiration-?month$)"
+ "|(^cc-?mm$)"
+ "|(^cc-?m$)"
+ "|(^card-?mm$)"
+ "|(^card-?m$)"
+ "|(^card-?exp-?mm$)"
+ "|(^cc-?exp-?mm$)"
+ "|(^exp-?mm$)"
+ "|(^exp-?m$)"
+ "|(^expire-?month$)"
+ "|(^expire-?mo$)"
+ "|(^expiry-?month$)"
+ "|(^expiry-?mo$)"
+ "|(^card-?expire-?month$)"
+ "|(^card-?expire-?mo$)"
+ "|(^card-?expiry-?month$)"
+ "|(^card-?expiry-?mo$)"
+ "|(^mois-?validite$)"
+ "|(^mois-?expiration$)"
+ "|(^m-?validite$)"
+ "|(^m-?expiration$)"
+ "|(^expiry-?date-?field-?month$)"
+ "|(^expiration-?date-?month$)"
+ "|(^expiration-?date-?mm$)"
+ "|(^exp-?mon$)"
+ "|(^validity-?mo$)"
+ "|(^exp-?date-?mo$)"
+ "|(^cb-?date-?mois$)"
+ "|(^date-?m$)"
+ // Rules are from Chromium source codes
+ "|exp.*mo|ccmonth|cardmonth|addmonth"
+ "|monat" // de-DE
+ // "|fecha" // es
+ // "|date.*exp" // fr-FR
+ // "|scadenza" // it-IT
+ // "|有効期限" // ja-JP
+ // "|validade" // pt-BR, pt-PT
+ // "|Срок действия карты" // ru
+ "|月"}, // zh-CN
+
+ {RegexKey::CC_EXP_YEAR,
+ // Firefox-specific rules
+ "(cc|kk)year" // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?year$)"
+ "|(^cc-?exp-?year$)"
+ "|(^cc-?year$)"
+ "|(^card-?year$)"
+ "|(^cc-?yr$)"
+ "|(^card-?yr$)"
+ "|(^exp-?yr$)"
+ "|(^card-?exp-?yr$)"
+ "|(^cc-?exp-?yr$)"
+ "|(^card-?expiration-?year$)"
+ "|(^expiration-?year$)"
+ "|(^cc-?yy$)"
+ "|(^cc-?y$)"
+ "|(^card-?yy$)"
+ "|(^card-?y$)"
+ "|(^card-?exp-?yy$)"
+ "|(^cc-?exp-?yy$)"
+ "|(^exp-?yy$)"
+ "|(^exp-?y$)"
+ "|(^cc-?yyyy$)"
+ "|(^card-?yyyy$)"
+ "|(^card-?exp-?yyyy$)"
+ "|(^cc-?exp-?yyyy$)"
+ "|(^expire-?year$)"
+ "|(^expire-?yr$)"
+ "|(^expiry-?year$)"
+ "|(^expiry-?yr$)"
+ "|(^card-?expire-?year$)"
+ "|(^card-?expire-?yr$)"
+ "|(^card-?expiry-?year$)"
+ "|(^card-?expiry-?yr$)"
+ "|(^an-?validite$)"
+ "|(^an-?expiration$)"
+ "|(^annee-?validite$)"
+ "|(^annee-?expiration$)"
+ "|(^expiry-?date-?field-?year$)"
+ "|(^expiration-?date-?year$)"
+ "|(^cb-?date-?ann$)"
+ "|(^expiration-?date-?yy$)"
+ "|(^expiration-?date-?yyyy$)"
+ "|(^validity-?year$)"
+ "|(^exp-?date-?year$)"
+ "|(^date-?y$)"
+ // Rules are from Chromium source codes
+ "|(add)?year"
+ "|jahr" // de-DE
+ // "|fecha" // es
+ // "|scadenza" // it-IT
+ // "|有効期限" // ja-JP
+ // "|validade" // pt-BR, pt-PT
+ // "|Срок действия карты" // ru
+ "|年|有效期"}, // zh-CN
+
+ {RegexKey::CC_TYPE,
+ // Firefox-specific rules
+ "type"
+ // de-DE
+ "|Kartenmarke"
+ // Rules from Bitwarden
+ "|(^cc-?type$)"
+ "|(^card-?type$)"
+ "|(^card-?brand$)"
+ "|(^cc-?brand$)"
+ "|(^cb-?type$)"},
+ // Rules are from Chromium source codes
+};
+
+static double Sigmoid(double x) { return 1.0 / (1.0 + exp(-x)); }
+
+class FormAutofillImpl {
+ public:
+ FormAutofillImpl();
+
+ void GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv);
+
+ private:
+ const RustRegex& GetRegex(RegexKey key);
+
+ bool StringMatchesRegExp(const nsACString& str, RegexKey key);
+ bool StringMatchesRegExp(const nsAString& str, RegexKey key);
+ bool TextContentMatchesRegExp(Element& element, RegexKey key);
+ size_t CountRegExpMatches(const nsACString& str, RegexKey key);
+ size_t CountRegExpMatches(const nsAString& str, RegexKey key);
+ bool IdOrNameMatchRegExp(Element& element, RegexKey key);
+ bool NextFieldMatchesExpYearAutocomplete(Element* aNextField);
+ bool PreviousFieldMatchesExpMonthAutocomplete(Element* aPrevField);
+ bool LabelMatchesRegExp(Element& element, const nsTArray<nsCString>* labels,
+ RegexKey key);
+ bool ClosestLabelMatchesRegExp(Element& aElement, RegexKey aKey);
+ bool PlaceholderMatchesRegExp(Element& element, RegexKey key);
+ bool AriaLabelMatchesRegExp(Element& element, RegexKey key);
+ bool AutocompleteStringMatches(Element& aElement, const nsAString& aKey);
+
+ bool HasTemplatedValue(Element& element);
+ bool MaxLengthIs(Element& aElement, int32_t aValue);
+ bool IsExpirationMonthLikely(Element& element);
+ bool IsExpirationYearLikely(Element& element);
+ bool InputTypeNotNumbery(Element& element);
+ bool IsSelectWithCreditCardOptions(Element& element);
+ bool IsRadioWithCreditCardText(Element& element,
+ const nsTArray<nsCString>* labels,
+ ErrorResult& aRv);
+ bool MatchesExpYearAutocomplete(Element& element);
+ bool RoleIsMenu(Element& element);
+
+ Element* FindRootForField(Element* aElement);
+
+ Element* FindField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex, int8_t aDirection);
+ Element* NextField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex);
+ Element* PrevField(const Sequence<OwningNonNull<Element>>& aElements,
+ uint32_t aStartIndex);
+
+ // Array contains regular expressions to match the corresponding
+ // field. Ex, CC number, CC type, etc.
+ using RegexStringArray =
+ EnumeratedArray<RegexKey, RegexKey::Count, nsCString>;
+ RegexStringArray mRuleMap;
+
+ // Array that holds RegexWrapper that created by regex::ffi::regex_new
+ using RegexWrapperArray =
+ EnumeratedArray<RegexKey, RegexKey::Count,
+ RustRegex>;
+ RegexWrapperArray mRegexes;
+};
+
+FormAutofillImpl::FormAutofillImpl() {
+ const Rule* rulesets[] = {&kFirefoxRules[0], &kCreditCardRules[0]};
+ size_t rulesetLengths[] = {ArrayLength(kFirefoxRules),
+ ArrayLength(kCreditCardRules)};
+
+ for (uint32_t i = 0; i < ArrayLength(rulesetLengths); ++i) {
+ for (uint32_t j = 0; j < rulesetLengths[i]; ++j) {
+ nsCString& rule = mRuleMap[rulesets[i][j].key];
+ if (!rule.IsEmpty()) {
+ rule.Append("|");
+ }
+ rule.Append(rulesets[i][j].pattern);
+ }
+ }
+}
+
+const RustRegex& FormAutofillImpl::GetRegex(RegexKey aKey) {
+ if (!mRegexes[aKey]) {
+ RustRegex regex(mRuleMap[aKey], RustRegexOptions().CaseInsensitive(true));
+ MOZ_DIAGNOSTIC_ASSERT(regex);
+ mRegexes[aKey] = std::move(regex);
+ }
+ return mRegexes[aKey];
+}
+
+bool FormAutofillImpl::StringMatchesRegExp(const nsACString& aStr,
+ RegexKey aKey) {
+ return GetRegex(aKey).IsMatch(aStr);
+}
+
+bool FormAutofillImpl::StringMatchesRegExp(const nsAString& aStr,
+ RegexKey aKey) {
+ return StringMatchesRegExp(NS_ConvertUTF16toUTF8(aStr), aKey);
+}
+
+bool FormAutofillImpl::TextContentMatchesRegExp(Element& element,
+ RegexKey key) {
+ ErrorResult rv;
+ nsAutoString text;
+ element.GetTextContent(text, rv);
+ if (rv.Failed()) {
+ return false;
+ }
+
+ return StringMatchesRegExp(text, key);
+}
+
+size_t FormAutofillImpl::CountRegExpMatches(const nsACString& aStr,
+ RegexKey aKey) {
+ return GetRegex(aKey).CountMatches(aStr);
+}
+
+size_t FormAutofillImpl::CountRegExpMatches(const nsAString& aStr,
+ RegexKey aKey) {
+ return CountRegExpMatches(NS_ConvertUTF16toUTF8(aStr), aKey);
+}
+
+bool FormAutofillImpl::NextFieldMatchesExpYearAutocomplete(
+ Element* aNextField) {
+ return AutocompleteStringMatches(*aNextField, u"cc-exp-year"_ns);
+}
+
+bool FormAutofillImpl::PreviousFieldMatchesExpMonthAutocomplete(
+ Element* aPrevField) {
+ return AutocompleteStringMatches(*aPrevField, u"cc-exp-month"_ns);
+}
+
+bool FormAutofillImpl::IdOrNameMatchRegExp(Element& aElement, RegexKey key) {
+ nsAutoString str;
+ aElement.GetId(str);
+ if (StringMatchesRegExp(str, key)) {
+ return true;
+ }
+ aElement.GetAttr(nsGkAtoms::name, str);
+ return StringMatchesRegExp(str, key);
+}
+
+bool FormAutofillImpl::LabelMatchesRegExp(
+ Element& aElement, const nsTArray<nsCString>* labelStrings, RegexKey key) {
+ if (!labelStrings) {
+ return false;
+ }
+
+ for (const auto& str : *labelStrings) {
+ if (StringMatchesRegExp(str, key)) {
+ return true;
+ }
+ }
+
+ Element* parent = aElement.GetParentElement();
+ if (!parent) {
+ return false;
+ }
+
+ ErrorResult aRv;
+ if (parent->IsHTMLElement(nsGkAtoms::td)) {
+ Element* pp = parent->GetParentElement();
+ if (pp) {
+ return TextContentMatchesRegExp(*pp, key);
+ }
+ }
+ if (parent->IsHTMLElement(nsGkAtoms::td)) {
+ Element* pes = aElement.GetPreviousElementSibling();
+ if (pes) {
+ return TextContentMatchesRegExp(*pes, key);
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::ClosestLabelMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ ErrorResult aRv;
+ Element* pes = aElement.GetPreviousElementSibling();
+ if (pes && pes->IsHTMLElement(nsGkAtoms::label)) {
+ return TextContentMatchesRegExp(*pes, aKey);
+ }
+
+ Element* nes = aElement.GetNextElementSibling();
+ if (nes && nes->IsHTMLElement(nsGkAtoms::label)) {
+ return TextContentMatchesRegExp(*nes, aKey);
+ }
+
+ return false;
+}
+
+bool FormAutofillImpl::PlaceholderMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::placeholder, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, aKey);
+}
+
+bool FormAutofillImpl::AriaLabelMatchesRegExp(Element& aElement,
+ RegexKey aKey) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::aria_label, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, aKey);
+}
+
+bool FormAutofillImpl::AutocompleteStringMatches(Element& aElement,
+ const nsAString& aKey) {
+ Nullable<AutocompleteInfo> info;
+ if (auto* input = HTMLInputElement::FromNode(aElement)) {
+ input->GetAutocompleteInfo(info);
+ } else {
+ AutocompleteInfo autoInfo;
+ if (auto* select = HTMLSelectElement::FromNode(aElement)) {
+ select->GetAutocompleteInfo(autoInfo);
+ info.SetValue(autoInfo);
+ }
+ }
+
+ if (info.IsNull()) {
+ return false;
+ }
+
+ return info.Value().mFieldName.Equals(aKey);
+}
+
+bool FormAutofillImpl::HasTemplatedValue(Element& aElement) {
+ nsAutoString str;
+ if (!aElement.GetAttr(nsGkAtoms::value, str)) {
+ return false;
+ }
+ return StringMatchesRegExp(str, RegexKey::TEMPLATED_VALUE);
+}
+
+bool FormAutofillImpl::RoleIsMenu(Element& aElement) {
+ return aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::role,
+ nsGkAtoms::menu, eCaseMatters);
+}
+
+bool FormAutofillImpl::InputTypeNotNumbery(Element& aElement) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return true;
+ }
+
+ auto type = input->ControlType();
+ return type != FormControlType::InputText &&
+ type != FormControlType::InputTel &&
+ type != FormControlType::InputNumber;
+}
+
+bool FormAutofillImpl::IsSelectWithCreditCardOptions(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ nsCOMPtr<nsIHTMLCollection> options = select->Options();
+ for (uint32_t i = 0; i < options->Length(); ++i) {
+ auto* item = options->Item(i);
+ auto* option = HTMLOptionElement::FromNode(item);
+ if (!option) {
+ continue;
+ }
+ // Bug 1756799, consider using getAttribute("value") instead of .value
+ nsAutoString str;
+ option->GetValue(str);
+ if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) ||
+ StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) {
+ return true;
+ }
+
+ option->GetText(str);
+ if (StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_EXACT_MATCH) ||
+ StringMatchesRegExp(str, RegexKey::CREDIT_CARD_NETWORK_LONG)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::IsRadioWithCreditCardText(
+ Element& aElement, const nsTArray<nsCString>* aLabels, ErrorResult& aRv) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return false;
+ }
+ auto type = input->ControlType();
+ if (type != FormControlType::InputRadio) {
+ return false;
+ }
+
+ nsAutoString str;
+ input->GetValue(str, CallerType::System);
+ if (CountRegExpMatches(str, RegexKey::CREDIT_CARD_NETWORK) == 1) {
+ return true;
+ }
+
+ if (aLabels) {
+ size_t labelsMatched = 0;
+ for (const auto& label : *aLabels) {
+ size_t labelMatches =
+ CountRegExpMatches(label, RegexKey::CREDIT_CARD_NETWORK);
+ if (labelMatches > 1) {
+ return false;
+ }
+ if (labelMatches > 0) {
+ labelsMatched++;
+ }
+ }
+
+ if (labelsMatched) {
+ return labelsMatched == 1;
+ }
+ }
+
+ // Bug 1756798 : Remove reading text content in a <input>
+ nsAutoString text;
+ aElement.GetTextContent(text, aRv);
+ if (aRv.Failed()) {
+ return false;
+ }
+ return CountRegExpMatches(text, RegexKey::CREDIT_CARD_NETWORK) == 1;
+}
+
+bool FormAutofillImpl::MaxLengthIs(Element& aElement, int32_t aValue) {
+ auto* input = HTMLInputElement::FromNode(aElement);
+ if (!input) {
+ return false;
+ }
+ return input->MaxLength() == aValue;
+}
+
+static bool TestOptionElementForInteger(Element* aElement, int32_t aTestValue) {
+ auto* option = HTMLOptionElement::FromNodeOrNull(aElement);
+ if (!option) {
+ return false;
+ }
+ nsAutoString str;
+ option->GetValue(str);
+ nsContentUtils::ParseHTMLIntegerResultFlags parseFlags;
+ int32_t val = nsContentUtils::ParseHTMLInteger(str, &parseFlags);
+ if (val == aTestValue) {
+ return true;
+ }
+ option->GetRenderedLabel(str);
+ val = nsContentUtils::ParseHTMLInteger(str, &parseFlags);
+ return val == aTestValue;
+}
+
+static bool MatchOptionContiguousInteger(HTMLOptionsCollection* aOptions,
+ uint32_t aNumContiguous,
+ int32_t aInteger) {
+ uint32_t len = aOptions->Length();
+ if (aNumContiguous > len) {
+ return false;
+ }
+
+ for (uint32_t i = 0; i <= aOptions->Length() - aNumContiguous; i++) {
+ bool match = true;
+ for (uint32_t j = 0; j < aNumContiguous; j++) {
+ if (!TestOptionElementForInteger(aOptions->GetElementAt(i + j),
+ aInteger + j)) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool FormAutofillImpl::IsExpirationYearLikely(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ auto* options = select->Options();
+ if (!options) {
+ return false;
+ }
+
+ PRExplodedTime tm;
+ PR_ExplodeTime(PR_Now(), PR_LocalTimeParameters, &tm);
+ uint16_t currentYear = tm.tm_year;
+
+ return MatchOptionContiguousInteger(options, 3, currentYear);
+}
+
+bool FormAutofillImpl::IsExpirationMonthLikely(Element& aElement) {
+ auto* select = HTMLSelectElement::FromNode(aElement);
+ if (!select) {
+ return false;
+ }
+
+ auto* options = select->Options();
+ if (!options) {
+ return false;
+ }
+
+ if (options->Length() != 12 && options->Length() != 13) {
+ return false;
+ }
+
+ return MatchOptionContiguousInteger(options, 12, 1);
+}
+
+Element* FormAutofillImpl::FindRootForField(Element* aElement) {
+ if (const auto* control =
+ nsGenericHTMLFormControlElement::FromNode(aElement)) {
+ if (Element* form = control->GetForm()) {
+ return form;
+ }
+ }
+
+ return aElement->OwnerDoc()->GetDocumentElement();
+}
+
+Element* FormAutofillImpl::FindField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex,
+ int8_t aDirection) {
+ MOZ_ASSERT(aDirection == 1 || aDirection == -1);
+ MOZ_ASSERT(aStartIndex < aElements.Length());
+
+ Element* curFieldRoot = FindRootForField(aElements[aStartIndex]);
+ bool isRootForm = curFieldRoot->IsHTMLElement(nsGkAtoms::form);
+
+ uint32_t num =
+ aDirection == 1 ? aElements.Length() - aStartIndex - 1 : aStartIndex;
+ for (uint32_t i = 0, searchIndex = aStartIndex; i < num; i++) {
+ searchIndex += aDirection;
+ const auto& element = aElements[searchIndex];
+ Element* root = FindRootForField(element);
+
+ if (isRootForm) {
+ // Only search fields that are within the same root element.
+ if (curFieldRoot != root) {
+ return nullptr;
+ }
+ } else {
+ // Exclude elements inside the rootElement that are already in a <form>.
+ if (root->IsHTMLElement(nsGkAtoms::form)) {
+ continue;
+ }
+ }
+
+ if (element->IsAnyOfHTMLElements(nsGkAtoms::input, nsGkAtoms::select)) {
+ return element.get();
+ }
+ }
+
+ return nullptr;
+}
+
+Element* FormAutofillImpl::NextField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) {
+ return FindField(aElements, aStartIndex, 1);
+}
+
+Element* FormAutofillImpl::PrevField(
+ const Sequence<OwningNonNull<Element>>& aElements, uint32_t aStartIndex) {
+ return FindField(aElements, aStartIndex, -1);
+}
+
+static void ExtractLabelStrings(nsINode* aNode, nsTArray<nsCString>& aStrings,
+ ErrorResult& aRv) {
+ if (aNode->IsAnyOfHTMLElements(nsGkAtoms::script, nsGkAtoms::noscript,
+ nsGkAtoms::option, nsGkAtoms::style)) {
+ return;
+ }
+
+ if (aNode->IsText() || !aNode->HasChildren()) {
+ nsAutoString text;
+ aNode->GetTextContent(text, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ text.Trim(kWhitespace);
+ CopyUTF16toUTF8(text, *aStrings.AppendElement());
+ return;
+ }
+
+ for (nsINode* child = aNode->GetFirstChild(); child;
+ child = child->GetNextSibling()) {
+ if (child->IsElement() || child->IsText()) {
+ ExtractLabelStrings(child, aStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ }
+ }
+}
+
+nsTArray<nsCString>* GetLabelStrings(
+ Element* aElement,
+ const nsTHashMap<void*, nsTArray<nsCString>>& aElementMap,
+ const nsTHashMap<nsAtom*, nsTArray<nsCString>>& aIdMap) {
+ if (!aElement) {
+ return nullptr;
+ }
+
+ if (nsAtom* idAtom = aElement->GetID()) {
+ return aIdMap.Lookup(idAtom).DataPtrOrNull();
+ }
+
+ return aElementMap.Lookup(aElement).DataPtrOrNull();
+}
+
+void FormAutofillImpl::GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) {
+ if (aElements.IsEmpty()) {
+ return;
+ }
+
+ // Create Labels
+ auto* document = aElements[0]->OwnerDoc();
+#ifdef DEBUG
+ for (uint32_t i = 1; i < aElements.Length(); ++i) {
+ MOZ_ASSERT(document == aElements[i]->OwnerDoc());
+ }
+#endif
+
+ RefPtr<nsContentList> labels = document->GetElementsByTagName(u"label"_ns);
+ nsTHashMap<void*, nsTArray<nsCString>> elementsToLabelStrings;
+ nsTHashMap<nsAtom*, nsTArray<nsCString>> elementsIdToLabelStrings;
+ if (labels) {
+ for (uint32_t i = 0; i < labels->Length(); ++i) {
+ auto* item = labels->Item(i);
+ auto* label = HTMLLabelElement::FromNode(item);
+ if (NS_WARN_IF(!label)) {
+ continue;
+ }
+ auto* control = label->GetControl();
+ if (!control) {
+ continue;
+ }
+ nsTArray<nsCString> labelStrings;
+ ExtractLabelStrings(label, labelStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ // We need two maps here to keep track controls with id and without id.
+ // We can't just use map without id to cover all cases because there
+ // might be multiple elements with the same id.
+ if (control->GetID()) {
+ elementsIdToLabelStrings.LookupOrInsert(control->GetID())
+ .AppendElements(std::move(labelStrings));
+ } else {
+ elementsToLabelStrings.LookupOrInsert(control).AppendElements(
+ std::move(labelStrings));
+ }
+ }
+ }
+
+ nsTArray<AutofillParams> paramSet;
+ paramSet.SetLength(aElements.Length());
+
+ for (uint32_t i = 0; i < aElements.Length(); ++i) {
+ auto& params = paramSet[i];
+ const auto& element = aElements[i];
+
+ const nsTArray<nsCString>* labelStrings = GetLabelStrings(
+ element, elementsToLabelStrings, elementsIdToLabelStrings);
+
+ bool idOrNameMatchDwfrmAndBml =
+ IdOrNameMatchRegExp(element, RegexKey::DWFRM) &&
+ IdOrNameMatchRegExp(element, RegexKey::BML);
+ bool hasTemplatedValue = HasTemplatedValue(element);
+ bool inputTypeNotNumbery = InputTypeNotNumbery(element);
+ bool idOrNameMatchSubscription =
+ IdOrNameMatchRegExp(element, RegexKey::SUBSCRIPTION);
+ bool idOrNameMatchFirstAndLast =
+ IdOrNameMatchRegExp(element, RegexKey::FIRST) &&
+ IdOrNameMatchRegExp(element, RegexKey::LAST);
+
+#define RULE_IMPL2(rule, type) params.m##type##Params[type##Params::rule]
+#define RULE_IMPL(rule, type) RULE_IMPL2(rule, type)
+#define RULE(rule) RULE_IMPL(rule, RULE_TYPE)
+
+ // cc-number
+#define RULE_TYPE CCNumber
+ RULE(idOrNameMatchNumberRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_NUMBER);
+ RULE(labelsMatchNumberRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NUMBER);
+ RULE(closestLabelMatchesNumberRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(placeholderMatchesNumberRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(ariaLabelMatchesNumberRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_NUMBER);
+ RULE(idOrNameMatchGift) = IdOrNameMatchRegExp(element, RegexKey::GIFT);
+ RULE(labelsMatchGift) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::GIFT);
+ RULE(placeholderMatchesGift) =
+ PlaceholderMatchesRegExp(element, RegexKey::GIFT);
+ RULE(ariaLabelMatchesGift) =
+ AriaLabelMatchesRegExp(element, RegexKey::GIFT);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+ RULE(inputTypeNotNumbery) = inputTypeNotNumbery;
+#undef RULE_TYPE
+
+ // cc-name
+#define RULE_TYPE CCName
+ RULE(idOrNameMatchNameRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_NAME);
+ RULE(labelsMatchNameRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_NAME);
+ RULE(closestLabelMatchesNameRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(placeholderMatchesNameRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(ariaLabelMatchesNameRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_NAME);
+ RULE(idOrNameMatchFirst) = IdOrNameMatchRegExp(element, RegexKey::FIRST);
+ RULE(labelsMatchFirst) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::FIRST);
+ RULE(placeholderMatchesFirst) =
+ PlaceholderMatchesRegExp(element, RegexKey::FIRST);
+ RULE(ariaLabelMatchesFirst) =
+ AriaLabelMatchesRegExp(element, RegexKey::FIRST);
+ RULE(idOrNameMatchLast) = IdOrNameMatchRegExp(element, RegexKey::LAST);
+ RULE(labelsMatchLast) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::LAST);
+ RULE(placeholderMatchesLast) =
+ PlaceholderMatchesRegExp(element, RegexKey::LAST);
+ RULE(ariaLabelMatchesLast) =
+ AriaLabelMatchesRegExp(element, RegexKey::LAST);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchFirstAndLast) = idOrNameMatchFirstAndLast;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // We only use Fathom to detect cc-number & cc-name fields for now.
+ // Comment out code below instead of removing them to make it clear that
+ // the current design is to support multiple rules.
+/*
+ Element* nextFillableField = NextField(aElements, i);
+ Element* prevFillableField = PrevField(aElements, i);
+
+ const nsTArray<nsCString>* nextLabelStrings = GetLabelStrings(
+ nextFillableField, elementsToLabelStrings, elementsIdToLabelStrings);
+ const nsTArray<nsCString>* prevLabelStrings = GetLabelStrings(
+ prevFillableField, elementsToLabelStrings, elementsIdToLabelStrings);
+ bool roleIsMenu = RoleIsMenu(element);
+
+ // cc-type
+#define RULE_TYPE CCType
+ RULE(idOrNameMatchTypeRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_TYPE);
+ RULE(labelsMatchTypeRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_TYPE);
+ RULE(closestLabelMatchesTypeRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_TYPE);
+ RULE(idOrNameMatchVisaCheckout) =
+ IdOrNameMatchRegExp(element, RegexKey::VISA_CHECKOUT);
+ RULE(ariaLabelMatchesVisaCheckout) =
+ AriaLabelMatchesRegExp(element, RegexKey::VISA_CHECKOUT);
+ RULE(isSelectWithCreditCardOptions) =
+ IsSelectWithCreditCardOptions(element);
+ RULE(isRadioWithCreditCardText) =
+ IsRadioWithCreditCardText(element, labelStrings, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // cc-exp
+#define RULE_TYPE CCExp
+ RULE(labelsMatchExpRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP);
+ RULE(closestLabelMatchesExpRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP);
+ RULE(placeholderMatchesExpRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP);
+ RULE(labelsMatchExpWith2Or4DigitYear) = LabelMatchesRegExp(
+ element, labelStrings, RegexKey::TWO_OR_FOUR_DIGIT_YEAR);
+ RULE(placeholderMatchesExpWith2Or4DigitYear) =
+ PlaceholderMatchesRegExp(element, RegexKey::TWO_OR_FOUR_DIGIT_YEAR);
+ RULE(labelsMatchMMYY) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::MMYY);
+ RULE(placeholderMatchesMMYY) =
+ PlaceholderMatchesRegExp(element, RegexKey::MMYY);
+ RULE(maxLengthIs7) = MaxLengthIs(element, 7);
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+ RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element);
+ RULE(isExpirationYearLikely) = IsExpirationYearLikely(element);
+ RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH);
+ RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR);
+ RULE(idOrNameMatchExpMonthRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(idOrNameMatchExpYearRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(idOrNameMatchValidation) =
+ IdOrNameMatchRegExp(element, RegexKey::VALIDATION);
+#undef RULE_TYPE
+
+ // cc-exp-month
+#define RULE_TYPE CCExpMonth
+ RULE(idOrNameMatchExpMonthRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(labelsMatchExpMonthRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_MONTH);
+ RULE(closestLabelMatchesExpMonthRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(placeholderMatchesExpMonthRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(ariaLabelMatchesExpMonthRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_MONTH);
+ RULE(idOrNameMatchMonth) = IdOrNameMatchRegExp(element, RegexKey::MONTH);
+ RULE(labelsMatchMonth) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::MONTH);
+ RULE(placeholderMatchesMonth) =
+ PlaceholderMatchesRegExp(element, RegexKey::MONTH);
+ RULE(ariaLabelMatchesMonth) =
+ AriaLabelMatchesRegExp(element, RegexKey::MONTH);
+ RULE(nextFieldIdOrNameMatchExpYearRegExp) =
+ nextFillableField &&
+ IdOrNameMatchRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldLabelsMatchExpYearRegExp) =
+ nextFillableField &&
+ LabelMatchesRegExp(element, nextLabelStrings, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldPlaceholderMatchExpYearRegExp) =
+ nextFillableField &&
+ PlaceholderMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldAriaLabelMatchExpYearRegExp) =
+ nextFillableField &&
+ AriaLabelMatchesRegExp(*nextFillableField, RegexKey::CC_EXP_YEAR);
+ RULE(nextFieldIdOrNameMatchYear) =
+ nextFillableField &&
+ IdOrNameMatchRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldLabelsMatchYear) =
+ nextFillableField &&
+ LabelMatchesRegExp(element, nextLabelStrings, RegexKey::YEAR);
+ RULE(nextFieldPlaceholderMatchesYear) =
+ nextFillableField &&
+ PlaceholderMatchesRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldAriaLabelMatchesYear) =
+ nextFillableField &&
+ AriaLabelMatchesRegExp(*nextFillableField, RegexKey::YEAR);
+ RULE(nextFieldMatchesExpYearAutocomplete) =
+ nextFillableField &&
+ NextFieldMatchesExpYearAutocomplete(nextFillableField);
+ RULE(isExpirationMonthLikely) = IsExpirationMonthLikely(element);
+ RULE(nextFieldIsExpirationYearLikely) =
+ nextFillableField && IsExpirationYearLikely(*nextFillableField);
+ RULE(maxLengthIs2) = MaxLengthIs(element, 2);
+ RULE(placeholderMatchesMM) =
+ PlaceholderMatchesRegExp(element, RegexKey::MM_MONTH);
+ RULE(roleIsMenu) = roleIsMenu;
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+
+ // cc-exp-year
+#define RULE_TYPE CCExpYear
+ RULE(idOrNameMatchExpYearRegExp) =
+ IdOrNameMatchRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(labelsMatchExpYearRegExp) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::CC_EXP_YEAR);
+ RULE(closestLabelMatchesExpYearRegExp) =
+ ClosestLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(placeholderMatchesExpYearRegExp) =
+ PlaceholderMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(ariaLabelMatchesExpYearRegExp) =
+ AriaLabelMatchesRegExp(element, RegexKey::CC_EXP_YEAR);
+ RULE(idOrNameMatchYear) = IdOrNameMatchRegExp(element, RegexKey::YEAR);
+ RULE(labelsMatchYear) =
+ LabelMatchesRegExp(element, labelStrings, RegexKey::YEAR);
+ RULE(placeholderMatchesYear) =
+ PlaceholderMatchesRegExp(element, RegexKey::YEAR);
+ RULE(ariaLabelMatchesYear) =
+ AriaLabelMatchesRegExp(element, RegexKey::YEAR);
+ RULE(previousFieldIdOrNameMatchExpMonthRegExp) =
+ prevFillableField &&
+ IdOrNameMatchRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldLabelsMatchExpMonthRegExp) =
+ prevFillableField &&
+ LabelMatchesRegExp(element, prevLabelStrings, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldPlaceholderMatchExpMonthRegExp) =
+ prevFillableField &&
+ PlaceholderMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldAriaLabelMatchExpMonthRegExp) =
+ prevFillableField &&
+ AriaLabelMatchesRegExp(*prevFillableField, RegexKey::CC_EXP_MONTH);
+ RULE(previousFieldIdOrNameMatchMonth) =
+ prevFillableField &&
+ IdOrNameMatchRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldLabelsMatchMonth) =
+ prevFillableField &&
+ LabelMatchesRegExp(element, prevLabelStrings, RegexKey::MONTH);
+ RULE(previousFieldPlaceholderMatchesMonth) =
+ prevFillableField &&
+ PlaceholderMatchesRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldAriaLabelMatchesMonth) =
+ prevFillableField &&
+ AriaLabelMatchesRegExp(*prevFillableField, RegexKey::MONTH);
+ RULE(previousFieldMatchesExpMonthAutocomplete) =
+ prevFillableField &&
+ PreviousFieldMatchesExpMonthAutocomplete(prevFillableField);
+ RULE(isExpirationYearLikely) = IsExpirationYearLikely(element);
+ RULE(previousFieldIsExpirationMonthLikely) =
+ prevFillableField && IsExpirationMonthLikely(*prevFillableField);
+ RULE(placeholderMatchesYYOrYYYY) =
+ PlaceholderMatchesRegExp(element, RegexKey::YY_OR_YYYY);
+ RULE(roleIsMenu) = roleIsMenu;
+ RULE(idOrNameMatchSubscription) = idOrNameMatchSubscription;
+ RULE(idOrNameMatchDwfrmAndBml) = idOrNameMatchDwfrmAndBml;
+ RULE(hasTemplatedValue) = hasTemplatedValue;
+#undef RULE_TYPE
+*/
+
+#undef RULE_IMPL2
+#undef RULE_IMPL
+#undef RULE
+
+#define CALCULATE_SCORE(type, score) \
+ for (auto i : MakeEnumeratedRange(type##Params::Count)) { \
+ (score) += params.m##type##Params[i] * kCoefficents.m##type##Params[i]; \
+ } \
+ (score) = Sigmoid(score + k##type##Bias);
+
+ // Calculating the final score of each rule
+ FormAutofillConfidences score;
+ CALCULATE_SCORE(CCNumber, score.mCcNumber)
+ CALCULATE_SCORE(CCName, score.mCcName)
+
+ // Comment out code that are not used right now
+ // CALCULATE_SCORE(CCType, score.mCcType)
+ // CALCULATE_SCORE(CCExp, score.mCcExp)
+ // CALCULATE_SCORE(CCExpMonth, score.mCcExpMonth)
+ // CALCULATE_SCORE(CCExpYear, score.mCcExpYear)
+
+#undef CALCULATE_SCORE
+
+ aResults.AppendElement(score);
+ }
+}
+
+static StaticAutoPtr<FormAutofillImpl> sFormAutofillInstance;
+
+static FormAutofillImpl* GetFormAutofillImpl() {
+ if (!sFormAutofillInstance) {
+ sFormAutofillInstance = new FormAutofillImpl();
+ ClearOnShutdown(&sFormAutofillInstance);
+ }
+ return sFormAutofillInstance;
+}
+
+/* static */
+void FormAutofillNative::GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv) {
+ GetFormAutofillImpl()->GetFormAutofillConfidences(aGlobal, aElements,
+ aResults, aRv);
+}
+
+} // namespace mozilla::dom
diff --git a/toolkit/components/formautofill/FormAutofillNative.h b/toolkit/components/formautofill/FormAutofillNative.h
new file mode 100644
index 0000000000..bca124631a
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillNative.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* 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/. */
+
+#ifndef mozilla_dom_FormAutofillNative_h
+#define mozilla_dom_FormAutofillNative_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/ChromeUtilsBinding.h"
+
+namespace mozilla::dom {
+class Element;
+
+class FormAutofillNative {
+ public:
+ static void GetFormAutofillConfidences(
+ GlobalObject& aGlobal, const Sequence<OwningNonNull<Element>>& aElements,
+ nsTArray<FormAutofillConfidences>& aResults, ErrorResult& aRv);
+};
+
+} // namespace mozilla::dom
+
+#endif // mozilla_dom_FormAutofillNative_h
diff --git a/toolkit/components/formautofill/FormAutofillParent.sys.mjs b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
new file mode 100644
index 0000000000..c0ae98b851
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
@@ -0,0 +1,607 @@
+/* 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 a service used to access storage and communicate with content.
+ *
+ * A "fields" array is used to communicate with FormAutofillContent. Each item
+ * represents a single input field in the content page as well as its
+ * @autocomplete properties. The schema is as below. Please refer to
+ * FormAutofillContent.js for more details.
+ *
+ * [
+ * {
+ * section,
+ * addressType,
+ * contactType,
+ * fieldName,
+ * value,
+ * index
+ * },
+ * {
+ * // ...
+ * }
+ * ]
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+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 lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs",
+ FormAutofillPreferences:
+ "resource://autofill/FormAutofillPreferences.sys.mjs",
+ FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillParent")
+);
+
+const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } =
+ FormAutofill;
+
+const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
+ FormAutofillUtils;
+
+let gMessageObservers = new Set();
+
+export let FormAutofillStatus = {
+ _initialized: false,
+
+ /**
+ * Cache of the Form Autofill status (considering preferences and storage).
+ */
+ _active: null,
+
+ /**
+ * Initializes observers and registers the message handler.
+ */
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ Services.obs.addObserver(this, "privacy-pane-loaded");
+
+ // Observing the pref and storage changes
+ Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+
+ // Only listen to credit card related preference if it is available
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+
+ // We have to use empty window type to get all opened windows here because the
+ // window type parameter may not be available during startup.
+ for (let win of Services.wm.getEnumerator("")) {
+ let { documentElement } = win.document;
+ if (documentElement?.getAttribute("windowtype") == "navigator:browser") {
+ this.injectElements(win.document);
+ } else {
+ // Manually call onOpenWindow for windows that are already opened but not
+ // yet have the window type set. This ensures we inject the elements we need
+ // when its docuemnt is ready.
+ this.onOpenWindow(win);
+ }
+ }
+ Services.wm.addListener(this);
+
+ Services.telemetry.setEventRecordingEnabled("creditcard", true);
+ Services.telemetry.setEventRecordingEnabled("address", true);
+ },
+
+ /**
+ * Uninitializes FormAutofillStatus. This is for testing only.
+ *
+ * @private
+ */
+ uninit() {
+ lazy.gFormAutofillStorage._saveImmediately();
+
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ this._active = null;
+
+ Services.obs.removeObserver(this, "privacy-pane-loaded");
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.wm.removeListener(this);
+
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+ },
+
+ get formAutofillStorage() {
+ return lazy.gFormAutofillStorage;
+ },
+
+ /**
+ * Broadcast the status to frames when the form autofill status changes.
+ */
+ onStatusChanged() {
+ lazy.log.debug("onStatusChanged: Status changed to", this._active);
+ Services.ppmm.sharedData.set("FormAutofill:enabled", this._active);
+ // Sync autofill enabled to make sure the value is up-to-date
+ // no matter when the new content process is initialized.
+ Services.ppmm.sharedData.flush();
+ },
+
+ /**
+ * Query preference and storage status to determine the overall status of the
+ * form autofill feature.
+ *
+ * @returns {boolean} whether form autofill is active (enabled and has data)
+ */
+ computeStatus() {
+ const savedFieldNames = Services.ppmm.sharedData.get(
+ "FormAutofill:savedFieldNames"
+ );
+
+ return (
+ (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) ||
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) &&
+ savedFieldNames &&
+ savedFieldNames.size > 0
+ );
+ },
+
+ /**
+ * Update the status and trigger onStatusChanged, if necessary.
+ */
+ updateStatus() {
+ lazy.log.debug("updateStatus");
+ let wasActive = this._active;
+ this._active = this.computeStatus();
+ if (this._active !== wasActive) {
+ this.onStatusChanged();
+ }
+ },
+
+ async updateSavedFieldNames() {
+ lazy.log.debug("updateSavedFieldNames");
+
+ let savedFieldNames;
+ const addressNames =
+ await lazy.gFormAutofillStorage.addresses.getSavedFieldNames();
+
+ // Don't access the credit cards store unless it is enabled.
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ const creditCardNames =
+ await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames();
+ savedFieldNames = new Set([...addressNames, ...creditCardNames]);
+ } else {
+ savedFieldNames = addressNames;
+ }
+
+ Services.ppmm.sharedData.set(
+ "FormAutofill:savedFieldNames",
+ savedFieldNames
+ );
+ Services.ppmm.sharedData.flush();
+
+ this.updateStatus();
+ },
+
+ injectElements(doc) {
+ Services.scriptloader.loadSubScript(
+ "chrome://formautofill/content/customElements.js",
+ doc.ownerGlobal
+ );
+ },
+
+ onOpenWindow(xulWindow) {
+ const win = xulWindow.docShell.domWindow;
+ win.addEventListener(
+ "load",
+ () => {
+ if (
+ win.document.documentElement.getAttribute("windowtype") ==
+ "navigator:browser"
+ ) {
+ this.injectElements(win.document);
+ }
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow() {},
+
+ async observe(subject, topic, data) {
+ lazy.log.debug("observe:", topic, "with data:", data);
+ switch (topic) {
+ case "privacy-pane-loaded": {
+ let formAutofillPreferences = new lazy.FormAutofillPreferences();
+ let document = subject.document;
+ let prefFragment = formAutofillPreferences.init(document);
+ let formAutofillGroupBox = document.getElementById(
+ "formAutofillGroupBox"
+ );
+ formAutofillGroupBox.appendChild(prefFragment);
+ break;
+ }
+
+ case "nsPref:changed": {
+ // Observe pref changes and update _active cache if status is changed.
+ this.updateStatus();
+ break;
+ }
+
+ case "formautofill-storage-changed": {
+ // Early exit if only metadata is changed
+ if (data == "notifyUsed") {
+ break;
+ }
+
+ await this.updateSavedFieldNames();
+ break;
+ }
+
+ default: {
+ throw new Error(
+ `FormAutofillStatus: Unexpected topic observed: ${topic}`
+ );
+ }
+ }
+ },
+};
+
+// Lazily load the storage JSM to avoid disk I/O until absolutely needed.
+// Once storage is loaded we need to update saved field names and inform content processes.
+XPCOMUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => {
+ let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ lazy.log.debug("Loading formAutofillStorage");
+
+ formAutofillStorage.initialize().then(() => {
+ // Update the saved field names to compute the status and update child processes.
+ FormAutofillStatus.updateSavedFieldNames();
+ });
+
+ return formAutofillStorage;
+});
+
+export class FormAutofillParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ FormAutofillStatus.init();
+ }
+
+ static addMessageObserver(observer) {
+ gMessageObservers.add(observer);
+ }
+
+ static removeMessageObserver(observer) {
+ gMessageObservers.delete(observer);
+ }
+
+ /**
+ * Handles the message coming from FormAutofillContent.
+ *
+ * @param {object} message
+ * @param {string} message.name The name of the message.
+ * @param {object} message.data The data of the message.
+ */
+ async receiveMessage({ name, data }) {
+ switch (name) {
+ case "FormAutofill:InitStorage": {
+ await lazy.gFormAutofillStorage.initialize();
+ await FormAutofillStatus.updateSavedFieldNames();
+ break;
+ }
+ case "FormAutofill:GetRecords": {
+ return FormAutofillParent._getRecords(data);
+ }
+ case "FormAutofill:OnFormSubmit": {
+ this.notifyMessageObservers("onFormSubmitted", data);
+ await this._onFormSubmit(data);
+ break;
+ }
+ case "FormAutofill:OpenPreferences": {
+ const win = lazy.BrowserWindowTracker.getTopWindow();
+ win.openPreferences("privacy-form-autofill");
+ break;
+ }
+ case "FormAutofill:GetDecryptedString": {
+ let { cipherText, reauth } = data;
+ if (!FormAutofillUtils._reauthEnabledByUser) {
+ lazy.log.debug("Reauth is disabled");
+ reauth = false;
+ }
+ let string;
+ try {
+ string = await lazy.OSKeyStore.decrypt(cipherText, reauth);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ lazy.log.warn("User canceled encryption login");
+ }
+ return string;
+ }
+ case "FormAutofill:UpdateWarningMessage":
+ this.notifyMessageObservers("updateWarningNote", data);
+ break;
+
+ case "FormAutofill:FieldsIdentified":
+ this.notifyMessageObservers("fieldsIdentified", data);
+ break;
+
+ // The remaining Save and Remove messages are invoked only by tests.
+ case "FormAutofill:SaveAddress": {
+ if (data.guid) {
+ await lazy.gFormAutofillStorage.addresses.update(
+ data.guid,
+ data.address
+ );
+ } else {
+ await lazy.gFormAutofillStorage.addresses.add(data.address);
+ }
+ break;
+ }
+ case "FormAutofill:SaveCreditCard": {
+ if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
+ lazy.log.warn("User canceled encryption login");
+ return undefined;
+ }
+ await lazy.gFormAutofillStorage.creditCards.add(data.creditcard);
+ break;
+ }
+ case "FormAutofill:RemoveAddresses": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.addresses.remove(guid)
+ );
+ break;
+ }
+ case "FormAutofill:RemoveCreditCards": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.creditCards.remove(guid)
+ );
+ break;
+ }
+ }
+
+ return undefined;
+ }
+
+ notifyMessageObservers(callbackName, data) {
+ for (let observer of gMessageObservers) {
+ try {
+ if (callbackName in observer) {
+ observer[callbackName](
+ data,
+ this.manager.browsingContext.topChromeWindow
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Get the records from profile store and return results back to content
+ * process. It will decrypt the credit card number and append
+ * "cc-number-decrypted" to each record if OSKeyStore isn't set.
+ *
+ * This is static as a unit test calls this.
+ *
+ * @private
+ * @param {object} data
+ * @param {string} data.collectionName
+ * The name used to specify which collection to retrieve records.
+ * @param {string} data.searchString
+ * The typed string for filtering out the matched records.
+ * @param {string} data.info
+ * The input autocomplete property's information.
+ */
+ static async _getRecords({ collectionName, searchString, info }) {
+ let collection = lazy.gFormAutofillStorage[collectionName];
+ if (!collection) {
+ return [];
+ }
+
+ let recordsInCollection = await collection.getAll();
+ if (!info || !info.fieldName || !recordsInCollection.length) {
+ return recordsInCollection;
+ }
+
+ let isCC = collectionName == CREDITCARDS_COLLECTION_NAME;
+ // We don't filter "cc-number"
+ if (isCC && info.fieldName == "cc-number") {
+ recordsInCollection = recordsInCollection.filter(
+ record => !!record["cc-number"]
+ );
+ return recordsInCollection;
+ }
+
+ let records = [];
+ let lcSearchString = searchString.toLowerCase();
+
+ for (let record of recordsInCollection) {
+ let fieldValue = record[info.fieldName];
+ if (!fieldValue) {
+ continue;
+ }
+
+ if (
+ collectionName == ADDRESSES_COLLECTION_NAME &&
+ record.country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
+ ) {
+ // Address autofill isn't supported for the record's country so we don't
+ // want to attempt to potentially incorrectly fill the address fields.
+ continue;
+ }
+
+ if (
+ lcSearchString &&
+ !String(fieldValue).toLowerCase().startsWith(lcSearchString)
+ ) {
+ continue;
+ }
+ records.push(record);
+ }
+
+ return records;
+ }
+
+ async _onAddressSubmit(address, browser) {
+ const storage = lazy.gFormAutofillStorage.addresses;
+
+ // Make sure record is normalized before comparing with records in the storage
+ storage._normalizeRecord(address.record);
+
+ const newAddress = new lazy.AddressComponent(
+ address.record,
+ // Invalid address fields in the address form will not be captured.
+ { ignoreInvalid: true }
+ );
+
+ let mergeableRecord = null;
+ let mergeableFields = [];
+
+ // Exams all stored record to determine whether to show the prompt or not.
+ for (const record of await storage.getAll()) {
+ const savedAddress = new lazy.AddressComponent(record);
+ // filter invalid field
+ const result = newAddress.compare(savedAddress);
+
+ // If any of the fields in the new address are different from the corresponding fields
+ // in the saved address, the two addresses are considered different. For example, if
+ // the name, email, country are the same but the street address is different, the two
+ // addresses are not considered the same.
+ if (Object.values(result).includes("different")) {
+ continue;
+ // If every field of the new address is either the same or is subset of the corresponding
+ // field in the saved address, the new address is duplicated. We don't need capture
+ // the new address.
+ } else if (
+ Object.values(result).every(r => ["same", "subset"].includes(r))
+ ) {
+ lazy.log.debug(
+ "A duplicated address record is found, do not show the prompt"
+ );
+ storage.notifyUsed(record.guid);
+ return false;
+ // If the new address is neither a duplicate of the saved address nor a different address.
+ // There must be at least one field we can merge, show the update doorhanger
+ } else {
+ lazy.log.debug(
+ "A mergeable address record is found, show the update prompt"
+ );
+ // If we find multiple mergeable records, choose the record with fewest mergeable fields.
+ // TODO: Bug 1830841. Add a testcase
+ let fields = Object.entries(result)
+ .filter(v => ["superset", "similar"].includes(v[1]))
+ .map(v => v[0]);
+ if (!mergeableFields.length || mergeableFields.length > fields.length) {
+ mergeableRecord = record;
+ mergeableFields = fields;
+ }
+ }
+ }
+
+ if (
+ !FormAutofill.isAutofillAddressesCaptureEnabled &&
+ !FormAutofill.isAutofillAddressesCaptureV2Enabled
+ ) {
+ return false;
+ }
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveAddress(
+ browser,
+ storage,
+ address.record,
+ address.flowId,
+ { mergeableRecord, mergeableFields }
+ );
+ };
+ }
+
+ async _onCreditCardSubmit(creditCard, browser) {
+ // Let's reset the credit card to empty, and then network auto-detect will
+ // pick it up.
+ delete creditCard.record["cc-type"];
+
+ const storage = lazy.gFormAutofillStorage.creditCards;
+ // Make sure record is normalized before comparing with records in the storage
+ storage._normalizeRecord(creditCard.record);
+
+ // If the record alreay exists in the storage, don't bother showing the prompt
+ const matchRecord = (
+ await storage.getMatchRecords(creditCard.record).next()
+ ).value;
+ if (matchRecord) {
+ storage.notifyUsed(matchRecord.guid);
+ return false;
+ }
+
+ // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
+ if (!FormAutofill.isAutofillCreditCardsEnabled) {
+ return false;
+ }
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveCreditCard(
+ browser,
+ storage,
+ creditCard.record,
+ creditCard.flowId
+ );
+ };
+ }
+
+ async _onFormSubmit(data) {
+ let { address, creditCard } = data;
+
+ let browser = this.manager.browsingContext.top.embedderElement;
+
+ // Transmit the telemetry immediately in the meantime form submitted, and handle these pending
+ // doorhangers at a later.
+ await Promise.all(
+ [
+ await Promise.all(
+ address.map(addrRecord => this._onAddressSubmit(addrRecord, browser))
+ ),
+ await Promise.all(
+ creditCard.map(ccRecord =>
+ this._onCreditCardSubmit(ccRecord, browser)
+ )
+ ),
+ ]
+ .map(pendingDoorhangers => {
+ return pendingDoorhangers.filter(
+ pendingDoorhanger =>
+ !!pendingDoorhanger && typeof pendingDoorhanger == "function"
+ );
+ })
+ .map(pendingDoorhangers =>
+ (async () => {
+ for (const showDoorhanger of pendingDoorhangers) {
+ await showDoorhanger();
+ }
+ })()
+ )
+ );
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs
new file mode 100644
index 0000000000..55e757a2af
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillPreferences.sys.mjs
@@ -0,0 +1,389 @@
+/* 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/. */
+
+/**
+ * Injects the form autofill section into about:preferences.
+ */
+
+// Add addresses enabled flag in telemetry environment for recording the number of
+// users who disable/enable the address autofill feature.
+const BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";
+const MANAGE_ADDRESSES_URL =
+ "chrome://formautofill/content/manageAddresses.xhtml";
+const MANAGE_CREDITCARDS_URL =
+ "chrome://formautofill/content/manageCreditCards.xhtml";
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+const {
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+} = FormAutofill;
+const {
+ MANAGE_ADDRESSES_L10N_IDS,
+ EDIT_ADDRESS_L10N_IDS,
+ MANAGE_CREDITCARDS_L10N_IDS,
+ EDIT_CREDITCARD_L10N_IDS,
+} = FormAutofillUtils;
+// Add credit card enabled flag in telemetry environment for recording the number of
+// users who disable/enable the credit card autofill feature.
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+export function FormAutofillPreferences() {
+ this.bundle = Services.strings.createBundle(BUNDLE_URI);
+}
+
+FormAutofillPreferences.prototype = {
+ /**
+ * Create the Form Autofill preference group.
+ *
+ * @param {HTMLDocument} document
+ * @returns {XULElement}
+ */
+ init(document) {
+ this.createPreferenceGroup(document);
+ this.attachEventListeners();
+
+ return this.refs.formAutofillFragment;
+ },
+
+ /**
+ * Remove event listeners and the preference group.
+ */
+ uninit() {
+ this.detachEventListeners();
+ this.refs.formAutofillGroup.remove();
+ },
+
+ /**
+ * Create Form Autofill preference group
+ *
+ * @param {HTMLDocument} document
+ */
+ createPreferenceGroup(document) {
+ let formAutofillFragment = document.createDocumentFragment();
+ let formAutofillGroupBoxLabel = document.createXULElement("label");
+ let formAutofillGroupBoxLabelHeading = document.createElementNS(
+ HTML_NS,
+ "h2"
+ );
+ let formAutofillGroup = document.createXULElement("vbox");
+ // Wrappers are used to properly compute the search tooltip positions
+ // let savedAddressesBtnWrapper = document.createXULElement("hbox");
+ // let savedCreditCardsBtnWrapper = document.createXULElement("hbox");
+ this.refs = {};
+ this.refs.formAutofillGroup = formAutofillGroup;
+ this.refs.formAutofillFragment = formAutofillFragment;
+
+ formAutofillGroupBoxLabel.appendChild(formAutofillGroupBoxLabelHeading);
+ formAutofillFragment.appendChild(formAutofillGroupBoxLabel);
+ formAutofillFragment.appendChild(formAutofillGroup);
+
+ let showAddressUI = FormAutofill.isAutofillAddressesAvailable;
+ let showCreditCardUI = FormAutofill.isAutofillCreditCardsAvailable;
+
+ if (!showAddressUI && !showCreditCardUI) {
+ return;
+ }
+
+ formAutofillGroupBoxLabelHeading.textContent =
+ this.bundle.GetStringFromName("autofillHeader");
+
+ if (showAddressUI) {
+ let savedAddressesBtnWrapper = document.createXULElement("hbox");
+ let addressAutofill = document.createXULElement("hbox");
+ let addressAutofillCheckboxGroup = document.createXULElement("hbox");
+ let addressAutofillCheckbox = document.createXULElement("checkbox");
+ let addressAutofillLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+ let savedAddressesBtn = document.createXULElement("button", {
+ is: "highlightable-button",
+ });
+ savedAddressesBtn.className = "accessory-button";
+ addressAutofillCheckbox.className = "tail-with-learn-more";
+
+ formAutofillGroup.id = "formAutofillGroup";
+ addressAutofill.id = "addressAutofill";
+ addressAutofillLearnMore.id = "addressAutofillLearnMore";
+
+ addressAutofill.setAttribute("data-subcategory", "address-autofill");
+ addressAutofillCheckbox.setAttribute(
+ "label",
+ this.bundle.GetStringFromName("autofillAddressesCheckbox")
+ );
+ savedAddressesBtn.setAttribute(
+ "label",
+ this.bundle.GetStringFromName("savedAddressesBtnLabel")
+ );
+ // Align the start to keep the savedAddressesBtn as original size
+ // when addressAutofillCheckboxGroup's height is changed by a longer l10n string
+ savedAddressesBtnWrapper.setAttribute("align", "start");
+
+ addressAutofillLearnMore.setAttribute(
+ "support-page",
+ "autofill-card-address"
+ );
+
+ // Add preferences search support
+ savedAddressesBtn.setAttribute(
+ "search-l10n-ids",
+ MANAGE_ADDRESSES_L10N_IDS.concat(EDIT_ADDRESS_L10N_IDS).join(",")
+ );
+
+ // Manually set the checked state
+ if (FormAutofill.isAutofillAddressesEnabled) {
+ addressAutofillCheckbox.setAttribute("checked", true);
+ }
+ if (FormAutofill.isAutofillAddressesLocked) {
+ addressAutofillCheckbox.disabled = true;
+ }
+
+ addressAutofillCheckboxGroup.setAttribute("align", "center");
+ addressAutofillCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(addressAutofill);
+ addressAutofill.appendChild(addressAutofillCheckboxGroup);
+ addressAutofillCheckboxGroup.appendChild(addressAutofillCheckbox);
+ addressAutofillCheckboxGroup.appendChild(addressAutofillLearnMore);
+ addressAutofill.appendChild(savedAddressesBtnWrapper);
+ savedAddressesBtnWrapper.appendChild(savedAddressesBtn);
+
+ this.refs.formAutofillFragment = formAutofillFragment;
+ this.refs.addressAutofillCheckbox = addressAutofillCheckbox;
+ this.refs.savedAddressesBtn = savedAddressesBtn;
+ }
+
+ if (showCreditCardUI) {
+ let savedCreditCardsBtnWrapper = document.createXULElement("hbox");
+ let creditCardAutofill = document.createXULElement("hbox");
+ let creditCardAutofillCheckboxGroup = document.createXULElement("hbox");
+ let creditCardAutofillCheckbox = document.createXULElement("checkbox");
+ let creditCardAutofillLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+ let savedCreditCardsBtn = document.createXULElement("button", {
+ is: "highlightable-button",
+ });
+ savedCreditCardsBtn.className = "accessory-button";
+ creditCardAutofillCheckbox.className = "tail-with-learn-more";
+
+ creditCardAutofill.id = "creditCardAutofill";
+ creditCardAutofillLearnMore.id = "creditCardAutofillLearnMore";
+
+ creditCardAutofill.setAttribute(
+ "data-subcategory",
+ "credit-card-autofill"
+ );
+ creditCardAutofillCheckbox.setAttribute(
+ "label",
+ this.bundle.GetStringFromName("autofillCreditCardsCheckbox")
+ );
+
+ savedCreditCardsBtn.setAttribute(
+ "label",
+ this.bundle.GetStringFromName("savedCreditCardsBtnLabel")
+ );
+ // Align the start to keep the savedCreditCardsBtn as original size
+ // when creditCardAutofillCheckboxGroup's height is changed by a longer l10n string
+ savedCreditCardsBtnWrapper.setAttribute("align", "start");
+
+ creditCardAutofillLearnMore.setAttribute(
+ "support-page",
+ "credit-card-autofill"
+ );
+
+ // Add preferences search support
+ savedCreditCardsBtn.setAttribute(
+ "search-l10n-ids",
+ MANAGE_CREDITCARDS_L10N_IDS.concat(EDIT_CREDITCARD_L10N_IDS).join(",")
+ );
+
+ // Manually set the checked state
+ if (FormAutofill.isAutofillCreditCardsEnabled) {
+ creditCardAutofillCheckbox.setAttribute("checked", true);
+ }
+ if (FormAutofill.isAutofillCreditCardsLocked) {
+ creditCardAutofillCheckbox.disabled = true;
+ }
+
+ creditCardAutofillCheckboxGroup.setAttribute("align", "center");
+ creditCardAutofillCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(creditCardAutofill);
+ creditCardAutofill.appendChild(creditCardAutofillCheckboxGroup);
+ creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillCheckbox);
+ creditCardAutofillCheckboxGroup.appendChild(creditCardAutofillLearnMore);
+ creditCardAutofill.appendChild(savedCreditCardsBtnWrapper);
+ savedCreditCardsBtnWrapper.appendChild(savedCreditCardsBtn);
+
+ this.refs.creditCardAutofillCheckbox = creditCardAutofillCheckbox;
+ this.refs.savedCreditCardsBtn = savedCreditCardsBtn;
+
+ if (lazy.OSKeyStore.canReauth()) {
+ let reauth = document.createXULElement("hbox");
+ let reauthCheckboxGroup = document.createXULElement("hbox");
+ let reauthCheckbox = document.createXULElement("checkbox");
+ let reauthLearnMore = document.createElement("a", {
+ is: "moz-support-link",
+ });
+
+ reauthCheckboxGroup.classList.add("indent");
+ reauthCheckbox.classList.add("tail-with-learn-more");
+ reauthCheckbox.setAttribute("flex", "1");
+ reauthCheckbox.disabled = !FormAutofill.isAutofillCreditCardsEnabled;
+
+ reauth.id = "creditCardReauthenticate";
+ reauthLearnMore.id = "creditCardReauthenticateLearnMore";
+
+ reauth.setAttribute("data-subcategory", "reauth-credit-card-autofill");
+
+ let autofillReauthCheckboxLabel = "autofillReauthCheckbox";
+ // We reuse the if/else order from wizard markup to increase
+ // odds of consistent behavior.
+ if (AppConstants.platform == "macosx") {
+ autofillReauthCheckboxLabel += "Mac";
+ } else if (AppConstants.platform == "linux") {
+ autofillReauthCheckboxLabel += "Lin";
+ } else {
+ autofillReauthCheckboxLabel += "Win";
+ }
+ reauthCheckbox.setAttribute(
+ "label",
+ this.bundle.GetStringFromName(autofillReauthCheckboxLabel)
+ );
+
+ reauthLearnMore.setAttribute(
+ "support-page",
+ "credit-card-autofill#w_require-authentication-for-autofill"
+ );
+
+ // Manually set the checked state
+ if (FormAutofillUtils._reauthEnabledByUser) {
+ reauthCheckbox.setAttribute("checked", true);
+ }
+
+ reauthCheckboxGroup.setAttribute("align", "center");
+ reauthCheckboxGroup.setAttribute("flex", "1");
+
+ formAutofillGroup.appendChild(reauth);
+ reauth.appendChild(reauthCheckboxGroup);
+ reauthCheckboxGroup.appendChild(reauthCheckbox);
+ reauthCheckboxGroup.appendChild(reauthLearnMore);
+ this.refs.reauthCheckbox = reauthCheckbox;
+ }
+ }
+ },
+
+ /**
+ * Handle events
+ *
+ * @param {DOMEvent} event
+ */
+ async handleEvent(event) {
+ switch (event.type) {
+ case "command": {
+ let target = event.target;
+
+ if (target == this.refs.addressAutofillCheckbox) {
+ // Set preference directly instead of relying on <Preference>
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_ADDRESSES_PREF,
+ target.checked
+ );
+ } else if (target == this.refs.creditCardAutofillCheckbox) {
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_CREDITCARDS_PREF,
+ target.checked
+ );
+ if (this.refs.reauthCheckbox) {
+ this.refs.reauthCheckbox.disabled = !target.checked;
+ }
+ } else if (target == this.refs.reauthCheckbox) {
+ if (!lazy.OSKeyStore.canReauth()) {
+ break;
+ }
+
+ let messageTextId = "autofillReauthOSDialog";
+ // We reuse the if/else order from wizard markup to increase
+ // odds of consistent behavior.
+ if (AppConstants.platform == "macosx") {
+ messageTextId += "Mac";
+ } else if (AppConstants.platform == "linux") {
+ messageTextId += "Lin";
+ } else {
+ messageTextId += "Win";
+ }
+
+ let messageText = this.bundle.GetStringFromName(messageTextId);
+
+ const brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let win = target.ownerGlobal.docShell.chromeEventHandler.ownerGlobal;
+ let loggedIn = await lazy.OSKeyStore.ensureLoggedIn(
+ messageText,
+ brandBundle.GetStringFromName("brandFullName"),
+ win,
+ false
+ );
+ if (!loggedIn.authenticated) {
+ target.checked = !target.checked;
+ break;
+ }
+
+ Services.prefs.setBoolPref(
+ ENABLED_AUTOFILL_CREDITCARDS_REAUTH_PREF,
+ target.checked
+ );
+ } else if (target == this.refs.savedAddressesBtn) {
+ target.ownerGlobal.gSubDialog.open(MANAGE_ADDRESSES_URL);
+ } else if (target == this.refs.savedCreditCardsBtn) {
+ target.ownerGlobal.gSubDialog.open(MANAGE_CREDITCARDS_URL);
+ }
+ break;
+ }
+ case "click": {
+ let target = event.target;
+
+ if (target == this.refs.addressAutofillCheckboxLabel) {
+ let pref = FormAutofill.isAutofillAddressesEnabled;
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF, !pref);
+ this.refs.addressAutofillCheckbox.checked = !pref;
+ } else if (target == this.refs.creditCardAutofillCheckboxLabel) {
+ let pref = FormAutofill.isAutofillCreditCardsEnabled;
+ Services.prefs.setBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF, !pref);
+ this.refs.creditCardAutofillCheckbox.checked = !pref;
+ this.refs.reauthCheckbox.disabled = pref;
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Attach event listener
+ */
+ attachEventListeners() {
+ this.refs.formAutofillGroup.addEventListener("command", this);
+ this.refs.formAutofillGroup.addEventListener("click", this);
+ },
+
+ /**
+ * Remove event listener
+ */
+ detachEventListeners() {
+ this.refs.formAutofillGroup.removeEventListener("command", this);
+ this.refs.formAutofillGroup.removeEventListener("click", this);
+ },
+};
diff --git a/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
new file mode 100644
index 0000000000..186b53d78b
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
@@ -0,0 +1,2219 @@
+/* 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/. */
+
+/*
+ * Interface for the storage of Form Autofill.
+ *
+ * The data is stored in JSON format, without indentation and the computed
+ * fields, using UTF-8 encoding. With indentation and computed fields applied,
+ * the schema would look like this:
+ *
+ * {
+ * version: 1,
+ * addresses: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // address fields
+ * given-name,
+ * additional-name,
+ * family-name,
+ * organization, // Company
+ * street-address, // (Multiline)
+ * address-level3, // Suburb/Sublocality
+ * address-level2, // City/Town
+ * address-level1, // Province (Standardized code if possible)
+ * postal-code,
+ * country, // ISO 3166
+ * tel, // Stored in E.164 format
+ * email,
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * name,
+ * address-line1,
+ * address-line2,
+ * address-line3,
+ * country-name,
+ * tel-country-code,
+ * tel-national,
+ * tel-area-code,
+ * tel-local,
+ * tel-local-prefix,
+ * tel-local-suffix,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ],
+ * creditCards: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // credit card fields
+ * billingAddressGUID, // An optional GUID of an autofill address record
+ * which may or may not exist locally.
+ *
+ * cc-name,
+ * cc-number, // will be stored in masked format (************1234)
+ * // (see details below)
+ * cc-exp-month,
+ * cc-exp-year, // 2-digit year will be converted to 4 digits
+ * // upon saving
+ * cc-type, // Optional card network id (instrument type)
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * cc-given-name,
+ * cc-additional-name,
+ * cc-family-name,
+ * cc-number-encrypted, // encrypted from the original unmasked "cc-number"
+ * // (see details below)
+ * cc-exp,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ]
+ * }
+ *
+ *
+ * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted):
+ *
+ * When saving or updating a credit-card record, the storage will encrypt the
+ * value of "cc-number", store the encrypted number in "cc-number-encrypted"
+ * field, and replace "cc-number" field with the masked number. These all happen
+ * in "computeFields". We do reverse actions in "_stripComputedFields", which
+ * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes
+ * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by
+ * "computeFields" can make sure the encrypt-related fields are up-to-date.
+ *
+ * In general, you have to decrypt the number by your own outside FormAutofillStorage
+ * when necessary. However, you will get the decrypted records when querying
+ * data with "rawData=true" to ensure they're ready to sync.
+ *
+ *
+ * Sync Metadata:
+ *
+ * Records may also have a _sync field, which consists of:
+ * {
+ * changeCounter, // integer - the number of changes made since the last
+ * // sync.
+ * lastSyncedFields, // object - hashes of the original values for fields
+ * // changed since the last sync.
+ * }
+ *
+ * Records with such a field have previously been synced. Records without such
+ * a field are yet to be synced, so are treated specially in some cases (eg,
+ * they don't need a tombstone, de-duping logic treats them as special etc).
+ * Records without the field are always considered "dirty" from Sync's POV
+ * (meaning they will be synced on the next sync), at which time they will gain
+ * this new field.
+ */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.jsm",
+});
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+const STORAGE_SCHEMA_VERSION = 1;
+
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+// (And if it did ever change, it must never be "4" due to the reconcile hacks
+// below which repairs credit-cards with version=4)
+export const ADDRESS_SCHEMA_VERSION = 1;
+
+// Version 2: Bug 1486954 - Encrypt `cc-number`
+// Version 3: Bug 1639795 - Update keystore name
+// Version 4: (deprecated!!! See Bug 1812235): Bug 1667257 - Do not store `cc-type` field
+// Next version should be 5
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+export const CREDIT_CARD_SCHEMA_VERSION = 3;
+
+const VALID_ADDRESS_FIELDS = [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "organization",
+ "street-address",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "tel",
+ "email",
+];
+
+const STREET_ADDRESS_COMPONENTS = [
+ "address-line1",
+ "address-line2",
+ "address-line3",
+];
+
+const TEL_COMPONENTS = [
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+];
+
+const VALID_ADDRESS_COMPUTED_FIELDS = ["name", "country-name"].concat(
+ STREET_ADDRESS_COMPONENTS,
+ TEL_COMPONENTS
+);
+
+const VALID_CREDIT_CARD_FIELDS = [
+ "billingAddressGUID",
+ "cc-name",
+ "cc-number",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+];
+
+const VALID_CREDIT_CARD_COMPUTED_FIELDS = [
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ "cc-number-encrypted",
+ "cc-exp",
+];
+
+const INTERNAL_FIELDS = [
+ "guid",
+ "version",
+ "timeCreated",
+ "timeLastUsed",
+ "timeLastModified",
+ "timesUsed",
+];
+
+function sha512(string) {
+ if (string == null) {
+ return null;
+ }
+ let encoder = new TextEncoder();
+ let bytes = encoder.encode(string);
+ let hash = new CryptoHash("sha512");
+ hash.update(bytes, bytes.length);
+ return hash.finish(/* base64 */ true);
+}
+
+/**
+ * Class that manipulates records in a specified collection.
+ *
+ * Note that it is responsible for converting incoming data to a consistent
+ * format in the storage. For example, computed fields will be transformed to
+ * the original fields and 2-digit years will be calculated into 4 digits.
+ */
+class AutofillRecords {
+ /**
+ * Creates an AutofillRecords.
+ *
+ * @param {JSONFile} store
+ * An instance of JSONFile.
+ * @param {string} collectionName
+ * A key of "store.data".
+ * @param {Array.<string>} validFields
+ * A list containing non-metadata field names.
+ * @param {Array.<string>} validComputedFields
+ * A list containing computed field names.
+ * @param {number} schemaVersion
+ * The schema version for the new record.
+ */
+ constructor(
+ store,
+ collectionName,
+ validFields,
+ validComputedFields,
+ schemaVersion
+ ) {
+ this.log = FormAutofill.defineLogGetter(
+ lazy,
+ "AutofillRecords:" + collectionName
+ );
+
+ this.VALID_FIELDS = validFields;
+ this.VALID_COMPUTED_FIELDS = validComputedFields;
+
+ this._store = store;
+ this._collectionName = collectionName;
+ this._schemaVersion = schemaVersion;
+
+ this._initialize();
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ }
+
+ _initialize() {
+ this._initializePromise = Promise.all(
+ this._data.map(async (record, index) =>
+ this._migrateRecord(record, index)
+ )
+ ).then(hasChangesArr => {
+ let dataHasChanges = hasChangesArr.includes(true);
+ if (dataHasChanges) {
+ this._store.saveSoon();
+ }
+ });
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "formautofill-storage-changed":
+ let collectionName = subject.wrappedJSObject.collectionName;
+ if (collectionName != this._collectionName) {
+ return;
+ }
+ const telemetryType =
+ subject.wrappedJSObject.collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ const count = this._data.filter(entry => !entry.deleted).length;
+ lazy.AutofillTelemetry.recordAutofillProfileCount(telemetryType, count);
+ break;
+ }
+ }
+
+ /**
+ * Gets the schema version number.
+ *
+ * @returns {number}
+ * The current schema version number.
+ */
+ get version() {
+ return this._schemaVersion;
+ }
+
+ /**
+ * Gets the data of this collection.
+ *
+ * @returns {Array}
+ * The data object.
+ */
+ get _data() {
+ return this._getData();
+ }
+
+ _getData() {
+ return this._store.data[this._collectionName];
+ }
+
+ // Ensures that we don't try to apply synced records with newer schema
+ // versions. This is a temporary measure to ensure we don't accidentally
+ // bump the schema version without a syncing strategy in place (bug 1377204).
+ _ensureMatchingVersion(record) {
+ if (record.version != this.version) {
+ throw new Error(
+ `Got unknown record version ${record.version}; want ${this.version}`
+ );
+ }
+ }
+
+ /**
+ * Initialize the records in the collection, resolves when the migration completes.
+ *
+ * @returns {Promise}
+ */
+ initialize() {
+ return this._initializePromise;
+ }
+
+ /**
+ * Adds a new record.
+ *
+ * @param {object} record
+ * The new record for saving.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did sync generate this addition?
+ * @returns {Promise<string>}
+ * The GUID of the newly added item..
+ */
+ async add(record, { sourceSync = false } = {}) {
+ let recordToSave = this._clone(record);
+
+ if (sourceSync) {
+ // Remove tombstones for incoming items that were changed on another
+ // device. Local deletions always lose to avoid data loss.
+ let index = this._findIndexByGUID(recordToSave.guid, {
+ includeDeleted: true,
+ });
+ if (index > -1) {
+ let existing = this._data[index];
+ if (existing.deleted) {
+ this._data.splice(index, 1);
+ } else {
+ throw new Error(`Record ${recordToSave.guid} already exists`);
+ }
+ }
+ } else if (!recordToSave.deleted) {
+ this._normalizeRecord(recordToSave);
+ // _normalizeRecord shouldn't do any validation (throw) because in the
+ // `update` case it is called with partial records whereas
+ // `_validateFields` is called with a complete one.
+ this._validateFields(recordToSave);
+
+ recordToSave.guid = this._generateGUID();
+ recordToSave.version = this.version;
+
+ // Metadata
+ let now = Date.now();
+ recordToSave.timeCreated = now;
+ recordToSave.timeLastModified = now;
+ recordToSave.timeLastUsed = 0;
+ recordToSave.timesUsed = 0;
+ }
+
+ return this._saveRecord(recordToSave, { sourceSync });
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ if (!record.guid) {
+ throw new Error("Record missing GUID");
+ }
+
+ let recordToSave;
+ if (record.deleted) {
+ if (this._findByGUID(record.guid, { includeDeleted: true })) {
+ throw new Error("a record with this GUID already exists");
+ }
+ recordToSave = {
+ guid: record.guid,
+ timeLastModified: record.timeLastModified || Date.now(),
+ deleted: true,
+ };
+ } else {
+ this._ensureMatchingVersion(record);
+ recordToSave = record;
+ await this.computeFields(recordToSave);
+ }
+
+ if (sourceSync) {
+ let sync = this._getSyncMetaData(recordToSave, true);
+ sync.changeCounter = 0;
+ }
+
+ this._data.push(recordToSave);
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid: record.guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "add"
+ );
+ return recordToSave.guid;
+ }
+
+ _generateGUID() {
+ let guid;
+ while (!guid || this._findByGUID(guid)) {
+ guid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}-]/g, "")
+ .substring(0, 12);
+ }
+ return guid;
+ }
+
+ /**
+ * Update the specified record.
+ *
+ * @param {string} guid
+ * Indicates which record to update.
+ * @param {object} record
+ * The new record used to overwrite the old one.
+ * @param {Promise<boolean>} [preserveOldProperties = false]
+ * Preserve old record's properties if they don't exist in new record.
+ */
+ async update(guid, record, preserveOldProperties = false) {
+ this.log.debug(`update: ${guid}`);
+
+ let recordFoundIndex = this._findIndexByGUID(guid);
+ if (recordFoundIndex == -1) {
+ throw new Error("No matching record.");
+ }
+
+ // Clone the record before modifying it to avoid exposing incomplete changes.
+ let recordFound = this._clone(this._data[recordFoundIndex]);
+ await this._stripComputedFields(recordFound);
+
+ let recordToUpdate = this._clone(record);
+ this._normalizeRecord(recordToUpdate, true);
+
+ let hasValidField = false;
+ for (let field of this.VALID_FIELDS) {
+ let oldValue = recordFound[field];
+ let newValue = recordToUpdate[field];
+
+ // Resume the old field value in the perserve case
+ if (preserveOldProperties && newValue === undefined) {
+ newValue = oldValue;
+ }
+
+ if (newValue === undefined || newValue === "") {
+ delete recordFound[field];
+ } else {
+ hasValidField = true;
+ recordFound[field] = newValue;
+ }
+
+ this._maybeStoreLastSyncedField(recordFound, field, oldValue);
+ }
+
+ if (!hasValidField) {
+ throw new Error("Record contains no valid field.");
+ }
+
+ // _normalizeRecord above is called with the `record` argument provided to
+ // `update` which may not contain all resulting fields when
+ // `preserveOldProperties` is used. This means we need to validate for
+ // missing fields after we compose the record (`recordFound`) with the stored
+ // record like we do in the loop above.
+ this._validateFields(recordFound);
+
+ recordFound.timeLastModified = Date.now();
+ let syncMetadata = this._getSyncMetaData(recordFound);
+ if (syncMetadata) {
+ syncMetadata.changeCounter += 1;
+ }
+
+ await this.computeFields(recordFound);
+ this._data[recordFoundIndex] = recordFound;
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "update"
+ );
+ }
+
+ /**
+ * Notifies the storage of the use of the specified record, so we can update
+ * the metadata accordingly. This does not bump the Sync change counter, since
+ * we don't sync `timesUsed` or `timeLastUsed`.
+ *
+ * @param {string} guid
+ * Indicates which record to be notified.
+ */
+ notifyUsed(guid) {
+ this.log.debug("notifyUsed:", guid);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ throw new Error("No matching record.");
+ }
+
+ recordFound.timesUsed++;
+ recordFound.timeLastUsed = Date.now();
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ }
+
+ updateUseCountTelemetry() {
+ const telemetryType =
+ this._collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ let records = this._data.filter(r => !r.deleted);
+ lazy.AutofillTelemetry.recordNumberOfUse(telemetryType, records);
+ }
+
+ /**
+ * Removes the specified record. No error occurs if the record isn't found.
+ *
+ * @param {string} guid
+ * Indicates which record to remove.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did Sync generate this removal?
+ */
+ remove(guid, { sourceSync = false } = {}) {
+ this.log.debug("remove:", guid);
+
+ if (sourceSync) {
+ this._removeSyncedRecord(guid);
+ } else {
+ let index = this._findIndexByGUID(guid, { includeDeleted: false });
+ if (index == -1) {
+ this.log.warn("attempting to remove non-existing entry", guid);
+ return;
+ }
+ let existing = this._data[index];
+ if (existing.deleted) {
+ return; // already a tombstone - don't touch it.
+ }
+ let existingSync = this._getSyncMetaData(existing);
+ if (existingSync) {
+ // existing sync metadata means it has been synced. This means we must
+ // leave a tombstone behind.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: existingSync,
+ };
+ existingSync.changeCounter++;
+ } else {
+ // If there's no sync meta-data, this record has never been synced, so
+ // we can delete it.
+ this._data.splice(index, 1);
+ }
+ }
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "remove"
+ );
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ this.log.debug(`get: ${guid}`);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ return null;
+ }
+
+ // The record is cloned to avoid accidental modifications from outside.
+ let clonedRecord = this._cloneAndCleanUp(recordFound);
+ if (rawData) {
+ await this._stripComputedFields(clonedRecord);
+ } else {
+ this._recordReadProcessor(clonedRecord);
+ }
+ return clonedRecord;
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ this.log.debug(`getAll. includeDeleted = ${includeDeleted}`);
+
+ let records = this._data.filter(r => !r.deleted || includeDeleted);
+ // Records are cloned to avoid accidental modifications from outside.
+ let clonedRecords = records.map(r => this._cloneAndCleanUp(r));
+ await Promise.all(
+ clonedRecords.map(async record => {
+ if (rawData) {
+ await this._stripComputedFields(record);
+ } else {
+ this._recordReadProcessor(record);
+ }
+ })
+ );
+ return clonedRecords;
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Promise<Set>} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ this.log.debug("getSavedFieldNames");
+
+ let records = this._data.filter(r => !r.deleted);
+ records
+ .map(record => this._cloneAndCleanUp(record))
+ .forEach(record => this._recordReadProcessor(record));
+
+ let fieldNames = new Set();
+ for (let record of records) {
+ for (let fieldName of Object.keys(record)) {
+ if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) {
+ continue;
+ }
+ fieldNames.add(fieldName);
+ }
+ }
+
+ return fieldNames;
+ }
+
+ /**
+ * Functions intended to be used in the support of Sync.
+ */
+
+ /**
+ * Stores a hash of the last synced value for a field in a locally updated
+ * record. We use this value to rebuild the shared parent, or base, when
+ * reconciling incoming records that may have changed on another device.
+ *
+ * Storing the hash of the values that we last wrote to the Sync server lets
+ * us determine if a remote change conflicts with a local change. If the
+ * hashes for the base, current local value, and remote value all differ, we
+ * have a conflict.
+ *
+ * These fields are not themselves synced, and will be removed locally as
+ * soon as we have successfully written the record to the Sync server - so
+ * it is expected they will not remain for long, as changes which cause a
+ * last synced field to be written will itself cause a sync.
+ *
+ * We also skip this for updates made by Sync, for internal fields, for
+ * records that haven't been uploaded yet, and for fields which have already
+ * been changed since the last sync.
+ *
+ * @param {object} record
+ * The updated local record.
+ * @param {string} field
+ * The field name.
+ * @param {string} lastSyncedValue
+ * The last synced field value.
+ */
+ _maybeStoreLastSyncedField(record, field, lastSyncedValue) {
+ let sync = this._getSyncMetaData(record);
+ if (!sync) {
+ // The record hasn't been uploaded yet, so we can't end up with merge
+ // conflicts.
+ return;
+ }
+ let alreadyChanged = field in sync.lastSyncedFields;
+ if (alreadyChanged) {
+ // This field was already changed multiple times since the last sync.
+ return;
+ }
+ let newValue = record[field];
+ if (lastSyncedValue != newValue) {
+ sync.lastSyncedFields[field] = sha512(lastSyncedValue);
+ }
+ }
+
+ /**
+ * Attempts a three-way merge between a changed local record, an incoming
+ * remote record, and the shared parent that we synthesize from the last
+ * synced fields - see _maybeStoreLastSyncedField.
+ *
+ * @param {object} strippedLocalRecord
+ * The changed local record, currently in storage. Computed fields
+ * are stripped.
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {object | null}
+ * The merged record, or `null` if there are conflicts and the
+ * records can't be merged.
+ */
+ _mergeSyncedRecords(strippedLocalRecord, remoteRecord) {
+ let sync = this._getSyncMetaData(strippedLocalRecord, true);
+
+ // Copy all internal fields from the remote record. We'll update their
+ // values in `_replaceRecordAt`.
+ let mergedRecord = {};
+ for (let field of INTERNAL_FIELDS) {
+ if (remoteRecord[field] != null) {
+ mergedRecord[field] = remoteRecord[field];
+ }
+ }
+
+ for (let field of this.VALID_FIELDS) {
+ let isLocalSame = false;
+ let isRemoteSame = false;
+ if (field in sync.lastSyncedFields) {
+ // If the field has changed since the last sync, compare hashes to
+ // determine if the local and remote values are different. Hashing is
+ // expensive, but we don't expect this to happen frequently.
+ let lastSyncedValue = sync.lastSyncedFields[field];
+ isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]);
+ isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]);
+ } else {
+ // Otherwise, if the field hasn't changed since the last sync, we know
+ // it's the same locally.
+ isLocalSame = true;
+ isRemoteSame = strippedLocalRecord[field] == remoteRecord[field];
+ }
+
+ let value;
+ if (isLocalSame && isRemoteSame) {
+ // Local and remote are the same; doesn't matter which one we pick.
+ value = strippedLocalRecord[field];
+ } else if (isLocalSame && !isRemoteSame) {
+ value = remoteRecord[field];
+ } else if (!isLocalSame && isRemoteSame) {
+ // We don't need to bump the change counter when taking the local
+ // change, because the counter must already be > 0 if we're attempting
+ // a three-way merge.
+ value = strippedLocalRecord[field];
+ } else if (strippedLocalRecord[field] == remoteRecord[field]) {
+ // Shared parent doesn't match either local or remote, but the values
+ // are identical, so there's no conflict.
+ value = strippedLocalRecord[field];
+ } else {
+ // Both local and remote changed to different values. We'll need to fork
+ // the local record to resolve the conflict.
+ return null;
+ }
+
+ if (value != null) {
+ mergedRecord[field] = value;
+ }
+ }
+
+ // When merging records, we shouldn't persist any unknown fields on the local and instead
+ // rely on the remote for unknown fields, so we filter the fields we know and keep the rest
+ Object.keys(remoteRecord)
+ .filter(
+ key =>
+ !this.VALID_FIELDS.includes(key) && !INTERNAL_FIELDS.includes(key)
+ )
+ .forEach(key => (mergedRecord[key] = remoteRecord[key]));
+ return mergedRecord;
+ }
+
+ /**
+ * Replaces a local record with a remote or merged record, copying internal
+ * fields and Sync metadata.
+ *
+ * @param {number} index
+ * @param {object} remoteRecord
+ * @param {object} options
+ * @param {Promise<boolean>} [options.keepSyncMetadata = false]
+ * Should we copy Sync metadata? This is true if `remoteRecord` is a
+ * merged record with local changes that we need to upload. Passing
+ * `keepSyncMetadata` retains the record's change counter and
+ * last synced fields, so that we don't clobber the local change if
+ * the sync is interrupted after the record is merged, but before
+ * it's uploaded.
+ */
+ async _replaceRecordAt(
+ index,
+ remoteRecord,
+ { keepSyncMetadata = false } = {}
+ ) {
+ let localRecord = this._data[index];
+ let newRecord = this._clone(remoteRecord);
+
+ await this._stripComputedFields(newRecord);
+
+ this._data[index] = newRecord;
+
+ if (keepSyncMetadata) {
+ // It's safe to move the Sync metadata from the old record to the new
+ // record, since we always clone records when we return them, and we
+ // never hand out references to the metadata object via public methods.
+ newRecord._sync = localRecord._sync;
+ } else {
+ // As a side effect, `_getSyncMetaData` marks the record as syncing if the
+ // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing
+ // local with remote.
+ let sync = this._getSyncMetaData(newRecord, true);
+ sync.changeCounter = 0;
+ }
+
+ if (
+ !newRecord.timeCreated ||
+ localRecord.timeCreated < newRecord.timeCreated
+ ) {
+ newRecord.timeCreated = localRecord.timeCreated;
+ }
+
+ if (
+ !newRecord.timeLastModified ||
+ localRecord.timeLastModified > newRecord.timeLastModified
+ ) {
+ newRecord.timeLastModified = localRecord.timeLastModified;
+ }
+
+ // Copy local-only fields from the existing local record.
+ for (let field of ["timeLastUsed", "timesUsed"]) {
+ if (localRecord[field] != null) {
+ newRecord[field] = localRecord[field];
+ }
+ }
+
+ await this.computeFields(newRecord);
+ }
+
+ /**
+ * Clones a local record, giving the clone a new GUID and Sync metadata. The
+ * original record remains unchanged in storage.
+ *
+ * @param {object} strippedLocalRecord
+ * The local record. Computed fields are stripped.
+ * @returns {string}
+ * A clone of the local record with a new GUID.
+ */
+ async _forkLocalRecord(strippedLocalRecord) {
+ let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord);
+ forkedLocalRecord.guid = this._generateGUID();
+
+ // Give the record fresh Sync metadata and bump its change counter as a
+ // side effect. This also excludes the forked record from de-duping on the
+ // next sync, if the current sync is interrupted before the record can be
+ // uploaded.
+ this._getSyncMetaData(forkedLocalRecord, true);
+
+ await this.computeFields(forkedLocalRecord);
+ this._data.push(forkedLocalRecord);
+
+ return forkedLocalRecord;
+ }
+
+ /**
+ * Reconciles an incoming remote record into the matching local record. This
+ * method is only used by Sync; other callers should use `merge`.
+ *
+ * @param {object} remoteRecord
+ * The incoming record. `remoteRecord` must not be a tombstone, and
+ * must have a matching local record with the same GUID. Use
+ * `add` to insert remote records that don't exist locally, and
+ * `remove` to apply remote tombstones.
+ * @returns {Promise<object>}
+ * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge
+ * succeeded without conflicts, or a new GUID referencing the
+ * existing locally modified record if the conflicts could not be
+ * resolved.
+ */
+ async reconcile(remoteRecord) {
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`);
+ }
+
+ let localIndex = this._findIndexByGUID(remoteRecord.guid);
+ if (localIndex < 0) {
+ throw new Error(`Record ${remoteRecord.guid} not found`);
+ }
+
+ let localRecord = this._data[localIndex];
+ let sync = this._getSyncMetaData(localRecord, true);
+
+ let forkedGUID = null;
+
+ // NOTE: This implies a credit-card - so it's critical ADDRESS_SCHEMA_VERSION
+ // never equals 4 while this code exists!
+ let requiresForceUpdate =
+ localRecord.version != remoteRecord.version && remoteRecord.version == 4;
+
+ if (requiresForceUpdate) {
+ // Another desktop device that is still using version=4 has created or
+ // modified a remote record. Here we downgrade it to version=3 so we can
+ // treat it normally, then cause it to be re-uploaded so other desktop
+ // or mobile devices can still see it.
+ // That device still using version=4 *will* again see it, and again
+ // upgrade it, but thankfully that 3->4 migration doesn't force a reupload
+ // of all records, or we'd be going back and forward on every sync.
+ // Once that version=4 device gets updated to roll back to version=3, it
+ // will then yet again re-upload it, this time with version=3, but the
+ // content will be the same here, so everything should work out in the end.
+ //
+ // If we just ignored this incoming record, it would remain on the server
+ // with version=4. If the device that wrote that went away (ie, never
+ // synced again) nothing would ever repair it back to 3, which would
+ // be bad because mobile would remain broken until the user edited the
+ // card somewhere.
+ remoteRecord = await this._computeMigratedRecord(remoteRecord);
+ }
+ if (sync.changeCounter === 0) {
+ // Local not modified. Replace local with remote.
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ } else {
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let mergedRecord = this._mergeSyncedRecords(
+ strippedLocalRecord,
+ remoteRecord
+ );
+ if (mergedRecord) {
+ // Local and remote modified, but we were able to merge. Replace the
+ // local record with the merged record.
+ await this._replaceRecordAt(localIndex, mergedRecord, {
+ keepSyncMetadata: true,
+ });
+ } else {
+ // Merge conflict. Fork the local record, then replace the original
+ // with the merged record.
+ let forkedLocalRecord = await this._forkLocalRecord(
+ strippedLocalRecord
+ );
+ forkedGUID = forkedLocalRecord.guid;
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ }
+ }
+
+ if (requiresForceUpdate) {
+ // The incoming record was version=4 and we want to re-upload it as version=3.
+ // We need to reach directly into self._data[] so we can poke at the
+ // sync metadata directly.
+ let indexToUpdate = this._findIndexByGUID(remoteRecord.guid);
+ let toUpdate = this._data[indexToUpdate];
+ this._getSyncMetaData(toUpdate, true).changeCounter += 1;
+ this.log.info(
+ `Flagging record ${toUpdate.guid} for re-upload after record version downgrade`
+ );
+ }
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync: true,
+ guid: remoteRecord.guid,
+ forkedGUID,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "reconcile"
+ );
+
+ return { forkedGUID };
+ }
+
+ _removeSyncedRecord(guid) {
+ let index = this._findIndexByGUID(guid, { includeDeleted: true });
+ if (index == -1) {
+ // Removing a record we don't know about. It may have been synced and
+ // removed by another device before we saw it. Store the tombstone in
+ // case the server is later wiped and we need to reupload everything.
+ let tombstone = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ let sync = this._getSyncMetaData(tombstone, true);
+ sync.changeCounter = 0;
+ this._data.push(tombstone);
+ return;
+ }
+
+ let existing = this._data[index];
+ let sync = this._getSyncMetaData(existing, true);
+ if (sync.changeCounter > 0) {
+ // Deleting a record with unsynced local changes. To avoid potential
+ // data loss, we ignore the deletion in favor of the changed record.
+ this.log.info(
+ "Ignoring deletion for record with local changes",
+ existing
+ );
+ return;
+ }
+
+ if (existing.deleted) {
+ this.log.info("Ignoring deletion for tombstone", existing);
+ return;
+ }
+
+ // Removing a record that's not changed locally, and that's not already
+ // deleted. Replace the record with a synced tombstone.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: sync,
+ };
+ }
+
+ /**
+ * Provide an object that describes the changes to sync.
+ *
+ * This is called at the start of the sync process to determine what needs
+ * to be updated on the server. As the server is updated, sync will update
+ * entries in the returned object, and when sync is complete it will pass
+ * the object to pushSyncChanges, which will apply the changes to the store.
+ *
+ * @returns {object}
+ * An object describing the changes to sync.
+ */
+ pullSyncChanges() {
+ let changes = {};
+
+ let profiles = this._data;
+ for (let profile of profiles) {
+ let sync = this._getSyncMetaData(profile, true);
+ if (sync.changeCounter < 1) {
+ if (sync.changeCounter != 0) {
+ this.log.error("negative change counter", profile);
+ }
+ continue;
+ }
+ changes[profile.guid] = {
+ profile,
+ counter: sync.changeCounter,
+ modified: profile.timeLastModified,
+ synced: false,
+ };
+ }
+ this._store.saveSoon();
+
+ return changes;
+ }
+
+ /**
+ * Apply the metadata changes made by Sync.
+ *
+ * This is called with metadata about what was synced - see pullSyncChanges.
+ *
+ * @param {object} changes
+ * The possibly modified object obtained via pullSyncChanges.
+ */
+ pushSyncChanges(changes) {
+ for (let [guid, { counter, synced }] of Object.entries(changes)) {
+ if (!synced) {
+ continue;
+ }
+ let recordFound = this._findByGUID(guid, { includeDeleted: true });
+ if (!recordFound) {
+ this.log.warn("No profile found to persist changes for guid " + guid);
+ continue;
+ }
+ let sync = this._getSyncMetaData(recordFound, true);
+ sync.changeCounter = Math.max(0, sync.changeCounter - counter);
+ if (sync.changeCounter === 0) {
+ // Clear the shared parent fields once we've uploaded all pending
+ // changes, since the server now matches what we have locally.
+ sync.lastSyncedFields = {};
+ }
+ }
+ this._store.saveSoon();
+ }
+
+ /**
+ * Reset all sync metadata for all items.
+ *
+ * This is called when Sync is disconnected from this device. All sync
+ * metadata for all items is removed.
+ */
+ resetSync() {
+ for (let record of this._data) {
+ delete record._sync;
+ }
+ // XXX - we should probably also delete all tombstones?
+ this.log.info("All sync metadata was reset");
+ }
+
+ /**
+ * Changes the GUID of an item. This should be called only by Sync. There
+ * must be an existing record with oldID and it must never have been synced
+ * or an error will be thrown. There must be no existing record with newID.
+ *
+ * No tombstone will be created for the old GUID - we check it hasn't
+ * been synced, so no tombstone is necessary.
+ *
+ * @param {string} oldID
+ * GUID of the existing item to change the GUID of.
+ * @param {string} newID
+ * The new GUID for the item.
+ */
+ changeGUID(oldID, newID) {
+ this.log.debug("changeGUID: ", oldID, newID);
+ if (oldID == newID) {
+ throw new Error("changeGUID: old and new IDs are the same");
+ }
+ if (this._findIndexByGUID(newID) >= 0) {
+ throw new Error("changeGUID: record with destination id exists already");
+ }
+
+ let index = this._findIndexByGUID(oldID);
+ let profile = this._data[index];
+ if (!profile) {
+ throw new Error("changeGUID: no source record");
+ }
+ if (this._getSyncMetaData(profile)) {
+ throw new Error("changeGUID: existing record has already been synced");
+ }
+
+ profile.guid = newID;
+
+ this._store.saveSoon();
+ }
+
+ // Used to get, and optionally create, sync metadata. Brand new records will
+ // *not* have sync meta-data - it will be created when they are first
+ // synced.
+ _getSyncMetaData(record, forceCreate = false) {
+ if (!record._sync && forceCreate) {
+ // create default metadata and indicate we need to save.
+ record._sync = {
+ changeCounter: 1,
+ lastSyncedFields: {},
+ };
+ this._store.saveSoon();
+ }
+ return record._sync;
+ }
+
+ /**
+ * Finds a local record with matching common fields and a different GUID.
+ * Sync uses this method to find and update unsynced local records with
+ * fields that match incoming remote records. This avoids creating
+ * duplicate profiles with the same information.
+ *
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {Promise<string|null>}
+ * The GUID of the matching local record, or `null` if no records
+ * match.
+ */
+ async findDuplicateGUID(remoteRecord) {
+ if (!remoteRecord.guid) {
+ throw new Error("Record missing GUID");
+ }
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ // Tombstones don't carry enough info to de-dupe, and we should have
+ // handled them separately when applying the record.
+ throw new Error("Tombstones can't have duplicates");
+ }
+ let localRecords = this._data;
+ for (let localRecord of localRecords) {
+ if (localRecord.deleted) {
+ continue;
+ }
+ if (localRecord.guid == remoteRecord.guid) {
+ throw new Error(`Record ${remoteRecord.guid} already exists`);
+ }
+ if (this._getSyncMetaData(localRecord)) {
+ // This local record has already been uploaded, so it can't be a dupe of
+ // another incoming item.
+ continue;
+ }
+
+ // Ignore computed fields when matching records as they aren't synced at all.
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let keys = new Set(Object.keys(remoteRecord));
+ for (let key of Object.keys(strippedLocalRecord)) {
+ keys.add(key);
+ }
+ // Ignore internal fields when matching records. Internal fields are synced,
+ // but almost certainly have different values than the local record, and
+ // we'll update them in `reconcile`.
+ for (let field of INTERNAL_FIELDS) {
+ keys.delete(field);
+ }
+ if (!keys.size) {
+ // This shouldn't ever happen; a valid record will always have fields
+ // that aren't computed or internal. Sync can't do anything about that,
+ // so we ignore the dubious local record instead of throwing.
+ continue;
+ }
+ let same = true;
+ for (let key of keys) {
+ // For now, we ensure that both (or neither) records have the field
+ // with matching values. This doesn't account for the version yet
+ // (bug 1377204).
+ same =
+ key in strippedLocalRecord == key in remoteRecord &&
+ strippedLocalRecord[key] == remoteRecord[key];
+ if (!same) {
+ break;
+ }
+ }
+ if (same) {
+ return strippedLocalRecord.guid;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Internal helper functions.
+ */
+
+ _clone(record) {
+ return Object.assign({}, record);
+ }
+
+ _cloneAndCleanUp(record) {
+ let result = {};
+ for (let key in record) {
+ // Do not expose hidden fields and fields with empty value (mainly used
+ // as placeholders of the computed fields).
+ if (!key.startsWith("_") && record[key] !== "") {
+ result[key] = record[key];
+ }
+ }
+ return result;
+ }
+
+ _findByGUID(guid, { includeDeleted = false } = {}) {
+ let found = this._findIndexByGUID(guid, { includeDeleted });
+ return found < 0 ? undefined : this._data[found];
+ }
+
+ _findIndexByGUID(guid, { includeDeleted = false } = {}) {
+ return this._data.findIndex(record => {
+ return record.guid == guid && (!record.deleted || includeDeleted);
+ });
+ }
+
+ async _migrateRecord(record, index) {
+ let hasChanges = false;
+
+ if (record.deleted) {
+ return hasChanges;
+ }
+
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ this.log.warn("Invalid record version:", record.version);
+
+ // Force to run the migration.
+ record.version = 0;
+ }
+
+ if (this._isMigrationNeeded(record.version)) {
+ hasChanges = true;
+
+ record = await this._computeMigratedRecord(record);
+
+ if (record.deleted) {
+ // record is deleted by _computeMigratedRecord(),
+ // go ahead and put it in the store.
+ this._data[index] = record;
+ return hasChanges;
+ }
+
+ // Compute the computed fields before putting it to store.
+ await this.computeFields(record);
+ this._data[index] = record;
+
+ return hasChanges;
+ }
+
+ hasChanges |= await this.computeFields(record);
+ return hasChanges;
+ }
+
+ _normalizeRecord(record, preserveEmptyFields = false) {
+ this._normalizeFields(record);
+
+ for (let key in record) {
+ if (!this.VALID_FIELDS.includes(key)) {
+ // Though we allow unknown fields, certain fields are still protected
+ // from being changed
+ if (INTERNAL_FIELDS.includes(key)) {
+ throw new Error(`"${key}" is not a valid field.`);
+ } else {
+ // We shouldn't try to normalize unknown fields. We'll just roundtrip them
+ this.log.warn(`${key} is not a known field. Skipping normalization.`);
+ continue;
+ }
+ }
+ if (typeof record[key] !== "string" && typeof record[key] !== "number") {
+ throw new Error(
+ `"${key}" contains invalid data type: ${typeof record[key]}`
+ );
+ }
+ if (!preserveEmptyFields && record[key] === "") {
+ delete record[key];
+ }
+ }
+
+ if (!Object.keys(record).length) {
+ throw new Error("Record contains no valid field.");
+ }
+ }
+
+ /**
+ * Merge the record if storage has multiple mergeable records.
+ *
+ * @param {object} targetRecord
+ * The record for merge.
+ * @param {boolean} [strict = false]
+ * 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 {Array.<string>}
+ * Return an array of the merged GUID string.
+ */
+ async mergeToStorage(targetRecord, strict = false) {
+ let mergedGUIDs = [];
+ for (let record of this._data) {
+ if (
+ !record.deleted &&
+ (await this.mergeIfPossible(record.guid, targetRecord, strict))
+ ) {
+ mergedGUIDs.push(record.guid);
+ }
+ }
+ this.log.debug(
+ "Existing records matching and merging count is",
+ mergedGUIDs.length
+ );
+ return mergedGUIDs;
+ }
+
+ /**
+ * Unconditionally remove all data and tombstones for this collection.
+ */
+ removeAll({ sourceSync = false } = {}) {
+ this._store.data[this._collectionName] = [];
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "removeAll"
+ );
+ }
+
+ _isMigrationNeeded(recordVersion) {
+ return recordVersion < this.version;
+ }
+
+ /**
+ * Strip the computed fields based on the record version.
+ *
+ * @param {object} record The record to migrate
+ * @returns {object} Migrated record.
+ * Record is always cloned, with version updated,
+ * with computed fields stripped.
+ * Could be a tombstone record, if the record
+ * should be discorded.
+ */
+ async _computeMigratedRecord(record) {
+ if (!record.deleted) {
+ record = this._clone(record);
+ await this._stripComputedFields(record);
+ record.version = this.version;
+ }
+ return record;
+ }
+
+ async _stripComputedFields(record) {
+ this.VALID_COMPUTED_FIELDS.forEach(field => delete record[field]);
+ }
+
+ // An interface to be inherited.
+ _recordReadProcessor(record) {}
+
+ // An interface to be inherited.
+ async computeFields(record) {}
+
+ /**
+ * An interface to be inherited to mutate the argument to normalize it.
+ *
+ * @param {object} partialRecord containing the record passed by the consumer of
+ * storage and in the case of `update` with
+ * `preserveOldProperties` will only include the
+ * properties that the user is changing so the
+ * lack of a field doesn't mean that the record
+ * won't have that field.
+ */
+ _normalizeFields(partialRecord) {}
+
+ /**
+ * An interface to be inherited to validate that the complete record is
+ * consistent and isn't missing required fields. Overrides should throw for
+ * invalid records.
+ *
+ * @param {object} record containing the complete record that would be stored
+ * if this doesn't throw due to an error.
+ * @throws
+ */
+ _validateFields(record) {}
+
+ // An interface to be inherited.
+ async mergeIfPossible(guid, record, strict) {}
+}
+
+export class AddressesBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "addresses",
+ VALID_ADDRESS_FIELDS,
+ VALID_ADDRESS_COMPUTED_FIELDS,
+ ADDRESS_SCHEMA_VERSION
+ );
+ }
+
+ _recordReadProcessor(address) {
+ if (address.country && !FormAutofill.countries.has(address.country)) {
+ delete address.country;
+ delete address["country-name"];
+ }
+ }
+
+ async computeFields(address) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (address.deleted) {
+ return hasNewComputedFields;
+ }
+
+ // Compute name
+ if (!("name" in address)) {
+ let name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"],
+ middle: address["additional-name"],
+ family: address["family-name"],
+ });
+ address.name = name;
+ hasNewComputedFields = true;
+ }
+
+ // Compute address lines
+ if (!("address-line1" in address)) {
+ let streetAddress = [];
+ if (address["street-address"]) {
+ streetAddress = address["street-address"]
+ .split("\n")
+ .map(s => s.trim());
+ }
+ for (let i = 0; i < 3; i++) {
+ address[`address-line${i + 1}`] = streetAddress[i] || "";
+ }
+ if (streetAddress.length > 3) {
+ address["address-line3"] = lazy.FormAutofillUtils.toOneLineAddress(
+ streetAddress.slice(2)
+ );
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute country name
+ if (!("country-name" in address)) {
+ if (address.country) {
+ try {
+ address["country-name"] = Services.intl.getRegionDisplayNames(
+ undefined,
+ [address.country]
+ );
+ } catch (e) {
+ address["country-name"] = "";
+ }
+ } else {
+ address["country-name"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute tel
+ if (!("tel-national" in address)) {
+ if (address.tel) {
+ let tel = lazy.PhoneNumber.Parse(
+ address.tel,
+ address.country || FormAutofill.DEFAULT_REGION
+ );
+ if (tel) {
+ if (tel.countryCode) {
+ address["tel-country-code"] = tel.countryCode;
+ }
+ if (tel.nationalNumber) {
+ address["tel-national"] = tel.nationalNumber;
+ }
+
+ // PhoneNumberUtils doesn't support parsing the components of a telephone
+ // number so we hard coded the parser for US numbers only. We will need
+ // to figure out how to parse numbers from other regions when we support
+ // new countries in the future.
+ if (tel.nationalNumber && tel.countryCode == "+1") {
+ let telComponents = tel.nationalNumber.match(
+ /(\d{3})((\d{3})(\d{4}))$/
+ );
+ if (telComponents) {
+ address["tel-area-code"] = telComponents[1];
+ address["tel-local"] = telComponents[2];
+ address["tel-local-prefix"] = telComponents[3];
+ address["tel-local-suffix"] = telComponents[4];
+ }
+ }
+ } else {
+ // Treat "tel" as "tel-national" directly if it can't be parsed.
+ address["tel-national"] = address.tel;
+ }
+ }
+
+ TEL_COMPONENTS.forEach(c => {
+ address[c] = address[c] || "";
+ });
+ }
+
+ return hasNewComputedFields;
+ }
+
+ _normalizeFields(address) {
+ this._normalizeName(address);
+ this._normalizeAddress(address);
+ this._normalizeCountry(address);
+ this._normalizeTel(address);
+ }
+
+ _normalizeName(address) {
+ if (address.name) {
+ let nameParts = lazy.FormAutofillNameUtils.splitName(address.name);
+ if (!address["given-name"] && nameParts.given) {
+ address["given-name"] = nameParts.given;
+ }
+ if (!address["additional-name"] && nameParts.middle) {
+ address["additional-name"] = nameParts.middle;
+ }
+ if (!address["family-name"] && nameParts.family) {
+ address["family-name"] = nameParts.family;
+ }
+ }
+ delete address.name;
+ }
+
+ _normalizeAddress(address) {
+ if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) {
+ // Treat "street-address" as "address-line1" if it contains only one line
+ // and "address-line1" is omitted.
+ if (
+ !address["address-line1"] &&
+ address["street-address"] &&
+ !address["street-address"].includes("\n")
+ ) {
+ address["address-line1"] = address["street-address"];
+ delete address["street-address"];
+ }
+
+ // Concatenate "address-line*" if "street-address" is omitted.
+ if (!address["street-address"]) {
+ address["street-address"] = STREET_ADDRESS_COMPONENTS.map(
+ c => address[c]
+ )
+ .join("\n")
+ .replace(/\n+$/, "");
+ }
+ }
+ STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ _normalizeCountry(address) {
+ let country;
+
+ if (address.country) {
+ country = address.country.toUpperCase();
+ } else if (address["country-name"]) {
+ country = lazy.FormAutofillUtils.identifyCountryCode(
+ address["country-name"]
+ );
+ }
+
+ // Only values included in the region list will be saved.
+ let hasLocalizedName = false;
+ try {
+ if (country) {
+ let localizedName = Services.intl.getRegionDisplayNames(undefined, [
+ country,
+ ]);
+ hasLocalizedName = localizedName != country;
+ }
+ } catch (e) {}
+
+ if (country && hasLocalizedName) {
+ address.country = country;
+ } else {
+ delete address.country;
+ }
+
+ delete address["country-name"];
+ }
+
+ _normalizeTel(address) {
+ if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) {
+ lazy.FormAutofillUtils.compressTel(address);
+
+ let possibleRegion = address.country || FormAutofill.DEFAULT_REGION;
+ let tel = lazy.PhoneNumber.Parse(address.tel, possibleRegion);
+
+ if (tel && tel.internationalNumber) {
+ // Force to save numbers in E.164 format if parse success.
+ address.tel = tel.internationalNumber;
+ }
+ }
+ TEL_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ /**
+ * 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) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ compareAddressField(field, a, b, collator) {
+ switch (field) {
+ case "street-address":
+ let ret = lazy.FormAutofillUtils.compareStreetAddress(a, b, collator);
+ return ret;
+ // TODO: support other cases
+ default:
+ return a == b;
+ }
+ }
+
+ /**
+ * Normalize the given record and return records that are either the same
+ * or is superset of the normalized given record.
+ *
+ * See the comments in `getDuplicateRecords` to see the difference between
+ * `getDuplicateRecords` and `getMatchRecords`
+ *
+ * @param {object} record
+ * The address entry for match checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first matched record found in storage, null otherwise.
+ */
+ async *getMatchRecords(record) {
+ const collators = lazy.FormAutofillUtils.getSearchCollators(
+ FormAutofill.DEFAULT_REGION
+ );
+
+ for (const recordInStorage of this._data) {
+ if (
+ this.VALID_FIELDS.every(
+ field =>
+ !record[field] ||
+ this.compareAddressField(
+ field,
+ record[field],
+ recordInStorage[field],
+ collators
+ )
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Normalize the given record and return a duplicate address record in
+ * the storage.
+ *
+ * This is different from `getMatchRecords`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The address entry for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ const collators = lazy.FormAutofillUtils.getSearchCollators(
+ FormAutofill.DEFAULT_REGION
+ );
+
+ for (const recordInStorage of this._data) {
+ if (
+ this.VALID_FIELDS.every(
+ field =>
+ !record[field] ||
+ !recordInStorage[field] ||
+ this.compareAddressField(
+ field,
+ record[field],
+ recordInStorage[field],
+ collators
+ )
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+}
+
+export class CreditCardsBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "creditCards",
+ VALID_CREDIT_CARD_FIELDS,
+ VALID_CREDIT_CARD_COMPUTED_FIELDS,
+ CREDIT_CARD_SCHEMA_VERSION
+ );
+ }
+
+ async computeFields(creditCard) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (creditCard.deleted) {
+ return hasNewComputedFields;
+ }
+
+ let type = lazy.CreditCard.getType(creditCard["cc-number"]);
+ if (type) {
+ creditCard["cc-type"] = type;
+ }
+
+ // Compute split names
+ if (!("cc-given-name" in creditCard)) {
+ let nameParts = lazy.FormAutofillNameUtils.splitName(
+ creditCard["cc-name"]
+ );
+ creditCard["cc-given-name"] = nameParts.given;
+ creditCard["cc-additional-name"] = nameParts.middle;
+ creditCard["cc-family-name"] = nameParts.family;
+ hasNewComputedFields = true;
+ }
+
+ // Compute credit card expiration date
+ if (!("cc-exp" in creditCard)) {
+ if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) {
+ creditCard["cc-exp"] =
+ String(creditCard["cc-exp-year"]) +
+ "-" +
+ String(creditCard["cc-exp-month"]).padStart(2, "0");
+ } else {
+ creditCard["cc-exp"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Encrypt credit card number
+ await this._encryptNumber(creditCard);
+
+ return hasNewComputedFields;
+ }
+
+ async _encryptNumber(creditCard) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ _isMigrationNeeded(recordVersion) {
+ return (
+ // version 4 is deprecated and is rolled back to version 3
+ recordVersion == 4 || recordVersion < this.version
+ );
+ }
+
+ async _computeMigratedRecord(creditCard) {
+ if (creditCard.version <= 2) {
+ if (creditCard["cc-number-encrypted"]) {
+ // We cannot decrypt the data, so silently remove the record for
+ // the user.
+ if (!creditCard.deleted) {
+ this.log.warn(
+ "Removing version",
+ creditCard.version,
+ "credit card record to migrate to new encryption:",
+ creditCard.guid
+ );
+
+ // Replace the record with a tombstone record here,
+ // regardless of existence of sync metadata.
+ let existingSync = this._getSyncMetaData(creditCard);
+ creditCard = {
+ guid: creditCard.guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ if (existingSync) {
+ creditCard._sync = existingSync;
+ existingSync.changeCounter++;
+ }
+ }
+ }
+ }
+
+ // Do not remove the migration code until we're sure no users have version 4
+ // credit card records (created in Fx110 or Fx111)
+ if (creditCard.version == 4) {
+ // Version 4 is deprecated, so downgrade or upgrade to the current version
+ // Since the only change made in version 4 is deleting `cc-type` field, so
+ // nothing else need to be done here expect flagging sync needed
+ let existingSync = this._getSyncMetaData(creditCard);
+ if (existingSync) {
+ existingSync.changeCounter++;
+ }
+ }
+
+ return super._computeMigratedRecord(creditCard);
+ }
+
+ async _stripComputedFields(creditCard) {
+ if (creditCard["cc-number-encrypted"]) {
+ try {
+ creditCard["cc-number"] = await lazy.OSKeyStore.decrypt(
+ creditCard["cc-number-encrypted"]
+ );
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_ABORT) {
+ throw ex;
+ }
+ // Quietly recover from encryption error,
+ // so existing credit card entry with undecryptable number
+ // can be updated.
+ }
+ }
+ await super._stripComputedFields(creditCard);
+ }
+
+ _normalizeFields(creditCard) {
+ this._normalizeCCName(creditCard);
+ this._normalizeCCNumber(creditCard);
+ this._normalizeCCExpirationDate(creditCard);
+ }
+
+ _normalizeCCName(creditCard) {
+ if (
+ creditCard["cc-given-name"] ||
+ creditCard["cc-additional-name"] ||
+ creditCard["cc-family-name"]
+ ) {
+ if (!creditCard["cc-name"]) {
+ creditCard["cc-name"] = lazy.FormAutofillNameUtils.joinNameParts({
+ given: creditCard["cc-given-name"],
+ middle: creditCard["cc-additional-name"],
+ family: creditCard["cc-family-name"],
+ });
+ }
+ }
+ delete creditCard["cc-given-name"];
+ delete creditCard["cc-additional-name"];
+ delete creditCard["cc-family-name"];
+ }
+
+ _normalizeCCNumber(creditCard) {
+ if (!("cc-number" in creditCard)) {
+ return;
+ }
+ if (!lazy.CreditCard.isValidNumber(creditCard["cc-number"])) {
+ delete creditCard["cc-number"];
+ return;
+ }
+ let card = new lazy.CreditCard({ number: creditCard["cc-number"] });
+ creditCard["cc-number"] = card.number;
+ }
+
+ _normalizeCCExpirationDate(creditCard) {
+ let normalizedExpiration = lazy.CreditCard.normalizeExpiration({
+ expirationMonth: creditCard["cc-exp-month"],
+ expirationYear: creditCard["cc-exp-year"],
+ expirationString: creditCard["cc-exp"],
+ });
+ if (normalizedExpiration.month) {
+ creditCard["cc-exp-month"] = normalizedExpiration.month;
+ } else {
+ delete creditCard["cc-exp-month"];
+ }
+ if (normalizedExpiration.year) {
+ creditCard["cc-exp-year"] = normalizedExpiration.year;
+ } else {
+ delete creditCard["cc-exp-year"];
+ }
+ delete creditCard["cc-exp"];
+ }
+
+ _validateFields(creditCard) {
+ if (!creditCard["cc-number"]) {
+ throw new Error("Missing/invalid cc-number");
+ }
+ }
+
+ _ensureMatchingVersion(record) {
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ throw new Error(
+ `Got invalid record version ${record.version}; want ${this.version}`
+ );
+ }
+
+ if (record.version == 4) {
+ // Version 4 is deprecated, we need to force downloading it from sync
+ // and let migration do the work to downgrade it back to the current version.
+ return true;
+ } else if (record.version < this.version) {
+ switch (record.version) {
+ case 1:
+ case 2:
+ // The difference between version 1 and 2 is only about the encryption
+ // method used for the cc-number-encrypted field.
+ // The difference between version 2 and 3 is the name of the OS
+ // key encryption record.
+ // As long as the record is already decrypted, it is safe to bump the
+ // version directly.
+ if (!record["cc-number-encrypted"]) {
+ record.version = this.version;
+ } else {
+ throw new Error(
+ "Could not migrate record version:",
+ record.version,
+ "->",
+ this.version
+ );
+ }
+ break;
+ default:
+ throw new Error(
+ "Unknown credit card version to match: " + record.version
+ );
+ }
+ }
+
+ return super._ensureMatchingVersion(record);
+ }
+
+ /**
+ * Find a match credit card record in storage that is either exactly the same
+ * as the given record or a superset of the given record.
+ *
+ * See the comments in `getDuplicateRecords` to see the difference between
+ * `getDuplicateRecords` and `getMatchRecords`
+ *
+ * @param {object} record
+ * The credit card for match checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first matched record found in storage, null otherwise.
+ */
+ async *getMatchRecords(record) {
+ for await (const recordInStorage of this.getDuplicateRecords(record)) {
+ const fields = this.VALID_FIELDS.filter(f => f != "cc-number");
+ if (
+ fields.every(
+ field => !record[field] || record[field] == recordInStorage[field]
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find a duplicate credit card record in the storage.
+ *
+ * A record is considered as a duplicate of another record when two records
+ * are the "same". This might be true even when some of their fields are
+ * different. For example, one record has the same credit card number but has
+ * different expiration date as the other record are still considered as
+ * "duplicate".
+ * This is different from `getMatchRecords`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The credit card for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ if (!record["cc-number"]) {
+ return null;
+ }
+
+ for (const recordInStorage of this._data) {
+ if (recordInStorage.deleted) {
+ continue;
+ }
+
+ const decrypted = await lazy.OSKeyStore.decrypt(
+ recordInStorage["cc-number-encrypted"],
+ false
+ );
+
+ if (decrypted == record["cc-number"]) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 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) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+export class FormAutofillStorageBase {
+ constructor(path) {
+ this._path = path;
+ this._initializePromise = null;
+ this.INTERNAL_FIELDS = INTERNAL_FIELDS;
+ }
+
+ get version() {
+ return STORAGE_SCHEMA_VERSION;
+ }
+
+ get addresses() {
+ return this.getAddresses();
+ }
+
+ get creditCards() {
+ return this.getCreditCards();
+ }
+
+ getAddresses() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getCreditCards() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Initialize storage to memory.
+ *
+ * @returns {Promise} When the operation finished successfully.
+ * @throws JavaScript exception.
+ */
+ initialize() {
+ if (!this._initializePromise) {
+ this._store = this._initializeStore();
+ this._initializePromise = this._store.load().then(() => {
+ let initializeAutofillRecords = [
+ this.addresses.initialize(),
+ this.creditCards.initialize(),
+ ];
+ return Promise.all(initializeAutofillRecords);
+ });
+ }
+ return this._initializePromise;
+ }
+
+ _initializeStore() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ // For test only.
+ _saveImmediately() {
+ return this._store._save();
+ }
+
+ _finalize() {
+ return this._store.finalize();
+ }
+}
diff --git a/toolkit/components/formautofill/FormAutofillSync.sys.mjs b/toolkit/components/formautofill/FormAutofillSync.sys.mjs
new file mode 100644
index 0000000000..d45c2c04ca
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillSync.sys.mjs
@@ -0,0 +1,386 @@
+/* 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 {
+ Changeset,
+ Store,
+ SyncEngine,
+ Tracker,
+} from "resource://services-sync/engines.sys.mjs";
+import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
+import { Utils } from "resource://services-sync/util.sys.mjs";
+
+import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "resource://gre/modules/Log.sys.mjs",
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
+});
+
+// A helper to sanitize address and creditcard records suitable for logging.
+export function sanitizeStorageObject(ob) {
+ if (!ob) {
+ return null;
+ }
+ const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"];
+ let result = {};
+ for (let key of Object.keys(ob)) {
+ let origVal = ob[key];
+ if (allowList.includes(key)) {
+ result[key] = origVal;
+ } else if (typeof origVal == "string") {
+ result[key] = "X".repeat(origVal.length);
+ } else {
+ result[key] = typeof origVal; // *shrug*
+ }
+ }
+ return result;
+}
+
+export function AutofillRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+AutofillRecord.prototype = {
+ toEntry() {
+ return Object.assign(
+ {
+ guid: this.id,
+ },
+ this.entry
+ );
+ },
+
+ fromEntry(entry) {
+ this.id = entry.guid;
+ this.entry = entry;
+ // The GUID is already stored in record.id, so we nuke it from the entry
+ // itself to save a tiny bit of space. The formAutofillStorage clones profiles,
+ // so nuking in-place is OK.
+ delete this.entry.guid;
+ },
+
+ cleartextToString() {
+ // And a helper so logging a *Sync* record auto sanitizes.
+ let record = this.cleartext;
+ return JSON.stringify({ entry: sanitizeStorageObject(record.entry) });
+ },
+};
+Object.setPrototypeOf(AutofillRecord.prototype, CryptoWrapper.prototype);
+
+// Profile data is stored in the "entry" object of the record.
+Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]);
+
+function FormAutofillStore(name, engine) {
+ Store.call(this, name, engine);
+}
+
+FormAutofillStore.prototype = {
+ _subStorageName: null, // overridden below.
+ _storage: null,
+
+ get storage() {
+ if (!this._storage) {
+ this._storage = lazy.formAutofillStorage[this._subStorageName];
+ }
+ return this._storage;
+ },
+
+ async getAllIDs() {
+ let result = {};
+ for (let { guid } of await this.storage.getAll({ includeDeleted: true })) {
+ result[guid] = true;
+ }
+ return result;
+ },
+
+ async changeItemID(oldID, newID) {
+ this.storage.changeGUID(oldID, newID);
+ },
+
+ // Note: this function intentionally returns false in cases where we only have
+ // a (local) tombstone - and formAutofillStorage.get() filters them for us.
+ async itemExists(id) {
+ return Boolean(await this.storage.get(id));
+ },
+
+ async applyIncoming(remoteRecord) {
+ if (remoteRecord.deleted) {
+ this._log.trace("Deleting record", remoteRecord);
+ this.storage.remove(remoteRecord.id, { sourceSync: true });
+ return;
+ }
+
+ if (await this.itemExists(remoteRecord.id)) {
+ // We will never get a tombstone here, so we are updating a real record.
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // No matching local record. Try to dedupe a NEW local record.
+ let localDupeID = await this.storage.findDuplicateGUID(
+ remoteRecord.toEntry()
+ );
+ if (localDupeID) {
+ this._log.trace(
+ `Deduping local record ${localDupeID} to remote`,
+ remoteRecord
+ );
+ // Change the local GUID to match the incoming record, then apply the
+ // incoming record.
+ await this.changeItemID(localDupeID, remoteRecord.id);
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // We didn't find a dupe, either, so must be a new record (or possibly
+ // a non-deleted version of an item we have a tombstone for, which add()
+ // handles for us.)
+ this._log.trace("Add record", remoteRecord);
+ let entry = remoteRecord.toEntry();
+ await this.storage.add(entry, { sourceSync: true });
+ },
+
+ async createRecord(id, collection) {
+ this._log.trace("Create record", id);
+ let record = new AutofillRecord(collection, id);
+ let entry = await this.storage.get(id, {
+ rawData: true,
+ });
+ if (entry) {
+ record.fromEntry(entry);
+ } else {
+ // We should consider getting a more authortative indication it's actually deleted.
+ this._log.debug(
+ `Failed to get autofill record with id "${id}", assuming deleted`
+ );
+ record.deleted = true;
+ }
+ return record;
+ },
+
+ async _doUpdateRecord(record) {
+ this._log.trace("Updating record", record);
+
+ let entry = record.toEntry();
+ let { forkedGUID } = await this.storage.reconcile(entry);
+ if (this._log.level <= lazy.Log.Level.Debug) {
+ let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null;
+ let reconciledRecord = await this.storage.get(record.id);
+ this._log.debug("Updated local record", {
+ forked: sanitizeStorageObject(forkedRecord),
+ updated: sanitizeStorageObject(reconciledRecord),
+ });
+ }
+ },
+
+ // NOTE: Because we re-implement the incoming/reconcilliation logic we leave
+ // the |create|, |remove| and |update| methods undefined - the base
+ // implementation throws, which is what we want to happen so we can identify
+ // any places they are "accidentally" called.
+};
+Object.setPrototypeOf(FormAutofillStore.prototype, Store.prototype);
+
+function FormAutofillTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+
+FormAutofillTracker.prototype = {
+ async observe(subject, topic, data) {
+ if (topic != "formautofill-storage-changed") {
+ return;
+ }
+ if (
+ subject &&
+ subject.wrappedJSObject &&
+ subject.wrappedJSObject.sourceSync
+ ) {
+ return;
+ }
+ switch (data) {
+ case "add":
+ case "update":
+ case "remove":
+ this.score += SCORE_INCREMENT_XLARGE;
+ break;
+ default:
+ this._log.debug("unrecognized autofill notification", data);
+ break;
+ }
+ },
+
+ onStart() {
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ },
+
+ onStop() {
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ },
+};
+Object.setPrototypeOf(FormAutofillTracker.prototype, Tracker.prototype);
+
+// This uses the same conventions as BookmarkChangeset in
+// services/sync/modules/engines/bookmarks.js. Specifically,
+// - "synced" means the item has already been synced (or we have another reason
+// to ignore it), and should be ignored in most methods.
+class AutofillChangeset extends Changeset {
+ constructor() {
+ super();
+ }
+
+ getModifiedTimestamp(id) {
+ throw new Error("Don't use timestamps to resolve autofill merge conflicts");
+ }
+
+ has(id) {
+ let change = this.changes[id];
+ if (change) {
+ return !change.synced;
+ }
+ return false;
+ }
+
+ delete(id) {
+ let change = this.changes[id];
+ if (change) {
+ // Mark the change as synced without removing it from the set. We do this
+ // so that we can update FormAutofillStorage in `trackRemainingChanges`.
+ change.synced = true;
+ }
+ }
+}
+
+function FormAutofillEngine(service, name) {
+ SyncEngine.call(this, name, service);
+}
+
+FormAutofillEngine.prototype = {
+ // the priority for this engine is == addons, so will happen after bookmarks
+ // prefs and tabs, but before forms, history, etc.
+ syncPriority: 5,
+
+ // We don't use SyncEngine.initialize() for this, as we initialize even if
+ // the engine is disabled, and we don't want to be the loader of
+ // FormAutofillStorage in this case.
+ async _syncStartup() {
+ await lazy.formAutofillStorage.initialize();
+ await SyncEngine.prototype._syncStartup.call(this);
+ },
+
+ // We handle reconciliation in the store, not the engine.
+ async _reconcile() {
+ return true;
+ },
+
+ emptyChangeset() {
+ return new AutofillChangeset();
+ },
+
+ async _uploadOutgoing() {
+ this._modified.replace(this._store.storage.pullSyncChanges());
+ await SyncEngine.prototype._uploadOutgoing.call(this);
+ },
+
+ // Typically, engines populate the changeset before downloading records.
+ // However, we handle conflict resolution in the store, so we can wait
+ // to pull changes until we're ready to upload.
+ async pullAllChanges() {
+ return {};
+ },
+
+ async pullNewChanges() {
+ return {};
+ },
+
+ async trackRemainingChanges() {
+ this._store.storage.pushSyncChanges(this._modified.changes);
+ },
+
+ _deleteId(id) {
+ this._noteDeletedId(id);
+ },
+
+ async _resetClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.resetSync();
+ },
+
+ async _wipeClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.removeAll({ sourceSync: true });
+ },
+};
+Object.setPrototypeOf(FormAutofillEngine.prototype, SyncEngine.prototype);
+
+// The concrete engines
+
+function AddressesRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+AddressesRecord.prototype = {
+ _logName: "Sync.Record.Addresses",
+};
+Object.setPrototypeOf(AddressesRecord.prototype, AutofillRecord.prototype);
+
+function AddressesStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+AddressesStore.prototype = {
+ _subStorageName: "addresses",
+};
+Object.setPrototypeOf(AddressesStore.prototype, FormAutofillStore.prototype);
+
+export function AddressesEngine(service) {
+ FormAutofillEngine.call(this, service, "Addresses");
+}
+
+AddressesEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: AddressesStore,
+ _recordObj: AddressesRecord,
+
+ get prefName() {
+ return "addresses";
+ },
+};
+Object.setPrototypeOf(AddressesEngine.prototype, FormAutofillEngine.prototype);
+
+function CreditCardsRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+CreditCardsRecord.prototype = {
+ _logName: "Sync.Record.CreditCards",
+};
+Object.setPrototypeOf(CreditCardsRecord.prototype, AutofillRecord.prototype);
+
+function CreditCardsStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+CreditCardsStore.prototype = {
+ _subStorageName: "creditCards",
+};
+Object.setPrototypeOf(CreditCardsStore.prototype, FormAutofillStore.prototype);
+
+export function CreditCardsEngine(service) {
+ FormAutofillEngine.call(this, service, "CreditCards");
+}
+
+CreditCardsEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: CreditCardsStore,
+ _recordObj: CreditCardsRecord,
+ get prefName() {
+ return "creditcards";
+ },
+};
+Object.setPrototypeOf(
+ CreditCardsEngine.prototype,
+ FormAutofillEngine.prototype
+);
diff --git a/toolkit/components/formautofill/Helpers.ios.mjs b/toolkit/components/formautofill/Helpers.ios.mjs
new file mode 100644
index 0000000000..b634056e60
--- /dev/null
+++ b/toolkit/components/formautofill/Helpers.ios.mjs
@@ -0,0 +1,166 @@
+/* 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 { IOSAppConstants } from "resource://gre/modules/shared/Constants.ios.mjs";
+import Overrides from "resource://gre/modules/Overrides.ios.js";
+
+/* eslint mozilla/use-isInstance: 0 */
+HTMLSelectElement.isInstance = element => element instanceof HTMLSelectElement;
+HTMLInputElement.isInstance = element => element instanceof HTMLInputElement;
+HTMLFormElement.isInstance = element => element instanceof HTMLFormElement;
+ShadowRoot.isInstance = element => element instanceof ShadowRoot;
+
+HTMLElement.prototype.ownerGlobal = window;
+HTMLInputElement.prototype.setUserInput = function (value) {
+ this.value = value;
+ this.dispatchEvent(new Event("input", { bubbles: true }));
+};
+
+// TODO: Bug 1828408.
+// Use WeakRef API directly in our codebase instead of legacy Cu.getWeakReference.
+window.Cu = class {
+ static getWeakReference(elements) {
+ const elementsWeakRef = new WeakRef(elements);
+ return {
+ get: () => elementsWeakRef.deref(),
+ };
+ }
+};
+
+// Mimic the behavior of .getAutocompleteInfo()
+// It should return an object with a fieldName property matching the autocomplete attribute
+// only if it's a valid value from this list https://searchfox.org/mozilla-central/source/dom/base/AutocompleteFieldList.h#89-149
+// Also found here: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete
+HTMLElement.prototype.getAutocompleteInfo = function () {
+ const autocomplete = this.getAttribute("autocomplete");
+
+ return {
+ fieldName: IOSAppConstants.validAutocompleteFields.includes(autocomplete)
+ ? autocomplete
+ : "",
+ };
+};
+
+// Bug 1835024. Webkit doesn't support `checkVisibility` API
+// https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility
+HTMLElement.prototype.checkVisibility = function (options) {
+ throw new Error(`Not implemented: WebKit doesn't support checkVisibility `);
+};
+
+// This function helps us debug better when an error occurs because a certain mock is missing
+const withNotImplementedError = obj =>
+ new Proxy(obj, {
+ get(target, prop) {
+ if (!Object.keys(target).includes(prop)) {
+ throw new Error(
+ `Not implemented: ${prop} doesn't exist in mocked object `
+ );
+ }
+ return Reflect.get(...arguments);
+ },
+ });
+
+// Webpack needs to be able to statically analyze require statements in order to build the dependency graph
+// In order to require modules dynamically at runtime, we use require.context() to create a dynamic require
+// that is still able to be parsed by Webpack at compile time. The "./" and ".mjs" tells webpack that files
+// in the current directory ending with .mjs might be needed and should be added to the dependency graph.
+// NOTE: This can't handle circular dependencies. A static import can be used in this case.
+// https://webpack.js.org/guides/dependency-management/
+const internalModuleResolvers = {
+ resolveModule(moduleURI) {
+ // eslint-disable-next-line no-undef
+ const moduleResolver = require.context("./", false, /.mjs$/);
+ // Desktop code uses uris for importing modules of the form resource://gre/modules/<module_path>
+ // We only need the filename here
+ const moduleName = moduleURI.split("/").pop();
+ const modulePath =
+ "./" + (Overrides.ModuleOverrides[moduleName] ?? moduleName);
+ return moduleResolver(modulePath);
+ },
+
+ resolveModules(obj, modules) {
+ for (const [exportName, moduleURI] of Object.entries(modules)) {
+ const resolvedModule = this.resolveModule(moduleURI);
+ obj[exportName] = resolvedModule?.[exportName];
+ }
+ },
+};
+
+// Define mock for XPCOMUtils
+export const XPCOMUtils = withNotImplementedError({
+ defineLazyGetter: (obj, prop, getFn) => {
+ obj[prop] = getFn?.();
+ },
+ defineLazyPreferenceGetter: (
+ obj,
+ prop,
+ pref,
+ defaultValue = null,
+ onUpdate = null,
+ transform = val => val
+ ) => {
+ if (!Object.keys(IOSAppConstants.prefs).includes(pref)) {
+ throw Error(`Pref ${pref} is not defined.`);
+ }
+ obj[prop] = transform(IOSAppConstants.prefs[pref] ?? defaultValue);
+ },
+ defineLazyModuleGetters(obj, modules) {
+ internalModuleResolvers.resolveModules(obj, modules);
+ },
+});
+
+// eslint-disable-next-line no-shadow
+export const ChromeUtils = withNotImplementedError({
+ defineESModuleGetters(obj, modules) {
+ internalModuleResolvers.resolveModules(obj, modules);
+ },
+ importESModule(moduleURI) {
+ return internalModuleResolvers.resolveModule(moduleURI);
+ },
+});
+window.ChromeUtils = ChromeUtils;
+
+// Define mock for Region.sys.mjs
+export const Region = withNotImplementedError({
+ home: "US",
+});
+
+// Define mock for OSKeyStore.sys.mjs
+export const OSKeyStore = withNotImplementedError({
+ ensureLoggedIn: () => true,
+});
+
+// Define mock for Services
+// NOTE: Services is a global so we need to attach it to the window
+// eslint-disable-next-line no-shadow
+export const Services = withNotImplementedError({
+ intl: withNotImplementedError({
+ getAvailableLocaleDisplayNames: () => [],
+ getRegionDisplayNames: () => [],
+ }),
+ locale: withNotImplementedError({ isAppLocaleRTL: false }),
+ prefs: withNotImplementedError({ prefIsLocked: () => false }),
+ strings: withNotImplementedError({
+ createBundle: () =>
+ withNotImplementedError({
+ GetStringFromName: () => "",
+ formatStringFromName: () => "",
+ }),
+ }),
+ uuid: withNotImplementedError({ generateUUID: () => "" }),
+});
+window.Services = Services;
+
+export const windowUtils = withNotImplementedError({
+ removeManuallyManagedState: () => {},
+ addManuallyManagedState: () => {},
+});
+window.windowUtils = windowUtils;
+
+export const AutofillTelemetry = withNotImplementedError({
+ recordFormInteractionEvent: () => {},
+ recordDetectedSectionCount: () => {},
+});
+
+export { IOSAppConstants as AppConstants } from "resource://gre/modules/shared/Constants.ios.mjs";
diff --git a/toolkit/components/formautofill/Overrides.ios.js b/toolkit/components/formautofill/Overrides.ios.js
new file mode 100644
index 0000000000..a0023a267c
--- /dev/null
+++ b/toolkit/components/formautofill/Overrides.ios.js
@@ -0,0 +1,22 @@
+/* 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";
+
+// This array defines overrides that webpack will use when bundling the JS on iOS
+// in order to load the right modules
+const ModuleOverrides = {
+ "AutofillTelemetry.sys.mjs": "Helpers.ios.mjs",
+ "AppConstants.sys.mjs": "Helpers.ios.mjs",
+ "XPCOMUtils.sys.mjs": "Helpers.ios.mjs",
+ "Region.sys.mjs": "Helpers.ios.mjs",
+ "OSKeyStore.sys.mjs": "Helpers.ios.mjs",
+ "FormAutofill.sys.mjs": "FormAutofill.ios.sys.mjs",
+ "EntryFile.sys.mjs": "FormAutofillChild.ios.sys.mjs",
+};
+
+// We need this because not all webpack libraries used in iOS are ES Modules
+// Hence we defer to CommonJS.
+// eslint-disable-next-line no-undef
+module.exports = { ModuleOverrides };
diff --git a/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
new file mode 100644
index 0000000000..6707af7a58
--- /dev/null
+++ b/toolkit/components/formautofill/ProfileAutoCompleteResult.sys.mjs
@@ -0,0 +1,485 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["browser/preferences/formAutofill.ftl"], true)
+);
+
+class ProfileAutoCompleteResult {
+ constructor(
+ searchString,
+ focusedFieldName,
+ allFieldNames,
+ matchingProfiles,
+ { resultCode = null, isSecure = true, isInputAutofilled = false }
+ ) {
+ // nsISupports
+ this.QueryInterface = ChromeUtils.generateQI(["nsIAutoCompleteResult"]);
+
+ // The user's query string
+ this.searchString = searchString;
+ // The field name of the focused input.
+ this._focusedFieldName = focusedFieldName;
+ // The matching profiles contains the information for filling forms.
+ this._matchingProfiles = matchingProfiles;
+ // The default item that should be entered if none is selected
+ this.defaultIndex = 0;
+ // The reason the search failed
+ this.errorDescription = "";
+ // The value used to determine whether the form is secure or not.
+ this._isSecure = isSecure;
+ // The value to indicate whether the focused input has been autofilled or not.
+ this._isInputAutofilled = isInputAutofilled;
+ // All fillable field names in the form including the field name of the currently-focused input.
+ this._allFieldNames = [
+ ...this._matchingProfiles.reduce((fieldSet, curProfile) => {
+ for (let field of Object.keys(curProfile)) {
+ fieldSet.add(field);
+ }
+
+ return fieldSet;
+ }, new Set()),
+ ].filter(field => allFieldNames.includes(field));
+
+ // Force return success code if the focused field is auto-filled in order
+ // to show clear form button popup.
+ if (isInputAutofilled) {
+ resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ }
+ // The result code of this result object.
+ if (resultCode) {
+ this.searchResult = resultCode;
+ } else if (matchingProfiles.length) {
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ } else {
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ }
+
+ // An array of primary and secondary labels for each profile.
+ this._popupLabels = this._generateLabels(
+ this._focusedFieldName,
+ this._allFieldNames,
+ this._matchingProfiles
+ );
+ }
+
+ /**
+ * @returns {number} The number of results
+ */
+ get matchCount() {
+ return this._popupLabels.length;
+ }
+
+ _checkIndexBounds(index) {
+ if (index < 0 || index >= this._popupLabels.length) {
+ throw Components.Exception(
+ "Index out of range.",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ }
+
+ /**
+ * Get the secondary label based on the focused field name and related field names
+ * in the same form.
+ *
+ * @param {string} focusedFieldName The field name of the focused input
+ * @param {Array<object>} allFieldNames The field names in the same section
+ * @param {object} profile The profile providing the labels to show.
+ * @returns {string} The secondary label
+ */
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ return "";
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {}
+
+ /**
+ * Get the value of the result at the given index.
+ *
+ * Always return empty string for form autofill feature to suppress
+ * AutoCompleteController from autofilling, as we'll populate the
+ * fields on our own.
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getValueAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ }
+
+ getLabelAt(index) {
+ this._checkIndexBounds(index);
+
+ let label = this._popupLabels[index];
+ if (typeof label == "string") {
+ return label;
+ }
+ return JSON.stringify(label);
+ }
+
+ /**
+ * Retrieves a comment (metadata instance)
+ *
+ * @param {number} index The index of the comment requested
+ * @returns {string} The comment at the specified index
+ */
+ getCommentAt(index) {
+ this._checkIndexBounds(index);
+ return JSON.stringify(this._matchingProfiles[index]);
+ }
+
+ /**
+ * Retrieves a style hint specific to a particular index.
+ *
+ * @param {number} index The index of the style hint requested
+ * @returns {string} The style hint at the specified index
+ */
+ getStyleAt(index) {
+ this._checkIndexBounds(index);
+ if (index == this.matchCount - 1) {
+ return "autofill-footer";
+ }
+ if (this._isInputAutofilled) {
+ return "autofill-clear-button";
+ }
+
+ return "autofill-profile";
+ }
+
+ /**
+ * Retrieves an image url.
+ *
+ * @param {number} index The index of the image url requested
+ * @returns {string} The image url at the specified index
+ */
+ getImageAt(index) {
+ this._checkIndexBounds(index);
+ return "";
+ }
+
+ /**
+ * Retrieves a result
+ *
+ * @param {number} index The index of the result requested
+ * @returns {string} The result at the specified index
+ */
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ }
+
+ /**
+ * Returns true if the value at the given index is removable
+ *
+ * @param {number} index The index of the result to remove
+ * @returns {boolean} True if the value is removable
+ */
+ isRemovableAt(index) {
+ return true;
+ }
+
+ /**
+ * Removes a result from the resultset
+ *
+ * @param {number} index The index of the result to remove
+ */
+ removeValueAt(index) {
+ // There is no plan to support removing profiles via autocomplete.
+ }
+}
+
+export class AddressResult extends ProfileAutoCompleteResult {
+ constructor(...args) {
+ super(...args);
+ }
+
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ // We group similar fields into the same field name so we won't pick another
+ // field in the same group as the secondary label.
+ const GROUP_FIELDS = {
+ name: ["name", "given-name", "additional-name", "family-name"],
+ "street-address": [
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ ],
+ "country-name": ["country", "country-name"],
+ tel: [
+ "tel",
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+ ],
+ };
+
+ const secondaryLabelOrder = [
+ "street-address", // Street address
+ "name", // Full name
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country-name", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (
+ currentFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ return profile["-moz-street-address-one-line"];
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primaryLabel = profile[focusedFieldName];
+ if (
+ focusedFieldName == "street-address" &&
+ profile["-moz-street-address-one-line"]
+ ) {
+ primaryLabel = profile["-moz-street-address-one-line"];
+ }
+ return {
+ primary: primaryLabel,
+ secondary: this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ ),
+ };
+ });
+ // Add an empty result entry for footer. Its content will come from
+ // the footer binding, so don't assign any value to it.
+ // The additional properties: categories and focusedCategory are required of
+ // the popup to generate autofill hint on the footer.
+ labels.push({
+ primary: "",
+ secondary: "",
+ categories: lazy.FormAutofillUtils.getCategoriesFromFieldNames(
+ this._allFieldNames
+ ),
+ focusedCategory: lazy.FormAutofillUtils.getCategoryFromFieldName(
+ this._focusedFieldName
+ ),
+ });
+
+ return labels;
+ }
+}
+
+export class CreditCardResult extends ProfileAutoCompleteResult {
+ constructor(...args) {
+ super(...args);
+ this._cardTypes = this._generateCardTypes(
+ this._focusedFieldName,
+ this._allFieldNames,
+ this._matchingProfiles
+ );
+ }
+
+ _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+ const GROUP_FIELDS = {
+ "cc-name": [
+ "cc-name",
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ ],
+ "cc-exp": ["cc-exp", "cc-exp-month", "cc-exp-year"],
+ };
+
+ const secondaryLabelOrder = [
+ "cc-number", // Credit card number
+ "cc-name", // Full name
+ "cc-exp", // Expiration date
+ ];
+
+ for (let field in GROUP_FIELDS) {
+ if (GROUP_FIELDS[field].includes(focusedFieldName)) {
+ focusedFieldName = field;
+ break;
+ }
+ }
+
+ for (const currentFieldName of secondaryLabelOrder) {
+ if (focusedFieldName == currentFieldName || !profile[currentFieldName]) {
+ continue;
+ }
+
+ let matching = GROUP_FIELDS[currentFieldName]
+ ? allFieldNames.some(fieldName =>
+ GROUP_FIELDS[currentFieldName].includes(fieldName)
+ )
+ : allFieldNames.includes(currentFieldName);
+
+ if (matching) {
+ if (currentFieldName == "cc-number") {
+ let { affix, label } = lazy.CreditCard.formatMaskedNumber(
+ profile[currentFieldName]
+ );
+ return affix + label;
+ }
+ return profile[currentFieldName];
+ }
+ }
+
+ return ""; // Nothing matched.
+ }
+
+ _generateLabels(focusedFieldName, allFieldNames, profiles) {
+ if (!this._isSecure) {
+ let brandName =
+ lazy.FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+
+ return [
+ lazy.FormAutofillUtils.stringBundle.formatStringFromName(
+ "insecureFieldWarningDescription",
+ [brandName]
+ ),
+ ];
+ }
+
+ if (this._isInputAutofilled) {
+ return [
+ { primary: "", secondary: "" }, // Clear button
+ { primary: "", secondary: "" }, // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let labels = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => {
+ let primaryAffix;
+ let primary = profile[focusedFieldName];
+
+ if (focusedFieldName == "cc-number") {
+ let { affix, label } = lazy.CreditCard.formatMaskedNumber(primary);
+ primaryAffix = affix;
+ primary = label;
+ }
+ const secondary = this._getSecondaryLabel(
+ focusedFieldName,
+ allFieldNames,
+ profile
+ );
+ // The card type is displayed visually using an image. For a11y, we need
+ // to expose it as text. We do this using aria-label. However,
+ // aria-label overrides the text content, so we must include that also.
+ const ccType = profile["cc-type"];
+ const ccTypeL10nId = lazy.CreditCard.getNetworkL10nId(ccType);
+ const ccTypeName = ccTypeL10nId
+ ? lazy.l10n.formatValueSync(ccTypeL10nId)
+ : ccType ?? ""; // Unknown card type
+ const ariaLabel = [ccTypeName, primaryAffix, primary, secondary]
+ .filter(chunk => !!chunk) // Exclude empty chunks.
+ .join(" ");
+ return {
+ primaryAffix,
+ primary,
+ secondary,
+ ariaLabel,
+ };
+ });
+ // Add an empty result entry for footer.
+ labels.push({ primary: "", secondary: "" });
+
+ return labels;
+ }
+
+ // This method needs to return an array that parallels the
+ // array returned by _generateLabels, above. As a consequence,
+ // its logic follows very closely.
+ _generateCardTypes(focusedFieldName, allFieldNames, profiles) {
+ if (this._isInputAutofilled) {
+ return [
+ "", // Clear button
+ "", // Footer
+ ];
+ }
+
+ // Skip results without a primary label.
+ let cardTypes = profiles
+ .filter(profile => {
+ return !!profile[focusedFieldName];
+ })
+ .map(profile => profile["cc-type"]);
+
+ // Add an empty result entry for footer.
+ cardTypes.push("");
+ return cardTypes;
+ }
+
+ getStyleAt(index) {
+ this._checkIndexBounds(index);
+ if (!this._isSecure) {
+ return "autofill-insecureWarning";
+ }
+
+ return super.getStyleAt(index);
+ }
+
+ getImageAt(index) {
+ this._checkIndexBounds(index);
+ let network = this._cardTypes[index];
+ return lazy.CreditCard.getCreditCardLogo(network);
+ }
+}
diff --git a/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs
new file mode 100644
index 0000000000..6ac3744dac
--- /dev/null
+++ b/toolkit/components/formautofill/android/FormAutofillPrompter.sys.mjs
@@ -0,0 +1,70 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+});
+
+// Sync with Autocomplete.SaveOption.Hint in Autocomplete.java
+const CreditCardStorageHint = {
+ NONE: 0,
+ GENERATED: 1 << 0,
+ LOW_CONFIDENCE: 1 << 1,
+};
+
+export let FormAutofillPrompter = {
+ _createMessage(creditCards) {
+ let hint = CreditCardStorageHint.NONE;
+ return {
+ // Sync with PromptController
+ type: "Autocomplete:Save:CreditCard",
+ hint,
+ creditCards,
+ };
+ },
+
+ async promptToSaveAddress(browser, type, description) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async promptToSaveCreditCard(browser, storage, record, flowId) {
+ const prompt = new lazy.GeckoViewPrompter(browser.ownerGlobal);
+
+ const duplicateRecord = (await storage.getDuplicateRecords(record).next())
+ .value;
+ let newCreditCard;
+ if (duplicateRecord) {
+ newCreditCard = { ...duplicateRecord, ...record };
+ } else {
+ newCreditCard = record;
+ }
+
+ prompt.asyncShowPrompt(
+ this._createMessage([lazy.CreditCard.fromGecko(newCreditCard)]),
+ result => {
+ const selectedCreditCard = result?.selection?.value;
+
+ if (!selectedCreditCard) {
+ return;
+ }
+
+ lazy.GeckoViewAutocomplete.onCreditCardSave(selectedCreditCard);
+ }
+ );
+ },
+};
diff --git a/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs
new file mode 100644
index 0000000000..76d583536b
--- /dev/null
+++ b/toolkit/components/formautofill/android/FormAutofillStorage.sys.mjs
@@ -0,0 +1,277 @@
+/* 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 for GeckoView.
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import {
+ FormAutofillStorageBase,
+ CreditCardsBase,
+ AddressesBase,
+} from "resource://autofill/FormAutofillStorageBase.sys.mjs";
+import { JSONFile } from "resource://gre/modules/JSONFile.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ Address: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+ CreditCard: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+});
+
+class GeckoViewStorage extends JSONFile {
+ constructor() {
+ super({ path: null, sanitizedBasename: "GeckoViewStorage" });
+ }
+
+ async updateCreditCards() {
+ const creditCards =
+ await lazy.GeckoViewAutocomplete.fetchCreditCards().then(
+ results => results?.map(r => lazy.CreditCard.parse(r).toGecko()) ?? [],
+ _ => []
+ );
+ super.data.creditCards = creditCards;
+ }
+
+ async updateAddresses() {
+ const addresses = await lazy.GeckoViewAutocomplete.fetchAddresses().then(
+ results => results?.map(r => lazy.Address.parse(r).toGecko()) ?? [],
+ _ => []
+ );
+ super.data.addresses = addresses;
+ }
+
+ async load() {
+ super.data = { creditCards: {}, addresses: {} };
+ await this.updateCreditCards();
+ await this.updateAddresses();
+ }
+
+ ensureDataReady() {
+ if (this.dataReady) {
+ return;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async _save() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+class Addresses extends AddressesBase {
+ // Override AutofillRecords methods.
+
+ _initialize() {
+ this._initializePromise = Promise.resolve();
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ lazy.GeckoViewAutocomplete.onAddressSave(lazy.Address.fromGecko(record));
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ await this._store.updateAddresses();
+ return super.get(guid, { rawData });
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ await this._store.updateAddresses();
+ return super.getAll({ rawData, includeDeleted });
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Set} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ await this._store.updateAddresses();
+ return super.getSavedFieldNames();
+ }
+
+ async reconcile(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async findDuplicateGUID(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async mergeToStorage(targetRecord, strict = false) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+class CreditCards extends CreditCardsBase {
+ async _encryptNumber(creditCard) {
+ // Don't encrypt or obfuscate for GV, since we don't store or show
+ // the number. The API has to always provide the original number.
+ }
+
+ // Override AutofillRecords methods.
+
+ _initialize() {
+ this._initializePromise = Promise.resolve();
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ lazy.GeckoViewAutocomplete.onCreditCardSave(
+ lazy.CreditCard.fromGecko(record)
+ );
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ await this._store.updateCreditCards();
+ return super.get(guid, { rawData });
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ await this._store.updateCreditCards();
+ return super.getAll({ rawData, includeDeleted });
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Set} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ await this._store.updateCreditCards();
+ return super.getSavedFieldNames();
+ }
+
+ /**
+ * Find a duplicate credit card record in the storage.
+ *
+ * A record is considered as a duplicate of another record when two records
+ * are the "same". This might be true even when some of their fields are
+ * different. For example, one record has the same credit card number but has
+ * different expiration date as the other record are still considered as
+ * "duplicate".
+ * This is different from `getMatchRecord`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The credit card for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ if (!record["cc-number"]) {
+ return null;
+ }
+
+ await this._store.updateCreditCards();
+ for (const recordInStorage of this._data) {
+ if (recordInStorage.deleted) {
+ continue;
+ }
+
+ if (recordInStorage["cc-number"] == record["cc-number"]) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ async reconcile(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async findDuplicateGUID(remoteRecord) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ async mergeToStorage(targetRecord, strict = false) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+export class FormAutofillStorage extends FormAutofillStorageBase {
+ constructor() {
+ super(null);
+ }
+
+ 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;
+ }
+
+ /**
+ * Initializes the in-memory async store API.
+ *
+ * @returns {JSONFile}
+ * The JSONFile store.
+ */
+ _initializeStore() {
+ return new GeckoViewStorage();
+ }
+}
+
+// The singleton exposed by this module.
+export const formAutofillStorage = new FormAutofillStorage();
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)
+);
diff --git a/toolkit/components/formautofill/jar.mn b/toolkit/components/formautofill/jar.mn
new file mode 100644
index 0000000000..d840f267d3
--- /dev/null
+++ b/toolkit/components/formautofill/jar.mn
@@ -0,0 +1,17 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+toolkit.jar:
+% resource autofill %res/autofill/
+ res/autofill/ (./*.sys.mjs)
+ res/autofill/phonenumberutils/ (./phonenumberutils/*.sys.mjs)
+ res/autofill/addressmetadata/ (./addressmetadata/*)
+ res/autofill/content/ (./content/*)
+#ifdef ANDROID
+ res/autofill/FormAutofillPrompter.sys.mjs (./android/FormAutofillPrompter.sys.mjs)
+ res/autofill/FormAutofillStorage.sys.mjs (./android/FormAutofillStorage.sys.mjs)
+#else
+ res/autofill/FormAutofillPrompter.sys.mjs (./default/FormAutofillPrompter.sys.mjs)
+ res/autofill/FormAutofillStorage.sys.mjs (./default/FormAutofillStorage.sys.mjs)
+#endif
diff --git a/toolkit/components/formautofill/moz.build b/toolkit/components/formautofill/moz.build
new file mode 100644
index 0000000000..9795990ba6
--- /dev/null
+++ b/toolkit/components/formautofill/moz.build
@@ -0,0 +1,33 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Form Autofill")
+
+EXTRA_JS_MODULES.shared += [
+ "shared/AddressComponent.sys.mjs",
+ "shared/AddressParser.sys.mjs",
+ "shared/CreditCardRuleset.sys.mjs",
+ "shared/FieldScanner.sys.mjs",
+ "shared/FormAutofillHandler.sys.mjs",
+ "shared/FormAutofillHeuristics.sys.mjs",
+ "shared/FormAutofillNameUtils.sys.mjs",
+ "shared/FormAutofillSection.sys.mjs",
+ "shared/FormAutofillUtils.sys.mjs",
+ "shared/FormStateManager.sys.mjs",
+ "shared/HeuristicsRegExp.sys.mjs",
+ "shared/LabelUtils.sys.mjs",
+]
+
+EXPORTS.mozilla += ["FormAutofillNative.h"]
+
+UNIFIED_SOURCES += ["FormAutofillNative.cpp"]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+FINAL_LIBRARY = "xul"
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs
new file mode 100644
index 0000000000..80b5e43acb
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.sys.mjs
@@ -0,0 +1,474 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+// This library came from https://github.com/andreasgal/PhoneNumber.js but will
+// be further maintained by our own in Form Autofill codebase.
+
+import { PHONE_NUMBER_META_DATA } from "resource://autofill/phonenumberutils/PhoneNumberMetaData.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PhoneNumberNormalizer:
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs",
+});
+
+export var PhoneNumber = (function (dataBase) {
+ const MAX_PHONE_NUMBER_LENGTH = 50;
+ const NON_ALPHA_CHARS = /[^a-zA-Z]/g;
+ const NON_DIALABLE_CHARS = /[^,#+\*\d]/g;
+ const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source);
+ const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/;
+ const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g;
+
+ // Format of the string encoded meta data. If the name contains "^" or "$"
+ // we will generate a regular expression from the value, with those special
+ // characters as prefix/suffix.
+ const META_DATA_ENCODING = [
+ "region",
+ "^(?:internationalPrefix)",
+ "nationalPrefix",
+ "^(?:nationalPrefixForParsing)",
+ "nationalPrefixTransformRule",
+ "nationalPrefixFormattingRule",
+ "^possiblePattern$",
+ "^nationalPattern$",
+ "formats",
+ ];
+
+ const FORMAT_ENCODING = [
+ "^pattern$",
+ "nationalFormat",
+ "^leadingDigits",
+ "nationalPrefixFormattingRule",
+ "internationalFormat",
+ ];
+
+ let regionCache = Object.create(null);
+
+ // Parse an array of strings into a convenient object. We store meta
+ // data as arrays since thats much more compact than JSON.
+ function ParseArray(array, encoding, obj) {
+ for (let n = 0; n < encoding.length; ++n) {
+ let value = array[n];
+ if (!value) {
+ continue;
+ }
+ let field = encoding[n];
+ let fieldAlpha = field.replace(NON_ALPHA_CHARS, "");
+ if (field != fieldAlpha) {
+ value = new RegExp(field.replace(fieldAlpha, value));
+ }
+ obj[fieldAlpha] = value;
+ }
+ return obj;
+ }
+
+ // Parse string encoded meta data into a convenient object
+ // representation.
+ function ParseMetaData(countryCode, md) {
+ let array = JSON.parse(md);
+ md = ParseArray(array, META_DATA_ENCODING, { countryCode });
+ regionCache[md.region] = md;
+ return md;
+ }
+
+ // Parse string encoded format data into a convenient object
+ // representation.
+ function ParseFormat(md) {
+ let formats = md.formats;
+ if (!formats) {
+ return;
+ }
+ // Bail if we already parsed the format definitions.
+ if (!Array.isArray(formats[0])) {
+ return;
+ }
+ for (let n = 0; n < formats.length; ++n) {
+ formats[n] = ParseArray(formats[n], FORMAT_ENCODING, {});
+ }
+ }
+
+ // Search for the meta data associated with a region identifier ("US") in
+ // our database, which is indexed by country code ("1"). Since we have
+ // to walk the entire database for this, we cache the result of the lookup
+ // for future reference.
+ function FindMetaDataForRegion(region) {
+ // Check in the region cache first. This will find all entries we have
+ // already resolved (parsed from a string encoding).
+ let md = regionCache[region];
+ if (md) {
+ return md;
+ }
+ for (let countryCode in dataBase) {
+ let entry = dataBase[countryCode];
+ // Each entry is a string encoded object of the form '["US..', or
+ // an array of strings. We don't want to parse the string here
+ // to save memory, so we just substring the region identifier
+ // and compare it. For arrays, we compare against all region
+ // identifiers with that country code. We skip entries that are
+ // of type object, because they were already resolved (parsed into
+ // an object), and their country code should have been in the cache.
+ if (Array.isArray(entry)) {
+ for (let n = 0; n < entry.length; n++) {
+ if (typeof entry[n] == "string" && entry[n].substr(2, 2) == region) {
+ if (n > 0) {
+ // Only the first entry has the formats field set.
+ // Parse the main country if we haven't already and use
+ // the formats field from the main country.
+ if (typeof entry[0] == "string") {
+ entry[0] = ParseMetaData(countryCode, entry[0]);
+ }
+ let formats = entry[0].formats;
+ let current = ParseMetaData(countryCode, entry[n]);
+ current.formats = formats;
+ entry[n] = current;
+ return entry[n];
+ }
+
+ entry[n] = ParseMetaData(countryCode, entry[n]);
+ return entry[n];
+ }
+ }
+ continue;
+ }
+ if (typeof entry == "string" && entry.substr(2, 2) == region) {
+ dataBase[countryCode] = ParseMetaData(countryCode, entry);
+ return dataBase[countryCode];
+ }
+ }
+ }
+
+ // Format a national number for a given region. The boolean flag "intl"
+ // indicates whether we want the national or international format.
+ function FormatNumber(regionMetaData, number, intl) {
+ // We lazily parse the format description in the meta data for the region,
+ // so make sure to parse it now if we haven't already done so.
+ ParseFormat(regionMetaData);
+ let formats = regionMetaData.formats;
+ if (!formats) {
+ return null;
+ }
+ for (let n = 0; n < formats.length; ++n) {
+ let format = formats[n];
+ // The leading digits field is optional. If we don't have it, just
+ // use the matching pattern to qualify numbers.
+ if (format.leadingDigits && !format.leadingDigits.test(number)) {
+ continue;
+ }
+ if (!format.pattern.test(number)) {
+ continue;
+ }
+ if (intl) {
+ // If there is no international format, just fall back to the national
+ // format.
+ let internationalFormat = format.internationalFormat;
+ if (!internationalFormat) {
+ internationalFormat = format.nationalFormat;
+ }
+ // Some regions have numbers that can't be dialed from outside the
+ // country, indicated by "NA" for the international format of that
+ // number format pattern.
+ if (internationalFormat == "NA") {
+ return null;
+ }
+ // Prepend "+" and the country code.
+ number =
+ "+" +
+ regionMetaData.countryCode +
+ " " +
+ number.replace(format.pattern, internationalFormat);
+ } else {
+ number = number.replace(format.pattern, format.nationalFormat);
+ // The region has a national prefix formatting rule, and it can be overwritten
+ // by each actual number format rule.
+ let nationalPrefixFormattingRule =
+ regionMetaData.nationalPrefixFormattingRule;
+ if (format.nationalPrefixFormattingRule) {
+ nationalPrefixFormattingRule = format.nationalPrefixFormattingRule;
+ }
+ if (nationalPrefixFormattingRule) {
+ // The prefix formatting rule contains two magic markers, "$NP" and "$FG".
+ // "$NP" will be replaced by the national prefix, and "$FG" with the
+ // first group of numbers.
+ let match = number.match(SPLIT_FIRST_GROUP);
+ if (match) {
+ let firstGroup = match[1];
+ let rest = match[2];
+ let prefix = nationalPrefixFormattingRule;
+ prefix = prefix.replace("$NP", regionMetaData.nationalPrefix);
+ prefix = prefix.replace("$FG", firstGroup);
+ number = prefix + rest;
+ }
+ }
+ }
+ return number == "NA" ? null : number;
+ }
+ return null;
+ }
+
+ function NationalNumber(regionMetaData, number) {
+ this.region = regionMetaData.region;
+ this.regionMetaData = regionMetaData;
+ this.number = number;
+ }
+
+ // NationalNumber represents the result of parsing a phone number. We have
+ // three getters on the prototype that format the number in national and
+ // international format. Once called, the getters put a direct property
+ // onto the object, caching the result.
+ NationalNumber.prototype = {
+ // +1 949-726-2896
+ get internationalFormat() {
+ let value = FormatNumber(this.regionMetaData, this.number, true);
+ Object.defineProperty(this, "internationalFormat", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // (949) 726-2896
+ get nationalFormat() {
+ let value = FormatNumber(this.regionMetaData, this.number, false);
+ Object.defineProperty(this, "nationalFormat", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // +19497262896
+ get internationalNumber() {
+ let value = this.internationalFormat
+ ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "")
+ : null;
+ Object.defineProperty(this, "internationalNumber", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // 9497262896
+ get nationalNumber() {
+ let value = this.nationalFormat
+ ? this.nationalFormat.replace(NON_DIALABLE_CHARS, "")
+ : null;
+ Object.defineProperty(this, "nationalNumber", {
+ value,
+ enumerable: true,
+ });
+ return value;
+ },
+ // country name 'US'
+ get countryName() {
+ let value = this.region ? this.region : null;
+ Object.defineProperty(this, "countryName", { value, enumerable: true });
+ return value;
+ },
+ // country code '+1'
+ get countryCode() {
+ let value = this.regionMetaData.countryCode
+ ? "+" + this.regionMetaData.countryCode
+ : null;
+ Object.defineProperty(this, "countryCode", { value, enumerable: true });
+ return value;
+ },
+ };
+
+ // Check whether the number is valid for the given region.
+ function IsValidNumber(number, md) {
+ return md.possiblePattern.test(number);
+ }
+
+ // Check whether the number is a valid national number for the given region.
+ /* eslint-disable no-unused-vars */
+ function IsNationalNumber(number, md) {
+ return IsValidNumber(number, md) && md.nationalPattern.test(number);
+ }
+
+ // Determine the country code a number starts with, or return null if
+ // its not a valid country code.
+ function ParseCountryCode(number) {
+ for (let n = 1; n <= 3; ++n) {
+ let cc = number.substr(0, n);
+ if (dataBase[cc]) {
+ return cc;
+ }
+ }
+ return null;
+ }
+
+ // Parse a national number for a specific region. Return null if the
+ // number is not a valid national number (it might still be a possible
+ // number for parts of that region).
+ function ParseNationalNumber(number, md) {
+ if (!md.possiblePattern.test(number) || !md.nationalPattern.test(number)) {
+ return null;
+ }
+ // Success.
+ return new NationalNumber(md, number);
+ }
+
+ function ParseNationalNumberAndCheckNationalPrefix(number, md) {
+ let ret;
+
+ // This is not an international number. See if its a national one for
+ // the current region. National numbers can start with the national
+ // prefix, or without.
+ if (md.nationalPrefixForParsing) {
+ // Some regions have specific national prefix parse rules. Apply those.
+ let withoutPrefix = number.replace(
+ md.nationalPrefixForParsing,
+ md.nationalPrefixTransformRule || ""
+ );
+ ret = ParseNationalNumber(withoutPrefix, md);
+ if (ret) {
+ return ret;
+ }
+ } else {
+ // If there is no specific national prefix rule, just strip off the
+ // national prefix from the beginning of the number (if there is one).
+ let nationalPrefix = md.nationalPrefix;
+ if (
+ nationalPrefix &&
+ number.indexOf(nationalPrefix) == 0 &&
+ (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md))
+ ) {
+ return ret;
+ }
+ }
+ ret = ParseNationalNumber(number, md);
+ if (ret) {
+ return ret;
+ }
+ }
+
+ function ParseNumberByCountryCode(number, countryCode) {
+ let ret;
+
+ // Lookup the meta data for the region (or regions) and if the rest of
+ // the number parses for that region, return the parsed number.
+ let entry = dataBase[countryCode];
+ if (Array.isArray(entry)) {
+ for (let n = 0; n < entry.length; ++n) {
+ if (typeof entry[n] == "string") {
+ entry[n] = ParseMetaData(countryCode, entry[n]);
+ }
+ if (n > 0) {
+ entry[n].formats = entry[0].formats;
+ }
+ ret = ParseNationalNumberAndCheckNationalPrefix(number, entry[n]);
+ if (ret) {
+ return ret;
+ }
+ }
+ return null;
+ }
+ if (typeof entry == "string") {
+ entry = dataBase[countryCode] = ParseMetaData(countryCode, entry);
+ }
+ return ParseNationalNumberAndCheckNationalPrefix(number, entry);
+ }
+
+ // Parse an international number that starts with the country code. Return
+ // null if the number is not a valid international number.
+ function ParseInternationalNumber(number) {
+ // Parse and strip the country code.
+ let countryCode = ParseCountryCode(number);
+ if (!countryCode) {
+ return null;
+ }
+ number = number.substr(countryCode.length);
+
+ return ParseNumberByCountryCode(number, countryCode);
+ }
+
+ // Parse a number and transform it into the national format, removing any
+ // international dial prefixes and country codes.
+ function ParseNumber(number, defaultRegion) {
+ let ret;
+
+ // Remove formating characters and whitespace.
+ number = lazy.PhoneNumberNormalizer.Normalize(number);
+
+ // If there is no defaultRegion or the defaultRegion is the global region,
+ // we can't parse international access codes.
+ if ((!defaultRegion || defaultRegion === "001") && number[0] !== "+") {
+ return null;
+ }
+
+ // Detect and strip leading '+'.
+ if (number[0] === "+") {
+ return ParseInternationalNumber(
+ number.replace(LEADING_PLUS_CHARS_PATTERN, "")
+ );
+ }
+
+ // If "defaultRegion" is a country code, use it to parse the number directly.
+ let matches = String(defaultRegion).match(/^\+?(\d+)/);
+ if (matches) {
+ let countryCode = ParseCountryCode(matches[1]);
+ if (!countryCode) {
+ return null;
+ }
+ return ParseNumberByCountryCode(number, countryCode);
+ }
+
+ // Lookup the meta data for the given region.
+ let md = FindMetaDataForRegion(defaultRegion.toUpperCase());
+ if (!md) {
+ dump("Couldn't find Meta Data for region: " + defaultRegion + "\n");
+ return null;
+ }
+
+ // See if the number starts with an international prefix, and if the
+ // number resulting from stripping the code is valid, then remove the
+ // prefix and flag the number as international.
+ if (md.internationalPrefix.test(number)) {
+ let possibleNumber = number.replace(md.internationalPrefix, "");
+ ret = ParseInternationalNumber(possibleNumber);
+ if (ret) {
+ return ret;
+ }
+ }
+
+ ret = ParseNationalNumberAndCheckNationalPrefix(number, md);
+ if (ret) {
+ return ret;
+ }
+
+ // Now lets see if maybe its an international number after all, but
+ // without '+' or the international prefix.
+ ret = ParseInternationalNumber(number);
+ if (ret) {
+ return ret;
+ }
+
+ // If the number matches the possible numbers of the current region,
+ // return it as a possible number.
+ if (md.possiblePattern.test(number)) {
+ return new NationalNumber(md, number);
+ }
+
+ // We couldn't parse the number at all.
+ return null;
+ }
+
+ function IsPlainPhoneNumber(number) {
+ if (typeof number !== "string") {
+ return false;
+ }
+
+ let length = number.length;
+ let isTooLong = length > MAX_PHONE_NUMBER_LENGTH;
+ let isEmpty = length === 0;
+ return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number));
+ }
+
+ return {
+ IsPlain: IsPlainPhoneNumber,
+ IsValid: IsValidNumber,
+ Parse: ParseNumber,
+ FindMetaDataForRegion,
+ };
+})(PHONE_NUMBER_META_DATA);
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs
new file mode 100644
index 0000000000..3338ce7c16
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberMetaData.sys.mjs
@@ -0,0 +1,291 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+/*
+ * This data was generated base on libphonenumber v8.4.1 via the script in
+ * https://github.com/andreasgal/PhoneNumber.js
+ *
+ * The XML format of libphonenumber has changed since v8.4.2 so we can only stay
+ * in this version for now.
+ */
+
+export var PHONE_NUMBER_META_DATA = {
+ 46: '["SE","00","0",null,null,"$NP$FG","\\\\d{6,12}","[1-35-9]\\\\d{5,11}|4\\\\d{6,8}",[["(8)(\\\\d{2,3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3 $4","8",null,"$1 $2 $3 $4"],["([1-69]\\\\d)(\\\\d{2,3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[013689]|2[0136]|3[1356]|4[0246]|54|6[03]|90",null,"$1 $2 $3 $4"],["([1-469]\\\\d)(\\\\d{3})(\\\\d{2})","$1-$2 $3","1[136]|2[136]|3[356]|4[0246]|6[03]|90",null,"$1 $2 $3"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3 $4"],["(\\\\d{3})(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","1[2457]|2(?:[247-9]|5[0138])|3[0247-9]|4[1357-9]|5[0-35-9]|6(?:[124-689]|7[0-2])|9(?:[125-8]|3[0-5]|4[0-3])",null,"$1 $2 $3"],["(7\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4","7",null,"$1 $2 $3 $4"],["(77)(\\\\d{2})(\\\\d{2})","$1-$2$3","7",null,"$1 $2 $3"],["(20)(\\\\d{2,3})(\\\\d{2})","$1-$2 $3","20",null,"$1 $2 $3"],["(9[034]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1-$2 $3 $4","9[034]",null,"$1 $2 $3 $4"],["(9[034]\\\\d)(\\\\d{4})","$1-$2","9[034]",null,"$1 $2"],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2 $3 $4 $5","25[245]|67[3-6]",null,"$1 $2 $3 $4 $5"]]]',
+ 299: '["GL","00",null,null,null,null,"\\\\d{6}","[1-689]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 385: '["HR","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{5,8}|[89]\\\\d{6,8}",[["(1)(\\\\d{4})(\\\\d{3})","$1 $2 $3","1",null],["([2-5]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-5]",null],["(9\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null],["(6[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","6[01]",null],["([67]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[67]",null],["(80[01])(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","8",null],["(80[01])(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 670: '["TL","00",null,null,null,null,"\\\\d{7,8}","[2-489]\\\\d{6}|7\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-489]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","7",null]]]',
+ 258: '["MZ","00",null,null,null,null,"\\\\d{8,9}","[28]\\\\d{7,8}",[["([28]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2|8[2-7]",null],["(80\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","80",null]]]',
+ 359: '["BG","00","0",null,null,"$NP$FG","\\\\d{5,9}","[23567]\\\\d{5,7}|[489]\\\\d{6,8}",[["(2)(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","2",null],["(2)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{4})","$1 $2","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","43[124-7]|70[1-9]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[78]00",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","999",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[356]|4[124-7]|7[1-9]|8[1-6]|9[1-7]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","48|8[7-9]|9[08]",null]]]',
+ 682: '["CK","00",null,null,null,null,"\\\\d{5}","[2-8]\\\\d{4}",[["(\\\\d{2})(\\\\d{3})","$1 $2",null,null]]]',
+ 852: '["HK","00(?:[126-9]|30|5[09])?",null,null,null,null,"\\\\d{5,11}","[235-7]\\\\d{7}|8\\\\d{7,8}|9\\\\d{4,10}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[235-7]|[89](?:0[1-9]|[1-9])",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["(900)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","900",null],["(900)(\\\\d{2,5})","$1 $2","900",null]]]',
+ 998: '["UZ","810","8",null,null,"$NP $FG","\\\\d{7,9}","[679]\\\\d{8}",[["([679]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 291: '["ER","00","0",null,null,"$NP$FG","\\\\d{6,7}","[178]\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 95: '["MM","00","0",null,null,"$NP$FG","\\\\d{5,10}","[1478]\\\\d{5,7}|[256]\\\\d{5,8}|9(?:[279]\\\\d{0,2}|[58]|[34]\\\\d{1,2}|6\\\\d?)\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1|2[245]",null],["(2)(\\\\d{4})(\\\\d{4})","$1 $2 $3","251",null],["(\\\\d)(\\\\d{2})(\\\\d{3})","$1 $2 $3","16|2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","67|81",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3,4})","$1 $2 $3","[4-8]",null],["(9)(\\\\d{3})(\\\\d{4,6})","$1 $2 $3","9(?:2[0-4]|[35-9]|4[137-9])",null],["(9)([34]\\\\d{4})(\\\\d{4})","$1 $2 $3","9(?:3[0-36]|4[0-57-9])",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","92[56]",null],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4","93",null]]]',
+ 266: '["LS","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 245: '["GW","00",null,null,null,null,"\\\\d{7,9}","(?:4(?:0\\\\d{5}|4\\\\d{7})|9\\\\d{8})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","44|9[567]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","40",null]]]',
+ 374: '["AM","00","0",null,null,"($NP$FG)","\\\\d{5,8}","[1-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2","1|47",null],["(\\\\d{2})(\\\\d{6})","$1 $2","4[1349]|[5-7]|9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{5})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","8|90","$NP $FG"]]]',
+ 61: [
+ '["AU","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","1\\\\d{4,9}|[2-578]\\\\d{8}",[["([2378])(\\\\d{4})(\\\\d{4})","$1 $2 $3","[2378]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]|14","$NP$FG"],["(16)(\\\\d{3,4})","$1 $2","16","$NP$FG"],["(16)(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","16","$NP$FG"],["(1[389]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[38]0|90)","$FG"],["(180)(2\\\\d{3})","$1 $2","180","$FG"],["(19\\\\d)(\\\\d{3})","$1 $2","19[13]","$FG"],["(19\\\\d{2})(\\\\d{4})","$1 $2","19[679]","$FG"],["(13)(\\\\d{2})(\\\\d{2})","$1 $2 $3","13[1-9]","$FG"]]]',
+ '["CC","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]',
+ '["CX","(?:14(?:1[14]|34|4[17]|[56]6|7[47]|88))?001[14-689]","0",null,null,null,"\\\\d{6,10}","[1458]\\\\d{5,9}"]',
+ ],
+ 500: '["FK","00",null,null,null,null,"\\\\d{5}","[2-7]\\\\d{4}"]',
+ 261: '["MG","00","0",null,null,"$NP$FG","\\\\d{7,9}","[23]\\\\d{8}",[["([23]\\\\d)(\\\\d{2})(\\\\d{3})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 92: '["PK","00","0",null,null,"($NP$FG)","\\\\d{6,12}","1\\\\d{8}|[2-8]\\\\d{5,11}|9(?:[013-9]\\\\d{4,9}|2\\\\d(?:111\\\\d{6}|\\\\d{3,7}))",[["(\\\\d{2})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)1",null],["(\\\\d{3})(111)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(\\\\d{2})(\\\\d{7,8})","$1 $2","(?:2[125]|4[0-246-9]|5[1-35-7]|6[1-8]|7[14]|8[16]|91)[2-9]",null],["(\\\\d{3})(\\\\d{6,7})","$1 $2","2[349]|45|54|60|72|8[2-5]|9[2-9]",null],["(3\\\\d{2})(\\\\d{7})","$1 $2","3","$NP$FG"],["([15]\\\\d{3})(\\\\d{5,6})","$1 $2","58[12]|1",null],["(586\\\\d{2})(\\\\d{5})","$1 $2","586",null],["([89]00)(\\\\d{3})(\\\\d{2})","$1 $2 $3","[89]00","$NP$FG"]]]',
+ 234: '["NG","009","0",null,null,"$NP$FG","\\\\d{5,14}","[1-6]\\\\d{5,8}|9\\\\d{5,9}|[78]\\\\d{5,13}",[["(\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[12]|9(?:0[3-9]|[1-9])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","[3-6]|7(?:[1-79]|0[1-9])|8[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","70|8[01]|90[235-9]",null],["([78]00)(\\\\d{4})(\\\\d{4,5})","$1 $2 $3","[78]00",null],["([78]00)(\\\\d{5})(\\\\d{5,6})","$1 $2 $3","[78]00",null],["(78)(\\\\d{2})(\\\\d{3})","$1 $2 $3","78",null]]]',
+ 350: '["GI","00",null,null,null,null,"\\\\d{8}","[2568]\\\\d{7}",[["(\\\\d{3})(\\\\d{5})","$1 $2","2",null]]]',
+ 45: '["DK","00",null,null,null,null,"\\\\d{8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 963: '["SY","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-59]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-5]",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9",null]]]',
+ 226: '["BF","00",null,null,null,null,"\\\\d{8}","[25-7]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 974: '["QA","00",null,null,null,null,"\\\\d{7,8}","[2-8]\\\\d{6,7}",[["([28]\\\\d{2})(\\\\d{4})","$1 $2","[28]",null],["([3-7]\\\\d{3})(\\\\d{4})","$1 $2","[3-7]",null]]]',
+ 218: '["LY","00","0",null,null,"$NP$FG","\\\\d{7,9}","[25679]\\\\d{8}",[["([25679]\\\\d)(\\\\d{7})","$1-$2",null,null]]]',
+ 51: '["PE","19(?:1[124]|77|90)00","0",null,null,"($NP$FG)","\\\\d{6,9}","[14-9]\\\\d{7,8}",[["(1)(\\\\d{7})","$1 $2","1",null],["([4-8]\\\\d)(\\\\d{6})","$1 $2","[4-7]|8[2-4]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","80",null],["(9\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9","$FG"]]]',
+ 62: '["ID","0(?:0[1789]|10(?:00|1[67]))","0",null,null,"$NP$FG","\\\\d{5,12}","(?:[1-79]\\\\d{6,10}|8\\\\d{7,11})",[["(\\\\d{2})(\\\\d{5,8})","$1 $2","2[124]|[36]1","($NP$FG)"],["(\\\\d{3})(\\\\d{5,8})","$1 $2","[4579]|2[035-9]|[36][02-9]","($NP$FG)"],["(8\\\\d{2})(\\\\d{3,4})(\\\\d{3})","$1-$2-$3","8[1-35-9]",null],["(8\\\\d{2})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","8[1-35-9]",null],["(1)(500)(\\\\d{3})","$1 $2 $3","15","$FG"],["(177)(\\\\d{6,8})","$1 $2","17",null],["(800)(\\\\d{5,7})","$1 $2","800",null],["(804)(\\\\d{3})(\\\\d{4})","$1 $2 $3","804",null],["(80\\\\d)(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","80[79]",null]]]',
+ 298: '["FO","00",null,"(10(?:01|[12]0|88))",null,null,"\\\\d{6}","[2-9]\\\\d{5}",[["(\\\\d{6})","$1",null,null]]]',
+ 381: '["RS","00","0",null,null,"$NP$FG","\\\\d{5,12}","[126-9]\\\\d{4,11}|3(?:[0-79]\\\\d{3,10}|8[2-9]\\\\d{2,9})",[["([23]\\\\d{2})(\\\\d{4,9})","$1 $2","(?:2[389]|39)0",null],["([1-3]\\\\d)(\\\\d{5,10})","$1 $2","1|2(?:[0-24-7]|[389][1-9])|3(?:[0-8]|9[1-9])",null],["(6\\\\d)(\\\\d{6,8})","$1 $2","6",null],["([89]\\\\d{2})(\\\\d{3,9})","$1 $2","[89]",null],["(7[26])(\\\\d{4,9})","$1 $2","7[26]",null],["(7[08]\\\\d)(\\\\d{4,9})","$1 $2","7[08]",null]]]',
+ 975: '["BT","00",null,null,null,null,"\\\\d{6,8}","[1-8]\\\\d{6,7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","1|77",null],["([2-8])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-68]|7[246]",null]]]',
+ 34: '["ES","00",null,null,null,null,"\\\\d{9}","[5-9]\\\\d{8}",[["([89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89]00",null],["([5-9]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[568]|[79][0-8]",null]]]',
+ 881: '["001",null,null,null,null,null,"\\\\d{9}","[67]\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{5})","$1 $2 $3","[67]",null]]]',
+ 855: '["KH","00[14-9]","0",null,null,null,"\\\\d{6,10}","[1-9]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1\\\\d[1-9]|[2-9]","$NP$FG"],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[89]0",null]]]',
+ 420: '["CZ","00",null,null,null,null,"\\\\d{9,12}","[2-8]\\\\d{8}|9\\\\d{8,11}",[["([2-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8]|9[015-7]",null],["(96\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","96",null],["(9\\\\d)(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","9[36]",null]]]',
+ 216: '["TN","00",null,null,null,null,"\\\\d{8}","[2-57-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 673: '["BN","00",null,null,null,null,"\\\\d{7}","[2-578]\\\\d{6}",[["([2-578]\\\\d{2})(\\\\d{4})","$1 $2",null,null]]]',
+ 290: [
+ '["SH","00",null,null,null,null,"\\\\d{4,5}","[256]\\\\d{4}"]',
+ '["TA","00",null,null,null,null,"\\\\d{4}","8\\\\d{3}"]',
+ ],
+ 882: '["001",null,null,null,null,null,"\\\\d{7,12}","[13]\\\\d{6,11}",[["(\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","3[23]",null],["(\\\\d{2})(\\\\d{5})","$1 $2","16|342",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","34[57]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","348",null],["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","16",null],["(\\\\d{2})(\\\\d{4,5})(\\\\d{5})","$1 $2 $3","16|39",null]]]',
+ 267: '["BW","00",null,null,null,null,"\\\\d{7,8}","[2-79]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[2-6]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","7",null],["(90)(\\\\d{5})","$1 $2","9",null]]]',
+ 94: '["LK","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{1})(\\\\d{6})","$1 $2 $3","[1-689]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ 356: '["MT","00",null,null,null,null,"\\\\d{8}","[2357-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 375: '["BY","810","8","8?0?",null,null,"\\\\d{5,11}","[1-4]\\\\d{8}|800\\\\d{3,7}|[89]\\\\d{9,10}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","17[0-3589]|2[4-9]|[34]","$NP 0$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","1(?:5[24]|6[235]|7[467])|2(?:1[246]|2[25]|3[26])","$NP 0$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{3})","$1 $2-$3","1(?:5[169]|6[3-5]|7[179])|2(?:1[35]|2[34]|3[3-5])","$NP 0$FG"],["([89]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[01]|9","$NP $FG"],["(82\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","82","$NP $FG"],["(800)(\\\\d{3})","$1 $2","800","$NP $FG"],["(800)(\\\\d{2})(\\\\d{2,4})","$1 $2 $3","800","$NP $FG"]]]',
+ 690: '["TK","00",null,null,null,null,"\\\\d{4,7}","[2-47]\\\\d{3,6}"]',
+ 507: '["PA","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[1-57-9]",null],["(\\\\d{4})(\\\\d{4})","$1-$2","6",null]]]',
+ 692: '["MH","011","1",null,null,null,"\\\\d{7}","[2-6]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null]]]',
+ 250: '["RW","00","0",null,null,null,"\\\\d{8,9}","[027-9]\\\\d{7,8}",[["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2","$FG"],["([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[7-9]","$NP$FG"],["(0\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]',
+ 81: '["JP","010","0",null,null,"$NP$FG","\\\\d{8,17}","[1-9]\\\\d{8,9}|00(?:[36]\\\\d{7,14}|7\\\\d{5,7}|8\\\\d{7})",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1-$2-$3","(?:12|57|99)0",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","800",null],["(\\\\d{4})(\\\\d{4})","$1-$2","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{3,4})","$1-$2-$3","0077","$FG","NA"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1-$2-$3","0088","$FG","NA"],["(\\\\d{4})(\\\\d{3})(\\\\d{3,4})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{4})(\\\\d{4,5})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{5})(\\\\d{5,6})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{4})(\\\\d{6})(\\\\d{6,7})","$1-$2-$3","00(?:37|66)","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","[2579]0|80[1-9]",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1-$2-$3","1(?:26|3[79]|4[56]|5[4-68]|6[3-5])|5(?:76|97)|499|746|8(?:3[89]|63|47|51)|9(?:49|80|9[16])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","1(?:2[3-6]|3[3-9]|4[2-6]|5[2-8]|[68][2-7]|7[2-689]|9[1-578])|2(?:2[03-689]|3[3-58]|4[0-468]|5[04-8]|6[013-8]|7[06-9]|8[02-57-9]|9[13])|4(?:2[28]|3[689]|6[035-7]|7[05689]|80|9[3-5])|5(?:3[1-36-9]|4[4578]|5[013-8]|6[1-9]|7[2-8]|8[14-7]|9[4-9])|7(?:2[15]|3[5-9]|4[02-9]|6[135-8]|7[0-4689]|9[014-9])|8(?:2[49]|3[3-8]|4[5-8]|5[2-9]|6[35-9]|7[579]|8[03-579]|9[2-8])|9(?:[23]0|4[02-46-9]|5[024-79]|6[4-9]|7[2-47-9]|8[02-7]|9[3-7])",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","1|2(?:2[37]|5[5-9]|64|78|8[39]|91)|4(?:2[2689]|64|7[347])|5(?:[2-589]|39)|60|8(?:[46-9]|3[279]|2[124589])|9(?:[235-8]|93)",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","2(?:9[14-79]|74|[34]7|[56]9)|82|993",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1-$2-$3","3|4(?:2[09]|7[01])|6[1-9]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2479][1-9]",null]]]',
+ 237: '["CM","00",null,null,null,null,"\\\\d{8,9}","[2368]\\\\d{7,8}",[["([26])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[26]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|88",null],["(800)(\\\\d{2})(\\\\d{3})","$1 $2 $3","80",null]]]',
+ 351: '["PT","00",null,null,null,null,"\\\\d{9}","[2-46-9]\\\\d{8}",[["(2\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[12]",null],["([2-46-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[3-9]|[346-9]",null]]]',
+ 246: '["IO","00",null,null,null,null,"\\\\d{7}","3\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 227: '["NE","00",null,null,null,null,"\\\\d{8}","[0289]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[289]|09",null],["(08)(\\\\d{3})(\\\\d{3})","$1 $2 $3","08",null]]]',
+ 27: '["ZA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[1-79]\\\\d{8}|8\\\\d{4,8}",[["(860)(\\\\d{3})(\\\\d{3})","$1 $2 $3","860",null],["(\\\\d{2})(\\\\d{3,4})","$1 $2","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2,3})","$1 $2 $3","8[1-4]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-79]|8(?:[0-57]|6[1-9])",null]]]',
+ 962: '["JO","00","0",null,null,"$NP$FG","\\\\d{8,9}","[235-9]\\\\d{7,8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2356]|87","($NP$FG)"],["(7)(\\\\d{4})(\\\\d{4})","$1 $2 $3","7[457-9]",null],["(\\\\d{3})(\\\\d{5,6})","$1 $2","70|8[0158]|9",null]]]',
+ 387: '["BA","00","0",null,null,"$NP$FG","\\\\d{6,9}","[3-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2-$3","[3-5]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[1-356]|[7-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","6[047]",null]]]',
+ 33: '["FR","00","0",null,null,"$NP$FG","\\\\d{9}","[1-9]\\\\d{8}",[["([1-79])(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","[1-79]",null],["(1\\\\d{2})(\\\\d{3})","$1 $2","11","$FG","NA"],["(8\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8","$NP $FG"]]]',
+ 972: '["IL","0(?:0|1[2-9])","0",null,null,"$FG","\\\\d{4,12}","1\\\\d{6,11}|[2-589]\\\\d{3}(?:\\\\d{3,6})?|6\\\\d{3}|7\\\\d{6,9}",[["([2-489])(\\\\d{3})(\\\\d{4})","$1-$2-$3","[2-489]","$NP$FG"],["([57]\\\\d)(\\\\d{3})(\\\\d{4})","$1-$2-$3","[57]","$NP$FG"],["(153)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","153",null],["(1)([7-9]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1-$2-$3-$4","1[7-9]",null],["(1255)(\\\\d{3})","$1-$2","125",null],["(1200)(\\\\d{3})(\\\\d{3})","$1-$2-$3","120",null],["(1212)(\\\\d{2})(\\\\d{2})","$1-$2-$3","121",null],["(1599)(\\\\d{6})","$1-$2","15",null],["(\\\\d{4})","*$1","[2-689]",null]]]',
+ 248: '["SC","0(?:[02]|10?)",null,null,null,null,"\\\\d{6,7}","[24689]\\\\d{5,6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[246]",null]]]',
+ 297: '["AW","00",null,null,null,null,"\\\\d{7}","[25-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 421: '["SK","00","0",null,null,"$NP$FG","\\\\d{6,9}","(?:[2-68]\\\\d{5,8}|9\\\\d{6,8})",[["(2)(1[67])(\\\\d{3,4})","$1 $2 $3","21[67]",null],["([3-5]\\\\d)(1[67])(\\\\d{2,3})","$1 $2 $3","[3-5]",null],["(2)(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1/$2 $3 $4","2",null],["([3-5]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1/$2 $3 $4","[3-5]",null],["([689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[689]",null],["(9090)(\\\\d{3})","$1 $2","9090",null]]]',
+ 672: '["NF","00",null,null,null,null,"\\\\d{5,6}","[13]\\\\d{5}",[["(\\\\d{2})(\\\\d{4})","$1 $2","1",null],["(\\\\d)(\\\\d{5})","$1 $2","3",null]]]',
+ 870: '["001",null,null,null,null,null,"\\\\d{9}","[35-7]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 883: '["001",null,null,null,null,null,"\\\\d{9}(?:\\\\d{3})?","51\\\\d{7}(?:\\\\d{3})?",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","510",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","510",null],["(\\\\d{4})(\\\\d{4})(\\\\d{4})","$1 $2 $3","51[13]",null]]]',
+ 264: '["NA","00","0",null,null,"$NP$FG","\\\\d{8,9}","[68]\\\\d{7,8}",[["(8\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","8[1235]",null],["(6\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","6",null],["(88)(\\\\d{3})(\\\\d{3})","$1 $2 $3","88",null],["(870)(\\\\d{3})(\\\\d{3})","$1 $2 $3","870",null]]]',
+ 878: '["001",null,null,null,null,null,"\\\\d{12}","1\\\\d{11}",[["(\\\\d{2})(\\\\d{5})(\\\\d{5})","$1 $2 $3",null,null]]]',
+ 239: '["ST","00",null,null,null,null,"\\\\d{7}","[29]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 357: '["CY","00",null,null,null,null,"\\\\d{8}","[257-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]',
+ 240: '["GQ","00",null,null,null,null,"\\\\d{9}","[23589]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","[89]",null]]]',
+ 506: '["CR","00",null,"(19(?:0[012468]|1[09]|20|66|77|99))",null,null,"\\\\d{8,10}","[24-9]\\\\d{7,9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24-7]|8[3-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[89]0",null]]]',
+ 86: '["CN","(1(?:[129]\\\\d{3}|79\\\\d{2}))?00","0","(1(?:[129]\\\\d{3}|79\\\\d{2}))|0",null,null,"\\\\d{4,12}","[1-7]\\\\d{6,11}|8[0-357-9]\\\\d{6,9}|9\\\\d{7,10}",[["(80\\\\d{2})(\\\\d{4})","$1 $2","80[2678]","$NP$FG"],["([48]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[48]00",null],["(\\\\d{5,6})","$1","100|95",null,"NA"],["(\\\\d{2})(\\\\d{5,6})","$1 $2","(?:10|2\\\\d)[19]","$NP$FG"],["(\\\\d{3})(\\\\d{5,6})","$1 $2","[3-9]","$NP$FG"],["(\\\\d{3,4})(\\\\d{4})","$1 $2","[2-9]",null,"NA"],["(21)(\\\\d{4})(\\\\d{4,6})","$1 $2 $3","21","$NP$FG"],["([12]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","10[1-9]|2[02-9]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","3(?:1[02-9]|35|49|5|7[02-68]|9[1-68])|4(?:1[02-9]|2[179]|[35][2-9]|6[4789]|7\\\\d|8[23])|5(?:3[03-9]|4[36]|5[02-9]|6[1-46]|7[028]|80|9[2-46-9])|6(?:3[1-5]|6[0238]|9[12])|7(?:01|[1579]|2[248]|3[04-9]|4[3-6]|6[2368])|8(?:1[236-8]|2[5-7]|3|5[1-9]|7[02-9]|8[3678]|9[1-7])|9(?:0[1-3689]|1[1-79]|[379]|4[13]|5[1-5])","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3(?:11|7[179])|4(?:[15]1|3[1-35])|5(?:1|2[37]|3[12]|51|7[13-79]|9[15])|7(?:31|5[457]|6[09]|91)|8(?:[57]1|98)","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","807","$NP$FG"],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","1[3-578]",null],["(10800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","108",null],["(\\\\d{3})(\\\\d{7,8})","$1 $2","950",null]]]',
+ 257: '["BI","00",null,null,null,null,"\\\\d{8}","[267]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 683: '["NU","00",null,null,null,null,"\\\\d{4}","[1-5]\\\\d{3}"]',
+ 43: '["AT","00","0",null,null,"$NP$FG","\\\\d{3,13}","[1-9]\\\\d{3,12}",[["(116\\\\d{3})","$1","116","$FG"],["(1)(\\\\d{3,12})","$1 $2","1",null],["(5\\\\d)(\\\\d{3,5})","$1 $2","5[079]",null],["(5\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","5[079]",null],["(5\\\\d)(\\\\d{4})(\\\\d{4,7})","$1 $2 $3","5[079]",null],["(\\\\d{3})(\\\\d{3,10})","$1 $2","316|46|51|732|6(?:5[0-3579]|[6-9])|7(?:[28]0)|[89]",null],["(\\\\d{4})(\\\\d{3,9})","$1 $2","2|3(?:1[1-578]|[3-8])|4[2378]|5[2-6]|6(?:[12]|4[1-9]|5[468])|7(?:2[1-8]|35|4[1-8]|[5-79])",null]]]',
+ 247: '["AC","00",null,null,null,null,"\\\\d{5,6}","[46]\\\\d{4}|[01589]\\\\d{5}"]',
+ 675: '["PG","00",null,null,null,null,"\\\\d{7,8}","[1-9]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-689]|27",null],["(\\\\d{4})(\\\\d{4})","$1 $2","20|7",null]]]',
+ 376: '["AD","00",null,null,null,null,"\\\\d{6,9}","[16]\\\\d{5,8}|[37-9]\\\\d{5}",[["(\\\\d{3})(\\\\d{3})","$1 $2","[137-9]|6[0-8]",null],["(\\\\d{4})(\\\\d{4})","$1 $2","180",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","690",null]]]',
+ 63: '["PH","00","0",null,null,null,"\\\\d{5,13}","2\\\\d{5,7}|[3-9]\\\\d{7,9}|1800\\\\d{7,9}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2","($NP$FG)"],["(2)(\\\\d{5})","$1 $2","2","($NP$FG)"],["(\\\\d{4})(\\\\d{4,6})","$1 $2","3(?:23|39|46)|4(?:2[3-6]|[35]9|4[26]|76)|5(?:22|44)|642|8(?:62|8[245])","($NP$FG)"],["(\\\\d{5})(\\\\d{4})","$1 $2","346|4(?:27|9[35])|883","($NP$FG)"],["([3-8]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[3-8]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","81|9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(1800)(\\\\d{1,2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","1",null]]]',
+ 236: '["CF","00",null,null,null,null,"\\\\d{8}","[278]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 590: [
+ '["GP","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["([56]90)(\\\\d{2})(\\\\d{4})","$1 $2-$3",null,null]]]',
+ '["BL","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]',
+ '["MF","00","0",null,null,null,"\\\\d{9}","[56]\\\\d{8}"]',
+ ],
+ 53: '["CU","119","0",null,null,"($NP$FG)","\\\\d{4,8}","[2-57]\\\\d{5,7}",[["(\\\\d)(\\\\d{6,7})","$1 $2","7",null],["(\\\\d{2})(\\\\d{4,6})","$1 $2","[2-4]",null],["(\\\\d)(\\\\d{7})","$1 $2","5","$NP$FG"]]]',
+ 64: '["NZ","0(?:0|161)","0",null,null,"$NP$FG","\\\\d{7,11}","6[235-9]\\\\d{6}|[2-57-9]\\\\d{7,10}",[["([34679])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[346]|7[2-57-9]|9[1-9]",null],["(24099)(\\\\d{3})","$1 $2","240",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","21",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,5})","$1 $2 $3","2(?:1[1-9]|[69]|7[0-35-9])|70|86",null],["(2\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[028]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:10|74)|5|[89]0",null]]]',
+ 965: '["KW","00",null,null,null,null,"\\\\d{7,8}","[12569]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{3,4})","$1 $2","[16]|2(?:[0-35-9]|4[0-35-9])|9[024-9]|52[25]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","244|5(?:[015]|66)",null]]]',
+ 224: '["GN","00",null,null,null,null,"\\\\d{8,9}","[367]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","3",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[67]",null]]]',
+ 973: '["BH","00",null,null,null,null,"\\\\d{8}","[136-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 32: '["BE","00","0",null,null,"$NP$FG","\\\\d{8,9}","[1-9]\\\\d{7,8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[6-9]",null],["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4[23]|9[2-4]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[156]|7[018]|8(?:0[1-9]|[1-79])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","(?:80|9)0",null]]]',
+ 249: '["SD","00","0",null,null,"$NP$FG","\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 678: '["VU","00",null,null,null,null,"\\\\d{5,7}","[2-57-9]\\\\d{4,6}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[579]",null]]]',
+ 52: '["MX","0[09]","01","0[12]|04[45](\\\\d{10})","1$1","$NP $FG","\\\\d{7,11}","[1-9]\\\\d{9,10}",[["([358]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","33|55|81",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2467]|3[0-2457-9]|5[089]|8[02-9]|9[0-35-9]",null],["(1)([358]\\\\d)(\\\\d{4})(\\\\d{4})","044 $2 $3 $4","1(?:33|55|81)","$FG","$1 $2 $3 $4"],["(1)(\\\\d{3})(\\\\d{3})(\\\\d{4})","044 $2 $3 $4","1(?:[2467]|3[0-2457-9]|5[089]|8[2-9]|9[1-35-9])","$FG","$1 $2 $3 $4"]]]',
+ 968: '["OM","00",null,null,null,null,"\\\\d{7,9}","(?:5|[279]\\\\d)\\\\d{6}|800\\\\d{5,6}",[["(2\\\\d)(\\\\d{6})","$1 $2","2",null],["([79]\\\\d{3})(\\\\d{4})","$1 $2","[79]",null],["([58]00)(\\\\d{4,6})","$1 $2","[58]",null]]]',
+ 599: [
+ '["CW","00",null,null,null,null,"\\\\d{7,8}","[169]\\\\d{6,7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[13-7]",null],["(9)(\\\\d{3})(\\\\d{4})","$1 $2 $3","9",null]]]',
+ '["BQ","00",null,null,null,null,"\\\\d{7}","[347]\\\\d{6}"]',
+ ],
+ 800: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 386: '["SI","00","0",null,null,"$NP$FG","\\\\d{5,8}","[1-7]\\\\d{6,7}|[89]\\\\d{4,7}",[["(\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[12]|3[24-8]|4[24-8]|5[2-8]|7[3-8]","($NP$FG)"],["([3-7]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[37][01]|4[0139]|51|6",null],["([89][09])(\\\\d{3,6})","$1 $2","[89][09]",null],["([58]\\\\d{2})(\\\\d{5})","$1 $2","59|8[1-3]",null]]]',
+ 679: '["FJ","0(?:0|52)",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[35-9]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[35-9]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0",null]]]',
+ 238: '["CV","0",null,null,null,null,"\\\\d{7}","[259]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 691: '["FM","00",null,null,null,null,"\\\\d{7}","[39]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 262: [
+ '["RE","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}",[["([268]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ '["YT","00","0",null,null,"$NP$FG","\\\\d{9}","[268]\\\\d{8}"]',
+ ],
+ 241: '["GA","00",null,null,null,null,"\\\\d{7,8}","0?\\\\d{7}",[["(\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]","0$FG"],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","0",null]]]',
+ 370: '["LT","00","8","[08]",null,"($NP-$FG)","\\\\d{8}","[3-9]\\\\d{7}",[["([34]\\\\d)(\\\\d{6})","$1 $2","37|4(?:1|5[45]|6[2-4])",null],["([3-6]\\\\d{2})(\\\\d{5})","$1 $2","3[148]|4(?:[24]|6[09])|528|6",null],["([7-9]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[7-9]","$NP $FG"],["(5)(2\\\\d{2})(\\\\d{4})","$1 $2 $3","52[0-79]",null]]]',
+ 256: '["UG","00[057]","0",null,null,"$NP$FG","\\\\d{5,9}","\\\\d{9}",[["(\\\\d{3})(\\\\d{6})","$1 $2","[7-9]|20(?:[013-8]|2[5-9])|4(?:6[45]|[7-9])",null],["(\\\\d{2})(\\\\d{7})","$1 $2","3|4(?:[1-5]|6[0-36-9])",null],["(2024)(\\\\d{5})","$1 $2","2024",null]]]',
+ 677: '["SB","0[01]",null,null,null,null,"\\\\d{5,7}","[1-9]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{5})","$1 $2","[7-9]",null]]]',
+ 377: '["MC","00","0",null,null,"$NP$FG","\\\\d{8,9}","[34689]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[39]","$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","4",null],["(6)(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","6",null],["(\\\\d{3})(\\\\d{3})(\\\\d{2})","$1 $2 $3","8","$FG"]]]',
+ 382: '["ME","00","0",null,null,"$NP$FG","\\\\d{6,9}","[2-9]\\\\d{7,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-57-9]|6[036-9]",null]]]',
+ 231: '["LR","00","0",null,null,"$NP$FG","\\\\d{7,9}","2\\\\d{7,8}|[378]\\\\d{8}|4\\\\d{6}|5\\\\d{6,8}",[["(2\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["([4-5])(\\\\d{3})(\\\\d{3})","$1 $2 $3","[45]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23578]",null]]]',
+ 591: '["BO","00(1\\\\d)?","0","0(1\\\\d)?",null,null,"\\\\d{7,8}","[23467]\\\\d{7}",[["([234])(\\\\d{7})","$1 $2","[234]",null],["([67]\\\\d{7})","$1","[67]",null]]]',
+ 808: '["001",null,null,null,null,null,"\\\\d{8}","\\\\d{8}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 964: '["IQ","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-7]\\\\d{7,9}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["([2-6]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-6]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ 225: '["CI","00",null,null,null,null,"\\\\d{8}","[02-8]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 992: '["TJ","810","8",null,null,"$FG","\\\\d{3,9}","[3-57-9]\\\\d{8}",[["([349]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[34]7|91[78]",null],["([457-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[148]|[578]|9(?:1[59]|[0235-9])",null],["(331700)(\\\\d)(\\\\d{2})","$1 $2 $3","331",null],["(\\\\d{4})(\\\\d)(\\\\d{4})","$1 $2 $3","3[1-5]",null]]]',
+ 55: '["BR","00(?:1[245]|2[1-35]|31|4[13]|[56]5|99)","0","(?:0|90)(?:(1[245]|2[135]|[34]1)(\\\\d{10,11}))?","$2",null,"\\\\d{8,11}","[1-46-9]\\\\d{7,10}|5(?:[0-4]\\\\d{7,9}|5(?:[2-8]\\\\d{7}|9\\\\d{7,8}))",[["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9](?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{5})(\\\\d{4})","$1-$2","9(?:[1-9]|0[1-9])","$FG","NA"],["(\\\\d{3,5})","$1","1[125689]","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2-$3","[1-9][1-9]","($FG)"],["(\\\\d{2})(\\\\d{5})(\\\\d{4})","$1 $2-$3","(?:[14689][1-9]|2[12478]|3[1-578]|5[1-5]|7[13-579])9","($FG)"],["(\\\\d{4})(\\\\d{4})","$1-$2","(?:300|40(?:0|20))",null],["([3589]00)(\\\\d{2,3})(\\\\d{4})","$1 $2 $3","[3589]00","$NP$FG"]]]',
+ 674: '["NR","00",null,null,null,null,"\\\\d{7}","[458]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 967: '["YE","00","0",null,null,"$NP$FG","\\\\d{6,9}","[1-7]\\\\d{6,8}",[["([1-7])(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[1-6]|7[24-68]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7[0137]",null]]]',
+ 49: '["DE","00","0",null,null,"$NP$FG","\\\\d{2,15}","[1-35-9]\\\\d{3,14}|4(?:[0-8]\\\\d{3,12}|9(?:[0-37]\\\\d|4(?:[1-35-8]|4\\\\d?)|5\\\\d{1,2}|6[1-8]\\\\d?)\\\\d{2,8})",[["(1\\\\d{2})(\\\\d{7,8})","$1 $2","1[67]",null],["(15\\\\d{3})(\\\\d{6})","$1 $2","15[0568]",null],["(1\\\\d{3})(\\\\d{7})","$1 $2","15",null],["(\\\\d{2})(\\\\d{3,11})","$1 $2","3[02]|40|[68]9",null],["(\\\\d{3})(\\\\d{3,11})","$1 $2","2(?:\\\\d1|0[2389]|1[24]|28|34)|3(?:[3-9][15]|40)|[4-8][1-9]1|9(?:06|[1-9]1)",null],["(\\\\d{4})(\\\\d{2,11})","$1 $2","[24-6]|[7-9](?:\\\\d[1-9]|[1-9]\\\\d)|3(?:[3569][02-46-9]|4[2-4679]|7[2-467]|8[2-46-8])",null],["(3\\\\d{4})(\\\\d{1,10})","$1 $2","3",null],["(800)(\\\\d{7,12})","$1 $2","800",null],["(\\\\d{3})(\\\\d)(\\\\d{4,10})","$1 $2 $3","(?:18|90)0|137",null],["(1\\\\d{2})(\\\\d{5,11})","$1 $2","181",null],["(18\\\\d{3})(\\\\d{6})","$1 $2","185",null],["(18\\\\d{2})(\\\\d{7})","$1 $2","18[68]",null],["(18\\\\d)(\\\\d{8})","$1 $2","18[2-579]",null],["(700)(\\\\d{4})(\\\\d{4})","$1 $2 $3","700",null],["(138)(\\\\d{4})","$1 $2","138",null],["(15[013-68])(\\\\d{2})(\\\\d{8})","$1 $2 $3","15[013-68]",null],["(15[279]\\\\d)(\\\\d{2})(\\\\d{7})","$1 $2 $3","15[279]",null],["(1[67]\\\\d)(\\\\d{2})(\\\\d{7,8})","$1 $2 $3","1(?:6[023]|7)",null]]]',
+ 31: '["NL","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,8}|[2-7]\\\\d{8}|[89]\\\\d{6,9}",[["([1-578]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[035]|2[0346]|3[03568]|4[0356]|5[0358]|7|8[4578]",null],["([1-5]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[16-8]|2[259]|3[124]|4[17-9]|5[124679]",null],["(6)(\\\\d{8})","$1 $2","6[0-57-9]",null],["(66)(\\\\d{7})","$1 $2","66",null],["(14)(\\\\d{3,4})","$1 $2","14","$FG"],["([89]0\\\\d)(\\\\d{4,7})","$1 $2","80|9",null]]]',
+ 970: '["PS","00","0",null,null,"$NP$FG","\\\\d{4,10}","[24589]\\\\d{7,8}|1(?:[78]\\\\d{8}|[49]\\\\d{2,3})",[["([2489])(2\\\\d{2})(\\\\d{4})","$1 $2 $3","[2489]",null],["(5[69]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","5",null],["(1[78]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1[78]","$FG"]]]',
+ 58: '["VE","00","0",null,null,"$NP$FG","\\\\d{7,10}","[24589]\\\\d{9}",[["(\\\\d{3})(\\\\d{7})","$1-$2",null,null]]]',
+ 856: '["LA","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-8]\\\\d{7,9}",[["(20)(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","20",null],["([2-8]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","2[13]|3[14]|[4-8]",null],["(30)(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","30",null]]]',
+ 354: '["IS","1(?:0(?:01|10|20)|100)|00",null,null,null,null,"\\\\d{7,9}","[4-9]\\\\d{6}|38\\\\d{7}",[["(\\\\d{3})(\\\\d{4})","$1 $2","[4-9]",null],["(3\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3",null]]]',
+ 242: '["CG","00",null,null,null,null,"\\\\d{9}","[028]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[02]",null],["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","8",null]]]',
+ 423: '["LI","00","0","0|10(?:01|20|66)",null,null,"\\\\d{7,9}","6\\\\d{8}|[23789]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[23789]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6[56]",null],["(69)(7\\\\d{2})(\\\\d{4})","$1 $2 $3","697",null]]]',
+ 213: '["DZ","00","0",null,null,"$NP$FG","\\\\d{8,9}","(?:[1-4]|[5-9]\\\\d)\\\\d{7}",[["([1-4]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1-4]",null],["([5-8]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-8]",null],["(9\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9",null]]]',
+ 371: '["LV","00",null,null,null,null,"\\\\d{8}","[2689]\\\\d{7}",[["([2689]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 503: '["SV","00",null,null,null,null,"\\\\d{7,8}|\\\\d{11}","[267]\\\\d{7}|[89]\\\\d{6}(?:\\\\d{4})?",[["(\\\\d{4})(\\\\d{4})","$1 $2","[267]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[89]",null]]]',
+ 685: '["WS","0",null,null,null,null,"\\\\d{5,7}","[2-8]\\\\d{4,6}",[["(8\\\\d{2})(\\\\d{3,4})","$1 $2","8",null],["(7\\\\d)(\\\\d{5})","$1 $2","7",null],["(\\\\d{5})","$1","[2-6]",null]]]',
+ 880: '["BD","00","0",null,null,"$NP$FG","\\\\d{6,10}","[2-79]\\\\d{5,9}|1\\\\d{9}|8[0-7]\\\\d{4,8}",[["(2)(\\\\d{7,8})","$1-$2","2",null],["(\\\\d{2})(\\\\d{4,6})","$1-$2","[3-79]1",null],["(\\\\d{4})(\\\\d{3,6})","$1-$2","1|3(?:0|[2-58]2)|4(?:0|[25]2|3[23]|[4689][25])|5(?:[02-578]2|6[25])|6(?:[0347-9]2|[26][25])|7[02-9]2|8(?:[023][23]|[4-7]2)|9(?:[02][23]|[458]2|6[016])",null],["(\\\\d{3})(\\\\d{3,7})","$1-$2","[3-79][2-9]|8",null]]]',
+ 265: '["MW","00","0",null,null,"$NP$FG","\\\\d{7,9}","(?:1(?:\\\\d{2})?|[2789]\\\\d{2})\\\\d{6}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1",null],["(2\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","2",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[1789]",null]]]',
+ 65: '["SG","0[0-3]\\\\d",null,null,null,null,"\\\\d{8,11}","[36]\\\\d{7}|[17-9]\\\\d{7,10}",[["([3689]\\\\d{3})(\\\\d{4})","$1 $2","[369]|8[1-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[89]",null],["(7000)(\\\\d{4})(\\\\d{3})","$1 $2 $3","70",null],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]',
+ 504: '["HN","00",null,null,null,null,"\\\\d{8}","[237-9]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1-$2",null,null]]]',
+ 688: '["TV","00",null,null,null,null,"\\\\d{5,7}","[279]\\\\d{4,6}"]',
+ 84: '["VN","00","0",null,null,"$NP$FG","\\\\d{7,10}","[167]\\\\d{6,9}|[2-59]\\\\d{7,9}|8\\\\d{6,8}",[["([17]99)(\\\\d{4})","$1 $2","[17]99",null],["([48])(\\\\d{4})(\\\\d{4})","$1 $2 $3","4|8(?:[1-57]|6[0-79]|9[0-7])",null],["([235-7]\\\\d)(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[025-79]|3[0136-9]|5[2-9]|6[0-46-8]|7[02-79]",null],["(80)(\\\\d{5})","$1 $2","80",null],["(69\\\\d)(\\\\d{4,5})","$1 $2","69",null],["([235-7]\\\\d{2})(\\\\d{4})(\\\\d{3})","$1 $2 $3","2[0-489]|3[25]|5[01]|65|7[18]",null],["([89]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8(?:68|8|9[89])|9",null],["(1[2689]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:[26]|8[68]|99)",null],["(1[89]00)(\\\\d{4,6})","$1 $2","1[89]0","$FG"]]]',
+ 255: '["TZ","00[056]","0",null,null,"$NP$FG","\\\\d{7,9}","\\\\d{9}",[["([24]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[24]",null],["([67]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[67]",null],["([89]\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3","[89]",null]]]',
+ 222: '["MR","00",null,null,null,null,"\\\\d{8}","[2-48]\\\\d{7}",[["([2-48]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 230: '["MU","0(?:0|[2-7]0|33)",null,null,null,null,"\\\\d{7,8}","[2-9]\\\\d{6,7}",[["([2-46-9]\\\\d{2})(\\\\d{4})","$1 $2","[2-46-9]",null],["(5\\\\d{3})(\\\\d{4})","$1 $2","5",null]]]',
+ 592: '["GY","001",null,null,null,null,"\\\\d{7}","[2-46-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 41: '["CH","00","0",null,null,"$NP$FG","\\\\d{9}(?:\\\\d{3})?","[2-9]\\\\d{8}|860\\\\d{9}",[["([2-9]\\\\d)(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[2-7]|[89]1",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[047]|90",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4 $5","860",null]]]',
+ 39: [
+ '["IT","00",null,null,null,null,"\\\\d{6,11}","[01589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9})",[["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[26]|55",null],["(0[26])(\\\\d{4})(\\\\d{5})","$1 $2 $3","0[26]",null],["(0[26])(\\\\d{4,6})","$1 $2","0[26]",null],["(0\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","0[13-57-9][0159]",null],["(\\\\d{3})(\\\\d{3,6})","$1 $2","0[13-57-9][0159]|8(?:03|4[17]|9[245])",null],["(0\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","0[13-57-9][2-46-8]",null],["(0\\\\d{3})(\\\\d{2,6})","$1 $2","0[13-57-9][2-46-8]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[13]|8(?:00|4[08]|9[59])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","894",null],["(\\\\d{3})(\\\\d{4})(\\\\d{4})","$1 $2 $3","3",null]]]',
+ '["VA","00",null,null,null,null,"\\\\d{6,11}","(?:0(?:878\\\\d{5}|6698\\\\d{5})|[1589]\\\\d{5,10}|3(?:[12457-9]\\\\d{8}|[36]\\\\d{7,9}))"]',
+ ],
+ 993: '["TM","810","8",null,null,"($NP $FG)","\\\\d{8}","[1-6]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","12",null],["(\\\\d{2})(\\\\d{6})","$1 $2","6","$NP $FG"],["(\\\\d{3})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","13|[2-5]",null]]]',
+ 888: '["001",null,null,null,null,null,"\\\\d{11}","\\\\d{11}",[["(\\\\d{3})(\\\\d{3})(\\\\d{5})","$1 $2 $3",null,null]]]',
+ 353: '["IE","00","0",null,null,"($NP$FG)","\\\\d{5,10}","[124-9]\\\\d{6,9}",[["(1)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{5})","$1 $2","2[24-9]|47|58|6[237-9]|9[35-9]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","40[24]|50[45]",null],["(48)(\\\\d{4})(\\\\d{4})","$1 $2 $3","48",null],["(818)(\\\\d{3})(\\\\d{3})","$1 $2 $3","81",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[24-69]|7[14]",null],["([78]\\\\d)(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","76|8[35-9]","$NP$FG"],["(700)(\\\\d{3})(\\\\d{3})","$1 $2 $3","70","$NP$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:8[059]|5)","$FG"]]]',
+ 966: '["SA","00","0",null,null,"$NP$FG","\\\\d{7,10}","1\\\\d{7,8}|(?:[2-467]|92)\\\\d{7}|5\\\\d{8}|8\\\\d{9}",[["([1-467])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[1-467]",null],["(1\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[1-467]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["(92\\\\d{2})(\\\\d{5})","$1 $2","92","$FG"],["(800)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80","$FG"],["(811)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","81",null]]]',
+ 380: '["UA","00","0",null,null,"$NP$FG","\\\\d{5,9}","[3-9]\\\\d{8}",[["([3-9]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[38]9|4(?:[45][0-5]|87)|5(?:0|6[37]|7[37])|6[36-8]|7|9[1-9]",null],["([3-689]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[1-8]2|4[13678]2|5(?:[12457]2|6[24])|6(?:[49]2|[12][29]|5[24])|8[0-8]|90",null],["([3-6]\\\\d{3})(\\\\d{5})","$1 $2","3(?:5[013-9]|[1-46-8])|4(?:[137][013-9]|6|[45][6-9]|8[4-6])|5(?:[1245][013-9]|6[0135-9]|3|7[4-6])|6(?:[49][013-9]|5[0135-9]|[12][13-8])",null]]]',
+ 98: '["IR","00","0",null,null,"$NP$FG","\\\\d{4,10}","[1-8]\\\\d{9}|9(?:[0-4]\\\\d{8}|9\\\\d{2,8})",[["(21)(\\\\d{3,5})","$1 $2","21",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","[1-8]",null],["(\\\\d{3})(\\\\d{3})","$1 $2","9",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","9",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","9",null]]]',
+ 971: '["AE","00","0",null,null,"$NP$FG","\\\\d{5,12}","[2-79]\\\\d{7,8}|800\\\\d{2,9}",[["([2-4679])(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-4679][2-8]",null],["(5\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","5",null],["([479]00)(\\\\d)(\\\\d{5})","$1 $2 $3","[479]0","$FG"],["([68]00)(\\\\d{2,9})","$1 $2","60|8","$FG"]]]',
+ 30: '["GR","00",null,null,null,null,"\\\\d{10}","[26-9]\\\\d{9}",[["([27]\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","21|7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","2[2-9]1|[689]",null],["(2\\\\d{3})(\\\\d{6})","$1 $2","2[2-9][02-9]",null]]]',
+ 228: '["TG","00",null,null,null,null,"\\\\d{8}","[29]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[29]",null]]]',
+ 48: '["PL","00",null,null,null,null,"\\\\d{6,9}","[12]\\\\d{6,8}|[3-57-9]\\\\d{8}|6\\\\d{5,8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[14]|2[0-57-9]|3[2-4]|5[24-689]|6[1-3578]|7[14-7]|8[1-79]|9[145]",null],["(\\\\d{2})(\\\\d{1})(\\\\d{4})","$1 $2 $3","[12]2",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","26|39|5[0137]|6[0469]|7[02389]|8[08]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2,3})","$1 $2 $3","64",null],["(\\\\d{3})(\\\\d{3})","$1 $2","64",null]]]',
+ 886: '["TW","0(?:0[25679]|19)","0",null,null,"$NP$FG","\\\\d{7,10}","2\\\\d{6,8}|[3-689]\\\\d{7,8}|7\\\\d{7,9}",[["(20)(\\\\d)(\\\\d{4})","$1 $2 $3","202",null],["(20)(\\\\d{3})(\\\\d{4})","$1 $2 $3","20[013-9]",null],["([2-8])(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","2[23-8]|[3-6]|[78][1-9]",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","80|9",null],["(70)(\\\\d{4})(\\\\d{4})","$1 $2 $3","70",null]]]',
+ 212: [
+ '["MA","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}",[["([5-7]\\\\d{2})(\\\\d{6})","$1-$2","5(?:2[015-7]|3[0-4])|[67]",null],["([58]\\\\d{3})(\\\\d{5})","$1-$2","5(?:2[2-489]|3[5-9]|92)|892",null],["(5\\\\d{4})(\\\\d{4})","$1-$2","5(?:29|38)",null],["([5]\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5(?:4[067]|5[03])",null],["(8[09])(\\\\d{7})","$1-$2","8(?:0|9[013-9])",null]]]',
+ '["EH","00","0",null,null,"$NP$FG","\\\\d{9}","[5-9]\\\\d{8}"]',
+ ],
+ 372: '["EE","00",null,null,null,null,"\\\\d{4,10}","1\\\\d{3,4}|[3-9]\\\\d{6,7}|800\\\\d{6,7}",[["([3-79]\\\\d{2})(\\\\d{4})","$1 $2","[369]|4[3-8]|5(?:[0-2]|5[0-478]|6[45])|7[1-9]",null],["(70)(\\\\d{2})(\\\\d{4})","$1 $2 $3","70",null],["(8000)(\\\\d{3})(\\\\d{3})","$1 $2 $3","800",null],["([458]\\\\d{3})(\\\\d{3,4})","$1 $2","40|5|8(?:00|[1-5])",null]]]',
+ 598: '["UY","0(?:1[3-9]\\\\d|0)","0",null,null,null,"\\\\d{7,8}","[2489]\\\\d{6,7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[24]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","9[1-9]","$NP$FG"],["(\\\\d{3})(\\\\d{4})","$1 $2","[89]0","$NP$FG"]]]',
+ 502: '["GT","00",null,null,null,null,"\\\\d{8}(?:\\\\d{3})?","[2-7]\\\\d{7}|1[89]\\\\d{9}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[2-7]",null],["(\\\\d{4})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null]]]',
+ 82: '["KR","00(?:[124-68]|3\\\\d{2}|7(?:[0-8]\\\\d|9[0-79]))","0","0(8[1-46-8]|85\\\\d{2})?",null,"$NP$FG","\\\\d{3,14}","007\\\\d{9,11}|[1-7]\\\\d{3,9}|8\\\\d{8}",[["(\\\\d{5})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","00798","$FG","NA"],["(\\\\d{5})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3 $4","00798","$FG","NA"],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1-$2-$3","1(?:0|1[19]|[69]9|5[458])|[57]0",null],["(\\\\d{2})(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","1(?:[01]|5[1-4]|6[2-8]|[7-9])|[68]0|[3-6][1-9][1-9]",null],["(\\\\d{3})(\\\\d)(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{2})(\\\\d{4})","$1-$2-$3","131",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1-$2-$3","13[2-9]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3-$4","30",null],["(\\\\d)(\\\\d{3,4})(\\\\d{4})","$1-$2-$3","2[1-9]",null],["(\\\\d)(\\\\d{3,4})","$1-$2","21[0-46-9]",null],["(\\\\d{2})(\\\\d{3,4})","$1-$2","[3-6][1-9]1",null],["(\\\\d{4})(\\\\d{4})","$1-$2","1(?:5[246-9]|6[04678]|8[03579])","$FG"]]]',
+ 253: '["DJ","00",null,null,null,null,"\\\\d{8}","[27]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 91: '["IN","00","0",null,null,"$NP$FG","\\\\d{6,13}","008\\\\d{9}|1\\\\d{7,12}|[2-9]\\\\d{9,10}",[["(\\\\d{5})(\\\\d{5})","$1 $2","600|7(?:[02-8]|19|9[037-9])|8(?:0[015-9]|[1-9]|20)|9",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","11|2[02]|33|4[04]|79[1-9]|80[2-46]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:2[0-249]|3[0-25]|4[145]|[59][14]|7[1257]|[68][1-9])|2(?:1[257]|3[013]|4[01]|5[0137]|6[0158]|78|8[1568]|9[14])|3(?:26|4[1-3]|5[34]|6[01489]|7[02-46]|8[159])|4(?:1[36]|2[1-47]|3[15]|5[12]|6[0-26-9]|7[0-24-9]|8[013-57]|9[014-7])|5(?:1[025]|[36][25]|22|4[28]|5[12]|[78]1|9[15])|6(?:12|[2-4]1|5[17]|6[13]|7[14]|80)|7(?:12|2[14]|3[134]|4[47]|5[15]|[67]1|88)|8(?:16|2[014]|3[126]|6[136]|7[078]|8[34]|91)",null],["(\\\\d{4})(\\\\d{3})(\\\\d{3})","$1 $2 $3","1(?:[23579]|[468][1-9])|[2-8]",null],["(\\\\d{2})(\\\\d{3})(\\\\d{4})(\\\\d{3})","$1 $2 $3 $4","008",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","140","$FG"],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2 $3","160","$FG"],["(\\\\d{4})(\\\\d{4,5})","$1 $2","180","$FG"],["(\\\\d{4})(\\\\d{2,4})(\\\\d{4})","$1 $2 $3","180","$FG"],["(\\\\d{4})(\\\\d{3,4})(\\\\d{4})","$1 $2 $3","186","$FG"],["(\\\\d{4})(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3 $4","18[06]","$FG"]]]',
+ 389: '["MK","00","0",null,null,"$NP$FG","\\\\d{6,8}","[2-578]\\\\d{7}",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([347]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[347]",null],["([58]\\\\d{2})(\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[58]",null]]]',
+ 1: [
+ '["US","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}",[["(\\\\d{3})(\\\\d{4})","$1-$2",null,null,"NA"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","($1) $2-$3",null,null,"$1-$2-$3"]]]',
+ '["AI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["AS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["BB","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["BM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]',
+ '["BS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["CA","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2-9]\\\\d{9}|3\\\\d{6}"]',
+ '["DM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[57-9]\\\\d{9}"]',
+ '["DO","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["GD","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[4589]\\\\d{9}"]',
+ '["GU","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["JM","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["KN","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["KY","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]',
+ '["LC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["MP","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["MS","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["PR","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["SX","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["TC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5689]\\\\d{9}"]',
+ '["TT","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[589]\\\\d{9}"]',
+ '["AG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["VC","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[5789]\\\\d{9}"]',
+ '["VG","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[2589]\\\\d{9}"]',
+ '["VI","011","1",null,null,null,"\\\\d{7}(?:\\\\d{3})?","[3589]\\\\d{9}"]',
+ ],
+ 60: '["MY","00","0",null,null,null,"\\\\d{6,10}","[13-9]\\\\d{7,9}",[["([4-79])(\\\\d{3})(\\\\d{4})","$1-$2 $3","[4-79]","$NP$FG"],["(3)(\\\\d{4})(\\\\d{4})","$1-$2 $3","3","$NP$FG"],["([18]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1-$2 $3","1[02-46-9][1-9]|8","$NP$FG"],["(1)([36-8]00)(\\\\d{2})(\\\\d{4})","$1-$2-$3-$4","1[36-8]0",null],["(11)(\\\\d{4})(\\\\d{4})","$1-$2 $3","11","$NP$FG"],["(15[49])(\\\\d{3})(\\\\d{4})","$1-$2 $3","15","$NP$FG"]]]',
+ 355: '["AL","00","0",null,null,"$NP$FG","\\\\d{5,9}","[2-57]\\\\d{7}|6\\\\d{8}|8\\\\d{5,7}|9\\\\d{5}",[["(4)(\\\\d{3})(\\\\d{4})","$1 $2 $3","4[0-6]",null],["(6\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","6",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2358][2-5]|4[7-9]",null],["(\\\\d{3})(\\\\d{3,5})","$1 $2","[235][16-9]|8[016-9]|[79]",null]]]',
+ 254: '["KE","000","0","005|0",null,"$NP$FG","\\\\d{7,10}","20\\\\d{6,7}|[4-9]\\\\d{6,9}",[["(\\\\d{2})(\\\\d{5,7})","$1 $2","[24-6]",null],["(\\\\d{3})(\\\\d{6})","$1 $2","7",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[89]",null]]]',
+ 223: '["ML","00",null,null,null,null,"\\\\d{8}","[246-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[246-9]",null],["(\\\\d{4})","$1","67|74",null,"NA"]]]',
+ 686: '["KI","00",null,"0",null,null,"\\\\d{5,8}","[2458]\\\\d{4}|3\\\\d{4,7}|7\\\\d{7}"]',
+ 994: '["AZ","00","0",null,null,"($NP$FG)","\\\\d{7,9}","[1-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","(?:1[28]|2(?:[45]2|[0-36])|365)",null],["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[4-8]","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","9","$NP$FG"]]]',
+ 979: '["001",null,null,null,null,null,"\\\\d{9}","\\\\d{9}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 66: '["TH","00","0",null,null,"$NP$FG","\\\\d{4}|\\\\d{8,10}","[2-9]\\\\d{7,8}|1\\\\d{3}(?:\\\\d{5,6})?",[["(2)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["([13-9]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","14|[3-9]",null],["(1[89]00)(\\\\d{3})(\\\\d{3})","$1 $2 $3","1","$FG"]]]',
+ 233: '["GH","00","0",null,null,"$NP$FG","\\\\d{7,9}","[235]\\\\d{8}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[235]",null],["(\\\\d{3})(\\\\d{5})","$1 $2","8",null]]]',
+ 593: '["EC","00","0",null,null,"($NP$FG)","\\\\d{7,11}","1\\\\d{9,10}|[2-8]\\\\d{7}|9\\\\d{8}",[["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2-$3","[247]|[356][2-8]",null,"$1-$2-$3"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","9","$NP$FG"],["(1800)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","1","$FG"]]]',
+ 509: '["HT","00",null,null,null,null,"\\\\d{8}","[2-489]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 54: '["AR","00","0","0?(?:(11|2(?:2(?:02?|[13]|2[13-79]|4[1-6]|5[2457]|6[124-8]|7[1-4]|8[13-6]|9[1267])|3(?:02?|1[467]|2[03-6]|3[13-8]|[49][2-6]|5[2-8]|[67])|4(?:7[3-578]|9)|6(?:[0136]|2[24-6]|4[6-8]?|5[15-8])|80|9(?:0[1-3]|[19]|2\\\\d|3[1-6]|4[02568]?|5[2-4]|6[2-46]|72?|8[23]?))|3(?:3(?:2[79]|6|8[2578])|4(?:0[0-24-9]|[12]|3[5-8]?|4[24-7]|5[4-68]?|6[02-9]|7[126]|8[2379]?|9[1-36-8])|5(?:1|2[1245]|3[237]?|4[1-46-9]|6[2-4]|7[1-6]|8[2-5]?)|6[24]|7(?:[069]|1[1568]|2[15]|3[145]|4[13]|5[14-8]|7[2-57]|8[126])|8(?:[01]|2[15-7]|3[2578]?|4[13-6]|5[4-8]?|6[1-357-9]|7[36-8]?|8[5-8]?|9[124])))?15)?","9$1","$NP$FG","\\\\d{6,11}","11\\\\d{8}|[2368]\\\\d{9}|9\\\\d{10}",[["([68]\\\\d{2})(\\\\d{3})(\\\\d{4})","$1-$2-$3","[68]",null],["(\\\\d{2})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{3})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(\\\\d{4})(\\\\d{4})","$1-$2","[2-9]","$FG","NA"],["(9)(11)(\\\\d{4})(\\\\d{4})","$2 15-$3-$4","911",null,"$1 $2 $3-$4"],["(9)(\\\\d{3})(\\\\d{3})(\\\\d{4})","$2 15-$3-$4","9(?:2[234689]|3[3-8])",null,"$1 $2 $3-$4"],["(9)(\\\\d{4})(\\\\d{2})(\\\\d{4})","$2 15-$3-$4","9[23]",null,"$1 $2 $3-$4"],["(11)(\\\\d{4})(\\\\d{4})","$1 $2-$3","1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2-$3","2(?:2[013]|3[067]|49|6[01346]|80|9[147-9])|3(?:36|4[1-358]|5[138]|6[24]|7[069]|8[013578])",null],["(\\\\d{4})(\\\\d{2})(\\\\d{4})","$1 $2-$3","[23]",null],["(\\\\d{3})","$1","1[012]|911","$FG","NA"]]]',
+ 57: '["CO","00(?:4(?:[14]4|56)|[579])","0","0([3579]|4(?:44|56))?",null,null,"\\\\d{7,11}","(?:[13]\\\\d{0,3}|[24-8])\\\\d{7}",[["(\\\\d)(\\\\d{7})","$1 $2","1(?:8[2-9]|9[0-3]|[2-7])|[24-8]","($FG)"],["(\\\\d{3})(\\\\d{7})","$1 $2","3",null],["(1)(\\\\d{3})(\\\\d{7})","$1-$2-$3","1(?:80|9[04])","$NP$FG","$1 $2 $3"]]]',
+ 597: '["SR","00",null,null,null,null,"\\\\d{6,7}","[2-8]\\\\d{5,6}",[["(\\\\d{3})(\\\\d{3})","$1-$2","[2-4]|5[2-58]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1-$2-$3","56",null],["(\\\\d{3})(\\\\d{4})","$1-$2","[6-8]",null]]]',
+ 676: '["TO","00",null,null,null,null,"\\\\d{5,7}","[02-8]\\\\d{4,6}",[["(\\\\d{2})(\\\\d{3})","$1-$2","[1-6]|7[0-4]|8[05]",null],["(\\\\d{3})(\\\\d{4})","$1 $2","7[5-9]|8[47-9]",null],["(\\\\d{4})(\\\\d{3})","$1 $2","0",null]]]',
+ 505: '["NI","00",null,null,null,null,"\\\\d{8}","[12578]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2",null,null]]]',
+ 850: '["KP","00|99","0",null,null,"$NP$FG","\\\\d{6,8}|\\\\d{10}","1\\\\d{9}|[28]\\\\d{7}",[["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","2",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 7: [
+ '["RU","810","8",null,null,"$NP ($FG)","\\\\d{10}","[3489]\\\\d{9}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1-$2-$3","[1-79]","$FG","NA"],["([3489]\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2-$3-$4","[34689]",null],["(7\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","7",null]]]',
+ '["KZ","810","8",null,null,null,"\\\\d{10}","(?:33\\\\d|7\\\\d{2}|80[09])\\\\d{7}"]',
+ ],
+ 268: '["SZ","00",null,null,null,null,"\\\\d{8}","[027]\\\\d{7}",[["(\\\\d{4})(\\\\d{4})","$1 $2","[027]",null]]]',
+ 501: '["BZ","00",null,null,null,null,"\\\\d{7}(?:\\\\d{4})?","[2-8]\\\\d{6}|0\\\\d{10}",[["(\\\\d{3})(\\\\d{4})","$1-$2","[2-8]",null],["(0)(800)(\\\\d{4})(\\\\d{3})","$1-$2-$3-$4","0",null]]]',
+ 252: '["SO","00","0",null,null,null,"\\\\d{6,9}","[1-9]\\\\d{5,8}",[["(\\\\d{6})","$1","[134]",null],["(\\\\d)(\\\\d{6})","$1 $2","2[0-79]|[13-5]",null],["(\\\\d)(\\\\d{7})","$1 $2","24|[67]",null],["(\\\\d{2})(\\\\d{4})","$1 $2","8[125]",null],["(\\\\d{2})(\\\\d{5,7})","$1 $2","15|28|6[1-35-9]|799|9[2-9]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","3[59]|4[89]|6[24-6]|79|8[08]|90",null]]]',
+ 229: '["BJ","00",null,null,null,null,"\\\\d{4,8}","[2689]\\\\d{7}|7\\\\d{3}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 680: '["PW","01[12]",null,null,null,null,"\\\\d{7}","[2-8]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 263: '["ZW","00","0",null,null,"$NP$FG","\\\\d{3,10}","2(?:[012457-9]\\\\d{3,8}|6(?:[14]\\\\d{7}|\\\\d{4}))|[13-79]\\\\d{4,9}|8[06]\\\\d{8}",[["([49])(\\\\d{3})(\\\\d{2,4})","$1 $2 $3","4|9[2-9]",null],["(7\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","7",null],["(86\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","86[24]",null],["([2356]\\\\d{2})(\\\\d{3,5})","$1 $2","2(?:0[45]|2[278]|[49]8|[78])|3(?:08|17|3[78]|7[1569]|8[37]|98)|5[15][78]|6(?:[29]8|[38]7|6[78]|75|[89]8)",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","2(?:1[39]|2[0157]|6[14]|7[35]|84)|329",null],["([1-356]\\\\d)(\\\\d{3,5})","$1 $2","1[3-9]|2[0569]|3[0-69]|5[05689]|6[0-46-9]",null],["([235]\\\\d)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[23]9|54",null],["([25]\\\\d{3})(\\\\d{3,5})","$1 $2","(?:25|54)8",null],["(8\\\\d{3})(\\\\d{6})","$1 $2","86",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null]]]',
+ 90: '["TR","00","0",null,null,null,"\\\\d{7,10}","[2-589]\\\\d{9}|444\\\\d{4}",[["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[23]|4(?:[0-35-9]|4[0-35-9])","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5[02-69]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","51|[89]","$NP$FG"],["(444)(\\\\d{1})(\\\\d{3})","$1 $2 $3","444",null]]]',
+ 352: '["LU","00",null,"(15(?:0[06]|1[12]|35|4[04]|55|6[26]|77|88|99)\\\\d)",null,null,"\\\\d{4,11}","[24-9]\\\\d{3,10}|3(?:[0-46-9]\\\\d{2,9}|5[013-9]\\\\d{1,8})",[["(\\\\d{2})(\\\\d{3})","$1 $2","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","[2-5]|7[1-9]|[89](?:[1-9]|0[2-9])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","20",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,2})","$1 $2 $3 $4 $5","2(?:[0367]|4[3-8])",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{1,4})","$1 $2 $3 $4","2(?:[12589]|4[12])|[3-5]|7[1-9]|8(?:[1-9]|0[2-9])|9(?:[1-9]|0[2-46-9])",null],["(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3","70|80[01]|90[015]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","6",null]]]',
+ 47: [
+ '["NO","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[2-9]\\\\d{7}",[["([489]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","[489]",null],["([235-7]\\\\d)(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[235-7]",null]]]',
+ '["SJ","00",null,null,null,null,"\\\\d{5}(?:\\\\d{3})?","0\\\\d{4}|[45789]\\\\d{7}"]',
+ ],
+ 243: '["CD","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-6]\\\\d{6}|[18]\\\\d{6,8}|9\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","12",null],["([89]\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","8[0-2459]|9",null],["(\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","88",null],["(\\\\d{2})(\\\\d{5})","$1 $2","[1-6]",null]]]',
+ 220: '["GM","00",null,null,null,null,"\\\\d{7}","[2-9]\\\\d{6}",[["(\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 687: '["NC","00",null,null,null,null,"\\\\d{6}","[2-57-9]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1.$2.$3","[2-46-9]|5[0-4]",null]]]',
+ 995: '["GE","00","0",null,null,null,"\\\\d{6,9}","[34578]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[348]","$NP$FG"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","7","$NP$FG"],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","5","$FG"]]]',
+ 961: '["LB","00","0",null,null,null,"\\\\d{7,8}","[13-9]\\\\d{6,7}",[["(\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[13-6]|7(?:[2-57]|62|8[0-7]|9[04-9])|8[02-9]|9","$NP$FG"],["([7-9]\\\\d)(\\\\d{3})(\\\\d{3})","$1 $2 $3","[89][01]|7(?:[01]|6[013-9]|8[89]|9[1-3])",null]]]',
+ 40: '["RO","00","0",null,null,"$NP$FG","\\\\d{6,9}","[23]\\\\d{5,8}|[7-9]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[23]1",null],["(\\\\d{2})(\\\\d{4})","$1 $2","[23]1",null],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[23][3-7]|[7-9]",null],["(2\\\\d{2})(\\\\d{3})","$1 $2","2[3-6]",null]]]',
+ 232: '["SL","00","0",null,null,"($NP$FG)","\\\\d{6,8}","[2-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{6})","$1 $2",null,null]]]',
+ 594: '["GF","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 976: '["MN","001","0",null,null,"$NP$FG","\\\\d{6,10}","[12]\\\\d{7,9}|[57-9]\\\\d{7}",[["([12]\\\\d)(\\\\d{2})(\\\\d{4})","$1 $2 $3","[12]1",null],["([12]2\\\\d)(\\\\d{5,6})","$1 $2","[12]2[1-3]",null],["([12]\\\\d{3})(\\\\d{5})","$1 $2","[12](?:27|[3-5])",null],["(\\\\d{4})(\\\\d{4})","$1 $2","[57-9]","$FG"],["([12]\\\\d{4})(\\\\d{4,5})","$1 $2","[12](?:27|[3-5])",null]]]',
+ 20: '["EG","00","0",null,null,"$NP$FG","\\\\d{5,10}","1\\\\d{4,9}|[2456]\\\\d{8}|3\\\\d{7}|[89]\\\\d{8,9}",[["(\\\\d)(\\\\d{7,8})","$1 $2","[23]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1[012]|[89]00",null],["(\\\\d{2})(\\\\d{6,7})","$1 $2","1[35]|[4-6]|[89][2-9]",null]]]',
+ 689: '["PF","00",null,null,null,null,"\\\\d{6}(?:\\\\d{2})?","4\\\\d{5,7}|8\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","4[09]|8[79]",null],["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3","44",null]]]',
+ 56: '["CL","(?:0|1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))0","0","0|(1(?:1[0-69]|2[0-57]|5[13-58]|69|7[0167]|8[018]))",null,"$NP$FG","\\\\d{7,11}","(?:[2-9]|600|123)\\\\d{7,8}",[["(\\\\d)(\\\\d{4})(\\\\d{4})","$1 $2 $3","2[23]","($FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[357]|4[1-35]|6[13-57]","($FG)"],["(9)(\\\\d{4})(\\\\d{4})","$1 $2 $3","9",null],["(44)(\\\\d{3})(\\\\d{4})","$1 $2 $3","44",null],["([68]00)(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","60|8","$FG"],["(600)(\\\\d{3})(\\\\d{2})(\\\\d{3})","$1 $2 $3 $4","60","$FG"],["(1230)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1","$FG"],["(\\\\d{5})(\\\\d{4})","$1 $2","219","($FG)"],["(\\\\d{4,5})","$1","[1-9]","$FG","NA"]]]',
+ 596: '["MQ","00","0",null,null,"$NP$FG","\\\\d{9}","[56]\\\\d{8}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 508: '["PM","00","0",null,null,"$NP$FG","\\\\d{6}","[45]\\\\d{5}",[["([45]\\\\d)(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 269: '["KM","00",null,null,null,null,"\\\\d{7}","[3478]\\\\d{6}",[["(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 358: [
+ '["FI","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{4,11}|[2-9]\\\\d{4,10}",[["(\\\\d{3})(\\\\d{3,7})","$1 $2","(?:[1-3]00|[6-8]0)",null],["(116\\\\d{3})","$1","116","$FG"],["(\\\\d{2})(\\\\d{4,10})","$1 $2","[14]|2[09]|50|7[135]",null],["(\\\\d)(\\\\d{4,11})","$1 $2","[25689][1-8]|3",null]]]',
+ '["AX","00|99(?:[02469]|5(?:11|33|5[59]|88|9[09]))","0",null,null,"$NP$FG","\\\\d{5,12}","1\\\\d{5,11}|[35]\\\\d{5,9}|[27]\\\\d{4,9}|4\\\\d{5,10}|6\\\\d{7,9}|8\\\\d{6,9}"]',
+ ],
+ 251: '["ET","00","0",null,null,"$NP$FG","\\\\d{7,9}","[1-59]\\\\d{8}",[["([1-59]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3",null,null]]]',
+ 681: '["WF","00",null,null,null,null,"\\\\d{6}","[4-8]\\\\d{5}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3",null,null]]]',
+ 853: '["MO","00",null,null,null,null,"\\\\d{8}","[268]\\\\d{7}",[["([268]\\\\d{3})(\\\\d{4})","$1 $2",null,null]]]',
+ 44: [
+ '["GB","00","0",null,null,"$NP$FG","\\\\d{4,10}","\\\\d{7,10}",[["(7\\\\d{3})(\\\\d{6})","$1 $2","7(?:[1-5789]|62)",null],["(\\\\d{2})(\\\\d{4})(\\\\d{4})","$1 $2 $3","2|5[56]|7[06]",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","1(?:1|\\\\d1)|3|9[018]",null],["(\\\\d{5})(\\\\d{4,5})","$1 $2","1(?:38|5[23]|69|76|94)",null],["(1\\\\d{3})(\\\\d{5,6})","$1 $2","1",null],["(800)(\\\\d{4})","$1 $2","800",null],["(845)(46)(4\\\\d)","$1 $2 $3","845",null],["(8\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8(?:4[2-5]|7[0-3])",null],["(80\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","80",null],["([58]00)(\\\\d{6})","$1 $2","[58]00",null]]]',
+ '["GG","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ '["IM","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ '["JE","00","0",null,null,"$NP$FG","\\\\d{6,10}","[135789]\\\\d{6,9}"]',
+ ],
+ 244: '["AO","00",null,null,null,null,"\\\\d{9}","[29]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,null]]]',
+ 211: '["SS","00","0",null,null,null,"\\\\d{9}","[19]\\\\d{8}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3",null,"$NP$FG"]]]',
+ 373: '["MD","00","0",null,null,"$NP$FG","\\\\d{8}","[235-9]\\\\d{7}",[["(\\\\d{2})(\\\\d{3})(\\\\d{3})","$1 $2 $3","22|3",null],["([25-7]\\\\d{2})(\\\\d{2})(\\\\d{3})","$1 $2 $3","2[13-9]|[5-7]",null],["([89]\\\\d{2})(\\\\d{5})","$1 $2","[89]",null]]]',
+ 996: '["KG","00","0",null,null,"$NP$FG","\\\\d{5,10}","[235-8]\\\\d{8,9}",[["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[25-7]|31[25]",null],["(\\\\d{4})(\\\\d{5})","$1 $2","3(?:1[36]|[2-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d)(\\\\d{3})","$1 $2 $3 $4","8",null]]]',
+ 93: '["AF","00","0",null,null,"$NP$FG","\\\\d{7,9}","[2-7]\\\\d{8}",[["([2-7]\\\\d)(\\\\d{3})(\\\\d{4})","$1 $2 $3","[2-7]",null]]]',
+ 260: '["ZM","00","0",null,null,"$NP$FG","\\\\d{9}","[289]\\\\d{8}",[["([29]\\\\d)(\\\\d{7})","$1 $2","[29]",null],["(800)(\\\\d{3})(\\\\d{3})","$1 $2 $3","8",null]]]',
+ 378: '["SM","00",null,"(?:0549)?([89]\\\\d{5})","0549$1",null,"\\\\d{6,10}","[05-7]\\\\d{7,9}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[5-7]",null],["(0549)(\\\\d{6})","$1 $2","0",null,"($1) $2"],["(\\\\d{6})","0549 $1","[89]",null,"(0549) $1"]]]',
+ 235: '["TD","00|16",null,null,null,null,"\\\\d{8}","[2679]\\\\d{7}",[["(\\\\d{2})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4",null,null]]]',
+ 960: '["MV","0(?:0|19)",null,null,null,null,"\\\\d{7,10}","[346-8]\\\\d{6,9}|9(?:00\\\\d{7}|\\\\d{6})",[["(\\\\d{3})(\\\\d{4})","$1-$2","[3467]|9(?:[1-9]|0[1-9])",null],["(\\\\d{3})(\\\\d{3})(\\\\d{4})","$1 $2 $3","[89]00",null]]]',
+ 221: '["SN","00",null,null,null,null,"\\\\d{9}","[3789]\\\\d{8}",[["(\\\\d{2})(\\\\d{3})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","[379]",null],["(\\\\d{3})(\\\\d{2})(\\\\d{2})(\\\\d{2})","$1 $2 $3 $4","8",null]]]',
+ 595: '["PY","00","0",null,null,null,"\\\\d{5,9}","5[0-5]\\\\d{4,7}|[2-46-9]\\\\d{5,8}",[["(\\\\d{2})(\\\\d{5})","$1 $2","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","(?:[26]1|3[289]|4[124678]|7[123]|8[1236])","($NP$FG)"],["(\\\\d{3})(\\\\d{3,6})","$1 $2","[2-9]0","$NP$FG"],["(\\\\d{3})(\\\\d{6})","$1 $2","9[1-9]","$NP$FG"],["(\\\\d{2})(\\\\d{3})(\\\\d{4})","$1 $2 $3","8700",null],["(\\\\d{3})(\\\\d{4,5})","$1 $2","[2-8][1-9]","($NP$FG)"],["(\\\\d{3})(\\\\d{3})(\\\\d{3})","$1 $2 $3","[2-8][1-9]","$NP$FG"]]]',
+ 977: '["NP","00","0",null,null,"$NP$FG","\\\\d{6,10}","[1-8]\\\\d{7}|9(?:[1-69]\\\\d{6,8}|7[2-6]\\\\d{5,7}|8\\\\d{8})",[["(1)(\\\\d{7})","$1-$2","1[2-6]",null],["(\\\\d{2})(\\\\d{6})","$1-$2","1[01]|[2-8]|9(?:[1-69]|7[15-9])",null],["(9\\\\d{2})(\\\\d{7})","$1-$2","9(?:6[013]|7[245]|8)","$FG"]]]',
+ 36: '["HU","00","06",null,null,"($FG)","\\\\d{6,9}","[1-9]\\\\d{7,8}",[["(1)(\\\\d{3})(\\\\d{4})","$1 $2 $3","1",null],["(\\\\d{2})(\\\\d{3})(\\\\d{3,4})","$1 $2 $3","[2-9]",null]]]',
+};
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs
new file mode 100644
index 0000000000..604eefe314
--- /dev/null
+++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs
@@ -0,0 +1,67 @@
+/* This Source Code Form is subject to the terms of the Apache License, Version
+ * 2.0. If a copy of the Apache License was not distributed with this file, You
+ * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */
+
+// This library came from https://github.com/andreasgal/PhoneNumber.js but will
+// be further maintained by our own in Form Autofill codebase.
+
+export var PhoneNumberNormalizer = (function () {
+ const UNICODE_DIGITS = /[\uFF10-\uFF19\u0660-\u0669\u06F0-\u06F9]/g;
+ const VALID_ALPHA_PATTERN = /[a-zA-Z]/g;
+ const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g;
+ const NON_DIALABLE_CHARS = /[^,#+\*\d]/g;
+
+ // Map letters to numbers according to the ITU E.161 standard
+ let E161 = {
+ a: 2,
+ b: 2,
+ c: 2,
+ d: 3,
+ e: 3,
+ f: 3,
+ g: 4,
+ h: 4,
+ i: 4,
+ j: 5,
+ k: 5,
+ l: 5,
+ m: 6,
+ n: 6,
+ o: 6,
+ p: 7,
+ q: 7,
+ r: 7,
+ s: 7,
+ t: 8,
+ u: 8,
+ v: 8,
+ w: 9,
+ x: 9,
+ y: 9,
+ z: 9,
+ };
+
+ // Normalize a number by converting unicode numbers and symbols to their
+ // ASCII equivalents and removing all non-dialable characters.
+ function NormalizeNumber(number, numbersOnly) {
+ if (typeof number !== "string") {
+ return "";
+ }
+
+ number = number.replace(UNICODE_DIGITS, function (ch) {
+ return String.fromCharCode(48 + (ch.charCodeAt(0) & 0xf));
+ });
+ if (!numbersOnly) {
+ number = number.replace(VALID_ALPHA_PATTERN, function (ch) {
+ return String(E161[ch.toLowerCase()] || 0);
+ });
+ }
+ number = number.replace(LEADING_PLUS_CHARS_PATTERN, "+");
+ number = number.replace(NON_DIALABLE_CHARS, "");
+ return number;
+ }
+
+ return {
+ Normalize: NormalizeNumber,
+ };
+})();
diff --git a/toolkit/components/formautofill/shared/AddressComponent.sys.mjs b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
new file mode 100644
index 0000000000..95779837b8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs
@@ -0,0 +1,1090 @@
+/* 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 { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs",
+ PhoneNumberNormalizer:
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs",
+});
+
+/**
+ * Class representing a collection of tokens extracted from a string.
+ */
+class Tokens {
+ #tokens = null;
+
+ // By default we split passed string with whitespace.
+ constructor(value, sep = /\s+/) {
+ this.#tokens = value.split(sep);
+ }
+
+ get tokens() {
+ return this.#tokens;
+ }
+
+ /**
+ * Checks if all the tokens in the current object can be found in another
+ * token object.
+ *
+ * @param {Tokens} other The other Tokens instance to compare with.
+ * @param {Function} compare An optional custom comparison function.
+ * @returns {boolean} True if the current Token object is a subset of the
+ * other Token object, false otherwise.
+ */
+ isSubset(other, compare = (a, b) => a == b) {
+ return this.tokens.every(tokenSelf => {
+ for (const tokenOther of other.tokens) {
+ if (compare(tokenSelf, tokenOther)) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Checks if all the tokens in the current object can be found in another
+ * Token object's tokens (in order).
+ * For example, ["John", "Doe"] is a subset of ["John", "Michael", "Doe"]
+ * in order but not a subset of ["Doe", "Michael", "John"] in order.
+ *
+ * @param {Tokens} other The other Tokens instance to compare with.
+ * @param {Function} compare An optional custom comparison function.
+ * @returns {boolean} True if the current Token object is a subset of the
+ * other Token object, false otherwise.
+ */
+ isSubsetInOrder(other, compare = (a, b) => a == b) {
+ if (this.tokens.length > other.tokens.length) {
+ return false;
+ }
+
+ let idx = 0;
+ return this.tokens.every(tokenSelf => {
+ for (; idx < other.tokens.length; idx++) {
+ if (compare(tokenSelf, other.tokens[idx])) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+}
+
+/**
+ * The AddressField class is a base class representing a single address field.
+ */
+class AddressField {
+ #userValue = null;
+
+ #region = null;
+
+ /**
+ * Create a representation of a single address field.
+ *
+ * @param {string} value
+ * The unnormalized value of an address field.
+ *
+ * @param {string} region
+ * The region of a single address field. Used to determine what collator should be
+ * for string comparisons of the address's field value.
+ */
+ constructor(value, region) {
+ this.#userValue = value?.trim();
+ this.#region = region;
+ }
+
+ /**
+ * Get the unnormalized value of the address field.
+ *
+ * @returns {string} The unnormalized field value.
+ */
+ get userValue() {
+ return this.#userValue;
+ }
+
+ /**
+ * Get the collator used for string comparisons.
+ *
+ * @returns {Intl.Collator} The collator.
+ */
+ get collator() {
+ return lazy.FormAutofillUtils.getSearchCollators(this.#region, {
+ ignorePunctuation: false,
+ });
+ }
+
+ get region() {
+ return this.#region;
+ }
+
+ /**
+ * Compares two strings using the collator.
+ *
+ * @param {string} a The first string to compare.
+ * @param {string} b The second string to compare.
+ * @returns {number} A negative, zero, or positive value, depending on the comparison result.
+ */
+ localeCompare(a, b) {
+ return lazy.FormAutofillUtils.strCompare(a, b, this.collator);
+ }
+
+ /**
+ * Checks if the field value is empty.
+ *
+ * @returns {boolean} True if the field value is empty, false otherwise.
+ */
+ isEmpty() {
+ return !this.#userValue;
+ }
+
+ /**
+ * Normalizes the unnormalized field value using the provided options.
+ *
+ * @param {object} options - Options for normalization.
+ * @returns {string} The normalized field value.
+ */
+ normalizeUserValue(options) {
+ return lazy.AddressParser.normalizeString(this.#userValue, options);
+ }
+
+ /**
+ * Returns a string representation of the address field.
+ * Ex. "Country: US", "PostalCode: 55123", etc.
+ */
+ toString() {
+ return `${this.constructor.name}: ${this.#userValue}\n`;
+ }
+
+ /**
+ * Checks if the field value is valid.
+ *
+ * @returns {boolean} True if the field value is valid, false otherwise.
+ */
+ isValid() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Compares the current field value with another field value for equality.
+ */
+ equals() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Checks if the current field value contains another field value.
+ */
+ contains() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+/**
+ * A street address.
+ * See autocomplete="street-address".
+ */
+class StreetAddress extends AddressField {
+ #structuredStreetAddress = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ this.#structuredStreetAddress = lazy.AddressParser.parseStreetAddress(
+ lazy.AddressParser.replaceControlCharacters(this.userValue, " ")
+ );
+ }
+
+ get structuredStreetAddress() {
+ return this.#structuredStreetAddress;
+ }
+ get street_number() {
+ return this.#structuredStreetAddress?.street_number;
+ }
+ get street_name() {
+ return this.#structuredStreetAddress?.street_name;
+ }
+ get floor_number() {
+ return this.#structuredStreetAddress?.floor_number;
+ }
+ get apartment_number() {
+ return this.#structuredStreetAddress?.apartment_number;
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ return (
+ this.street_number?.toLowerCase() == other.street_number?.toLowerCase() &&
+ this.street_name?.toLowerCase() == other.street_name?.toLowerCase() &&
+ this.apartment_number?.toLowerCase() ==
+ other.apartment_number?.toLowerCase() &&
+ this.floor_number?.toLowerCase() == other.floor_number?.toLowerCase()
+ );
+ }
+
+ contains(other) {
+ let selfStreetName = this.userValue;
+ let otherStreetName = other.userValue;
+
+ // Compare street number, apartment number and floor number if
+ // both addresses are parsed successfully.
+ if (this.structuredStreetAddress && other.structuredStreetAddress) {
+ if (
+ (other.street_number && this.street_number != other.street_number) ||
+ (other.apartment_number &&
+ this.apartment_number != other.apartment_number) ||
+ (other.floor_number && this.floor_number != other.floor_number)
+ ) {
+ return false;
+ }
+
+ // Use parsed street name to compare
+ selfStreetName = this.street_name;
+ otherStreetName = other.street_name;
+ }
+
+ // Check if one street name contains the other
+ const options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ };
+ const selfTokens = new Tokens(
+ lazy.AddressParser.normalizeString(selfStreetName, options),
+ /[\s\n\r]+/
+ );
+ const otherTokens = new Tokens(
+ lazy.AddressParser.normalizeString(otherStreetName, options),
+ /[\s\n\r]+/
+ );
+
+ return otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ );
+ }
+}
+
+/**
+ * A postal code / zip code
+ * See autocomplete="postal-code"
+ */
+class PostalCode extends AddressField {
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ isValid() {
+ const { postalCodePattern } = lazy.FormAutofillUtils.getFormFormat(
+ this.region
+ );
+ const regexp = new RegExp(`^${postalCodePattern}$`);
+ return regexp.test(this.userValue);
+ }
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ remove_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ const options = {
+ ignore_case: true,
+ remove_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ const self_normalized_value = this.normalizeUserValue(options);
+ const other_normalized_value = other.normalizeUserValue(options);
+
+ return (
+ self_normalized_value.endsWith(other_normalized_value) ||
+ self_normalized_value.startsWith(other_normalized_value)
+ );
+ }
+}
+
+/**
+ * City name.
+ * See autocomplete="address-level1"
+ */
+class City extends AddressField {
+ #city = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ const options = {
+ ignore_case: true,
+ };
+ this.#city = this.normalizeUserValue(options);
+ }
+
+ get city() {
+ return this.#city;
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ return this.city == other.city;
+ }
+
+ contains(other) {
+ const options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ merge_whitespace: true,
+ };
+
+ const selfTokens = new Tokens(this.normalizeUserValue(options));
+ const otherTokens = new Tokens(other.normalizeUserValue(options));
+
+ return otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ );
+ }
+}
+
+/**
+ * State.
+ * See autocomplete="address-level2"
+ */
+class State extends AddressField {
+ // The abbreviated region name. For example, California is abbreviated as CA
+ #state = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (!this.userValue) {
+ return;
+ }
+
+ const options = {
+ merge_whitespace: true,
+ remove_punctuation: true,
+ };
+ this.#state = lazy.FormAutofillUtils.getAbbreviatedSubregionName(
+ this.normalizeUserValue(options),
+ region
+ );
+ }
+
+ get state() {
+ return this.#state;
+ }
+
+ isValid() {
+ // If we can't get the abbreviated name, assume this is an invalid state name
+ return !!this.#state;
+ }
+
+ equals(other) {
+ // If we have an abbreviated name, compare with it.
+ if (this.state) {
+ return this.state == other.state;
+ }
+
+ // If we don't have an abbreviated name, just compare the userValue
+ return this.userValue == other.userValue;
+ }
+
+ contains(other) {
+ return this.equals(other);
+ }
+}
+
+/**
+ * A country or territory code.
+ * See autocomplete="country"
+ */
+class Country extends AddressField {
+ // iso 3166 2-alpha code
+ #country_code = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (this.isEmpty()) {
+ return;
+ }
+
+ const options = {
+ merge_whitespace: true,
+ remove_punctuation: true,
+ };
+
+ const country = this.normalizeUserValue(options);
+ this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(country);
+
+ // When the country name is not a valid one, we use the current region instead
+ if (!this.#country_code) {
+ this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(region);
+ }
+ }
+
+ get country_code() {
+ return this.#country_code;
+ }
+
+ isValid() {
+ return !!this.#country_code;
+ }
+
+ equals(other) {
+ return this.country_code == other.country_code;
+ }
+
+ contains(other) {
+ return false;
+ }
+}
+
+/**
+ * The field expects the value to be a person's full name.
+ * See autocomplete="name"
+ */
+class Name extends AddressField {
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ // Reference:
+ // https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/data_model/autofill_profile_comparator.cc;drc=566369da19275cc306eeb51a3d3451885299dabb;bpv=1;bpt=1;l=935
+ static createNameVariants(name) {
+ let tokens = name.trim().split(" ");
+
+ let variants = [""];
+ if (!tokens[0]) {
+ return variants;
+ }
+
+ for (const token of tokens) {
+ let tmp = [];
+ for (const variant of variants) {
+ tmp.push(variant + " " + token);
+ tmp.push(variant + " " + token[0]);
+ }
+ variants = variants.concat(tmp);
+ }
+
+ const options = {
+ merge_whitespace: true,
+ };
+ return variants.map(v => lazy.AddressParser.normalizeString(v, options));
+ }
+
+ isValid() {
+ return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true;
+ }
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ };
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ // Unify puncutation while comparing so users can choose the right one
+ // if the only different part is puncutation
+ // Ex. John O'Brian is similar to John O`Brian
+ let options = {
+ ignore_case: true,
+ replace_punctuation: " ",
+ merge_whitespace: true,
+ };
+ let selfName = this.normalizeUserValue(options);
+ let otherName = other.normalizeUserValue(options);
+ let selfTokens = new Tokens(selfName);
+ let otherTokens = new Tokens(otherName);
+
+ if (
+ otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ )
+ ) {
+ return true;
+ }
+
+ // Remove puncutation from self and test whether current contains other
+ // Ex. John O'Brian is similar to John OBrian
+ selfName = this.normalizeUserValue({
+ ignore_case: true,
+ remove_punctuation: true,
+ merge_whitespace: true,
+ });
+ otherName = other.normalizeUserValue({
+ ignore_case: true,
+ remove_punctuation: true,
+ merge_whitespace: true,
+ });
+
+ selfTokens = new Tokens(selfName);
+ otherTokens = new Tokens(otherName);
+ if (
+ otherTokens.isSubsetInOrder(selfTokens, (a, b) =>
+ this.localeCompare(a, b)
+ )
+ ) {
+ return true;
+ }
+
+ // Create variants of the names by generating initials for given and middle names.
+
+ selfName = lazy.FormAutofillNameUtils.splitName(selfName);
+ otherName = lazy.FormAutofillNameUtils.splitName(otherName);
+ // In the following we compare cases when people abbreviate first name
+ // and middle name with initials. So if family name is different,
+ // we can just skip and assume the two names are different
+ if (!this.localeCompare(selfName.family, otherName.family)) {
+ return false;
+ }
+
+ const otherNameWithoutFamily = lazy.FormAutofillNameUtils.joinNameParts({
+ given: otherName.given,
+ middle: otherName.middle,
+ });
+ let givenVariants = Name.createNameVariants(selfName.given);
+ let middleVariants = Name.createNameVariants(selfName.middle);
+
+ for (const given of givenVariants) {
+ for (const middle of middleVariants) {
+ const nameVariant = lazy.FormAutofillNameUtils.joinNameParts({
+ given,
+ middle,
+ });
+
+ if (this.localeCompare(nameVariant, otherNameWithoutFamily)) {
+ return true;
+ }
+ }
+ }
+
+ // Check cases when given name and middle name are abbreviated with initial
+ // and the initials are put together. ex. John Michael Doe to JM. Doe
+ if (selfName.given && selfName.middle) {
+ const nameVariant = [
+ ...selfName.given.split(" "),
+ ...selfName.middle.split(" "),
+ ].reduce((initials, name) => {
+ initials += name[0];
+ return initials;
+ }, "");
+
+ if (this.localeCompare(nameVariant, otherNameWithoutFamily)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * A full telephone number, including the country code.
+ * See autocomplete="tel"
+ */
+class Tel extends AddressField {
+ #valid = false;
+
+ // The country code part of a telphone number, such as "1" for the United States
+ #country_code = null;
+
+ // The national part of a telphone number. For example, the phone number "+1 520-248-6621"
+ // national part is "520-248-6621".
+ #national_number = null;
+
+ constructor(value, region) {
+ super(value, region);
+
+ if (!this.userValue) {
+ return;
+ }
+
+ // TODO: Support parse telephone extension
+ // We compress all tel-related fields into a single tel field when an an form
+ // is submitted, so we need to decompress it here.
+ const parsed_tel = lazy.PhoneNumber.Parse(this.userValue, region);
+ if (parsed_tel) {
+ this.#national_number = parsed_tel?.nationalNumber;
+ this.#country_code = parsed_tel?.countryCode;
+
+ this.#valid = true;
+ } else {
+ this.#national_number = lazy.PhoneNumberNormalizer.Normalize(
+ this.userValue
+ );
+
+ const md = lazy.PhoneNumber.FindMetaDataForRegion(region);
+ this.#country_code = md ? "+" + md.nationalPrefix : null;
+
+ this.#valid = lazy.PhoneNumber.IsValid(this.#national_number, md);
+ }
+ }
+
+ get country_code() {
+ return this.#country_code;
+ }
+
+ get national_number() {
+ return this.#national_number;
+ }
+
+ isValid() {
+ return this.#valid;
+ }
+
+ equals(other) {
+ return (
+ this.national_number == other.national_number &&
+ this.country_code == other.country_code
+ );
+ }
+
+ contains(other) {
+ if (!this.country_code || this.country_code != other.country_code) {
+ return false;
+ }
+
+ return this.national_number.endsWith(other.national_number);
+ }
+
+ toString() {
+ return `${this.constructor.name}: ${this.country_code} ${this.national_number}\n`;
+ }
+}
+
+/**
+ * A company or organization name.
+ * See autocomplete="organization".
+ */
+class Organization extends AddressField {
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ isValid() {
+ return this.userValue
+ ? !!/[\p{Letter}\p{Number}]/u.exec(this.userValue)
+ : true;
+ }
+
+ /**
+ * Two company names are considered equal only when everything is the same.
+ */
+ equals(other) {
+ return this.userValue == other.userValue;
+ }
+
+ // Mergeable use locale compare
+ contains(other) {
+ const options = {
+ replace_punctuation: " ", // mozilla org vs mozilla-org
+ merge_whitespace: true,
+ ignore_case: true, // mozilla vs Mozilla
+ };
+
+ // If every token in B can be found in A without considering order
+ // Example, 'Food & Pharmacy' contains 'Pharmacy & Food'
+ const selfTokens = new Tokens(this.normalizeUserValue(options));
+ const otherTokens = new Tokens(other.normalizeUserValue(options));
+
+ return otherTokens.isSubset(selfTokens, (a, b) => this.localeCompare(a, b));
+ }
+}
+
+/**
+ * An email address
+ * See autocomplete="email".
+ */
+class Email extends AddressField {
+ constructor(value, region) {
+ super(value, region);
+ }
+
+ // Since we are using the valid check to determine whether we capture the email field when users submitting a forma,
+ // use a less restrict email verification method so we capture an email for most of the cases.
+ // The current algorithm is based on the regular expression defined in
+ // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
+ //
+ // We might also change this to something similar to the algorithm used in
+ // EmailInputType::IsValidEmailAddress if we want a more strict email validation algorithm.
+ isValid() {
+ const regex =
+ /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+ const match = this.userValue.match(regex);
+ if (!match) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /*
+ // JS version of EmailInputType::IsValidEmailAddress
+ isValid() {
+ const regex = /^([a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+)@([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$/;
+ const match = this.userValue.match(regex);
+ if (!match) {
+ return false;
+ }
+ const local = match[1];
+ const domain = match[2];
+
+ // The domain name can't begin with a dot or a dash.
+ if (['-', '.'].includes(domain[0])) {
+ return false;
+ }
+
+ // A dot can't follow a dot or a dash.
+ // A dash can't follow a dot.
+ const pattern = /(\.\.)|(\.-)|(-\.)/;
+ if (pattern.test(domain)) {
+ return false;
+ }
+
+ return true;
+ }
+*/
+
+ equals(other) {
+ const options = {
+ ignore_case: true,
+ };
+
+ // email is case-insenstive
+ return (
+ this.normalizeUserValue(options) == other.normalizeUserValue(options)
+ );
+ }
+
+ contains(other) {
+ return false;
+ }
+}
+
+/**
+ * The AddressComparison class compares two AddressComponent instances and
+ * provides information about the differences or similarities between them.
+ *
+ * The comparison result is stored and the object and can be retrieved by calling
+ * 'result' getter.
+ */
+export class AddressComparison {
+ // Const to define the comparison result for two address fields
+ static BOTH_EMPTY = 0;
+ static A_IS_EMPTY = 1;
+ static B_IS_EMPTY = 2;
+ static A_CONTAINS_B = 3;
+ static B_CONTAINS_A = 4;
+ // When A contains B and B contains A Ex. "Pizza & Food vs Food & Pizza"
+ static SIMILAR = 5;
+ static SAME = 6;
+ static DIFFERENT = 7;
+
+ // The comparion result, keyed by field name.
+ #result = {};
+
+ /**
+ * Constructs AddressComparison by comparing two AddressComponent objects.
+ *
+ * @class
+ * @param {AddressComponent} addressA - The first address to compare.
+ * @param {AddressComponent} addressB - The second address to compare.
+ */
+ constructor(addressA, addressB) {
+ for (const fieldA of addressA.getAllFields()) {
+ const fieldName = fieldA.constructor.name;
+ const fieldB = addressB.getField(fieldName);
+ if (fieldB) {
+ this.#result[fieldName] = AddressComparison.compare(fieldA, fieldB);
+ } else {
+ this.#result[fieldName] = AddressComparison.B_IS_EMPTY;
+ }
+ }
+
+ for (const fieldB of addressB.getAllFields()) {
+ const fieldName = fieldB.constructor.name;
+ if (!addressB.getField(fieldName)) {
+ this.#result[fieldName] = AddressComparison.A_IS_EMPTY;
+ }
+ }
+ }
+
+ /**
+ * Retrieves the result object containing the comparison results.
+ *
+ * @returns {object} The result object with keys corresponding to field names
+ * and values being comparison constants.
+ */
+ get result() {
+ return this.#result;
+ }
+
+ /**
+ * Compares two address fields and returns the comparison result.
+ *
+ * @param {AddressField} fieldA The first field to compare.
+ * @param {AddressField} fieldB The second field to compare.
+ * @returns {number} A constant representing the comparison result.
+ */
+ static compare(fieldA, fieldB) {
+ if (fieldA.isEmpty()) {
+ return fieldB.isEmpty()
+ ? AddressComparison.BOTH_EMPTY
+ : AddressComparison.A_IS_EMPTY;
+ } else if (fieldB.isEmpty()) {
+ return AddressComparison.B_IS_EMPTY;
+ }
+
+ if (fieldA.equals(fieldB)) {
+ return AddressComparison.SAME;
+ }
+
+ if (fieldB.contains(fieldA)) {
+ if (fieldA.contains(fieldB)) {
+ return AddressComparison.SIMILAR;
+ }
+ return AddressComparison.B_CONTAINS_A;
+ } else if (fieldA.contains(fieldB)) {
+ return AddressComparison.A_CONTAINS_B;
+ }
+
+ return AddressComparison.DIFFERENT;
+ }
+
+ /**
+ * Converts a comparison result constant to a readable string.
+ *
+ * @param {number} result The comparison result constant.
+ * @returns {string} A readable string representing the comparison result.
+ */
+ static resultToString(result) {
+ switch (result) {
+ case AddressComparison.BOTH_EMPTY:
+ return "both fields are empty";
+ case AddressComparison.A_IS_EMPTY:
+ return "field A is empty";
+ case AddressComparison.B_IS_EMPTY:
+ return "field B is empty";
+ case AddressComparison.A_CONTAINS_B:
+ return "field A contains field B";
+ case AddressComparison.B_CONTAINS_B:
+ return "field B contains field A";
+ case AddressComparison.SIMILAR:
+ return "field A and field B are similar";
+ case AddressComparison.SAME:
+ return "two fields are the same";
+ case AddressComparison.DIFFERENT:
+ return "two fields are different";
+ }
+ return "";
+ }
+
+ /**
+ * Returns a formatted string representing the comparison results for each field.
+ *
+ * @returns {string} A formatted string with field names and their respective
+ * comparison results.
+ */
+ toString() {
+ let string = "Comparison Result:\n";
+ for (const [name, result] of Object.entries(this.#result)) {
+ string += `${name}: ${AddressComparison.resultToString(result)}\n`;
+ }
+ return string;
+ }
+}
+
+/**
+ * The AddressComponent class represents a structured address that is transformed
+ * from address record created in FormAutofillHandler 'createRecord' function.
+ *
+ * An AddressComponent object consisting of various fields such as state, city,
+ * country, postal code, etc. The class provides a compare methods
+ * to compare another AddressComponent against the current instance.
+ *
+ * Note. This class assumes records that pass to it have already been normalized.
+ */
+export class AddressComponent {
+ /**
+ * An object that stores individual address field instances
+ * (e.g., class State, class City, class Country, etc.), keyed by the
+ * field's clas name.
+ */
+ #fields = {};
+
+ /**
+ * Constructs an AddressComponent object by converting passed address record object.
+ *
+ * @class
+ * @param {object} record The address record object containing address data.
+ * @param {string} defaultRegion The default region to use if the record's
+ * country is not specified.
+ * @param {object} [options = {}] a list of options for this method
+ * @param {boolean} [options.ignoreInvalid = true] Whether to ignore invalid address
+ * fields in the AddressComponent object. If set to true,
+ * invalid fields will be ignored.
+ */
+ constructor(
+ record,
+ defaultRegion = FormAutofill.DEFAULT_REGION,
+ { ignoreInvalid = false } = {}
+ ) {
+ const fieldValue = this.#recordToFieldValue(record);
+
+ // Get country code first so we can use it to parse other fields
+ const country = new Country(fieldValue.country, defaultRegion);
+ this.#fields[Country.name] = country;
+ const region = country.isEmpty() ? defaultRegion : country.country_code;
+
+ this.#fields[State.name] = new State(fieldValue.state, region);
+ this.#fields[City.name] = new City(fieldValue.city, region);
+ this.#fields[PostalCode.name] = new PostalCode(
+ fieldValue.postal_code,
+ region
+ );
+ this.#fields[Tel.name] = new Tel(fieldValue.tel, region);
+ this.#fields[StreetAddress.name] = new StreetAddress(
+ fieldValue.street_address,
+ region
+ );
+ this.#fields[Name.name] = new Name(fieldValue.name, region);
+ this.#fields[Organization.name] = new Organization(
+ fieldValue.organization,
+ region
+ );
+ this.#fields[Email.name] = new Email(fieldValue.email, region);
+
+ if (ignoreInvalid) {
+ // TODO: We have to reset it or ignore non-existing fields while comparing
+ this.#fields.filter(f => f.IsValid());
+ }
+ }
+
+ /**
+ * Converts address record to a field value object.
+ *
+ * @param {object} record The record object containing address data.
+ * @returns {object} A value object with keys corresponding to specific
+ * address fields and their respective values.
+ */
+ #recordToFieldValue(record) {
+ let value = {};
+
+ if (record.name) {
+ value.name = record.name;
+ } else {
+ value.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: record["given-name"],
+ middle: record["additional-name"],
+ family: record["family-name"],
+ });
+ }
+
+ value.email = record.email ?? "";
+ value.organization = record.organization ?? "";
+ value.street_address = record["street-address"] ?? "";
+ value.state = record["address-level1"] ?? "";
+ value.city = record["address-level2"] ?? "";
+ value.country = record.country ?? "";
+ value.postal_code = record["postal-code"] ?? "";
+ value.tel = record.tel ?? "";
+
+ return value;
+ }
+
+ /**
+ * Retrieves all the address fields.
+ *
+ * @returns {Array} An array of address field objects.
+ */
+ getAllFields() {
+ return Object.values(this.#fields);
+ }
+
+ /**
+ * Retrieves the field object with the specified name.
+ *
+ * @param {string} name The name of the field to retrieve.
+ * @returns {object} The address field object with the specified name,
+ * or undefined if the field is not found.
+ */
+ getField(name) {
+ return this.#fields[name];
+ }
+
+ /**
+ * Compares the current AddressComponent with another AddressComponent.
+ *
+ * @param {AddressComponent} address The AddressComponent object to compare
+ * against the current one.
+ * @returns {object} An object containing comparison results. The keys of the object represent
+ * individual address field, and the values are strings indicating the comparison result:
+ * - "same" if both components are either empty or the same,
+ * - "superset" if the current contains the input or the input is empty,
+ * - "subset" if the input contains the current or the current is empty,
+ * - "similar" if the two address components are similar,
+ * - "different" if the two address components are different.
+ */
+ compare(address) {
+ let result = {};
+
+ const comparison = new AddressComparison(this, address);
+ for (const [k, v] of Object.entries(comparison.result)) {
+ if ([AddressComparison.BOTH_EMPTY, AddressComparison.SAME].includes(v)) {
+ result[k] = "same";
+ } else if (
+ [AddressComparison.B_IS_EMPTY, AddressComparison.A_CONTAINS_B].includes(
+ v
+ )
+ ) {
+ result[k] = "superset";
+ } else if (
+ [AddressComparison.A_IS_EMPTY, AddressComparison.B_CONTAINS_A].includes(
+ v
+ )
+ ) {
+ result[k] = "subset";
+ } else if ([AddressComparison.SIMILAR].includes(v)) {
+ result[k] = "similar";
+ } else {
+ result[k] = "different";
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Print all the fields in this AddressComponent object.
+ */
+ toString() {
+ let string = "";
+ for (const field of Object.values(this.#fields)) {
+ string += field.toString();
+ }
+ return string;
+ }
+}
diff --git a/toolkit/components/formautofill/shared/AddressParser.sys.mjs b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
new file mode 100644
index 0000000000..8fe0dc7f80
--- /dev/null
+++ b/toolkit/components/formautofill/shared/AddressParser.sys.mjs
@@ -0,0 +1,281 @@
+/* eslint-disable no-useless-concat */
+/* 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/. */
+
+// NamedCaptureGroup class represents a named capturing group in a regular expression
+class NamedCaptureGroup {
+ // The named of this capturing group
+ #name = null;
+
+ // The capturing group
+ #capture = null;
+
+ // The matched result
+ #match = null;
+
+ constructor(name, capture) {
+ this.#name = name;
+ this.#capture = capture;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ get capture() {
+ return this.#capture;
+ }
+
+ get match() {
+ return this.#match;
+ }
+
+ // Setter for the matched result based on the match groups
+ setMatch(matchGroups) {
+ this.#match = matchGroups[this.#name];
+ }
+}
+
+// Base class for different part of a street address regular expression.
+// The regular expression is constructed with prefix, pattern, suffix
+// and separator to extract "value" part.
+// For examplem, when we write "apt 4." to for floor number, its prefix is `apt`,
+// suffix is `.` and value to represent apartment number is `4`.
+class StreetAddressPartRegExp extends NamedCaptureGroup {
+ constructor(name, prefix, pattern, suffix, sep, optional = false) {
+ prefix = prefix ?? "";
+ suffix = suffix ?? "";
+ super(
+ name,
+ `((?:${prefix})(?<${name}>${pattern})(?:${suffix})(?:${sep})+)${
+ optional ? "?" : ""
+ }`
+ );
+ }
+}
+
+// A regular expression to match the street number portion of a street address,
+class StreetNumberRegExp extends StreetAddressPartRegExp {
+ static PREFIX = "((no|°|º|number)(\\.|-|\\s)*)?"; // From chromium source
+
+ static PATTERN = "\\d+\\w?";
+
+ // TODO: possible suffix : (th\\.|\\.)?
+ static SUFFIX = null;
+
+ constructor(sep, optional) {
+ super(
+ StreetNumberRegExp.name,
+ StreetNumberRegExp.PREFIX,
+ StreetNumberRegExp.PATTERN,
+ StreetNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the street name portion of a street address,
+class StreetNameRegExp extends StreetAddressPartRegExp {
+ static PREFIX = null;
+
+ static PATTERN = "(?:[^\\s,]+(?:[^\\S\\r\\n]+[^\\s,]+)*?)"; // From chromium source
+
+ // TODO: Should we consider suffix like (ave|st)?
+ static SUFFIX = null;
+
+ constructor(sep, optional) {
+ super(
+ StreetNameRegExp.name,
+ StreetNameRegExp.PREFIX,
+ StreetNameRegExp.PATTERN,
+ StreetNameRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the apartment number portion of a street address,
+class ApartmentNumberRegExp extends StreetAddressPartRegExp {
+ static keyword = "apt|apartment|wohnung|apto|-" + "|unit|suite|ste|#|room"; // From chromium source // Firefox specific
+ static PREFIX = `(${ApartmentNumberRegExp.keyword})(\\.|\\s|-)*`;
+
+ static PATTERN = "\\w*([-|\\/]\\w*)?";
+
+ static SUFFIX = "(\\.|\\s|-)*(ª)?"; // From chromium source
+
+ constructor(sep, optional) {
+ super(
+ ApartmentNumberRegExp.name,
+ ApartmentNumberRegExp.PREFIX,
+ ApartmentNumberRegExp.PATTERN,
+ ApartmentNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+// A regular expression to match the floor number portion of a street address,
+class FloorNumberRegExp extends StreetAddressPartRegExp {
+ static keyword =
+ "floor|flur|fl|og|obergeschoss|ug|untergeschoss|geschoss|andar|piso|º" + // From chromium source
+ "|level|lvl"; // Firefox specific
+ static PREFIX = `(${FloorNumberRegExp.keyword})?(\\.|\\s|-)*`; // TODO
+ static PATTERN = "\\d{1,3}\\w?";
+ static SUFFIX = `(st|nd|rd|th)?(\\.|\\s|-)*(${FloorNumberRegExp.keyword})?`; // TODO
+
+ constructor(sep, optional) {
+ super(
+ FloorNumberRegExp.name,
+ FloorNumberRegExp.PREFIX,
+ FloorNumberRegExp.PATTERN,
+ FloorNumberRegExp.SUFFIX,
+ sep,
+ optional
+ );
+ }
+}
+
+/**
+ * Class represents a street address with the following fields:
+ * - street number
+ * - street name
+ * - apartment number
+ * - floor number
+ */
+export class StructuredStreetAddress {
+ #street_number = null;
+ #street_name = null;
+ #apartment_number = null;
+ #floor_number = null;
+
+ constructor(street_number, street_name, apartment_number, floor_number) {
+ this.#street_number = street_number?.toString();
+ this.#street_name = street_name?.toString();
+ this.#apartment_number = apartment_number?.toString();
+ this.#floor_number = floor_number?.toString();
+ }
+
+ get street_number() {
+ return this.#street_number;
+ }
+
+ get street_name() {
+ return this.#street_name;
+ }
+
+ get apartment_number() {
+ return this.#apartment_number;
+ }
+
+ get floor_number() {
+ return this.#floor_number;
+ }
+
+ toString() {
+ return `
+ street number: ${this.#street_number}\n
+ street name: ${this.#street_name}\n
+ apartment number: ${this.#apartment_number}\n
+ floor number: ${this.#floor_number}\n
+ `;
+ }
+}
+
+export class AddressParser {
+ /**
+ * Parse street address with the following pattern.
+ * street number, street name, apartment number(optional), floor number(optional)
+ * For example, 2 Harrison St #175 floor 2
+ *
+ * @param {string} address The street address to be parsed.
+ * @returns {StructuredStreetAddress}
+ */
+ static parseStreetAddress(address) {
+ const separator = "(\\s|,|$)";
+
+ const regexpes = [
+ new StreetNumberRegExp(separator),
+ new StreetNameRegExp(separator),
+ new ApartmentNumberRegExp(separator, true),
+ new FloorNumberRegExp(separator, true),
+ ];
+
+ return AddressParser.parse(address, regexpes)
+ ? new StructuredStreetAddress(...regexpes.map(regexp => regexp.match))
+ : null;
+ }
+
+ static parse(address, regexpes) {
+ const options = {
+ trim: true,
+ merge_whitespace: true,
+ ignore_case: true,
+ };
+ address = AddressParser.normalizeString(address, options);
+
+ const match = address.match(
+ new RegExp(`^(${regexpes.map(regexp => regexp.capture).join("")})$`)
+ );
+ if (!match) {
+ return null;
+ }
+
+ regexpes.forEach(regexp => regexp.setMatch(match.groups));
+ return regexpes.reduce((acc, current) => {
+ return { ...acc, [current.name]: current.match };
+ }, {});
+ }
+
+ static normalizeString(s, options) {
+ if (typeof s != "string") {
+ return s;
+ }
+
+ if (options.ignore_case) {
+ s = s.toLowerCase();
+ }
+
+ // process punctuation before whitespace because if a punctuation
+ // is replaced with whitespace, we might want to merge it later
+ if (options.remove_punctuation) {
+ s = AddressParser.replacePunctuation(s, "");
+ } else if ("replace_punctuation" in options) {
+ const replace = options.replace_punctuation;
+ s = AddressParser.replacePunctuation(s, replace);
+ }
+
+ // process whitespace
+ if (options.merge_whitespace) {
+ s = AddressParser.mergeWhitespace(s);
+ } else if (options.remove_whitespace) {
+ s = AddressParser.removeWhitespace(s);
+ }
+
+ return s.trim();
+ }
+
+ static replacePunctuation(s, replace) {
+ const regex = /\p{Punctuation}/gu;
+ return s?.replace(regex, replace);
+ }
+
+ static removePunctuation(s) {
+ return s?.replace(/[.,\/#!$%\^&\*;:{}=\-_~()]/g, "");
+ }
+
+ static replaceControlCharacters(s, replace) {
+ return s?.replace(/[\t\n\r]/g, " ");
+ }
+
+ static removeWhitespace(s) {
+ return s?.replace(/[\s]/g, "");
+ }
+
+ static mergeWhitespace(s) {
+ return s?.replace(/\s{2,}/g, " ");
+ }
+}
diff --git a/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
new file mode 100644
index 0000000000..ed72d26018
--- /dev/null
+++ b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs
@@ -0,0 +1,1212 @@
+/* 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/. */
+
+/**
+ * Fathom ML model for identifying the fields of credit-card forms
+ *
+ * This is developed out-of-tree at https://github.com/mozilla-services/fathom-
+ * form-autofill, where there is also over a GB of training, validation, and
+ * testing data. To make changes, do your edits there (whether adding new
+ * training pages, adding new rules, or both), retrain and evaluate as
+ * documented at https://mozilla.github.io/fathom/training.html, paste the
+ * coefficients emitted by the trainer into the ruleset, and finally copy the
+ * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM
+ * TRAINING REPOSITORY" section.
+ */
+
+/**
+ * CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY:
+ */
+
+import {
+ element as clickedElement,
+ out,
+ rule,
+ ruleset,
+ score,
+ type,
+} from "resource://gre/modules/third_party/fathom/fathom.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import {
+ CreditCard,
+ NETWORK_NAMES,
+} from "resource://gre/modules/CreditCard.sys.mjs";
+
+import { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs";
+import { LabelUtils } from "resource://gre/modules/shared/LabelUtils.sys.mjs";
+
+/**
+ * Callthrough abstraction to allow .getAutocompleteInfo() to be mocked out
+ * during training
+ *
+ * @param {Element} element DOM element to get info about
+ * @returns {object} Page-author-provided autocomplete metadata
+ */
+function getAutocompleteInfo(element) {
+ return element.getAutocompleteInfo();
+}
+
+/**
+ * @param {string} selector A CSS selector that prunes away ineligible elements
+ * @returns {Lhs} An LHS yielding the element the user has clicked or, if
+ * pruned, none
+ */
+function queriedOrClickedElements(selector) {
+ return clickedElement(selector);
+}
+
+/**
+ * START OF CODE PASTED FROM TRAINING REPOSITORY
+ */
+var FathomHeuristicsRegExp = {
+ RULES: {
+ "cc-name": undefined,
+ "cc-number": undefined,
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ "cc-exp": undefined,
+ "cc-type": undefined,
+ },
+
+ RULE_SETS: [
+ {
+ /* eslint-disable */
+ // Let us keep our consistent wrapping.
+ "cc-name":
+ // Firefox-specific rules
+ "account.*holder.*name" +
+ // de-DE
+ "|^(kredit)?(karten|konto)inhaber" +
+ "|^(name).*karte" +
+ // fr-FR
+ "|nom.*(titulaire|détenteur)" +
+ "|(titulaire|détenteur).*(carte)" +
+ // it-IT
+ "|titolare.*carta" +
+ // pl-PL
+ "|posiadacz.*karty" +
+ // Rules from Bitwarden
+ "|cc-?name" +
+ "|card-?name" +
+ "|cardholder-?name" +
+ "|(^nom$)" +
+ // Rules are from Chromium source codes
+ "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" +
+ "|(?:card|cc).?name|cc.?full.?name" +
+ "|(?:card|cc).?owner" +
+ "|nombre.*tarjeta" + // es
+ "|nom.*carte" + // fr-FR
+ "|nome.*cart" + // it-IT
+ "|名前" + // ja-JP
+ "|Имя.*карты" + // ru
+ "|信用卡开户名|开户名|持卡人姓名" + // zh-CN
+ "|持卡人姓名", // zh-TW
+
+ "cc-number":
+ // Firefox-specific rules
+ // de-DE
+ "(cc|kk)nr" +
+ "|(kredit)?(karten)(nummer|nr)" +
+ // it-IT
+ "|numero.*carta" +
+ // fr-FR
+ "|(numero|número|numéro).*(carte)" +
+ // pl-PL
+ "|numer.*karty" +
+ // Rules from Bitwarden
+ "|cc-?number" +
+ "|cc-?num" +
+ "|card-?number" +
+ "|card-?num" +
+ "|cc-?no" +
+ "|card-?no" +
+ "|numero-?carte" +
+ "|num-?carte" +
+ "|cb-?num" +
+ // Rules are from Chromium source codes
+ "|(add)?(?:card|cc|acct).?(?:number|#|no|num)" +
+ "|カード番号" + // ja-JP
+ "|Номер.*карты" + // ru
+ "|信用卡号|信用卡号码" + // zh-CN
+ "|信用卡卡號" + // zh-TW
+ "|카드", // ko-KR
+
+ "cc-exp":
+ // Firefox-specific rules
+ "mm\\s*(\/|\\|-)\\s*(yy|jj|aa)" +
+ "|(month|mois)\\s*(\/|\\|-|et)\\s*(year|année)" +
+ // de-DE
+ // fr-FR
+ // Rules from Bitwarden
+ "|(^cc-?exp$)" +
+ "|(^card-?exp$)" +
+ "|(^cc-?expiration$)" +
+ "|(^card-?expiration$)" +
+ "|(^cc-?ex$)" +
+ "|(^card-?ex$)" +
+ "|(^card-?expire$)" +
+ "|(^card-?expiry$)" +
+ "|(^validite$)" +
+ "|(^expiration$)" +
+ "|(^expiry$)" +
+ "|mm-?yy" +
+ "|mm-?yyyy" +
+ "|yy-?mm" +
+ "|yyyy-?mm" +
+ "|expiration-?date" +
+ "|payment-?card-?expiration" +
+ "|(^payment-?cc-?date$)" +
+ // Rules are from Chromium source codes
+ "|expir|exp.*date|^expfield$" +
+ "|ablaufdatum|gueltig|gültig" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты", // ru
+
+ "cc-exp-month":
+ // Firefox-specific rules
+ "(cc|kk)month" + // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?month$)" +
+ "|(^cc-?exp-?month$)" +
+ "|(^cc-?month$)" +
+ "|(^card-?month$)" +
+ "|(^cc-?mo$)" +
+ "|(^card-?mo$)" +
+ "|(^exp-?mo$)" +
+ "|(^card-?exp-?mo$)" +
+ "|(^cc-?exp-?mo$)" +
+ "|(^card-?expiration-?month$)" +
+ "|(^expiration-?month$)" +
+ "|(^cc-?mm$)" +
+ "|(^cc-?m$)" +
+ "|(^card-?mm$)" +
+ "|(^card-?m$)" +
+ "|(^card-?exp-?mm$)" +
+ "|(^cc-?exp-?mm$)" +
+ "|(^exp-?mm$)" +
+ "|(^exp-?m$)" +
+ "|(^expire-?month$)" +
+ "|(^expire-?mo$)" +
+ "|(^expiry-?month$)" +
+ "|(^expiry-?mo$)" +
+ "|(^card-?expire-?month$)" +
+ "|(^card-?expire-?mo$)" +
+ "|(^card-?expiry-?month$)" +
+ "|(^card-?expiry-?mo$)" +
+ "|(^mois-?validite$)" +
+ "|(^mois-?expiration$)" +
+ "|(^m-?validite$)" +
+ "|(^m-?expiration$)" +
+ "|(^expiry-?date-?field-?month$)" +
+ "|(^expiration-?date-?month$)" +
+ "|(^expiration-?date-?mm$)" +
+ "|(^exp-?mon$)" +
+ "|(^validity-?mo$)" +
+ "|(^exp-?date-?mo$)" +
+ "|(^cb-?date-?mois$)" +
+ "|(^date-?m$)" +
+ // Rules are from Chromium source codes
+ "|exp.*mo|ccmonth|cardmonth|addmonth" +
+ "|monat" + // de-DE
+ // "|fecha" + // es
+ // "|date.*exp" + // fr-FR
+ // "|scadenza" + // it-IT
+ // "|有効期限" + // ja-JP
+ // "|validade" + // pt-BR, pt-PT
+ // "|Срок действия карты" + // ru
+ "|月", // zh-CN
+
+ "cc-exp-year":
+ // Firefox-specific rules
+ "(cc|kk)year" + // de-DE
+ // Rules from Bitwarden
+ "|(^exp-?year$)" +
+ "|(^cc-?exp-?year$)" +
+ "|(^cc-?year$)" +
+ "|(^card-?year$)" +
+ "|(^cc-?yr$)" +
+ "|(^card-?yr$)" +
+ "|(^exp-?yr$)" +
+ "|(^card-?exp-?yr$)" +
+ "|(^cc-?exp-?yr$)" +
+ "|(^card-?expiration-?year$)" +
+ "|(^expiration-?year$)" +
+ "|(^cc-?yy$)" +
+ "|(^cc-?y$)" +
+ "|(^card-?yy$)" +
+ "|(^card-?y$)" +
+ "|(^card-?exp-?yy$)" +
+ "|(^cc-?exp-?yy$)" +
+ "|(^exp-?yy$)" +
+ "|(^exp-?y$)" +
+ "|(^cc-?yyyy$)" +
+ "|(^card-?yyyy$)" +
+ "|(^card-?exp-?yyyy$)" +
+ "|(^cc-?exp-?yyyy$)" +
+ "|(^expire-?year$)" +
+ "|(^expire-?yr$)" +
+ "|(^expiry-?year$)" +
+ "|(^expiry-?yr$)" +
+ "|(^card-?expire-?year$)" +
+ "|(^card-?expire-?yr$)" +
+ "|(^card-?expiry-?year$)" +
+ "|(^card-?expiry-?yr$)" +
+ "|(^an-?validite$)" +
+ "|(^an-?expiration$)" +
+ "|(^annee-?validite$)" +
+ "|(^annee-?expiration$)" +
+ "|(^expiry-?date-?field-?year$)" +
+ "|(^expiration-?date-?year$)" +
+ "|(^cb-?date-?ann$)" +
+ "|(^expiration-?date-?yy$)" +
+ "|(^expiration-?date-?yyyy$)" +
+ "|(^validity-?year$)" +
+ "|(^exp-?date-?year$)" +
+ "|(^date-?y$)" +
+ // Rules are from Chromium source codes
+ "|(add)?year" +
+ "|jahr" + // de-DE
+ // "|fecha" + // es
+ // "|scadenza" + // it-IT
+ // "|有効期限" + // ja-JP
+ // "|validade" + // pt-BR, pt-PT
+ // "|Срок действия карты" + // ru
+ "|年|有效期", // zh-CN
+
+ "cc-type":
+ // Firefox-specific rules
+ "type" +
+ // de-DE
+ "|Kartenmarke" +
+ // Rules from Bitwarden
+ "|(^cc-?type$)" +
+ "|(^card-?type$)" +
+ "|(^card-?brand$)" +
+ "|(^cc-?brand$)" +
+ "|(^cb-?type$)",
+ // Rules are from Chromium source codes
+ },
+ ],
+
+ _getRule(name) {
+ let rules = [];
+ this.RULE_SETS.forEach(set => {
+ if (set[name]) {
+ rules.push(`(${set[name]})`.normalize("NFKC"));
+ }
+ });
+
+ const value = new RegExp(rules.join("|"), "iu");
+ Object.defineProperty(this.RULES, name, { get: undefined });
+ Object.defineProperty(this.RULES, name, { value });
+ return value;
+ },
+
+ init() {
+ Object.keys(this.RULES).forEach(field =>
+ Object.defineProperty(this.RULES, field, {
+ get() {
+ return FathomHeuristicsRegExp._getRule(field);
+ },
+ })
+ );
+ },
+};
+
+FathomHeuristicsRegExp.init();
+
+const MMRegExp = /^mm$|\(mm\)/i;
+const YYorYYYYRegExp = /^(yy|yyyy)$|\(yy\)|\(yyyy\)/i;
+const monthRegExp = /month/i;
+const yearRegExp = /year/i;
+const MMYYRegExp = /mm\s*(\/|\\)\s*yy/i;
+const VisaCheckoutRegExp = /visa(-|\s)checkout/i;
+const CREDIT_CARD_NETWORK_REGEXP = new RegExp(
+ CreditCard.getSupportedNetworks()
+ .concat(Object.keys(NETWORK_NAMES))
+ .join("|"),
+ "gui"
+ );
+const TwoDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/i;
+const FourDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/i;
+const dwfrmRegExp = /^dwfrm/i;
+const bmlRegExp = /bml/i;
+const templatedValue = /^\{\{.*\}\}$/;
+const firstRegExp = /first/i;
+const lastRegExp = /last/i;
+const giftRegExp = /gift/i;
+const subscriptionRegExp = /subscription/i;
+
+function autocompleteStringMatches(element, ccString) {
+ const info = getAutocompleteInfo(element);
+ return info.fieldName === ccString;
+}
+
+function getFillableFormElements(element) {
+ const formLike = FormLikeFactory.createFromField(element);
+ return Array.from(formLike.elements).filter(el =>
+ FormAutofillUtils.isCreditCardOrAddressFieldType(el)
+ );
+}
+
+function nextFillableFormField(element) {
+ const fillableFormElements = getFillableFormElements(element);
+ const elementIndex = fillableFormElements.indexOf(element);
+ return fillableFormElements[elementIndex + 1];
+}
+
+function previousFillableFormField(element) {
+ const fillableFormElements = getFillableFormElements(element);
+ const elementIndex = fillableFormElements.indexOf(element);
+ return fillableFormElements[elementIndex - 1];
+}
+
+function nextFieldPredicateIsTrue(element, predicate) {
+ const nextField = nextFillableFormField(element);
+ return !!nextField && predicate(nextField);
+}
+
+function previousFieldPredicateIsTrue(element, predicate) {
+ const previousField = previousFillableFormField(element);
+ return !!previousField && predicate(previousField);
+}
+
+function nextFieldMatchesExpYearAutocomplete(fnode) {
+ return nextFieldPredicateIsTrue(fnode.element, nextField =>
+ autocompleteStringMatches(nextField, "cc-exp-year")
+ );
+}
+
+function previousFieldMatchesExpMonthAutocomplete(fnode) {
+ return previousFieldPredicateIsTrue(fnode.element, previousField =>
+ autocompleteStringMatches(previousField, "cc-exp-month")
+ );
+}
+
+//////////////////////////////////////////////
+// Attribute Regular Expression Rules
+function idOrNameMatchRegExp(element, regExp) {
+ for (const str of [element.id, element.name]) {
+ if (regExp.test(str)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function getElementLabels(element) {
+ return {
+ *[Symbol.iterator]() {
+ const labels = LabelUtils.findLabelElements(element);
+ for (let label of labels) {
+ yield* LabelUtils.extractLabelStrings(label);
+ }
+ },
+ };
+}
+
+function labelsMatchRegExp(element, regExp) {
+ const elemStrings = getElementLabels(element);
+ for (const str of elemStrings) {
+ if (regExp.test(str)) {
+ return true;
+ }
+ }
+
+ const parentElement = element.parentElement;
+ // Bug 1634819: element.parentElement is null if element.parentNode is a ShadowRoot
+ if (!parentElement) {
+ return false;
+ }
+ // Check if the input is in a <td>, and, if so, check the textContent of the containing <tr>
+ if (parentElement.tagName === "TD" && parentElement.parentElement) {
+ // TODO: How bad is the assumption that the <tr> won't be the parent of the <td>?
+ return regExp.test(parentElement.parentElement.textContent);
+ }
+
+ // Check if the input is in a <dd>, and, if so, check the textContent of the preceding <dt>
+ if (
+ parentElement.tagName === "DD" &&
+ // previousElementSibling can be null
+ parentElement.previousElementSibling
+ ) {
+ return regExp.test(parentElement.previousElementSibling.textContent);
+ }
+ return false;
+}
+
+function closestLabelMatchesRegExp(element, regExp) {
+ const previousElementSibling = element.previousElementSibling;
+ if (
+ previousElementSibling !== null &&
+ previousElementSibling.tagName === "LABEL"
+ ) {
+ return regExp.test(previousElementSibling.textContent);
+ }
+
+ const nextElementSibling = element.nextElementSibling;
+ if (nextElementSibling !== null && nextElementSibling.tagName === "LABEL") {
+ return regExp.test(nextElementSibling.textContent);
+ }
+
+ return false;
+}
+
+function ariaLabelMatchesRegExp(element, regExp) {
+ const ariaLabel = element.getAttribute("aria-label");
+ return !!ariaLabel && regExp.test(ariaLabel);
+}
+
+function placeholderMatchesRegExp(element, regExp) {
+ const placeholder = element.getAttribute("placeholder");
+ return !!placeholder && regExp.test(placeholder);
+}
+
+function nextFieldIdOrNameMatchRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ idOrNameMatchRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldLabelsMatchRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ labelsMatchRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldPlaceholderMatchesRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ placeholderMatchesRegExp(nextField, regExp)
+ );
+}
+
+function nextFieldAriaLabelMatchesRegExp(element, regExp) {
+ return nextFieldPredicateIsTrue(element, nextField =>
+ ariaLabelMatchesRegExp(nextField, regExp)
+ );
+}
+
+function previousFieldIdOrNameMatchRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ idOrNameMatchRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldLabelsMatchRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ labelsMatchRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldPlaceholderMatchesRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ placeholderMatchesRegExp(previousField, regExp)
+ );
+}
+
+function previousFieldAriaLabelMatchesRegExp(element, regExp) {
+ return previousFieldPredicateIsTrue(element, previousField =>
+ ariaLabelMatchesRegExp(previousField, regExp)
+ );
+}
+//////////////////////////////////////////////
+
+function isSelectWithCreditCardOptions(fnode) {
+ // Check every select for options that match credit card network names in
+ // value or label.
+ const element = fnode.element;
+ if (element.tagName === "SELECT") {
+ for (let option of element.querySelectorAll("option")) {
+ if (
+ CreditCard.getNetworkFromName(option.value) ||
+ CreditCard.getNetworkFromName(option.text)
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * If any of the regular expressions match multiple times, we assume the tested
+ * string belongs to a radio button for payment type instead of card type.
+ *
+ * @param {Fnode} fnode
+ * @returns {boolean}
+ */
+function isRadioWithCreditCardText(fnode) {
+ const element = fnode.element;
+ const inputType = element.type;
+ if (!!inputType && inputType === "radio") {
+ const valueMatches = element.value.match(CREDIT_CARD_NETWORK_REGEXP);
+ if (valueMatches) {
+ return valueMatches.length === 1;
+ }
+
+ // Here we are checking that only one label matches only one entry in the regular expression.
+ const labels = getElementLabels(element);
+ let labelsMatched = 0;
+ for (const label of labels) {
+ const labelMatches = label.match(CREDIT_CARD_NETWORK_REGEXP);
+ if (labelMatches) {
+ if (labelMatches.length > 1) {
+ return false;
+ }
+ labelsMatched++;
+ }
+ }
+ if (labelsMatched > 0) {
+ return labelsMatched === 1;
+ }
+
+ const textContentMatches = element.textContent.match(
+ CREDIT_CARD_NETWORK_REGEXP
+ );
+ if (textContentMatches) {
+ return textContentMatches.length === 1;
+ }
+ }
+ return false;
+}
+
+function matchContiguousSubArray(array, subArray) {
+ return array.some((elm, i) =>
+ subArray.every((sElem, j) => sElem === array[i + j])
+ );
+}
+
+function isExpirationMonthLikely(element) {
+ if (element.tagName !== "SELECT") {
+ return false;
+ }
+
+ const options = [...element.options];
+ const desiredValues = Array(12)
+ .fill(1)
+ .map((v, i) => v + i);
+
+ // The number of month options shouldn't be less than 12 or larger than 13
+ // including the default option.
+ if (options.length < 12 || options.length > 13) {
+ return false;
+ }
+
+ return (
+ matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+}
+
+function isExpirationYearLikely(element) {
+ if (element.tagName !== "SELECT") {
+ return false;
+ }
+
+ const options = [...element.options];
+ // A normal expiration year select should contain at least the last three years
+ // in the list.
+ const curYear = new Date().getFullYear();
+ const desiredValues = Array(3)
+ .fill(0)
+ .map((v, i) => v + curYear + i);
+
+ return (
+ matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+}
+
+function nextFieldIsExpirationYearLikely(fnode) {
+ return nextFieldPredicateIsTrue(fnode.element, isExpirationYearLikely);
+}
+
+function previousFieldIsExpirationMonthLikely(fnode) {
+ return previousFieldPredicateIsTrue(fnode.element, isExpirationMonthLikely);
+}
+
+function attrsMatchExpWith2Or4DigitYear(fnode, regExpMatchingFunction) {
+ const element = fnode.element;
+ return (
+ regExpMatchingFunction(element, TwoDigitYearRegExp) ||
+ regExpMatchingFunction(element, FourDigitYearRegExp)
+ );
+}
+
+function maxLengthIs(fnode, maxLengthValue) {
+ return fnode.element.maxLength === maxLengthValue;
+}
+
+function roleIsMenu(fnode) {
+ const role = fnode.element.getAttribute("role");
+ return !!role && role === "menu";
+}
+
+function idOrNameMatchDwfrmAndBml(fnode) {
+ return (
+ idOrNameMatchRegExp(fnode.element, dwfrmRegExp) &&
+ idOrNameMatchRegExp(fnode.element, bmlRegExp)
+ );
+}
+
+function hasTemplatedValue(fnode) {
+ const value = fnode.element.getAttribute("value");
+ return !!value && templatedValue.test(value);
+}
+
+function inputTypeNotNumbery(fnode) {
+ const inputType = fnode.element.type;
+ if (inputType) {
+ return !["text", "tel", "number"].includes(inputType);
+ }
+ return false;
+}
+
+function idOrNameMatchFirstAndLast(fnode) {
+ return (
+ idOrNameMatchRegExp(fnode.element, firstRegExp) &&
+ idOrNameMatchRegExp(fnode.element, lastRegExp)
+ );
+}
+
+/**
+ * Compactly generate a series of rules that all take a single LHS type with no
+ * .when() clause and have only a score() call on the right- hand side.
+ *
+ * @param {Lhs} inType The incoming fnode type that all rules take
+ * @param {object} ruleMap A simple object used as a map with rule names
+ * pointing to scoring callbacks
+ * @yields {Rule}
+ */
+function* simpleScoringRules(inType, ruleMap) {
+ for (const [name, scoringCallback] of Object.entries(ruleMap)) {
+ yield rule(type(inType), score(scoringCallback), { name });
+ }
+}
+
+function makeRuleset(coeffs, biases) {
+ return ruleset(
+ [
+ /**
+ * Factor out the page scan just for a little more speed during training.
+ * This selector is good for most fields. cardType is an exception: it
+ * cannot be type=month.
+ */
+ rule(
+ queriedOrClickedElements(
+ "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=month], select, button"
+ ),
+ type("typicalCandidates")
+ ),
+
+ /**
+ * number rules
+ */
+ rule(type("typicalCandidates"), type("cc-number")),
+ ...simpleScoringRules("cc-number", {
+ idOrNameMatchNumberRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ labelsMatchNumberRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]),
+ closestLabelMatchesNumberRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]),
+ placeholderMatchesNumberRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ ariaLabelMatchesNumberRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-number"]
+ ),
+ idOrNameMatchGift: fnode =>
+ idOrNameMatchRegExp(fnode.element, giftRegExp),
+ labelsMatchGift: fnode => labelsMatchRegExp(fnode.element, giftRegExp),
+ placeholderMatchesGift: fnode =>
+ placeholderMatchesRegExp(fnode.element, giftRegExp),
+ ariaLabelMatchesGift: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, giftRegExp),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ inputTypeNotNumbery,
+ }),
+ rule(type("cc-number"), out("cc-number")),
+
+ /**
+ * name rules
+ */
+ rule(type("typicalCandidates"), type("cc-name")),
+ ...simpleScoringRules("cc-name", {
+ idOrNameMatchNameRegExp: fnode =>
+ idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ labelsMatchNameRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ closestLabelMatchesNameRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]),
+ placeholderMatchesNameRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-name"]
+ ),
+ ariaLabelMatchesNameRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-name"]
+ ),
+ idOrNameMatchFirst: fnode =>
+ idOrNameMatchRegExp(fnode.element, firstRegExp),
+ labelsMatchFirst: fnode =>
+ labelsMatchRegExp(fnode.element, firstRegExp),
+ placeholderMatchesFirst: fnode =>
+ placeholderMatchesRegExp(fnode.element, firstRegExp),
+ ariaLabelMatchesFirst: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, firstRegExp),
+ idOrNameMatchLast: fnode =>
+ idOrNameMatchRegExp(fnode.element, lastRegExp),
+ labelsMatchLast: fnode => labelsMatchRegExp(fnode.element, lastRegExp),
+ placeholderMatchesLast: fnode =>
+ placeholderMatchesRegExp(fnode.element, lastRegExp),
+ ariaLabelMatchesLast: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, lastRegExp),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchFirstAndLast,
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-name"), out("cc-name")),
+
+ /**
+ * cardType rules
+ */
+ rule(
+ queriedOrClickedElements(
+ "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=radio], select, button"
+ ),
+ type("cc-type")
+ ),
+ ...simpleScoringRules("cc-type", {
+ idOrNameMatchTypeRegExp: fnode =>
+ idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ labelsMatchTypeRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ closestLabelMatchesTypeRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]),
+ idOrNameMatchVisaCheckout: fnode =>
+ idOrNameMatchRegExp(fnode.element, VisaCheckoutRegExp),
+ ariaLabelMatchesVisaCheckout: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, VisaCheckoutRegExp),
+ isSelectWithCreditCardOptions,
+ isRadioWithCreditCardText,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-type"), out("cc-type")),
+
+ /**
+ * expiration rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp")),
+ ...simpleScoringRules("cc-exp", {
+ labelsMatchExpRegExp: fnode =>
+ labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]),
+ closestLabelMatchesExpRegExp: fnode =>
+ closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]),
+ placeholderMatchesExpRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp"]
+ ),
+ labelsMatchExpWith2Or4DigitYear: fnode =>
+ attrsMatchExpWith2Or4DigitYear(fnode, labelsMatchRegExp),
+ placeholderMatchesExpWith2Or4DigitYear: fnode =>
+ attrsMatchExpWith2Or4DigitYear(fnode, placeholderMatchesRegExp),
+ labelsMatchMMYY: fnode => labelsMatchRegExp(fnode.element, MMYYRegExp),
+ placeholderMatchesMMYY: fnode =>
+ placeholderMatchesRegExp(fnode.element, MMYYRegExp),
+ maxLengthIs7: fnode => maxLengthIs(fnode, 7),
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ isExpirationMonthLikely: fnode =>
+ isExpirationMonthLikely(fnode.element),
+ isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element),
+ idOrNameMatchMonth: fnode =>
+ idOrNameMatchRegExp(fnode.element, monthRegExp),
+ idOrNameMatchYear: fnode =>
+ idOrNameMatchRegExp(fnode.element, yearRegExp),
+ idOrNameMatchExpMonthRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ idOrNameMatchExpYearRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ idOrNameMatchValidation: fnode =>
+ idOrNameMatchRegExp(fnode.element, /validate|validation/i),
+ }),
+ rule(type("cc-exp"), out("cc-exp")),
+
+ /**
+ * expirationMonth rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp-month")),
+ ...simpleScoringRules("cc-exp-month", {
+ idOrNameMatchExpMonthRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ labelsMatchExpMonthRegExp: fnode =>
+ labelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ closestLabelMatchesExpMonthRegExp: fnode =>
+ closestLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ placeholderMatchesExpMonthRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ ariaLabelMatchesExpMonthRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ idOrNameMatchMonth: fnode =>
+ idOrNameMatchRegExp(fnode.element, monthRegExp),
+ labelsMatchMonth: fnode =>
+ labelsMatchRegExp(fnode.element, monthRegExp),
+ placeholderMatchesMonth: fnode =>
+ placeholderMatchesRegExp(fnode.element, monthRegExp),
+ ariaLabelMatchesMonth: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, monthRegExp),
+ nextFieldIdOrNameMatchExpYearRegExp: fnode =>
+ nextFieldIdOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldLabelsMatchExpYearRegExp: fnode =>
+ nextFieldLabelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldPlaceholderMatchExpYearRegExp: fnode =>
+ nextFieldPlaceholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldAriaLabelMatchExpYearRegExp: fnode =>
+ nextFieldAriaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ nextFieldIdOrNameMatchYear: fnode =>
+ nextFieldIdOrNameMatchRegExp(fnode.element, yearRegExp),
+ nextFieldLabelsMatchYear: fnode =>
+ nextFieldLabelsMatchRegExp(fnode.element, yearRegExp),
+ nextFieldPlaceholderMatchesYear: fnode =>
+ nextFieldPlaceholderMatchesRegExp(fnode.element, yearRegExp),
+ nextFieldAriaLabelMatchesYear: fnode =>
+ nextFieldAriaLabelMatchesRegExp(fnode.element, yearRegExp),
+ nextFieldMatchesExpYearAutocomplete,
+ isExpirationMonthLikely: fnode =>
+ isExpirationMonthLikely(fnode.element),
+ nextFieldIsExpirationYearLikely,
+ maxLengthIs2: fnode => maxLengthIs(fnode, 2),
+ placeholderMatchesMM: fnode =>
+ placeholderMatchesRegExp(fnode.element, MMRegExp),
+ roleIsMenu,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-exp-month"), out("cc-exp-month")),
+
+ /**
+ * expirationYear rules
+ */
+ rule(type("typicalCandidates"), type("cc-exp-year")),
+ ...simpleScoringRules("cc-exp-year", {
+ idOrNameMatchExpYearRegExp: fnode =>
+ idOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ labelsMatchExpYearRegExp: fnode =>
+ labelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ closestLabelMatchesExpYearRegExp: fnode =>
+ closestLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ placeholderMatchesExpYearRegExp: fnode =>
+ placeholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ ariaLabelMatchesExpYearRegExp: fnode =>
+ ariaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-year"]
+ ),
+ idOrNameMatchYear: fnode =>
+ idOrNameMatchRegExp(fnode.element, yearRegExp),
+ labelsMatchYear: fnode => labelsMatchRegExp(fnode.element, yearRegExp),
+ placeholderMatchesYear: fnode =>
+ placeholderMatchesRegExp(fnode.element, yearRegExp),
+ ariaLabelMatchesYear: fnode =>
+ ariaLabelMatchesRegExp(fnode.element, yearRegExp),
+ previousFieldIdOrNameMatchExpMonthRegExp: fnode =>
+ previousFieldIdOrNameMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldLabelsMatchExpMonthRegExp: fnode =>
+ previousFieldLabelsMatchRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldPlaceholderMatchExpMonthRegExp: fnode =>
+ previousFieldPlaceholderMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldAriaLabelMatchExpMonthRegExp: fnode =>
+ previousFieldAriaLabelMatchesRegExp(
+ fnode.element,
+ FathomHeuristicsRegExp.RULES["cc-exp-month"]
+ ),
+ previousFieldIdOrNameMatchMonth: fnode =>
+ previousFieldIdOrNameMatchRegExp(fnode.element, monthRegExp),
+ previousFieldLabelsMatchMonth: fnode =>
+ previousFieldLabelsMatchRegExp(fnode.element, monthRegExp),
+ previousFieldPlaceholderMatchesMonth: fnode =>
+ previousFieldPlaceholderMatchesRegExp(fnode.element, monthRegExp),
+ previousFieldAriaLabelMatchesMonth: fnode =>
+ previousFieldAriaLabelMatchesRegExp(fnode.element, monthRegExp),
+ previousFieldMatchesExpMonthAutocomplete,
+ isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element),
+ previousFieldIsExpirationMonthLikely,
+ placeholderMatchesYYOrYYYY: fnode =>
+ placeholderMatchesRegExp(fnode.element, YYorYYYYRegExp),
+ roleIsMenu,
+ idOrNameMatchSubscription: fnode =>
+ idOrNameMatchRegExp(fnode.element, subscriptionRegExp),
+ idOrNameMatchDwfrmAndBml,
+ hasTemplatedValue,
+ }),
+ rule(type("cc-exp-year"), out("cc-exp-year")),
+ ],
+ coeffs,
+ biases
+ );
+}
+
+const coefficients = {
+ "cc-number": [
+ ["idOrNameMatchNumberRegExp", 7.679469585418701],
+ ["labelsMatchNumberRegExp", 5.122580051422119],
+ ["closestLabelMatchesNumberRegExp", 2.1256935596466064],
+ ["placeholderMatchesNumberRegExp", 9.471800804138184],
+ ["ariaLabelMatchesNumberRegExp", 6.067715644836426],
+ ["idOrNameMatchGift", -22.946273803710938],
+ ["labelsMatchGift", -7.852959632873535],
+ ["placeholderMatchesGift", -2.355496406555176],
+ ["ariaLabelMatchesGift", -2.940307855606079],
+ ["idOrNameMatchSubscription", 0.11255314946174622],
+ ["idOrNameMatchDwfrmAndBml", -0.0006645023822784424],
+ ["hasTemplatedValue", -0.11370040476322174],
+ ["inputTypeNotNumbery", -3.750155210494995]
+ ],
+ "cc-name": [
+ ["idOrNameMatchNameRegExp", 7.496212959289551],
+ ["labelsMatchNameRegExp", 6.081472873687744],
+ ["closestLabelMatchesNameRegExp", 2.600574254989624],
+ ["placeholderMatchesNameRegExp", 5.750874042510986],
+ ["ariaLabelMatchesNameRegExp", 5.162227153778076],
+ ["idOrNameMatchFirst", -6.742659091949463],
+ ["labelsMatchFirst", -0.5234538912773132],
+ ["placeholderMatchesFirst", -3.4615235328674316],
+ ["ariaLabelMatchesFirst", -1.3145145177841187],
+ ["idOrNameMatchLast", -12.561869621276855],
+ ["labelsMatchLast", -0.27417105436325073],
+ ["placeholderMatchesLast", -1.434966802597046],
+ ["ariaLabelMatchesLast", -2.9319725036621094],
+ ["idOrNameMatchFirstAndLast", 24.123435974121094],
+ ["idOrNameMatchSubscription", 0.08349418640136719],
+ ["idOrNameMatchDwfrmAndBml", 0.01882520318031311],
+ ["hasTemplatedValue", 0.182317852973938]
+ ],
+ "cc-type": [
+ ["idOrNameMatchTypeRegExp", 2.0581533908843994],
+ ["labelsMatchTypeRegExp", 1.0784518718719482],
+ ["closestLabelMatchesTypeRegExp", 0.6995877623558044],
+ ["idOrNameMatchVisaCheckout", -3.320356845855713],
+ ["ariaLabelMatchesVisaCheckout", -3.4196767807006836],
+ ["isSelectWithCreditCardOptions", 10.337477684020996],
+ ["isRadioWithCreditCardText", 4.530318737030029],
+ ["idOrNameMatchSubscription", -3.7206356525421143],
+ ["idOrNameMatchDwfrmAndBml", -0.08782318234443665],
+ ["hasTemplatedValue", 0.1772511601448059]
+ ],
+ "cc-exp": [
+ ["labelsMatchExpRegExp", 7.588159561157227],
+ ["closestLabelMatchesExpRegExp", 1.41484534740448],
+ ["placeholderMatchesExpRegExp", 8.759064674377441],
+ ["labelsMatchExpWith2Or4DigitYear", -3.876218795776367],
+ ["placeholderMatchesExpWith2Or4DigitYear", 2.8364884853363037],
+ ["labelsMatchMMYY", 8.836017608642578],
+ ["placeholderMatchesMMYY", -0.5231751799583435],
+ ["maxLengthIs7", 1.3565447330474854],
+ ["idOrNameMatchSubscription", 0.1779913753271103],
+ ["idOrNameMatchDwfrmAndBml", 0.21037884056568146],
+ ["hasTemplatedValue", 0.14900512993335724],
+ ["isExpirationMonthLikely", -3.223409652709961],
+ ["isExpirationYearLikely", -2.536919593811035],
+ ["idOrNameMatchMonth", -3.6893014907836914],
+ ["idOrNameMatchYear", -3.108184337615967],
+ ["idOrNameMatchExpMonthRegExp", -2.264357089996338],
+ ["idOrNameMatchExpYearRegExp", -2.7957723140716553],
+ ["idOrNameMatchValidation", -2.29402756690979]
+ ],
+ "cc-exp-month": [
+ ["idOrNameMatchExpMonthRegExp", 0.2787344455718994],
+ ["labelsMatchExpMonthRegExp", 1.298413634300232],
+ ["closestLabelMatchesExpMonthRegExp", -11.206244468688965],
+ ["placeholderMatchesExpMonthRegExp", 1.2605619430541992],
+ ["ariaLabelMatchesExpMonthRegExp", 1.1330018043518066],
+ ["idOrNameMatchMonth", 6.1464314460754395],
+ ["labelsMatchMonth", 0.7051732540130615],
+ ["placeholderMatchesMonth", 0.7463492751121521],
+ ["ariaLabelMatchesMonth", 1.8244760036468506],
+ ["nextFieldIdOrNameMatchExpYearRegExp", 0.06347066164016724],
+ ["nextFieldLabelsMatchExpYearRegExp", -0.1692247837781906],
+ ["nextFieldPlaceholderMatchExpYearRegExp", 1.0434566736221313],
+ ["nextFieldAriaLabelMatchExpYearRegExp", 1.751156210899353],
+ ["nextFieldIdOrNameMatchYear", -0.532447338104248],
+ ["nextFieldLabelsMatchYear", 1.3248541355133057],
+ ["nextFieldPlaceholderMatchesYear", 0.604235827922821],
+ ["nextFieldAriaLabelMatchesYear", 1.5364223718643188],
+ ["nextFieldMatchesExpYearAutocomplete", 6.285938262939453],
+ ["isExpirationMonthLikely", 13.117807388305664],
+ ["nextFieldIsExpirationYearLikely", 7.182341575622559],
+ ["maxLengthIs2", 4.477289199829102],
+ ["placeholderMatchesMM", 14.403288841247559],
+ ["roleIsMenu", 5.770959854125977],
+ ["idOrNameMatchSubscription", -0.043085768818855286],
+ ["idOrNameMatchDwfrmAndBml", 0.02823038399219513],
+ ["hasTemplatedValue", 0.07234494388103485]
+ ],
+ "cc-exp-year": [
+ ["idOrNameMatchExpYearRegExp", 5.426016807556152],
+ ["labelsMatchExpYearRegExp", 1.3240209817886353],
+ ["closestLabelMatchesExpYearRegExp", -8.702284812927246],
+ ["placeholderMatchesExpYearRegExp", 0.9059725999832153],
+ ["ariaLabelMatchesExpYearRegExp", 0.5550334453582764],
+ ["idOrNameMatchYear", 5.362994194030762],
+ ["labelsMatchYear", 2.7185044288635254],
+ ["placeholderMatchesYear", 0.7883157134056091],
+ ["ariaLabelMatchesYear", 0.311492383480072],
+ ["previousFieldIdOrNameMatchExpMonthRegExp", 1.8155208826065063],
+ ["previousFieldLabelsMatchExpMonthRegExp", -0.46133187413215637],
+ ["previousFieldPlaceholderMatchExpMonthRegExp", 1.0374903678894043],
+ ["previousFieldAriaLabelMatchExpMonthRegExp", -0.5901495814323425],
+ ["previousFieldIdOrNameMatchMonth", -5.960310935974121],
+ ["previousFieldLabelsMatchMonth", 0.6495584845542908],
+ ["previousFieldPlaceholderMatchesMonth", 0.7198042273521423],
+ ["previousFieldAriaLabelMatchesMonth", 3.4590985774993896],
+ ["previousFieldMatchesExpMonthAutocomplete", 2.986003875732422],
+ ["isExpirationYearLikely", 4.021566390991211],
+ ["previousFieldIsExpirationMonthLikely", 9.298635482788086],
+ ["placeholderMatchesYYOrYYYY", 10.457176208496094],
+ ["roleIsMenu", 1.1051956415176392],
+ ["idOrNameMatchSubscription", 0.000688597559928894],
+ ["idOrNameMatchDwfrmAndBml", 0.15687309205532074],
+ ["hasTemplatedValue", -0.19141331315040588]
+ ],
+};
+
+const biases = [
+ ["cc-number", -4.948795795440674],
+ ["cc-name", -5.3578081130981445],
+ ["cc-type", -5.979659557342529],
+ ["cc-exp", -5.849575996398926],
+ ["cc-exp-month", -8.844199180603027],
+ ["cc-exp-year", -6.499860763549805],
+];
+
+/**
+ * END OF CODE PASTED FROM TRAINING REPOSITORY
+ */
+
+/**
+ * MORE CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY:
+ */
+// Currently there is a bug when a ruleset has multple types (ex, cc-name, cc-number)
+// and those types also has the same rules (ex. rule `hasTemplatedValue` is used in
+// all the tyoes). When the above case exists, the coefficient of the rule will be
+// overwritten, which means, we can't have different coefficient for the same rule on
+// different types. To workaround this issue, we create a new ruleset for each type.
+export var CreditCardRulesets = {
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "supportedTypes",
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ null,
+ null,
+ val => val.split(",")
+ );
+
+ for (const type of this.types) {
+ this[type] = makeRuleset([...coefficients[type]], biases);
+ }
+ },
+
+ get types() {
+ return this.supportedTypes;
+ },
+};
+
+CreditCardRulesets.init();
+
+export default CreditCardRulesets;
diff --git a/toolkit/components/formautofill/shared/FieldScanner.sys.mjs b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs
new file mode 100644
index 0000000000..ba64d046ea
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs
@@ -0,0 +1,211 @@
+/* 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/. */
+
+/**
+ * Represents the detailed information about a form field, including
+ * the inferred field name, the approach used for inferring, and additional metadata.
+ */
+export class FieldDetail {
+ // Reference to the elemenet
+ elementWeakRef = null;
+
+ // The inferred field name for this element
+ fieldName = null;
+
+ // The approach we use to infer the information for this element
+ // The possible values are "autocomplete", "fathom", and "regex-heuristic"
+ reason = null;
+
+ /*
+ * The "section", "addressType", and "contactType" values are
+ * used to identify the exact field when the serializable data is received
+ * from the backend. There cannot be multiple fields which have
+ * the same exact combination of these values.
+ */
+
+ // Which section the field belongs to. The value comes from autocomplete attribute.
+ // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens for more details
+ section = "";
+ addressType = "";
+ contactType = "";
+
+ // When a field is split into N fields, we use part to record which field it is
+ // For example, a credit card number field is split into 4 fields, the value of
+ // "part" for the first cc-number field is 1, for the last one is 4.
+ // If the field is not split, the value is null
+ part = null;
+
+ // Confidence value when the field name is inferred by "fathom"
+ confidence = null;
+
+ constructor(
+ element,
+ fieldName,
+ { autocompleteInfo = {}, confidence = null }
+ ) {
+ this.elementWeakRef = Cu.getWeakReference(element);
+ this.fieldName = fieldName;
+
+ if (autocompleteInfo) {
+ this.reason = "autocomplete";
+ this.section = autocompleteInfo.section;
+ this.addressType = autocompleteInfo.addressType;
+ this.contactType = autocompleteInfo.contactType;
+ } else if (confidence) {
+ this.reason = "fathom";
+ this.confidence = confidence;
+ } else {
+ this.reason = "regex-heuristic";
+ }
+ }
+
+ get element() {
+ return this.elementWeakRef.get();
+ }
+
+ get sectionName() {
+ return this.section || this.addressType;
+ }
+}
+
+/**
+ * A scanner for traversing all elements in a form. It also provides a
+ * cursor (parsingIndex) to indicate which element is waiting for parsing.
+ *
+ * The scanner retrives the field detail by calling heuristics handlers
+ * `inferFieldInfo` function.
+ */
+export class FieldScanner {
+ #elementsWeakRef = null;
+ #inferFieldInfoFn = null;
+
+ #parsingIndex = 0;
+
+ fieldDetails = [];
+
+ /**
+ * Create a FieldScanner based on form elements with the existing
+ * fieldDetails.
+ *
+ * @param {Array.DOMElement} elements
+ * The elements from a form for each parser.
+ * @param {Funcion} inferFieldInfoFn
+ * The callback function that is used to infer the field info of a given element
+ */
+ constructor(elements, inferFieldInfoFn) {
+ this.#elementsWeakRef = Cu.getWeakReference(elements);
+ this.#inferFieldInfoFn = inferFieldInfoFn;
+ }
+
+ get #elements() {
+ return this.#elementsWeakRef.get();
+ }
+
+ /**
+ * This cursor means the index of the element which is waiting for parsing.
+ *
+ * @returns {number}
+ * The index of the element which is waiting for parsing.
+ */
+ get parsingIndex() {
+ return this.#parsingIndex;
+ }
+
+ get parsingFinished() {
+ return this.parsingIndex >= this.#elements.length;
+ }
+
+ /**
+ * Move the parsingIndex to the next elements. Any elements behind this index
+ * means the parsing tasks are finished.
+ *
+ * @param {number} index
+ * The latest index of elements waiting for parsing.
+ */
+ set parsingIndex(index) {
+ if (index > this.#elements.length) {
+ throw new Error("The parsing index is out of range.");
+ }
+ this.#parsingIndex = index;
+ }
+
+ /**
+ * Retrieve the field detail by the index. If the field detail is not ready,
+ * the elements will be traversed until matching the index.
+ *
+ * @param {number} index
+ * The index of the element that you want to retrieve.
+ * @returns {object}
+ * The field detail at the specific index.
+ */
+ getFieldDetailByIndex(index) {
+ if (index >= this.#elements.length) {
+ throw new Error(
+ `The index ${index} is out of range.(${this.#elements.length})`
+ );
+ }
+
+ if (index < this.fieldDetails.length) {
+ return this.fieldDetails[index];
+ }
+
+ for (let i = this.fieldDetails.length; i < index + 1; i++) {
+ this.pushDetail();
+ }
+
+ return this.fieldDetails[index];
+ }
+
+ /**
+ * This function retrieves the first unparsed element and obtains its
+ * information by invoking the `inferFieldInfoFn` callback function.
+ * The field information is then stored in a FieldDetail object and
+ * appended to the `fieldDetails` array.
+ *
+ * Any element without the related detail will be used for adding the detail
+ * to the end of field details.
+ */
+ pushDetail() {
+ const elementIndex = this.fieldDetails.length;
+ if (elementIndex >= this.#elements.length) {
+ throw new Error("Try to push the non-existing element info.");
+ }
+ const element = this.#elements[elementIndex];
+ const [fieldName, autocompleteInfo, confidence] =
+ this.#inferFieldInfoFn(element);
+ const fieldDetail = new FieldDetail(element, fieldName, {
+ autocompleteInfo,
+ confidence,
+ });
+
+ this.fieldDetails.push(fieldDetail);
+ }
+
+ /**
+ * When a field detail should be changed its fieldName after parsing, use
+ * this function to update the fieldName which is at a specific index.
+ *
+ * @param {number} index
+ * The index indicates a field detail to be updated.
+ * @param {string} fieldName
+ * The new fieldName
+ * @param {string} reason
+ * What approach we use to identify this field
+ */
+ updateFieldName(index, fieldName, reason = null) {
+ if (index >= this.fieldDetails.length) {
+ throw new Error("Try to update the non-existing field detail.");
+ }
+ this.fieldDetails[index].fieldName = fieldName;
+ if (reason) {
+ this.fieldDetails[index].reason = reason;
+ }
+ }
+
+ elementExisting(index) {
+ return index < this.#elements.length;
+ }
+}
+
+export default FieldScanner;
diff --git a/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs
new file mode 100644
index 0000000000..b84064b716
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs
@@ -0,0 +1,400 @@
+/* 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 { 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 lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormAutofillAddressSection:
+ "resource://gre/modules/shared/FormAutofillSection.sys.mjs",
+ FormAutofillCreditCardSection:
+ "resource://gre/modules/shared/FormAutofillSection.sys.mjs",
+ FormAutofillHeuristics:
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+ FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
+});
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+/**
+ * Handles profile autofill for a DOM Form element.
+ */
+export class FormAutofillHandler {
+ // The window to which this form belongs
+ window = null;
+
+ // A WindowUtils reference of which Window the form belongs
+ winUtils = null;
+
+ // DOM Form element to which this object is attached
+ form = null;
+
+ // An array of section that are found in this form
+ sections = [];
+
+ // The section contains the focused input
+ #focusedSection = null;
+
+ // Caches the element to section mapping
+ #cachedSectionByElement = new WeakMap();
+
+ // Keeps track of filled state for all identified elements
+ #filledStateByElement = new WeakMap();
+ /**
+ * Array of collected data about relevant form fields. Each item is an object
+ * storing the identifying details of the field and a reference to the
+ * originally associated element from the form.
+ *
+ * The "section", "addressType", "contactType", and "fieldName" values are
+ * used to identify the exact field when the serializable data is received
+ * from the backend. There cannot be multiple fields which have
+ * the same exact combination of these values.
+ *
+ * A direct reference to the associated element cannot be sent to the user
+ * interface because processing may be done in the parent process.
+ */
+ fieldDetails = null;
+
+ /**
+ * Initialize the form from `FormLike` object to handle the section or form
+ * operations.
+ *
+ * @param {FormLike} form Form that need to be auto filled
+ * @param {Function} onFormSubmitted Function that can be invoked
+ * to simulate form submission. Function is passed
+ * three arguments: (1) a FormLike for the form being
+ * submitted, (2) the corresponding Window, and (3) the
+ * responsible FormAutofillHandler.
+ * @param {Function} onAutofillCallback Function that can be invoked
+ * when we want to suggest autofill on a form.
+ */
+ constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) {
+ this._updateForm(form);
+
+ this.window = this.form.rootElement.ownerGlobal;
+ this.winUtils = this.window.windowUtils;
+
+ // Enum for form autofill MANUALLY_MANAGED_STATES values
+ this.FIELD_STATE_ENUM = {
+ // not themed
+ [FIELD_STATES.NORMAL]: null,
+ // highlighted
+ [FIELD_STATES.AUTO_FILLED]: "autofill",
+ // highlighted && grey color text
+ [FIELD_STATES.PREVIEW]: "-moz-autofill-preview",
+ };
+
+ /**
+ * This function is used if the form handler (or one of its sections)
+ * determines that it needs to act as if the form had been submitted.
+ */
+ this.onFormSubmitted = () => {
+ onFormSubmitted(this.form, this.window, this);
+ };
+
+ this.onAutofillCallback = onAutofillCallback;
+
+ XPCOMUtils.defineLazyGetter(this, "log", () =>
+ FormAutofill.defineLogGetter(this, "FormAutofillHandler")
+ );
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "input": {
+ if (!event.isTrusted) {
+ return;
+ }
+ const target = event.target;
+ const targetFieldDetail = this.getFieldDetailByElement(target);
+ const isCreditCardField = FormAutofillUtils.isCreditCardField(
+ targetFieldDetail.fieldName
+ );
+
+ // If the user manually blanks a credit card field, then
+ // we want the popup to be activated.
+ if (
+ !HTMLSelectElement.isInstance(target) &&
+ isCreditCardField &&
+ target.value === ""
+ ) {
+ this.onAutofillCallback();
+ }
+
+ if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) {
+ return;
+ }
+
+ this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL);
+ const section = this.getSectionByElement(
+ targetFieldDetail.elementWeakRef.get()
+ );
+ section?.clearFilled(targetFieldDetail);
+ }
+ }
+ }
+
+ set focusedInput(element) {
+ const section = this.getSectionByElement(element);
+ if (!section) {
+ return;
+ }
+
+ this.#focusedSection = section;
+ this.#focusedSection.focusedInput = element;
+ }
+
+ getSectionByElement(element) {
+ const section =
+ this.#cachedSectionByElement.get(element) ??
+ this.sections.find(s => s.getFieldDetailByElement(element));
+ if (!section) {
+ return null;
+ }
+
+ this.#cachedSectionByElement.set(element, section);
+ return section;
+ }
+
+ getFieldDetailByElement(element) {
+ for (const section of this.sections) {
+ const detail = section.getFieldDetailByElement(element);
+ if (detail) {
+ return detail;
+ }
+ }
+ return null;
+ }
+
+ get activeSection() {
+ return this.#focusedSection;
+ }
+
+ /**
+ * Check the form is necessary to be updated. This function should be able to
+ * detect any changes including all control elements in the form.
+ *
+ * @param {HTMLElement} element The element supposed to be in the form.
+ * @returns {boolean} FormAutofillHandler.form is updated or not.
+ */
+ updateFormIfNeeded(element) {
+ // When the following condition happens, FormAutofillHandler.form should be
+ // updated:
+ // * The count of form controls is changed.
+ // * When the element can not be found in the current form.
+ //
+ // However, we should improve the function to detect the element changes.
+ // e.g. a tel field is changed from type="hidden" to type="tel".
+
+ let _formLike;
+ const getFormLike = () => {
+ if (!_formLike) {
+ _formLike = lazy.FormLikeFactory.createFromField(element);
+ }
+ return _formLike;
+ };
+
+ const currentForm = element.form ?? getFormLike();
+ if (currentForm.elements.length != this.form.elements.length) {
+ this.log.debug("The count of form elements is changed.");
+ this._updateForm(getFormLike());
+ return true;
+ }
+
+ if (!this.form.elements.includes(element)) {
+ this.log.debug("The element can not be found in the current form.");
+ this._updateForm(getFormLike());
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Update the form with a new FormLike, and the related fields should be
+ * updated or clear to ensure the data consistency.
+ *
+ * @param {FormLike} form a new FormLike to replace the original one.
+ */
+ _updateForm(form) {
+ this.form = form;
+
+ this.fieldDetails = null;
+
+ this.sections = [];
+ this.#cachedSectionByElement = new WeakMap();
+ }
+
+ /**
+ * Set fieldDetails from the form about fields that can be autofilled.
+ *
+ * @returns {Array} The valid address and credit card details.
+ */
+ collectFormFields(ignoreInvalid = true) {
+ const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form);
+ const allValidDetails = [];
+ for (const section of sections) {
+ let autofillableSection;
+ if (section.type == lazy.FormSection.ADDRESS) {
+ autofillableSection = new lazy.FormAutofillAddressSection(
+ section,
+ this
+ );
+ } else {
+ autofillableSection = new lazy.FormAutofillCreditCardSection(
+ section,
+ this
+ );
+ }
+
+ if (ignoreInvalid && !autofillableSection.isValidSection()) {
+ continue;
+ }
+
+ this.sections.push(autofillableSection);
+ allValidDetails.push(...autofillableSection.fieldDetails);
+ }
+
+ this.fieldDetails = allValidDetails;
+ return allValidDetails;
+ }
+
+ #hasFilledSection() {
+ return this.sections.some(section => section.isFilled());
+ }
+
+ getFilledStateByElement(element) {
+ return this.#filledStateByElement.get(element);
+ }
+
+ /**
+ * Change the state of a field to correspond with different presentations.
+ *
+ * @param {object} fieldDetail
+ * A fieldDetail of which its element is about to update the state.
+ * @param {string} nextState
+ * Used to determine the next state
+ */
+ changeFieldState(fieldDetail, nextState) {
+ const element = fieldDetail.elementWeakRef.get();
+ if (!element) {
+ this.log.warn(
+ fieldDetail.fieldName,
+ "is unreachable while changing state"
+ );
+ return;
+ }
+ if (!(nextState in this.FIELD_STATE_ENUM)) {
+ this.log.warn(
+ fieldDetail.fieldName,
+ "is trying to change to an invalid state"
+ );
+ return;
+ }
+
+ if (this.#filledStateByElement.get(element) == nextState) {
+ return;
+ }
+
+ let nextStateValue = null;
+ for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) {
+ // The NORMAL state is simply the absence of other manually
+ // managed states so we never need to add or remove it.
+ if (!mmStateValue) {
+ continue;
+ }
+
+ if (state == nextState) {
+ nextStateValue = mmStateValue;
+ } else {
+ this.winUtils.removeManuallyManagedState(element, mmStateValue);
+ }
+ }
+
+ if (nextStateValue) {
+ this.winUtils.addManuallyManagedState(element, nextStateValue);
+ }
+
+ if (nextState == FIELD_STATES.AUTO_FILLED) {
+ element.addEventListener("input", this, { mozSystemGroup: true });
+ }
+
+ this.#filledStateByElement.set(element, nextState);
+ }
+
+ /**
+ * Processes form fields that can be autofilled, and populates them with the
+ * profile provided by backend.
+ *
+ * @param {object} profile
+ * A profile to be filled in.
+ */
+ async autofillFormFields(profile) {
+ const noFilledSectionsPreviously = !this.#hasFilledSection();
+ await this.activeSection.autofillFields(profile);
+
+ const onChangeHandler = e => {
+ if (!e.isTrusted) {
+ return;
+ }
+ if (e.type == "reset") {
+ this.sections.map(section => section.resetFieldStates());
+ }
+ // Unregister listeners once no field is in AUTO_FILLED state.
+ if (!this.#hasFilledSection()) {
+ this.form.rootElement.removeEventListener("input", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ this.form.rootElement.removeEventListener("reset", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ }
+ };
+
+ if (noFilledSectionsPreviously) {
+ // Handle the highlight style resetting caused by user's correction afterward.
+ this.log.debug("register change handler for filled form:", this.form);
+ this.form.rootElement.addEventListener("input", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ this.form.rootElement.addEventListener("reset", onChangeHandler, {
+ mozSystemGroup: true,
+ });
+ }
+ }
+
+ /**
+ * Collect the filled sections within submitted form and convert all the valid
+ * field data into multiple records.
+ *
+ * @returns {object} records
+ * {Array.<Object>} records.address
+ * {Array.<Object>} records.creditCard
+ */
+ createRecords() {
+ const records = {
+ address: [],
+ creditCard: [],
+ };
+
+ for (const section of this.sections) {
+ const secRecord = section.createRecord();
+ if (!secRecord) {
+ continue;
+ }
+ if (section instanceof lazy.FormAutofillAddressSection) {
+ records.address.push(secRecord);
+ } else if (section instanceof lazy.FormAutofillCreditCardSection) {
+ records.creditCard.push(secRecord);
+ } else {
+ throw new Error("Unknown section type");
+ }
+ }
+
+ return records;
+ }
+}
diff --git a/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs
new file mode 100644
index 0000000000..f73af3a8f3
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs
@@ -0,0 +1,1168 @@
+/* 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 { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { HeuristicsRegExp } from "resource://gre/modules/shared/HeuristicsRegExp.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ CreditCardRulesets: "resource://gre/modules/shared/CreditCardRuleset.sys.mjs",
+ FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillHeuristics")
+);
+
+/**
+ * To help us classify sections, we want to know what fields can appear
+ * multiple times in a row.
+ * Such fields, like `address-line{X}`, should not break sections.
+ */
+const MULTI_FIELD_NAMES = [
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "tel",
+ "postal-code",
+ "email",
+ "street-address",
+];
+
+/**
+ * To help us classify sections that can appear only N times in a row.
+ * For example, the only time multiple cc-number fields are valid is when
+ * there are four of these fields in a row.
+ * Otherwise, multiple cc-number fields should be in separate sections.
+ */
+const MULTI_N_FIELD_NAMES = {
+ "cc-number": 4,
+};
+
+export class FormSection {
+ static ADDRESS = "address";
+ static CREDIT_CARD = "creditCard";
+
+ #fieldDetails = [];
+
+ #name = "";
+
+ constructor(fieldDetails) {
+ if (!fieldDetails.length) {
+ throw new TypeError("A section should contain at least one field");
+ }
+
+ fieldDetails.forEach(field => this.addField(field));
+
+ const fieldName = fieldDetails[0].fieldName;
+ if (lazy.FormAutofillUtils.isAddressField(fieldName)) {
+ this.type = FormSection.ADDRESS;
+ } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) {
+ this.type = FormSection.CREDIT_CARD;
+ } else {
+ throw new Error("Unknown field type to create a section.");
+ }
+ }
+
+ get fieldDetails() {
+ return this.#fieldDetails;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ addField(fieldDetail) {
+ this.#name ||= fieldDetail.sectionName;
+ this.#fieldDetails.push(fieldDetail);
+ }
+}
+
+/**
+ * Returns the autocomplete information of fields according to heuristics.
+ */
+export const FormAutofillHeuristics = {
+ RULES: HeuristicsRegExp.getRules(),
+
+ CREDIT_CARD_FIELDNAMES: [],
+ ADDRESS_FIELDNAMES: [],
+ /**
+ * Try to find a contiguous sub-array within an array.
+ *
+ * @param {Array} array
+ * @param {Array} subArray
+ *
+ * @returns {boolean}
+ * Return whether subArray was found within the array or not.
+ */
+ _matchContiguousSubArray(array, subArray) {
+ return array.some((elm, i) =>
+ subArray.every((sElem, j) => sElem == array[i + j])
+ );
+ },
+
+ /**
+ * Try to find the field that is look like a month select.
+ *
+ * @param {DOMElement} element
+ * @returns {boolean}
+ * Return true if we observe the trait of month select in
+ * the current element.
+ */
+ _isExpirationMonthLikely(element) {
+ if (!HTMLSelectElement.isInstance(element)) {
+ return false;
+ }
+
+ const options = [...element.options];
+ const desiredValues = Array(12)
+ .fill(1)
+ .map((v, i) => v + i);
+
+ // The number of month options shouldn't be less than 12 or larger than 13
+ // including the default option.
+ if (options.length < 12 || options.length > 13) {
+ return false;
+ }
+
+ return (
+ this._matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ this._matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+ },
+
+ /**
+ * Try to find the field that is look like a year select.
+ *
+ * @param {DOMElement} element
+ * @returns {boolean}
+ * Return true if we observe the trait of year select in
+ * the current element.
+ */
+ _isExpirationYearLikely(element) {
+ if (!HTMLSelectElement.isInstance(element)) {
+ return false;
+ }
+
+ const options = [...element.options];
+ // A normal expiration year select should contain at least the last three years
+ // in the list.
+ const curYear = new Date().getFullYear();
+ const desiredValues = Array(3)
+ .fill(0)
+ .map((v, i) => v + curYear + i);
+
+ return (
+ this._matchContiguousSubArray(
+ options.map(e => +e.value),
+ desiredValues
+ ) ||
+ this._matchContiguousSubArray(
+ options.map(e => +e.label),
+ desiredValues
+ )
+ );
+ },
+
+ /**
+ * Try to match the telephone related fields to the grammar
+ * list to see if there is any valid telephone set and correct their
+ * field names.
+ *
+ * @param {FieldScanner} fieldScanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parsePhoneFields(fieldScanner) {
+ let matchingResult;
+
+ const GRAMMARS = this.PHONE_FIELD_GRAMMARS;
+ for (let i = 0; i < GRAMMARS.length; i++) {
+ let detailStart = fieldScanner.parsingIndex;
+ let ruleStart = i;
+ for (
+ ;
+ i < GRAMMARS.length &&
+ GRAMMARS[i][0] &&
+ fieldScanner.elementExisting(detailStart);
+ i++, detailStart++
+ ) {
+ let detail = fieldScanner.getFieldDetailByIndex(detailStart);
+ if (
+ !detail ||
+ GRAMMARS[i][0] != detail.fieldName ||
+ detail?.reason == "autocomplete"
+ ) {
+ break;
+ }
+ let element = detail.elementWeakRef.get();
+ if (!element) {
+ break;
+ }
+ if (
+ GRAMMARS[i][2] &&
+ (!element.maxLength || GRAMMARS[i][2] < element.maxLength)
+ ) {
+ break;
+ }
+ }
+ if (i >= GRAMMARS.length) {
+ break;
+ }
+
+ if (!GRAMMARS[i][0]) {
+ matchingResult = {
+ ruleFrom: ruleStart,
+ ruleTo: i,
+ };
+ break;
+ }
+
+ // Fast rewinding to the next rule.
+ for (; i < GRAMMARS.length; i++) {
+ if (!GRAMMARS[i][0]) {
+ break;
+ }
+ }
+ }
+
+ let parsedField = false;
+ if (matchingResult) {
+ let { ruleFrom, ruleTo } = matchingResult;
+ let detailStart = fieldScanner.parsingIndex;
+ for (let i = ruleFrom; i < ruleTo; i++) {
+ fieldScanner.updateFieldName(detailStart, GRAMMARS[i][1]);
+ fieldScanner.parsingIndex++;
+ detailStart++;
+ parsedField = true;
+ }
+ }
+
+ if (fieldScanner.parsingFinished) {
+ return parsedField;
+ }
+
+ let nextField = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ if (
+ nextField &&
+ nextField.reason != "autocomplete" &&
+ fieldScanner.parsingIndex > 0
+ ) {
+ const regExpTelExtension = new RegExp(
+ "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT
+ "iu"
+ );
+ const previousField = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex - 1
+ );
+ const previousFieldType = lazy.FormAutofillUtils.getCategoryFromFieldName(
+ previousField.fieldName
+ );
+ if (
+ previousField &&
+ previousFieldType == "tel" &&
+ this._matchRegexp(nextField.elementWeakRef.get(), regExpTelExtension)
+ ) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "tel-extension"
+ );
+ fieldScanner.parsingIndex++;
+ parsedField = true;
+ }
+ }
+
+ return parsedField;
+ },
+
+ /**
+ * Try to find the correct address-line[1-3] sequence and correct their field
+ * names.
+ *
+ * @param {FieldScanner} fieldScanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseAddressFields(fieldScanner) {
+ if (fieldScanner.parsingFinished) {
+ return false;
+ }
+
+ // TODO: These address-line* regexps are for the lines with numbers, and
+ // they are the subset of the regexps in `heuristicsRegexp.js`. We have to
+ // find a better way to make them consistent.
+ const addressLines = ["address-line1", "address-line2", "address-line3"];
+ const addressLineRegexps = {
+ "address-line1": new RegExp(
+ "address[_-]?line(1|one)|address1|addr1" +
+ "|addrline1|address_1" + // Extra rules by Firefox
+ "|indirizzo1" + // it-IT
+ "|住所1" + // ja-JP
+ "|地址1" + // zh-CN
+ "|주소.?1", // ko-KR
+ "iu"
+ ),
+ "address-line2": new RegExp(
+ "address[_-]?line(2|two)|address2|addr2" +
+ "|addrline2|address_2" + // Extra rules by Firefox
+ "|indirizzo2" + // it-IT
+ "|住所2" + // ja-JP
+ "|地址2" + // zh-CN
+ "|주소.?2", // ko-KR
+ "iu"
+ ),
+ "address-line3": new RegExp(
+ "address[_-]?line(3|three)|address3|addr3" +
+ "|addrline3|address_3" + // Extra rules by Firefox
+ "|indirizzo3" + // it-IT
+ "|住所3" + // ja-JP
+ "|地址3" + // zh-CN
+ "|주소.?3", // ko-KR
+ "iu"
+ ),
+ };
+
+ let parsedFields = false;
+ const startIndex = fieldScanner.parsingIndex;
+ while (!fieldScanner.parsingFinished) {
+ let detail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ if (
+ !detail ||
+ !addressLines.includes(detail.fieldName) ||
+ detail.reason == "autocomplete"
+ ) {
+ // When the field is not related to any address-line[1-3] fields or
+ // determined by autocomplete attr, it means the parsing process can be
+ // terminated.
+ break;
+ }
+ parsedFields = false;
+ const elem = detail.elementWeakRef.get();
+ for (let regexp of Object.keys(addressLineRegexps)) {
+ if (this._matchRegexp(elem, addressLineRegexps[regexp])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, regexp);
+ parsedFields = true;
+ }
+ }
+ if (!parsedFields) {
+ break;
+ }
+ fieldScanner.parsingIndex++;
+ }
+
+ // If "address-line2" is found but the previous field is "street-address",
+ // then we assume what the website actually wants is "address-line1" instead
+ // of "street-address".
+ if (
+ startIndex > 0 &&
+ fieldScanner.getFieldDetailByIndex(startIndex)?.fieldName ==
+ "address-line2" &&
+ fieldScanner.getFieldDetailByIndex(startIndex - 1)?.fieldName ==
+ "street-address"
+ ) {
+ fieldScanner.updateFieldName(
+ startIndex - 1,
+ "address-line1",
+ "regexp-heuristic"
+ );
+ }
+
+ return parsedFields;
+ },
+
+ // The old heuristics can be removed when we fully adopt fathom, so disable the
+ // esline complexity check for now
+ /* eslint-disable complexity */
+ /**
+ * Try to look for expiration date fields and revise the field names if needed.
+ *
+ * @param {FieldScanner} fieldScanner
+ * The current parsing status for all elements
+ * @returns {boolean}
+ * Return true if there is any field can be recognized in the parser,
+ * otherwise false.
+ */
+ _parseCreditCardFields(fieldScanner) {
+ if (fieldScanner.parsingFinished) {
+ return false;
+ }
+
+ const savedIndex = fieldScanner.parsingIndex;
+ const detail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+
+ // Respect to autocomplete attr
+ if (!detail || detail?.reason == "autocomplete") {
+ return false;
+ }
+
+ const monthAndYearFieldNames = ["cc-exp-month", "cc-exp-year"];
+ // Skip the uninteresting fields
+ if (!["cc-exp", ...monthAndYearFieldNames].includes(detail.fieldName)) {
+ return false;
+ }
+
+ // The heuristic below should be covered by fathom rules, so we can skip doing
+ // it.
+ if (
+ lazy.FormAutofillUtils.isFathomCreditCardsEnabled() &&
+ lazy.CreditCardRulesets.types.includes(detail.fieldName)
+ ) {
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+
+ const element = detail.elementWeakRef.get();
+
+ // If the input type is a month picker, then assume it's cc-exp.
+ if (element.type == "month") {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+
+ return true;
+ }
+
+ // Don't process the fields if expiration month and expiration year are already
+ // matched by regex in correct order.
+ if (
+ fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++)
+ .fieldName == "cc-exp-month" &&
+ !fieldScanner.parsingFinished &&
+ fieldScanner.getFieldDetailByIndex(fieldScanner.parsingIndex++)
+ .fieldName == "cc-exp-year"
+ ) {
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Determine the field name by checking if the fields are month select and year select
+ // likely.
+ if (this._isExpirationMonthLikely(element)) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._isExpirationYearLikely(nextElement)) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Verify that the following consecutive two fields can match cc-exp-month and cc-exp-year
+ // respectively.
+ if (this._findMatchedFieldName(element, ["cc-exp-month"])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._findMatchedFieldName(nextElement, ["cc-exp-year"])) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Look for MM and/or YY(YY).
+ if (this._matchRegexp(element, /^mm$/gi)) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp-month");
+ fieldScanner.parsingIndex++;
+ if (!fieldScanner.parsingFinished) {
+ const nextDetail = fieldScanner.getFieldDetailByIndex(
+ fieldScanner.parsingIndex
+ );
+ const nextElement = nextDetail.elementWeakRef.get();
+ if (this._matchRegexp(nextElement, /^(yy|yyyy)$/)) {
+ fieldScanner.updateFieldName(
+ fieldScanner.parsingIndex,
+ "cc-exp-year"
+ );
+ fieldScanner.parsingIndex++;
+
+ return true;
+ }
+ }
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Look for a cc-exp with 2-digit or 4-digit year.
+ if (
+ this._matchRegexp(
+ element,
+ /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/gi
+ ) ||
+ this._matchRegexp(
+ element,
+ /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/gi
+ )
+ ) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Match general cc-exp regexp at last.
+ if (this._findMatchedFieldName(element, ["cc-exp"])) {
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, "cc-exp");
+ fieldScanner.parsingIndex++;
+ return true;
+ }
+ fieldScanner.parsingIndex = savedIndex;
+
+ // Set current field name to null as it failed to match any patterns.
+ fieldScanner.updateFieldName(fieldScanner.parsingIndex, null);
+ fieldScanner.parsingIndex++;
+ return true;
+ },
+
+ /**
+ * This function should provide all field details of a form which are placed
+ * in the belonging section. The details contain the autocomplete info
+ * (e.g. fieldName, section, etc).
+ *
+ * @param {HTMLFormElement} form
+ * the elements in this form to be predicted the field info.
+ * @returns {Array<FormSection>}
+ * all sections within its field details in the form.
+ */
+ getFormInfo(form) {
+ let elements = Array.from(form.elements).filter(element =>
+ lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element)
+ );
+
+ // Due to potential performance impact while running visibility check on
+ // a large amount of elements, a comprehensive visibility check
+ // (considering opacity and CSS visibility) is only applied when the number
+ // of eligible elements is below a certain threshold.
+ const runVisiblityCheck =
+ elements.length < lazy.FormAutofillUtils.visibilityCheckThreshold;
+ if (!runVisiblityCheck) {
+ lazy.log.debug(
+ `Skip running visibility check, because of too many elements (${elements.length})`
+ );
+ }
+
+ elements = elements.filter(element =>
+ lazy.FormAutofillUtils.isFieldVisible(element, runVisiblityCheck)
+ );
+
+ const fieldScanner = new lazy.FieldScanner(elements, element =>
+ this.inferFieldInfo(element, elements)
+ );
+
+ while (!fieldScanner.parsingFinished) {
+ let parsedPhoneFields = this._parsePhoneFields(fieldScanner);
+ let parsedAddressFields = this._parseAddressFields(fieldScanner);
+ let parsedExpirationDateFields =
+ this._parseCreditCardFields(fieldScanner);
+
+ // If there is no field parsed, the parsing cursor can be moved
+ // forward to the next one.
+ if (
+ !parsedPhoneFields &&
+ !parsedAddressFields &&
+ !parsedExpirationDateFields
+ ) {
+ fieldScanner.parsingIndex++;
+ }
+ }
+
+ lazy.LabelUtils.clearLabelMap();
+
+ const fields = fieldScanner.fieldDetails;
+ const sections = [
+ ...this._classifySections(
+ fields.filter(f => lazy.FormAutofillUtils.isAddressField(f.fieldName))
+ ),
+ ...this._classifySections(
+ fields.filter(f =>
+ lazy.FormAutofillUtils.isCreditCardField(f.fieldName)
+ )
+ ),
+ ];
+
+ return sections.sort(
+ (a, b) =>
+ fields.indexOf(a.fieldDetails[0]) - fields.indexOf(b.fieldDetails[0])
+ );
+ },
+
+ /**
+ * The result is an array contains the sections with its belonging field details.
+ *
+ * @param {Array<FieldDetails>} fieldDetails field detail array to be classified
+ * @returns {Array<FormSection>} The array with the sections.
+ */
+ _classifySections(fieldDetails) {
+ let sections = [];
+ for (let i = 0; i < fieldDetails.length; i++) {
+ const fieldName = fieldDetails[i].fieldName;
+ const sectionName = fieldDetails[i].sectionName;
+
+ const [currentSection] = sections.slice(-1);
+
+ // The section this field might belong to
+ let candidateSection = null;
+
+ // If the field doesn't have a section name, MAYBE put it to the previous
+ // section if exists. If the field has a section name, maybe put it to the
+ // nearest section that either has the same name or it doesn't has a name.
+ // Otherwise, create a new section.
+ if (!currentSection || !sectionName) {
+ candidateSection = currentSection;
+ } else if (sectionName) {
+ for (let idx = sections.length - 1; idx >= 0; idx--) {
+ if (!sections[idx].name || sections[idx].name == sectionName) {
+ candidateSection = sections[idx];
+ break;
+ }
+ }
+ }
+
+ // We got an candidate section to put the field to, check whether the section
+ // already has a field with the same field name. If yes, only add the field to when
+ // the type of the field might appear multiple times in a row.
+ if (candidateSection) {
+ let createNewSection = true;
+ if (candidateSection.fieldDetails.find(f => f.fieldName == fieldName)) {
+ const [lastFieldDetail] = candidateSection.fieldDetails.slice(-1);
+ if (lastFieldDetail.fieldName == fieldName) {
+ if (MULTI_FIELD_NAMES.includes(fieldName)) {
+ createNewSection = false;
+ } else if (fieldName in MULTI_N_FIELD_NAMES) {
+ // This is the heuristic to handle special cases where we can have multiple
+ // fields in one section, but only if the field has appeared N times in a row.
+ // For example, websites can use 4 consecutive 4-digit `cc-number` fields
+ // instead of one 16-digit `cc-number` field.
+
+ const N = MULTI_N_FIELD_NAMES[fieldName];
+ if (lastFieldDetail.part) {
+ // If `part` is set, we have already identified this field can be
+ // merged previously
+ if (lastFieldDetail.part < N) {
+ createNewSection = false;
+ fieldDetails[i].part = lastFieldDetail.part + 1;
+ }
+ // If the next N fields are all the same field, we can merge them
+ } else if (
+ N == 2 ||
+ fieldDetails
+ .slice(i + 1, i + N - 1)
+ .every(f => f.fieldName == fieldName)
+ ) {
+ lastFieldDetail.part = 1;
+ fieldDetails[i].part = 2;
+ createNewSection = false;
+ }
+ }
+ }
+ } else {
+ // The field doesn't exist in the candidate section, add it.
+ createNewSection = false;
+ }
+
+ if (!createNewSection) {
+ candidateSection.addField(fieldDetails[i]);
+ continue;
+ }
+ }
+
+ // Create a new section
+ sections.push(new FormSection([fieldDetails[i]]));
+ }
+
+ return sections;
+ },
+
+ _getPossibleFieldNames(element) {
+ let fieldNames = [];
+ const isAutoCompleteOff =
+ element.autocomplete == "off" || element.form?.autocomplete == "off";
+ if (
+ FormAutofill.isAutofillCreditCardsAvailable &&
+ (!isAutoCompleteOff || FormAutofill.creditCardsAutocompleteOff)
+ ) {
+ fieldNames.push(...this.CREDIT_CARD_FIELDNAMES);
+ }
+ if (
+ FormAutofill.isAutofillAddressesAvailable &&
+ (!isAutoCompleteOff || FormAutofill.addressesAutocompleteOff)
+ ) {
+ fieldNames.push(...this.ADDRESS_FIELDNAMES);
+ }
+
+ if (HTMLSelectElement.isInstance(element)) {
+ const FIELDNAMES_FOR_SELECT_ELEMENT = [
+ "address-level1",
+ "address-level2",
+ "country",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-exp",
+ "cc-type",
+ ];
+ fieldNames = fieldNames.filter(name =>
+ FIELDNAMES_FOR_SELECT_ELEMENT.includes(name)
+ );
+ }
+
+ return fieldNames;
+ },
+
+ /**
+ * Get inferred information about an input element using autocomplete info, fathom and regex-based heuristics.
+ *
+ * @param {HTMLElement} element - The input element to infer information about.
+ * @param {Array<HTMLElement>} elements - See `getFathomField` for details
+ * @returns {Array} - An array containing:
+ * [0]the inferred field name
+ * [1]autocomplete information if the element has autocompelte attribute, null otherwise.
+ * [2]fathom confidence if fathom considers it a cc field, null otherwise.
+ */
+ inferFieldInfo(element, elements = []) {
+ const autocompleteInfo = element.getAutocompleteInfo();
+
+ // An input[autocomplete="on"] will not be early return here since it stll
+ // needs to find the field name.
+ if (
+ autocompleteInfo?.fieldName &&
+ !["on", "off"].includes(autocompleteInfo.fieldName)
+ ) {
+ return [autocompleteInfo.fieldName, autocompleteInfo, null];
+ }
+
+ const fields = this._getPossibleFieldNames(element);
+
+ // "email" type of input is accurate for heuristics to determine its Email
+ // field or not. However, "tel" type is used for ZIP code for some web site
+ // (e.g. HomeDepot, BestBuy), so "tel" type should be not used for "tel"
+ // prediction.
+ if (element.type == "email" && fields.includes("email")) {
+ return ["email", null, null];
+ }
+
+ if (lazy.FormAutofillUtils.isFathomCreditCardsEnabled()) {
+ // We don't care fields that are not supported by fathom
+ const fathomFields = fields.filter(r =>
+ lazy.CreditCardRulesets.types.includes(r)
+ );
+ const [matchedFieldName, confidence] = this.getFathomField(
+ element,
+ fathomFields,
+ elements
+ );
+ // At this point, use fathom's recommendation if it has one
+ if (matchedFieldName) {
+ return [matchedFieldName, null, confidence];
+ }
+
+ // Continue to run regex-based heuristics even when fathom doesn't recognize
+ // the field. Since the regex-based heuristic has good search coverage but
+ // has a worse precision. We use it in conjunction with fathom to maximize
+ // our search coverage. For example, when a <input> is not considered cc-name
+ // by fathom but is considered cc-name by regex-based heuristic, if the form
+ // also contains a cc-number identified by fathom, we will treat the form as a
+ // valid cc form; hence both cc-number & cc-name are identified.
+ }
+
+ // Check every select for options that
+ // match credit card network names in value or label.
+ if (HTMLSelectElement.isInstance(element)) {
+ for (let option of element.querySelectorAll("option")) {
+ if (
+ lazy.CreditCard.getNetworkFromName(option.value) ||
+ lazy.CreditCard.getNetworkFromName(option.text)
+ ) {
+ return ["cc-type", null, null];
+ }
+ }
+ }
+
+ if (fields.length) {
+ // Find a matched field name using regex-based heuristics
+ const matchedFieldName = this._findMatchedFieldName(element, fields);
+ if (matchedFieldName) {
+ return [matchedFieldName, null, null];
+ }
+ }
+
+ return [null, null, null];
+ },
+
+ /**
+ * Using Fathom, say what kind of CC field an element is most likely to be.
+ * This function deoesn't only run fathom on the passed elements. It also
+ * runs fathom for all elements in the FieldScanner for optimization purpose.
+ *
+ * @param {HTMLElement} element
+ * @param {Array} fields
+ * @param {Array<HTMLElement>} elements - All other eligible elements in the same form. This is mainly used as an
+ * optimization approach to run fathom model on all eligible elements
+ * once instead of one by one
+ * @returns {Array} A tuple of [field name, probability] describing the
+ * highest-confidence classification
+ */
+ getFathomField(element, fields, elements = []) {
+ if (!fields.length) {
+ return [null, null];
+ }
+
+ if (!this._fathomConfidences?.get(element)) {
+ this._fathomConfidences = new Map();
+
+ // This should not throw unless we run into an OOM situation, at which
+ // point we have worse problems and this failing is not a big deal.
+ elements = elements.includes(element) ? elements : [element];
+ const confidences = this.getFormAutofillConfidences(elements);
+
+ for (let i = 0; i < elements.length; i++) {
+ this._fathomConfidences.set(elements[i], confidences[i]);
+ }
+ }
+
+ const elementConfidences = this._fathomConfidences.get(element);
+ if (!elementConfidences) {
+ return [null, null];
+ }
+
+ let highestField = null;
+ let highestConfidence = lazy.FormAutofillUtils.ccFathomConfidenceThreshold; // Start with a threshold of 0.5
+ for (let [key, value] of Object.entries(elementConfidences)) {
+ if (!fields.includes(key)) {
+ // ignore field that we don't care
+ continue;
+ }
+
+ if (value > highestConfidence) {
+ highestConfidence = value;
+ highestField = key;
+ }
+ }
+
+ if (!highestField) {
+ return [null, null];
+ }
+
+ // Used by test ONLY! This ensure testcases always get the same confidence
+ if (lazy.FormAutofillUtils.ccFathomTestConfidence > 0) {
+ highestConfidence = lazy.FormAutofillUtils.ccFathomTestConfidence;
+ }
+
+ return [highestField, highestConfidence];
+ },
+
+ /**
+ * @param {Array} elements Array of elements that we want to get result from fathom cc rules
+ * @returns {object} Fathom confidence keyed by field-type.
+ */
+ getFormAutofillConfidences(elements) {
+ if (
+ lazy.FormAutofillUtils.ccHeuristicsMode ==
+ lazy.FormAutofillUtils.CC_FATHOM_NATIVE
+ ) {
+ const confidences = ChromeUtils.getFormAutofillConfidences(elements);
+ return confidences.map(c => {
+ let result = {};
+ for (let [fieldName, confidence] of Object.entries(c)) {
+ let type =
+ lazy.FormAutofillUtils.formAutofillConfidencesKeyToCCFieldType(
+ fieldName
+ );
+ result[type] = confidence;
+ }
+ return result;
+ });
+ }
+
+ return elements.map(element => {
+ /**
+ * Return how confident our ML model is that `element` is a field of the
+ * given type.
+ *
+ * @param {string} fieldName The Fathom type to check against. This is
+ * conveniently the same as the autocomplete attribute value that means
+ * the same thing.
+ * @returns {number} Confidence in range [0, 1]
+ */
+ function confidence(fieldName) {
+ const ruleset = lazy.CreditCardRulesets[fieldName];
+ const fnodes = ruleset.against(element).get(fieldName);
+
+ // fnodes is either 0 or 1 item long, since we ran the ruleset
+ // against a single element:
+ return fnodes.length ? fnodes[0].scoreFor(fieldName) : 0;
+ }
+
+ // Bang the element against the ruleset for every type of field:
+ const confidences = {};
+ lazy.CreditCardRulesets.types.map(fieldName => {
+ confidences[fieldName] = confidence(fieldName);
+ });
+
+ return confidences;
+ });
+ },
+
+ /**
+ * @typedef ElementStrings
+ * @type {object}
+ * @yields {string} id - element id.
+ * @yields {string} name - element name.
+ * @yields {Array<string>} labels - extracted labels.
+ */
+
+ /**
+ * Extract all the signature strings of an element.
+ *
+ * @param {HTMLElement} element
+ * @returns {ElementStrings}
+ */
+ _getElementStrings(element) {
+ return {
+ *[Symbol.iterator]() {
+ yield element.id;
+ yield element.name;
+ yield element.placeholder?.trim();
+
+ const labels = lazy.LabelUtils.findLabelElements(element);
+ for (let label of labels) {
+ yield* lazy.LabelUtils.extractLabelStrings(label);
+ }
+ },
+ };
+ },
+
+ // In order to support webkit we need to avoid usage of negative lookbehind due to low support
+ // First safari version with support is 16.4 (Release Date: 27th March 2023)
+ // https://caniuse.com/js-regexp-lookbehind
+ // We can mimic the behaviour of negative lookbehinds by using a named capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ testRegex(regex, string) {
+ const matches = string?.matchAll(regex);
+ if (!matches) {
+ return false;
+ }
+
+ const excludeNegativeCaptureGroups = [];
+
+ for (const match of matches) {
+ excludeNegativeCaptureGroups.push(
+ ...match.filter(m => m !== match?.groups?.neg).filter(Boolean)
+ );
+ }
+ return excludeNegativeCaptureGroups?.length > 0;
+ },
+
+ /**
+ * Find the first matched field name of the element wih given regex list.
+ *
+ * @param {HTMLElement} element
+ * @param {Array<string>} regexps
+ * The regex key names that correspond to pattern in the rule list. It will
+ * be matched against the element string converted to lower case.
+ * @returns {?string} The first matched field name
+ */
+ _findMatchedFieldName(element, regexps) {
+ const getElementStrings = this._getElementStrings(element);
+ for (let regexp of regexps) {
+ for (let string of getElementStrings) {
+ if (this.testRegex(this.RULES[regexp], string?.toLowerCase())) {
+ return regexp;
+ }
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Determine whether the regexp can match any of element strings.
+ *
+ * @param {HTMLElement} element
+ * @param {RegExp} regexp
+ *
+ * @returns {boolean}
+ */
+ _matchRegexp(element, regexp) {
+ const elemStrings = this._getElementStrings(element);
+ for (const str of elemStrings) {
+ if (regexp.test(str)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Phone field grammars - first matched grammar will be parsed. Grammars are
+ * separated by { REGEX_SEPARATOR, FIELD_NONE, 0 }. Suffix and extension are
+ * parsed separately unless they are necessary parts of the match.
+ * The following notation is used to describe the patterns:
+ * <cc> - country code field.
+ * <ac> - area code field.
+ * <phone> - phone or prefix.
+ * <suffix> - suffix.
+ * <ext> - extension.
+ * :N means field is limited to N characters, otherwise it is unlimited.
+ * (pattern <field>)? means pattern is optional and matched separately.
+ *
+ * This grammar list from Chromium will be enabled partially once we need to
+ * support more cases of Telephone fields.
+ */
+ PHONE_FIELD_GRAMMARS: [
+ // Country code: <cc> Area Code: <ac> Phone: <phone> (- <suffix>
+
+ // (Ext: <ext>)?)?
+ // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // \( <ac> \) <phone>:3 <suffix>:4 (Ext: <ext>)?
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3},
+ // {REGEX_PHONE, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> <ac>:3 - <phone>:3 - <suffix>:4 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PHONE, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc>:3 <ac>:3 <phone>:3 <suffix>:4 (Ext: <ext>)?
+ ["tel", "tel-country-code", 3],
+ ["tel", "tel-area-code", 3],
+ ["tel", "tel-local-prefix", 3],
+ ["tel", "tel-local-suffix", 4],
+ [null, null, 0],
+
+ // Area Code: <ac> Phone: <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> <phone>:3 <suffix>:4 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 3},
+ // {REGEX_PHONE, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc> - <ac> - <phone> - <suffix> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Area code: <ac>:3 Prefix: <prefix>:3 Suffix: <suffix>:4 (Ext: <ext>)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 3},
+ // {REGEX_PREFIX, FIELD_PHONE, 3},
+ // {REGEX_SUFFIX, FIELD_SUFFIX, 4},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> Prefix: <phone> Suffix: <suffix> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_AREA_CODE, 0},
+ // {REGEX_PREFIX, FIELD_PHONE, 0},
+ // {REGEX_SUFFIX, FIELD_SUFFIX, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> - <phone>:3 - <suffix>:4 (Ext: <ext>)?
+ ["tel", "tel-area-code", 0],
+ ["tel", "tel-local-prefix", 3],
+ ["tel", "tel-local-suffix", 4],
+ [null, null, 0],
+
+ // Phone: <cc> - <ac> - <phone> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0},
+ // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0},
+ // {REGEX_SUFFIX_SEPARATOR, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <ac> - <phone> (Ext: <ext>)?
+ // {REGEX_AREA, FIELD_AREA_CODE, 0},
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <cc>:3 - <phone>:10 (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3},
+ // {REGEX_PHONE, FIELD_PHONE, 10},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Ext: <ext>
+ // {REGEX_EXTENSION, FIELD_EXTENSION, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+
+ // Phone: <phone> (Ext: <ext>)?
+ // {REGEX_PHONE, FIELD_PHONE, 0},
+ // {REGEX_SEPARATOR, FIELD_NONE, 0},
+ ],
+};
+
+XPCOMUtils.defineLazyGetter(
+ FormAutofillHeuristics,
+ "CREDIT_CARD_FIELDNAMES",
+ () =>
+ Object.keys(FormAutofillHeuristics.RULES).filter(name =>
+ lazy.FormAutofillUtils.isCreditCardField(name)
+ )
+);
+
+XPCOMUtils.defineLazyGetter(FormAutofillHeuristics, "ADDRESS_FIELDNAMES", () =>
+ Object.keys(FormAutofillHeuristics.RULES).filter(name =>
+ lazy.FormAutofillUtils.isAddressField(name)
+ )
+);
+
+export default FormAutofillHeuristics;
diff --git a/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs
new file mode 100644
index 0000000000..8a1d5ba55e
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs
@@ -0,0 +1,406 @@
+/* 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/. */
+
+// FormAutofillNameUtils is initially translated from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817
+export var FormAutofillNameUtils = {
+ NAME_PREFIXES: [
+ "1lt",
+ "1st",
+ "2lt",
+ "2nd",
+ "3rd",
+ "admiral",
+ "capt",
+ "captain",
+ "col",
+ "cpt",
+ "dr",
+ "gen",
+ "general",
+ "lcdr",
+ "lt",
+ "ltc",
+ "ltg",
+ "ltjg",
+ "maj",
+ "major",
+ "mg",
+ "mr",
+ "mrs",
+ "ms",
+ "pastor",
+ "prof",
+ "rep",
+ "reverend",
+ "rev",
+ "sen",
+ "st",
+ ],
+
+ NAME_SUFFIXES: [
+ "b.a",
+ "ba",
+ "d.d.s",
+ "dds",
+ "i",
+ "ii",
+ "iii",
+ "iv",
+ "ix",
+ "jr",
+ "m.a",
+ "m.d",
+ "ma",
+ "md",
+ "ms",
+ "ph.d",
+ "phd",
+ "sr",
+ "v",
+ "vi",
+ "vii",
+ "viii",
+ "x",
+ ],
+
+ FAMILY_NAME_PREFIXES: [
+ "d'",
+ "de",
+ "del",
+ "der",
+ "di",
+ "la",
+ "le",
+ "mc",
+ "san",
+ "st",
+ "ter",
+ "van",
+ "von",
+ ],
+
+ // The common and non-ambiguous CJK surnames (last names) that have more than
+ // one character.
+ COMMON_CJK_MULTI_CHAR_SURNAMES: [
+ // Korean, taken from the list of surnames:
+ // https://ko.wikipedia.org/wiki/%ED%95%9C%EA%B5%AD%EC%9D%98_%EC%84%B1%EC%94%A8_%EB%AA%A9%EB%A1%9D
+ "남궁",
+ "사공",
+ "서문",
+ "선우",
+ "제갈",
+ "황보",
+ "독고",
+ "망절",
+
+ // Chinese, taken from the top 10 Chinese 2-character surnames:
+ // https://zh.wikipedia.org/wiki/%E8%A4%87%E5%A7%93#.E5.B8.B8.E8.A6.8B.E7.9A.84.E8.A4.87.E5.A7.93
+ // Simplified Chinese (mostly mainland China)
+ "欧阳",
+ "令狐",
+ "皇甫",
+ "上官",
+ "司徒",
+ "诸葛",
+ "司马",
+ "宇文",
+ "呼延",
+ "端木",
+ // Traditional Chinese (mostly Taiwan)
+ "張簡",
+ "歐陽",
+ "諸葛",
+ "申屠",
+ "尉遲",
+ "司馬",
+ "軒轅",
+ "夏侯",
+ ],
+
+ // All Korean surnames that have more than one character, even the
+ // rare/ambiguous ones.
+ KOREAN_MULTI_CHAR_SURNAMES: [
+ "강전",
+ "남궁",
+ "독고",
+ "동방",
+ "망절",
+ "사공",
+ "서문",
+ "선우",
+ "소봉",
+ "어금",
+ "장곡",
+ "제갈",
+ "황목",
+ "황보",
+ ],
+
+ // The whitespace definition based on
+ // https://cs.chromium.org/chromium/src/base/strings/string_util_constants.cc?l=9&rcl=b861deff77abecff11ae6a9f6946e9cc844b9817
+ WHITESPACE: [
+ "\u0009", // CHARACTER TABULATION
+ "\u000A", // LINE FEED (LF)
+ "\u000B", // LINE TABULATION
+ "\u000C", // FORM FEED (FF)
+ "\u000D", // CARRIAGE RETURN (CR)
+ "\u0020", // SPACE
+ "\u0085", // NEXT LINE (NEL)
+ "\u00A0", // NO-BREAK SPACE
+ "\u1680", // OGHAM SPACE MARK
+ "\u2000", // EN QUAD
+ "\u2001", // EM QUAD
+ "\u2002", // EN SPACE
+ "\u2003", // EM SPACE
+ "\u2004", // THREE-PER-EM SPACE
+ "\u2005", // FOUR-PER-EM SPACE
+ "\u2006", // SIX-PER-EM SPACE
+ "\u2007", // FIGURE SPACE
+ "\u2008", // PUNCTUATION SPACE
+ "\u2009", // THIN SPACE
+ "\u200A", // HAIR SPACE
+ "\u2028", // LINE SEPARATOR
+ "\u2029", // PARAGRAPH SEPARATOR
+ "\u202F", // NARROW NO-BREAK SPACE
+ "\u205F", // MEDIUM MATHEMATICAL SPACE
+ "\u3000", // IDEOGRAPHIC SPACE
+ ],
+
+ // The middle dot is used as a separator for foreign names in Japanese.
+ MIDDLE_DOT: [
+ "\u30FB", // KATAKANA MIDDLE DOT
+ "\u00B7", // A (common?) typo for "KATAKANA MIDDLE DOT"
+ ],
+
+ // The Unicode range is based on Wiki:
+ // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs
+ // https://en.wikipedia.org/wiki/Hangul
+ // https://en.wikipedia.org/wiki/Japanese_writing_system
+ CJK_RANGE: [
+ "\u1100-\u11FF", // Hangul Jamo
+ "\u3040-\u309F", // Hiragana
+ "\u30A0-\u30FF", // Katakana
+ "\u3105-\u312C", // Bopomofo
+ "\u3130-\u318F", // Hangul Compatibility Jamo
+ "\u31F0-\u31FF", // Katakana Phonetic Extensions
+ "\u3200-\u32FF", // Enclosed CJK Letters and Months
+ "\u3400-\u4DBF", // CJK unified ideographs Extension A
+ "\u4E00-\u9FFF", // CJK Unified Ideographs
+ "\uA960-\uA97F", // Hangul Jamo Extended-A
+ "\uAC00-\uD7AF", // Hangul Syllables
+ "\uD7B0-\uD7FF", // Hangul Jamo Extended-B
+ "\uFF00-\uFFEF", // Halfwidth and Fullwidth Forms
+ ],
+
+ HANGUL_RANGE: [
+ "\u1100-\u11FF", // Hangul Jamo
+ "\u3130-\u318F", // Hangul Compatibility Jamo
+ "\uA960-\uA97F", // Hangul Jamo Extended-A
+ "\uAC00-\uD7AF", // Hangul Syllables
+ "\uD7B0-\uD7FF", // Hangul Jamo Extended-B
+ ],
+
+ _dataLoaded: false,
+
+ // Returns true if |set| contains |token|, modulo a final period.
+ _containsString(set, token) {
+ let target = token.replace(/\.$/, "").toLowerCase();
+ return set.includes(target);
+ },
+
+ // Removes common name prefixes from |name_tokens|.
+ _stripPrefixes(nameTokens) {
+ for (let i in nameTokens) {
+ if (!this._containsString(this.NAME_PREFIXES, nameTokens[i])) {
+ return nameTokens.slice(i);
+ }
+ }
+ return [];
+ },
+
+ // Removes common name suffixes from |name_tokens|.
+ _stripSuffixes(nameTokens) {
+ for (let i = nameTokens.length - 1; i >= 0; i--) {
+ if (!this._containsString(this.NAME_SUFFIXES, nameTokens[i])) {
+ return nameTokens.slice(0, i + 1);
+ }
+ }
+ return [];
+ },
+
+ _isCJKName(name) {
+ // The name is considered to be a CJK name if it is only CJK characters,
+ // spaces, and "middle dot" separators, with at least one CJK character, and
+ // no more than 2 words.
+ //
+ // Chinese and Japanese names are usually spelled out using the Han
+ // characters (logographs), which constitute the "CJK Unified Ideographs"
+ // block in Unicode, also referred to as Unihan. Korean names are usually
+ // spelled out in the Korean alphabet (Hangul), although they do have a Han
+ // equivalent as well.
+
+ if (!name) {
+ return false;
+ }
+
+ let previousWasCJK = false;
+ let wordCount = 0;
+
+ for (let c of name) {
+ let isMiddleDot = this.MIDDLE_DOT.includes(c);
+ let isCJK = !isMiddleDot && this.reCJK.test(c);
+ if (!isCJK && !isMiddleDot && !this.WHITESPACE.includes(c)) {
+ return false;
+ }
+ if (isCJK && !previousWasCJK) {
+ wordCount++;
+ }
+ previousWasCJK = isCJK;
+ }
+
+ return wordCount > 0 && wordCount < 3;
+ },
+
+ // Tries to split a Chinese, Japanese, or Korean name into its given name &
+ // surname parts. If splitting did not work for whatever reason, returns null.
+ _splitCJKName(nameTokens) {
+ // The convention for CJK languages is to put the surname (last name) first,
+ // and the given name (first name) second. In a continuous text, there is
+ // normally no space between the two parts of the name. When entering their
+ // name into a field, though, some people add a space to disambiguate. CJK
+ // names (almost) never have a middle name.
+
+ let reHangulName = new RegExp(
+ "^[" + this.HANGUL_RANGE.join("") + this.WHITESPACE.join("") + "]+$",
+ "u"
+ );
+ let nameParts = {
+ given: "",
+ middle: "",
+ family: "",
+ };
+
+ if (nameTokens.length == 1) {
+ // There is no space between the surname and given name. Try to infer
+ // where to separate between the two. Most Chinese and Korean surnames
+ // have only one character, but there are a few that have 2. If the name
+ // does not start with a surname from a known list, default to one
+ // character.
+ let name = nameTokens[0];
+ let isKorean = reHangulName.test(name);
+ let surnameLength = 0;
+
+ // 4-character Korean names are more likely to be 2/2 than 1/3, so use
+ // the full list of Korean 2-char surnames. (instead of only the common
+ // ones)
+ let multiCharSurnames =
+ isKorean && name.length > 3
+ ? this.KOREAN_MULTI_CHAR_SURNAMES
+ : this.COMMON_CJK_MULTI_CHAR_SURNAMES;
+
+ // Default to 1 character if the surname is not in the list.
+ surnameLength = multiCharSurnames.some(surname =>
+ name.startsWith(surname)
+ )
+ ? 2
+ : 1;
+
+ nameParts.family = name.substr(0, surnameLength);
+ nameParts.given = name.substr(surnameLength);
+ } else if (nameTokens.length == 2) {
+ // The user entered a space between the two name parts. This makes our job
+ // easier. Family name first, given name second.
+ nameParts.family = nameTokens[0];
+ nameParts.given = nameTokens[1];
+ } else {
+ return null;
+ }
+
+ return nameParts;
+ },
+
+ init() {
+ if (this._dataLoaded) {
+ return;
+ }
+ this._dataLoaded = true;
+
+ this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u");
+ },
+
+ splitName(name) {
+ let nameParts = {
+ given: "",
+ middle: "",
+ family: "",
+ };
+
+ if (!name) {
+ return nameParts;
+ }
+
+ let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/);
+ nameTokens = this._stripPrefixes(nameTokens);
+
+ if (this._isCJKName(name)) {
+ let parts = this._splitCJKName(nameTokens);
+ if (parts) {
+ return parts;
+ }
+ }
+
+ // Don't assume "Ma" is a suffix in John Ma.
+ if (nameTokens.length > 2) {
+ nameTokens = this._stripSuffixes(nameTokens);
+ }
+
+ if (!nameTokens.length) {
+ // Bad things have happened; just assume the whole thing is a given name.
+ nameParts.given = name;
+ return nameParts;
+ }
+
+ // Only one token, assume given name.
+ if (nameTokens.length == 1) {
+ nameParts.given = nameTokens[0];
+ return nameParts;
+ }
+
+ // 2 or more tokens. Grab the family, which is the last word plus any
+ // recognizable family prefixes.
+ let familyTokens = [nameTokens.pop()];
+ while (nameTokens.length) {
+ let lastToken = nameTokens[nameTokens.length - 1];
+ if (!this._containsString(this.FAMILY_NAME_PREFIXES, lastToken)) {
+ break;
+ }
+ familyTokens.unshift(lastToken);
+ nameTokens.pop();
+ }
+ nameParts.family = familyTokens.join(" ");
+
+ // Take the last remaining token as the middle name (if there are at least 2
+ // tokens).
+ if (nameTokens.length >= 2) {
+ nameParts.middle = nameTokens.pop();
+ }
+
+ // Remainder is given name.
+ nameParts.given = nameTokens.join(" ");
+
+ return nameParts;
+ },
+
+ joinNameParts({ given, middle, family }) {
+ if (this._isCJKName(given) && this._isCJKName(family) && !middle) {
+ return family + given;
+ }
+ return [given, middle, family]
+ .filter(part => part && part.length)
+ .join(" ");
+ },
+};
+
+FormAutofillNameUtils.init();
diff --git a/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs
new file mode 100644
index 0000000000..c7eb7622b5
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs
@@ -0,0 +1,1353 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs",
+});
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+export class FormAutofillSection {
+ static SHOULD_FOCUS_ON_AUTOFILL = true;
+ #focusedInput = null;
+
+ #section = null;
+
+ constructor(section, handler) {
+ this.#section = section;
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ this.handler = handler;
+ this.filledRecordGUID = null;
+
+ XPCOMUtils.defineLazyGetter(this, "reauthPasswordPromptMessage", () => {
+ const brandShortName =
+ FormAutofillUtils.brandBundle.GetStringFromName("brandShortName");
+ // The string name for Mac is changed because the value needed updating.
+ const platform = AppConstants.platform.replace("macosx", "macos");
+ return FormAutofillUtils.stringBundle.formatStringFromName(
+ `useCreditCardPasswordPrompt.${platform}`,
+ [brandShortName]
+ );
+ });
+
+ XPCOMUtils.defineLazyGetter(this, "log", () =>
+ FormAutofill.defineLogGetter(this, "FormAutofillHandler")
+ );
+
+ this._cacheValue = {
+ allFieldNames: null,
+ matchingSelectOption: null,
+ };
+
+ // Identifier used to correlate events relating to the same form
+ this.flowId = Services.uuid.generateUUID().toString();
+ this.log.debug(
+ "Creating new credit card section with flowId =",
+ this.flowId
+ );
+ }
+
+ get fieldDetails() {
+ return this.#section.fieldDetails;
+ }
+
+ /*
+ * Examine the section is a valid section or not based on its fieldDetails or
+ * other information. This method must be overrided.
+ *
+ * @returns {boolean} True for a valid section, otherwise false
+ *
+ */
+ isValidSection() {
+ throw new TypeError("isValidSection method must be overrided");
+ }
+
+ /*
+ * Examine the section is an enabled section type or not based on its
+ * preferences. This method must be overrided.
+ *
+ * @returns {boolean} True for an enabled section type, otherwise false
+ *
+ */
+ isEnabled() {
+ throw new TypeError("isEnabled method must be overrided");
+ }
+
+ /*
+ * Examine the section is createable for storing the profile. This method
+ * must be overrided.
+ *
+ * @param {Object} record The record for examining createable
+ * @returns {boolean} True for the record is createable, otherwise false
+ *
+ */
+ isRecordCreatable(record) {
+ throw new TypeError("isRecordCreatable method must be overridden");
+ }
+
+ /*
+ * Override this method if any data for `createRecord` is needed to be
+ * normalized before submitting the record.
+ *
+ * @param {Object} profile
+ * A record for normalization.
+ */
+ createNormalizedRecord(data) {}
+
+ /**
+ * Override this method if the profile is needed to apply some transformers.
+ *
+ * @param {object} profile
+ * A profile should be converted based on the specific requirement.
+ */
+ applyTransformers(profile) {}
+
+ /**
+ * Override this method if the profile is needed to be customized for
+ * previewing values.
+ *
+ * @param {object} profile
+ * A profile for pre-processing before previewing values.
+ */
+ preparePreviewProfile(profile) {}
+
+ /**
+ * Override this method if the profile is needed to be customized for filling
+ * values.
+ *
+ * @param {object} profile
+ * A profile for pre-processing before filling values.
+ * @returns {boolean} Whether the profile should be filled.
+ */
+ async prepareFillingProfile(profile) {
+ return true;
+ }
+
+ /**
+ * Override this method if the profile is needed to be customized for filling
+ * values.
+ *
+ * @param {object} fieldDetail A fieldDetail of the related element.
+ * @param {object} profile The profile to fill.
+ * @returns {string} The value to fill for the given field.
+ */
+ getFilledValueFromProfile(fieldDetail, profile) {
+ return (
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName]
+ );
+ }
+
+ /*
+ * Override this method if there is any field value needs to compute for a
+ * specific case. Return the original value in the default case.
+ * @param {String} value
+ * The original field value.
+ * @param {Object} fieldDetail
+ * A fieldDetail of the related element.
+ * @param {HTMLElement} element
+ * A element for checking converting value.
+ *
+ * @returns {String}
+ * A string of the converted value.
+ */
+ computeFillingValue(value, fieldName, element) {
+ return value;
+ }
+
+ set focusedInput(element) {
+ this.#focusedInput = element;
+ }
+
+ getFieldDetailByElement(element) {
+ return this.fieldDetails.find(
+ detail => detail.elementWeakRef.get() == element
+ );
+ }
+
+ getFieldDetailByName(fieldName) {
+ return this.fieldDetails.find(detail => detail.fieldName == fieldName);
+ }
+
+ get allFieldNames() {
+ if (!this._cacheValue.allFieldNames) {
+ this._cacheValue.allFieldNames = this.fieldDetails.map(
+ record => record.fieldName
+ );
+ }
+ return this._cacheValue.allFieldNames;
+ }
+
+ matchSelectOptions(profile) {
+ if (!this._cacheValue.matchingSelectOption) {
+ this._cacheValue.matchingSelectOption = new WeakMap();
+ }
+
+ for (let fieldName in profile) {
+ let fieldDetail = this.getFieldDetailByName(fieldName);
+ if (!fieldDetail) {
+ continue;
+ }
+
+ let element = fieldDetail.elementWeakRef.get();
+ if (!HTMLSelectElement.isInstance(element)) {
+ continue;
+ }
+
+ let cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ let value = profile[fieldName];
+ if (cache[value] && cache[value].get()) {
+ continue;
+ }
+
+ let option = FormAutofillUtils.findSelectOption(
+ element,
+ profile,
+ fieldName
+ );
+ if (option) {
+ cache[value] = Cu.getWeakReference(option);
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ } else {
+ if (cache[value]) {
+ delete cache[value];
+ this._cacheValue.matchingSelectOption.set(element, cache);
+ }
+ // Delete the field so the phishing hint won't treat it as a "also fill"
+ // field.
+ delete profile[fieldName];
+ }
+ }
+ }
+
+ adaptFieldMaxLength(profile) {
+ for (let key in profile) {
+ let detail = this.getFieldDetailByName(key);
+ if (!detail) {
+ continue;
+ }
+
+ let element = detail.elementWeakRef.get();
+ if (!element) {
+ continue;
+ }
+
+ let maxLength = element.maxLength;
+ if (
+ maxLength === undefined ||
+ maxLength < 0 ||
+ profile[key].toString().length <= maxLength
+ ) {
+ continue;
+ }
+
+ if (maxLength) {
+ switch (typeof profile[key]) {
+ case "string":
+ // If this is an expiration field and our previous
+ // adaptations haven't resulted in a string that is
+ // short enough to satisfy the field length, and the
+ // field is constrained to a length of 5, then we
+ // assume it is intended to hold an expiration of the
+ // form "MM/YY".
+ if (key == "cc-exp" && maxLength == 5) {
+ const month2Digits = (
+ "0" + profile["cc-exp-month"].toString()
+ ).slice(-2);
+ const year2Digits = profile["cc-exp-year"].toString().slice(-2);
+ profile[key] = `${month2Digits}/${year2Digits}`;
+ } else if (key == "cc-number") {
+ // We want to show the last four digits of credit card so that
+ // the masked credit card previews correctly and appears correctly
+ // in the autocomplete menu
+ profile[key] = profile[key].substr(
+ profile[key].length - maxLength
+ );
+ } else {
+ profile[key] = profile[key].substr(0, maxLength);
+ }
+ break;
+ case "number":
+ // There's no way to truncate a number smaller than a
+ // single digit.
+ if (maxLength < 1) {
+ maxLength = 1;
+ }
+ // The only numbers we store are expiration month/year,
+ // and if they truncate, we want the final digits, not
+ // the initial ones.
+ profile[key] = profile[key] % Math.pow(10, maxLength);
+ break;
+ default:
+ }
+ } else {
+ delete profile[key];
+ delete profile[`${key}-formatted`];
+ }
+ }
+ }
+
+ fillFieldValue(element, value) {
+ if (FormAutofillUtils.focusOnAutofill) {
+ element.focus({ preventScroll: true });
+ }
+ if (HTMLInputElement.isInstance(element)) {
+ element.setUserInput(value);
+ } else if (HTMLSelectElement.isInstance(element)) {
+ // Set the value of the select element so that web event handlers can react accordingly
+ element.value = value;
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("input", { bubbles: true })
+ );
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("change", { bubbles: true })
+ );
+ }
+ }
+
+ getAdaptedProfiles(originalProfiles) {
+ for (let profile of originalProfiles) {
+ this.applyTransformers(profile);
+ }
+ return originalProfiles;
+ }
+
+ /**
+ * Processes form fields that can be autofilled, and populates them with the
+ * profile provided by backend.
+ *
+ * @param {object} profile
+ * A profile to be filled in.
+ * @returns {boolean}
+ * True if successful, false if failed
+ */
+ async autofillFields(profile) {
+ if (!this.#focusedInput) {
+ throw new Error("No focused input.");
+ }
+
+ const focusedDetail = this.getFieldDetailByElement(this.#focusedInput);
+ if (!focusedDetail) {
+ throw new Error("No fieldDetail for the focused input.");
+ }
+
+ if (!(await this.prepareFillingProfile(profile))) {
+ this.log.debug("profile cannot be filled");
+ return false;
+ }
+
+ this.filledRecordGUID = profile.guid;
+ for (const fieldDetail of this.fieldDetails) {
+ // Avoid filling field value in the following cases:
+ // 1. a non-empty input field for an unfocused input
+ // 2. the invalid value set
+ // 3. value already chosen in select element
+
+ const element = fieldDetail.elementWeakRef.get();
+ // Skip the field if it is null or readonly or disabled
+ if (!FormAutofillUtils.isFieldAutofillable(element)) {
+ continue;
+ }
+
+ element.previewValue = "";
+ // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field
+ // that is generated when presentation ready data doesn't fit into the autofilling element.
+ // For example, autofilling expiration month into an input element will not work as expected if
+ // the month is less than 10, since the input is expected a zero-padded string.
+ // See Bug 1722941 for follow up.
+ const value = this.getFilledValueFromProfile(fieldDetail, profile);
+
+ if (HTMLInputElement.isInstance(element) && value) {
+ // For the focused input element, it will be filled with a valid value
+ // anyway.
+ // For the others, the fields should be only filled when their values are empty
+ // or their values are equal to the site prefill value
+ // or are the result of an earlier auto-fill.
+ if (
+ element == this.#focusedInput ||
+ (element != this.#focusedInput &&
+ (!element.value || element.value == element.defaultValue)) ||
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ this.fillFieldValue(element, value);
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
+ }
+ } else if (HTMLSelectElement.isInstance(element)) {
+ let cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ let option = cache[value] && cache[value].get();
+ if (!option) {
+ continue;
+ }
+ // Do not change value or dispatch events if the option is already selected.
+ // Use case for multiple select is not considered here.
+ if (!option.selected) {
+ option.selected = true;
+ this.fillFieldValue(element, option.value);
+ }
+ // Autofill highlight appears regardless if value is changed or not
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
+ }
+ }
+ this.#focusedInput.focus({ preventScroll: true });
+
+ lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, {
+ profile,
+ });
+
+ return true;
+ }
+
+ /**
+ * Populates result to the preview layers with given profile.
+ *
+ * @param {object} profile
+ * A profile to be previewed with
+ */
+ previewFormFields(profile) {
+ this.preparePreviewProfile(profile);
+
+ for (const fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ // Skip the field if it is null or readonly or disabled
+ if (!FormAutofillUtils.isFieldAutofillable(element)) {
+ continue;
+ }
+
+ let value =
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName] ||
+ "";
+ if (HTMLSelectElement.isInstance(element)) {
+ // Unlike text input, select element is always previewed even if
+ // the option is already selected.
+ if (value) {
+ const cache =
+ this._cacheValue.matchingSelectOption.get(element) ?? {};
+ const option = cache[value]?.get();
+ value = option?.text ?? "";
+ }
+ } else if (element.value && element.value != element.defaultValue) {
+ // Skip the field if the user has already entered text and that text is not the site prefilled value.
+ continue;
+ }
+ element.previewValue = value;
+ this.handler.changeFieldState(
+ fieldDetail,
+ value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL
+ );
+ }
+ }
+
+ /**
+ * Clear a previously autofilled field in this section
+ */
+ clearFilled(fieldDetail) {
+ lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, {
+ fieldName: fieldDetail.fieldName,
+ });
+
+ let isAutofilled = false;
+ const dimFieldDetails = [];
+ for (const fieldDetail of this.fieldDetails) {
+ const element = fieldDetail.elementWeakRef.get();
+
+ if (HTMLSelectElement.isInstance(element)) {
+ // Dim fields are those we don't attempt to revert their value
+ // when clear the target set, such as <select>.
+ dimFieldDetails.push(fieldDetail);
+ } else {
+ isAutofilled |=
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED;
+ }
+ }
+ if (!isAutofilled) {
+ // Restore the dim fields to initial state as well once we knew
+ // that user had intention to clear the filled form manually.
+ for (const fieldDetail of dimFieldDetails) {
+ // If we can't find a selected option, then we should just reset to the first option's value
+ let element = fieldDetail.elementWeakRef.get();
+ this._resetSelectElementValue(element);
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ this.filledRecordGUID = null;
+ }
+ }
+
+ /**
+ * Clear preview text and background highlight of all fields.
+ */
+ clearPreviewedFormFields() {
+ this.log.debug("clear previewed fields");
+
+ for (const fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ if (!element) {
+ this.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ element.previewValue = "";
+
+ // We keep the state if this field has
+ // already been auto-filled.
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ continue;
+ }
+
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ }
+
+ /**
+ * Clear value and highlight style of all filled fields.
+ */
+ clearPopulatedForm() {
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ if (!element) {
+ this.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ if (HTMLInputElement.isInstance(element)) {
+ element.setUserInput("");
+ } else if (HTMLSelectElement.isInstance(element)) {
+ // If we can't find a selected option, then we should just reset to the first option's value
+ this._resetSelectElementValue(element);
+ }
+ }
+ }
+ }
+
+ resetFieldStates() {
+ for (const fieldDetail of this.fieldDetails) {
+ const element = fieldDetail.elementWeakRef.get();
+ element.removeEventListener("input", this, { mozSystemGroup: true });
+ this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ this.filledRecordGUID = null;
+ }
+
+ isFilled() {
+ return !!this.filledRecordGUID;
+ }
+
+ /**
+ * Condenses multiple credit card number fields into one fieldDetail
+ * in order to submit the credit card record correctly.
+ *
+ * @param {Array.<object>} condensedDetails
+ * An array of fieldDetails
+ * @memberof FormAutofillSection
+ */
+ _condenseMultipleCCNumberFields(condensedDetails) {
+ let countOfCCNumbers = 0;
+ // We ignore the cases where there are more than or less than four credit card number
+ // fields in a form as this is not a valid case for filling the credit card number.
+ for (let i = condensedDetails.length - 1; i >= 0; i--) {
+ if (condensedDetails[i].fieldName == "cc-number") {
+ countOfCCNumbers++;
+ if (countOfCCNumbers == 4) {
+ countOfCCNumbers = 0;
+ condensedDetails[i].fieldValue =
+ condensedDetails[i].elementWeakRef.get()?.value +
+ condensedDetails[i + 1].elementWeakRef.get()?.value +
+ condensedDetails[i + 2].elementWeakRef.get()?.value +
+ condensedDetails[i + 3].elementWeakRef.get()?.value;
+ condensedDetails.splice(i + 1, 3);
+ }
+ } else {
+ countOfCCNumbers = 0;
+ }
+ }
+ }
+ /**
+ * Return the record that is converted from `fieldDetails` and only valid
+ * form record is included.
+ *
+ * @returns {object | null}
+ * A record object consists of three properties:
+ * - guid: The id of the previously-filled profile or null if omitted.
+ * - record: A valid record converted from details with trimmed result.
+ * - untouchedFields: Fields that aren't touched after autofilling.
+ * Return `null` for any uncreatable or invalid record.
+ */
+ createRecord() {
+ let details = this.fieldDetails;
+ if (!this.isEnabled() || !details || !details.length) {
+ return null;
+ }
+
+ let data = {
+ guid: this.filledRecordGUID,
+ record: {},
+ untouchedFields: [],
+ section: this,
+ };
+ if (this.flowId) {
+ data.flowId = this.flowId;
+ }
+ let condensedDetails = this.fieldDetails;
+
+ // TODO: This is credit card specific code...
+ this._condenseMultipleCCNumberFields(condensedDetails);
+
+ condensedDetails.forEach(detail => {
+ const element = detail.elementWeakRef.get();
+ // Remove the unnecessary spaces
+ let value = detail.fieldValue ?? (element && element.value.trim());
+ value = this.computeFillingValue(value, detail, element);
+
+ if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) {
+ // Keep the property and preserve more information for updating
+ data.record[detail.fieldName] = "";
+ return;
+ }
+
+ data.record[detail.fieldName] = value;
+
+ if (
+ this.handler.getFilledStateByElement(element) ==
+ FIELD_STATES.AUTO_FILLED
+ ) {
+ data.untouchedFields.push(detail.fieldName);
+ }
+ });
+
+ this.createNormalizedRecord(data);
+
+ if (!this.isRecordCreatable(data.record)) {
+ return null;
+ }
+
+ return data;
+ }
+
+ /**
+ * Resets a <select> element to its selected option or the first option if there is none selected.
+ *
+ * @param {HTMLElement} element
+ * @memberof FormAutofillSection
+ */
+ _resetSelectElementValue(element) {
+ if (!element.options.length) {
+ return;
+ }
+ let selected = [...element.options].find(option =>
+ option.hasAttribute("selected")
+ );
+ element.value = selected ? selected.value : element.options[0].value;
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("input", { bubbles: true })
+ );
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("change", { bubbles: true })
+ );
+ }
+}
+
+export class FormAutofillAddressSection extends FormAutofillSection {
+ constructor(fieldDetails, handler) {
+ super(fieldDetails, handler);
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ this._cacheValue.oneLineStreetAddress = null;
+
+ lazy.AutofillTelemetry.recordDetectedSectionCount(this);
+ lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this);
+ }
+
+ isValidSection() {
+ const fields = new Set(this.fieldDetails.map(f => f.fieldName));
+ return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD;
+ }
+
+ isEnabled() {
+ return FormAutofill.isAutofillAddressesEnabled;
+ }
+
+ isRecordCreatable(record) {
+ if (
+ record.country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
+ ) {
+ // We don't want to save data in the wrong fields due to not having proper
+ // heuristic regexes in countries we don't yet support.
+ this.log.warn(
+ "isRecordCreatable: Country not supported:",
+ record.country
+ );
+ return false;
+ }
+
+ let hasName = 0;
+ let length = 0;
+ for (let key of Object.keys(record)) {
+ if (!record[key]) {
+ continue;
+ }
+ if (FormAutofillUtils.getCategoryFromFieldName(key) == "name") {
+ hasName = 1;
+ continue;
+ }
+ length++;
+ }
+ return length + hasName >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD;
+ }
+
+ _getOneLineStreetAddress(address) {
+ if (!this._cacheValue.oneLineStreetAddress) {
+ this._cacheValue.oneLineStreetAddress = {};
+ }
+ if (!this._cacheValue.oneLineStreetAddress[address]) {
+ this._cacheValue.oneLineStreetAddress[address] =
+ FormAutofillUtils.toOneLineAddress(address);
+ }
+ return this._cacheValue.oneLineStreetAddress[address];
+ }
+
+ addressTransformer(profile) {
+ if (profile["street-address"]) {
+ // "-moz-street-address-one-line" is used by the labels in
+ // ProfileAutoCompleteResult.
+ profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress(
+ profile["street-address"]
+ );
+ let streetAddressDetail = this.getFieldDetailByName("street-address");
+ if (
+ streetAddressDetail &&
+ HTMLInputElement.isInstance(streetAddressDetail.elementWeakRef.get())
+ ) {
+ profile["street-address"] = profile["-moz-street-address-one-line"];
+ }
+
+ let waitForConcat = [];
+ for (let f of ["address-line3", "address-line2", "address-line1"]) {
+ waitForConcat.unshift(profile[f]);
+ if (this.getFieldDetailByName(f)) {
+ if (waitForConcat.length > 1) {
+ profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat);
+ }
+ waitForConcat = [];
+ }
+ }
+ }
+ }
+
+ /**
+ * Replace tel with tel-national if tel violates the input element's
+ * restriction.
+ *
+ * @param {object} profile
+ * A profile to be converted.
+ */
+ telTransformer(profile) {
+ if (!profile.tel || !profile["tel-national"]) {
+ return;
+ }
+
+ let detail = this.getFieldDetailByName("tel");
+ if (!detail) {
+ return;
+ }
+
+ let element = detail.elementWeakRef.get();
+ let _pattern;
+ let testPattern = str => {
+ if (!_pattern) {
+ // The pattern has to match the entire value.
+ _pattern = new RegExp("^(?:" + element.pattern + ")$", "u");
+ }
+ return _pattern.test(str);
+ };
+ if (element.pattern) {
+ if (testPattern(profile.tel)) {
+ return;
+ }
+ } else if (element.maxLength) {
+ if (
+ detail.reason == "autocomplete" &&
+ profile.tel.length <= element.maxLength
+ ) {
+ return;
+ }
+ }
+
+ if (detail.reason != "autocomplete") {
+ // Since we only target people living in US and using en-US websites in
+ // MVP, it makes more sense to fill `tel-national` instead of `tel`
+ // if the field is identified by heuristics and no other clues to
+ // determine which one is better.
+ // TODO: [Bug 1407545] This should be improved once more countries are
+ // supported.
+ profile.tel = profile["tel-national"];
+ } else if (element.pattern) {
+ if (testPattern(profile["tel-national"])) {
+ profile.tel = profile["tel-national"];
+ }
+ } else if (element.maxLength) {
+ if (profile["tel-national"].length <= element.maxLength) {
+ profile.tel = profile["tel-national"];
+ }
+ }
+ }
+
+ /*
+ * Apply all address related transformers.
+ *
+ * @param {Object} profile
+ * A profile for adjusting address related value.
+ * @override
+ */
+ applyTransformers(profile) {
+ this.addressTransformer(profile);
+ this.telTransformer(profile);
+ this.matchSelectOptions(profile);
+ this.adaptFieldMaxLength(profile);
+ }
+
+ computeFillingValue(value, fieldDetail, element) {
+ // Try to abbreviate the value of select element.
+ if (
+ fieldDetail.fieldName == "address-level1" &&
+ HTMLSelectElement.isInstance(element)
+ ) {
+ // Don't save the record when the option value is empty *OR* there
+ // are multiple options being selected. The empty option is usually
+ // assumed to be default along with a meaningless text to users.
+ if (!value || element.selectedOptions.length != 1) {
+ // Keep the property and preserve more information for address updating
+ value = "";
+ } else {
+ let text = element.selectedOptions[0].text.trim();
+ value =
+ FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text;
+ }
+ }
+ return value;
+ }
+
+ createNormalizedRecord(address) {
+ if (!address) {
+ return;
+ }
+
+ // Normalize Country
+ if (address.record.country) {
+ let detail = this.getFieldDetailByName("country");
+ // Try identifying country field aggressively if it doesn't come from
+ // @autocomplete.
+ if (detail.reason != "autocomplete") {
+ let countryCode = FormAutofillUtils.identifyCountryCode(
+ address.record.country
+ );
+ if (countryCode) {
+ address.record.country = countryCode;
+ }
+ }
+ }
+
+ // Normalize Tel
+ FormAutofillUtils.compressTel(address.record);
+ if (address.record.tel) {
+ let allTelComponentsAreUntouched = Object.keys(address.record)
+ .filter(
+ field => FormAutofillUtils.getCategoryFromFieldName(field) == "tel"
+ )
+ .every(field => address.untouchedFields.includes(field));
+ if (allTelComponentsAreUntouched) {
+ // No need to verify it if none of related fields are modified after autofilling.
+ if (!address.untouchedFields.includes("tel")) {
+ address.untouchedFields.push("tel");
+ }
+ } else {
+ let strippedNumber = address.record.tel.replace(/[\s\(\)-]/g, "");
+
+ // Remove "tel" if it contains invalid characters or the length of its
+ // number part isn't between 5 and 15.
+ // (The maximum length of a valid number in E.164 format is 15 digits
+ // according to https://en.wikipedia.org/wiki/E.164 )
+ if (!/^(\+?)[\da-zA-Z]{5,15}$/.test(strippedNumber)) {
+ address.record.tel = "";
+ }
+ }
+ }
+ }
+}
+
+export class FormAutofillCreditCardSection extends FormAutofillSection {
+ /**
+ * Credit Card Section Constructor
+ *
+ * @param {object} fieldDetails
+ * The fieldDetail objects for the fields in this section
+ * @param {object} handler
+ * The FormAutofillHandler responsible for this section
+ */
+ constructor(fieldDetails, handler) {
+ super(fieldDetails, handler);
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ lazy.AutofillTelemetry.recordDetectedSectionCount(this);
+ lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this);
+
+ // Check whether the section is in an <iframe>; and, if so,
+ // watch for the <iframe> to pagehide.
+ if (handler.window.location != handler.window.parent?.location) {
+ this.log.debug(
+ "Credit card form is in an iframe -- watching for pagehide",
+ fieldDetails
+ );
+ handler.window.addEventListener(
+ "pagehide",
+ this._handlePageHide.bind(this)
+ );
+ }
+ }
+
+ _handlePageHide(event) {
+ this.handler.window.removeEventListener(
+ "pagehide",
+ this._handlePageHide.bind(this)
+ );
+ this.log.debug("Credit card subframe is pagehideing", this.handler.form);
+ this.handler.onFormSubmitted();
+ }
+
+ /**
+ * Determine whether a set of cc fields identified by our heuristics form a
+ * valid credit card section.
+ * There are 4 different cases when a field is considered a credit card field
+ * 1. Identified by autocomplete attribute. ex <input autocomplete="cc-number">
+ * 2. Identified by fathom and fathom is pretty confident (when confidence
+ * value is higher than `highConfidenceThreshold`)
+ * 3. Identified by fathom. Confidence value is between `fathom.confidenceThreshold`
+ * and `fathom.highConfidenceThreshold`
+ * 4. Identified by regex-based heurstic. There is no confidence value in thise case.
+ *
+ * A form is considered a valid credit card form when one of the following condition
+ * is met:
+ * A. One of the cc field is identified by autocomplete (case 1)
+ * B. One of the cc field is identified by fathom (case 2 or 3), and there is also
+ * another cc field found by any of our heuristic (case 2, 3, or 4)
+ * C. Only one cc field is found in the section, but fathom is very confident (Case 2).
+ * Currently we add an extra restriction to this rule to decrease the false-positive
+ * rate. See comments below for details.
+ *
+ * @returns {boolean} True for a valid section, otherwise false
+ */
+ isValidSection() {
+ let ccNumberDetail = null;
+ let ccNameDetail = null;
+ let ccExpiryDetail = null;
+
+ for (let detail of this.fieldDetails) {
+ switch (detail.fieldName) {
+ case "cc-number":
+ ccNumberDetail = detail;
+ break;
+ case "cc-name":
+ case "cc-given-name":
+ case "cc-additional-name":
+ case "cc-family-name":
+ ccNameDetail = detail;
+ break;
+ case "cc-exp":
+ case "cc-exp-month":
+ case "cc-exp-year":
+ ccExpiryDetail = detail;
+ break;
+ }
+ }
+
+ // Condition A. Always trust autocomplete attribute. A section is considered a valid
+ // cc section as long as a field has autocomplete=cc-number, cc-name or cc-exp*
+ if (
+ ccNumberDetail?.reason == "autocomplete" ||
+ ccNameDetail?.reason == "autocomplete" ||
+ ccExpiryDetail?.reason == "autocomplete"
+ ) {
+ return true;
+ }
+
+ // Condition B. One of the field is identified by fathom, if this section also
+ // contains another cc field found by our heuristic (Case 2, 3, or 4), we consider
+ // this section a valid credit card seciton
+ if (ccNumberDetail?.reason == "fathom") {
+ if (ccNameDetail || ccExpiryDetail) {
+ return true;
+ }
+ } else if (ccNameDetail?.reason == "fathom") {
+ if (ccNumberDetail || ccExpiryDetail) {
+ return true;
+ }
+ }
+
+ // Condition C.
+ let highConfidenceThreshold =
+ FormAutofillUtils.ccFathomHighConfidenceThreshold;
+ let highConfidenceField;
+ if (ccNumberDetail?.confidence > highConfidenceThreshold) {
+ highConfidenceField = ccNumberDetail;
+ } else if (ccNameDetail?.confidence > highConfidenceThreshold) {
+ highConfidenceField = ccNameDetail;
+ }
+ if (highConfidenceField) {
+ // Temporarily add an addtional "the field is the only visible input" constraint
+ // when determining whether a form has only a high-confidence cc-* field a valid
+ // credit card section. We can remove this restriction once we are confident
+ // about only using fathom.
+ const element = highConfidenceField.elementWeakRef.get();
+ const root = element.form || element.ownerDocument;
+ const inputs = root.querySelectorAll("input:not([type=hidden])");
+ if (inputs.length == 1 && inputs[0] == element) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ isEnabled() {
+ return FormAutofill.isAutofillCreditCardsEnabled;
+ }
+
+ isRecordCreatable(record) {
+ return (
+ record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"])
+ );
+ }
+
+ /**
+ * Handles credit card expiry date transformation when
+ * the expiry date exists in a cc-exp field.
+ *
+ * @param {object} profile
+ * @memberof FormAutofillCreditCardSection
+ */
+ creditCardExpiryDateTransformer(profile) {
+ if (!profile["cc-exp"]) {
+ return;
+ }
+
+ let detail = this.getFieldDetailByName("cc-exp");
+ if (!detail) {
+ return;
+ }
+
+ function monthYearOrderCheck(
+ _expiryDateTransformFormat,
+ _ccExpMonth,
+ _ccExpYear
+ ) {
+ // Bug 1687681: This is a short term fix to other locales having
+ // different characters to represent year.
+ // For example, FR locales may use "A" to represent year.
+ // For example, DE locales may use "J" to represent year.
+ // This approach will not scale well and should be investigated in a follow up bug.
+ let monthChars = "m";
+ let yearChars = "yaj";
+ let result;
+
+ let monthFirstCheck = new RegExp(
+ "(?:\\b|^)((?:[" +
+ monthChars +
+ "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
+ yearChars +
+ "]{2}){1,2})(?:\\b|$)",
+ "i"
+ );
+
+ // If the month first check finds a result, where placeholder is "mm - yyyy",
+ // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"]
+ result = monthFirstCheck.exec(_expiryDateTransformFormat);
+ if (result) {
+ return (
+ _ccExpMonth.toString().padStart(result[1].length, "0") +
+ result[2] +
+ _ccExpYear.toString().substr(-1 * result[3].length)
+ );
+ }
+
+ let yearFirstCheck = new RegExp(
+ "(?:\\b|^)((?:[" +
+ yearChars +
+ "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + // either one or two counts of 'yy' or 'aa' sequence
+ monthChars +
+ "]){1,2})(?:\\b|$)",
+ "i" // either one or two counts of a 'm' sequence
+ );
+
+ // If the year first check finds a result, where placeholder is "yyyy mm",
+ // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"]
+ result = yearFirstCheck.exec(_expiryDateTransformFormat);
+
+ if (result) {
+ return (
+ _ccExpYear.toString().substr(-1 * result[1].length) +
+ result[2] +
+ _ccExpMonth.toString().padStart(result[3].length, "0")
+ );
+ }
+ return null;
+ }
+
+ let element = detail.elementWeakRef.get();
+ let result;
+ let ccExpMonth = profile["cc-exp-month"];
+ let ccExpYear = profile["cc-exp-year"];
+ if (element.tagName == "INPUT") {
+ // Use the placeholder to determine the expiry string format.
+ if (element.placeholder) {
+ result = monthYearOrderCheck(
+ element.placeholder,
+ ccExpMonth,
+ ccExpYear
+ );
+ }
+ // If the previous sibling is a label, it is most likely meant to describe the
+ // expiry field.
+ if (!result && element.previousElementSibling?.tagName == "LABEL") {
+ result = monthYearOrderCheck(
+ element.previousElementSibling.textContent,
+ ccExpMonth,
+ ccExpYear
+ );
+ }
+ }
+
+ if (result) {
+ profile["cc-exp"] = result;
+ } else {
+ // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
+ // preferred presentation format for credit card expiry dates.
+ profile["cc-exp"] =
+ ccExpMonth.toString().padStart(2, "0") + "/" + ccExpYear.toString();
+ }
+ }
+
+ /**
+ * Handles credit card expiry date transformation when the expiry date exists in
+ * the separate cc-exp-month and cc-exp-year fields
+ *
+ * @param {object} profile
+ * @memberof FormAutofillCreditCardSection
+ */
+ creditCardExpMonthAndYearTransformer(profile) {
+ const getInputElementByField = (field, self) => {
+ if (!field) {
+ return null;
+ }
+ let detail = self.getFieldDetailByName(field);
+ if (!detail) {
+ return null;
+ }
+ let element = detail.elementWeakRef.get();
+ return element.tagName === "INPUT" ? element : null;
+ };
+ let month = getInputElementByField("cc-exp-month", this);
+ if (month) {
+ // Transform the expiry month to MM since this is a common format needed for filling.
+ profile["cc-exp-month-formatted"] = profile["cc-exp-month"]
+ ?.toString()
+ .padStart(2, "0");
+ }
+ let year = getInputElementByField("cc-exp-year", this);
+ // If the expiration year element is an input,
+ // then we examine any placeholder to see if we should format the expiration year
+ // as a zero padded string in order to autofill correctly.
+ if (year) {
+ let placeholder = year.placeholder;
+
+ // Checks for 'YY'|'AA'|'JJ' placeholder and converts the year to a two digit string using the last two digits.
+ let result = /\b(yy|aa|jj)\b/i.test(placeholder);
+ if (result) {
+ profile["cc-exp-year-formatted"] = profile["cc-exp-year"]
+ .toString()
+ .substring(2);
+ }
+ }
+ }
+
+ async _decrypt(cipherText, reauth) {
+ // Get the window for the form field.
+ let window;
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ if (element) {
+ window = element.ownerGlobal;
+ break;
+ }
+ }
+ if (!window) {
+ return null;
+ }
+
+ let actor = window.windowGlobalChild.getActor("FormAutofill");
+ return actor.sendQuery("FormAutofill:GetDecryptedString", {
+ cipherText,
+ reauth,
+ });
+ }
+
+ /*
+ * Apply all credit card related transformers.
+ *
+ * @param {Object} profile
+ * A profile for adjusting credit card related value.
+ * @override
+ */
+ applyTransformers(profile) {
+ // The matchSelectOptions transformer must be placed after the expiry transformers.
+ // This ensures that the expiry value that is cached in the matchSelectOptions
+ // matches the expiry value that is stored in the profile ensuring that autofill works
+ // correctly when dealing with option elements.
+ this.creditCardExpiryDateTransformer(profile);
+ this.creditCardExpMonthAndYearTransformer(profile);
+ this.matchSelectOptions(profile);
+ this.adaptFieldMaxLength(profile);
+ }
+
+ getFilledValueFromProfile(fieldDetail, profile) {
+ const value = super.getFilledValueFromProfile(fieldDetail, profile);
+ if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) {
+ const part = fieldDetail.part;
+ return value.slice((part - 1) * 4, part * 4);
+ }
+ return value;
+ }
+
+ computeFillingValue(value, fieldDetail, element) {
+ if (
+ fieldDetail.fieldName != "cc-type" ||
+ !HTMLSelectElement.isInstance(element)
+ ) {
+ return value;
+ }
+
+ if (lazy.CreditCard.isValidNetwork(value)) {
+ return value;
+ }
+
+ // Don't save the record when the option value is empty *OR* there
+ // are multiple options being selected. The empty option is usually
+ // assumed to be default along with a meaningless text to users.
+ if (value && element.selectedOptions.length == 1) {
+ let selectedOption = element.selectedOptions[0];
+ let networkType =
+ lazy.CreditCard.getNetworkFromName(selectedOption.text) ??
+ lazy.CreditCard.getNetworkFromName(selectedOption.value);
+ if (networkType) {
+ return networkType;
+ }
+ }
+ // If we couldn't match the value to any network, we'll
+ // strip this field when submitting.
+ return value;
+ }
+
+ /**
+ * Customize for previewing profile
+ *
+ * @param {object} profile
+ * A profile for pre-processing before previewing values.
+ * @override
+ */
+ preparePreviewProfile(profile) {
+ // Always show the decrypted credit card number when Master Password is
+ // disabled.
+ if (profile["cc-number-decrypted"]) {
+ profile["cc-number"] = profile["cc-number-decrypted"];
+ } else if (!profile["cc-number"].startsWith("****")) {
+ // Show the previewed credit card as "**** 4444" which is
+ // needed when a credit card number field has a maxlength of four.
+ profile["cc-number"] = "****" + profile["cc-number"];
+ }
+ }
+
+ /**
+ * Customize for filling profile
+ *
+ * @param {object} profile
+ * A profile for pre-processing before filling values.
+ * @returns {boolean} Whether the profile should be filled.
+ * @override
+ */
+ async prepareFillingProfile(profile) {
+ // Prompt the OS login dialog to get the decrypted credit
+ // card number.
+ if (profile["cc-number-encrypted"]) {
+ let decrypted = await this._decrypt(
+ profile["cc-number-encrypted"],
+ this.reauthPasswordPromptMessage
+ );
+
+ if (!decrypted) {
+ // Early return if the decrypted is empty or undefined
+ return false;
+ }
+
+ profile["cc-number"] = decrypted;
+ }
+ return true;
+ }
+
+ async autofillFields(profile) {
+ this.getAdaptedProfiles([profile]);
+ if (!(await super.autofillFields(profile))) {
+ return false;
+ }
+
+ return true;
+ }
+
+ createNormalizedRecord(creditCard) {
+ if (!creditCard?.record["cc-number"]) {
+ return;
+ }
+ // Normalize cc-number
+ creditCard.record["cc-number"] = lazy.CreditCard.normalizeCardNumber(
+ creditCard.record["cc-number"]
+ );
+
+ // Normalize cc-exp-month and cc-exp-year
+ let { month, year } = lazy.CreditCard.normalizeExpiration({
+ expirationString: creditCard.record["cc-exp"],
+ expirationMonth: creditCard.record["cc-exp-month"],
+ expirationYear: creditCard.record["cc-exp-year"],
+ });
+ if (month) {
+ creditCard.record["cc-exp-month"] = month;
+ }
+ if (year) {
+ creditCard.record["cc-exp-year"] = year;
+ }
+ }
+}
diff --git a/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
new file mode 100644
index 0000000000..31845ee73c
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs
@@ -0,0 +1,1253 @@
+/* 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 { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+export let FormAutofillUtils;
+
+const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/";
+const ADDRESS_REFERENCES = "addressReferences.js";
+const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js";
+
+const ADDRESSES_COLLECTION_NAME = "addresses";
+const CREDITCARDS_COLLECTION_NAME = "creditCards";
+const MANAGE_ADDRESSES_L10N_IDS = [
+ "autofill-add-new-address-title",
+ "autofill-manage-addresses-title",
+];
+const EDIT_ADDRESS_L10N_IDS = [
+ "autofill-address-given-name",
+ "autofill-address-additional-name",
+ "autofill-address-family-name",
+ "autofill-address-organization",
+ "autofill-address-street",
+ "autofill-address-state",
+ "autofill-address-province",
+ "autofill-address-city",
+ "autofill-address-country",
+ "autofill-address-zip",
+ "autofill-address-postal-code",
+ "autofill-address-email",
+ "autofill-address-tel",
+];
+const MANAGE_CREDITCARDS_L10N_IDS = [
+ "autofill-add-new-card-title",
+ "autofill-manage-credit-cards-title",
+];
+const EDIT_CREDITCARD_L10N_IDS = [
+ "autofill-card-number",
+ "autofill-card-name-on-card",
+ "autofill-card-expires-month",
+ "autofill-card-expires-year",
+ "autofill-card-network",
+];
+const FIELD_STATES = {
+ NORMAL: "NORMAL",
+ AUTO_FILLED: "AUTO_FILLED",
+ PREVIEW: "PREVIEW",
+};
+
+const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"];
+
+// The maximum length of data to be saved in a single field for preventing DoS
+// attacks that fill the user's hard drive(s).
+const MAX_FIELD_VALUE_LENGTH = 200;
+
+export let AddressDataLoader = {
+ // Status of address data loading. We'll load all the countries with basic level 1
+ // information while requesting conutry information, and set country to true.
+ // Level 1 Set is for recording which country's level 1/level 2 data is loaded,
+ // since we only load this when getCountryAddressData called with level 1 parameter.
+ _dataLoaded: {
+ country: false,
+ level1: new Set(),
+ },
+
+ /**
+ * Load address data and extension script into a sandbox from different paths.
+ *
+ * @param {string} path
+ * The path for address data and extension script. It could be root of the address
+ * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/).
+ * @returns {object}
+ * A sandbox that contains address data object with properties from extension.
+ */
+ _loadScripts(path) {
+ let sandbox = {};
+ let extSandbox = {};
+
+ try {
+ sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES);
+ extSandbox = FormAutofillUtils.loadDataFromScript(
+ path + ADDRESS_REFERENCES_EXT
+ );
+ } catch (e) {
+ // Will return only address references if extension loading failed or empty sandbox if
+ // address references loading failed.
+ return sandbox;
+ }
+
+ if (extSandbox.addressDataExt) {
+ for (let key in extSandbox.addressDataExt) {
+ let addressDataForKey = sandbox.addressData[key];
+ if (!addressDataForKey) {
+ addressDataForKey = sandbox.addressData[key] = {};
+ }
+
+ Object.assign(addressDataForKey, extSandbox.addressDataExt[key]);
+ }
+ }
+ return sandbox;
+ },
+
+ /**
+ * Convert certain properties' string value into array. We should make sure
+ * the cached data is parsed.
+ *
+ * @param {object} data Original metadata from addressReferences.
+ * @returns {object} parsed metadata with property value that converts to array.
+ */
+ _parse(data) {
+ if (!data) {
+ return null;
+ }
+
+ const properties = [
+ "languages",
+ "sub_keys",
+ "sub_isoids",
+ "sub_names",
+ "sub_lnames",
+ ];
+ for (let key of properties) {
+ if (!data[key]) {
+ continue;
+ }
+ // No need to normalize data if the value is array already.
+ if (Array.isArray(data[key])) {
+ return data;
+ }
+
+ data[key] = data[key].split("~");
+ }
+ return data;
+ },
+
+ /**
+ * We'll cache addressData in the loader once the data loaded from scripts.
+ * It'll become the example below after loading addressReferences with extension:
+ * addressData: {
+ * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata
+ * "alternative_names": ... // Data defined in extension }
+ * "data/CA": {} // Other supported country metadata
+ * "data/TW": {} // Other supported country metadata
+ * "data/TW/台北市": {} // Other supported country level 1 metadata
+ * }
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Default locale metadata
+ */
+ _loadData(country, level1 = null) {
+ // Load the addressData if needed
+ if (!this._dataLoaded.country) {
+ this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData;
+ this._dataLoaded.country = true;
+ }
+ if (!level1) {
+ return this._parse(this._addressData[`data/${country}`]);
+ }
+ // If level1 is set, load addressReferences under country folder with specific
+ // country/level 1 for level 2 information.
+ if (!this._dataLoaded.level1.has(country)) {
+ Object.assign(
+ this._addressData,
+ this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData
+ );
+ this._dataLoaded.level1.add(country);
+ }
+ return this._parse(this._addressData[`data/${country}/${level1}`]);
+ },
+
+ /**
+ * Return the region metadata with default locale and other locales (if exists).
+ *
+ * @param {string} country
+ * @param {string?} level1
+ * @returns {object} Return default locale and other locales metadata.
+ */
+ getData(country, level1 = null) {
+ let defaultLocale = this._loadData(country, level1);
+ if (!defaultLocale) {
+ return null;
+ }
+
+ let countryData = this._parse(this._addressData[`data/${country}`]);
+ let locales = [];
+ // TODO: Should be able to support multi-locale level 1/ level 2 metadata query
+ // in Bug 1421886
+ if (countryData.languages) {
+ let list = countryData.languages.filter(key => key !== countryData.lang);
+ locales = list.map(key =>
+ this._parse(this._addressData[`${defaultLocale.id}--${key}`])
+ );
+ }
+ return { defaultLocale, locales };
+ },
+};
+
+FormAutofillUtils = {
+ get AUTOFILL_FIELDS_THRESHOLD() {
+ return 3;
+ },
+
+ ADDRESSES_COLLECTION_NAME,
+ CREDITCARDS_COLLECTION_NAME,
+ MANAGE_ADDRESSES_L10N_IDS,
+ EDIT_ADDRESS_L10N_IDS,
+ MANAGE_CREDITCARDS_L10N_IDS,
+ EDIT_CREDITCARD_L10N_IDS,
+ MAX_FIELD_VALUE_LENGTH,
+ FIELD_STATES,
+
+ _fieldNameInfo: {
+ name: "name",
+ "given-name": "name",
+ "additional-name": "name",
+ "family-name": "name",
+ organization: "organization",
+ "street-address": "address",
+ "address-line1": "address",
+ "address-line2": "address",
+ "address-line3": "address",
+ "address-level1": "address",
+ "address-level2": "address",
+ "postal-code": "address",
+ country: "address",
+ "country-name": "address",
+ tel: "tel",
+ "tel-country-code": "tel",
+ "tel-national": "tel",
+ "tel-area-code": "tel",
+ "tel-local": "tel",
+ "tel-local-prefix": "tel",
+ "tel-local-suffix": "tel",
+ "tel-extension": "tel",
+ email: "email",
+ "cc-name": "creditCard",
+ "cc-given-name": "creditCard",
+ "cc-additional-name": "creditCard",
+ "cc-family-name": "creditCard",
+ "cc-number": "creditCard",
+ "cc-exp-month": "creditCard",
+ "cc-exp-year": "creditCard",
+ "cc-exp": "creditCard",
+ "cc-type": "creditCard",
+ },
+
+ _collators: {},
+ _reAlternativeCountryNames: {},
+
+ isAddressField(fieldName) {
+ return (
+ !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
+ );
+ },
+
+ isCreditCardField(fieldName) {
+ return this._fieldNameInfo[fieldName] == "creditCard";
+ },
+
+ isCCNumber(ccNumber) {
+ return lazy.CreditCard.isValidNumber(ccNumber);
+ },
+
+ ensureLoggedIn(promptMessage) {
+ return lazy.OSKeyStore.ensureLoggedIn(
+ this._reauthEnabledByUser && promptMessage ? promptMessage : false
+ );
+ },
+
+ /**
+ * Get the array of credit card network ids ("types") we expect and offer as valid choices
+ *
+ * @returns {Array}
+ */
+ getCreditCardNetworks() {
+ return lazy.CreditCard.getSupportedNetworks();
+ },
+
+ getCategoryFromFieldName(fieldName) {
+ return this._fieldNameInfo[fieldName];
+ },
+
+ getCategoriesFromFieldNames(fieldNames) {
+ let categories = new Set();
+ for (let fieldName of fieldNames) {
+ let info = this.getCategoryFromFieldName(fieldName);
+ if (info) {
+ categories.add(info);
+ }
+ }
+ return Array.from(categories);
+ },
+
+ getAddressSeparator() {
+ // The separator should be based on the L10N address format, and using a
+ // white space is a temporary solution.
+ return " ";
+ },
+
+ /**
+ * Get address display label. It should display information separated
+ * by a comma.
+ *
+ * @param {object} address
+ * @returns {string}
+ */
+ getAddressLabel(address) {
+ // TODO: Implement a smarter way for deciding what to display
+ // as option text. Possibly improve the algorithm in
+ // ProfileAutoCompleteResult.jsm and reuse it here.
+ let fieldOrder = [
+ "name",
+ "-moz-street-address-one-line", // Street address
+ "address-level3", // Townland / Neighborhood / Village
+ "address-level2", // City/Town
+ "organization", // Company or organization name
+ "address-level1", // Province/State (Standardized code if possible)
+ "country-name", // Country name
+ "postal-code", // Postal code
+ "tel", // Phone number
+ "email", // Email address
+ ];
+
+ address = { ...address };
+ let parts = [];
+ if (address["street-address"]) {
+ address["-moz-street-address-one-line"] = this.toOneLineAddress(
+ address["street-address"]
+ );
+ }
+
+ if (!("name" in address)) {
+ address.name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"],
+ middle: address["additional-name"],
+ family: address["family-name"],
+ });
+ }
+
+ for (const fieldName of fieldOrder) {
+ let string = address[fieldName];
+ if (string) {
+ parts.push(string);
+ }
+ }
+ return parts.join(", ");
+ },
+
+ /**
+ * Internal method to split an address to multiple parts per the provided delimiter,
+ * removing blank parts.
+ *
+ * @param {string} address The address the split
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string[]}
+ */
+ _toStreetAddressParts(address, delimiter = "\n") {
+ let array = typeof address == "string" ? address.split(delimiter) : address;
+
+ if (!Array.isArray(array)) {
+ return [];
+ }
+ return array.map(s => (s ? s.trim() : "")).filter(s => s);
+ },
+
+ /**
+ * Converts a street address to a single line, removing linebreaks marked by the delimiter
+ *
+ * @param {string} address The address the convert
+ * @param {string} [delimiter] The separator that is used between lines in the address
+ * @returns {string}
+ */
+ toOneLineAddress(address, delimiter = "\n") {
+ let addressParts = this._toStreetAddressParts(address, delimiter);
+ return addressParts.join(this.getAddressSeparator());
+ },
+
+ /**
+ * Compares two addresses, removing internal whitespace
+ *
+ * @param {string} a The first address to compare
+ * @param {string} b The second address to compare
+ * @param {Array} collators Search collators that will be used for comparison
+ * @param {string} [delimiter="\n"] The separator that is used between lines in the address
+ * @returns {boolean} True if the addresses are equal, false otherwise
+ */
+ compareStreetAddress(a, b, collators, delimiter = "\n") {
+ let oneLineA = this._toStreetAddressParts(a, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ let oneLineB = this._toStreetAddressParts(b, delimiter)
+ .map(p => p.replace(/\s/g, ""))
+ .join("");
+ return this.strCompare(oneLineA, oneLineB, collators);
+ },
+
+ /**
+ * In-place concatenate tel-related components into a single "tel" field and
+ * delete unnecessary fields.
+ *
+ * @param {object} address An address record.
+ */
+ compressTel(address) {
+ let telCountryCode = address["tel-country-code"] || "";
+ let telAreaCode = address["tel-area-code"] || "";
+
+ if (!address.tel) {
+ if (address["tel-national"]) {
+ address.tel = telCountryCode + address["tel-national"];
+ } else if (address["tel-local"]) {
+ address.tel = telCountryCode + telAreaCode + address["tel-local"];
+ } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) {
+ address.tel =
+ telCountryCode +
+ telAreaCode +
+ address["tel-local-prefix"] +
+ address["tel-local-suffix"];
+ }
+ }
+
+ for (let field in address) {
+ if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
+ delete address[field];
+ }
+ }
+ },
+
+ /**
+ * Determines if an element can be autofilled or not.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if the element can be autofilled
+ */
+ isFieldAutofillable(element) {
+ return element && !element.readOnly && !element.disabled;
+ },
+
+ /**
+ * Determines if an element is visually hidden or not.
+ *
+ * @param {HTMLElement} element
+ * @param {boolean} visibilityCheck true to run visiblity check against
+ * element.checkVisibility API. Otherwise, test by only checking
+ * `hidden` and `display` attributes
+ * @returns {boolean} true if the element is visible
+ */
+ isFieldVisible(element, visibilityCheck = true) {
+ if (visibilityCheck) {
+ return element.checkVisibility({
+ checkOpacity: true,
+ checkVisibilityCSS: true,
+ });
+ }
+
+ return !element.hidden && element.style.display != "none";
+ },
+
+ /**
+ * Determines if an element is eligible to be used by credit card or address autofill.
+ *
+ * @param {HTMLElement} element
+ * @returns {boolean} true if element can be used by credit card or address autofill
+ */
+ isCreditCardOrAddressFieldType(element) {
+ if (!element) {
+ return false;
+ }
+
+ if (HTMLInputElement.isInstance(element)) {
+ // `element.type` can be recognized as `text`, if it's missing or invalid.
+ return ELIGIBLE_INPUT_TYPES.includes(element.type);
+ }
+
+ return HTMLSelectElement.isInstance(element);
+ },
+
+ loadDataFromScript(url, sandbox = {}) {
+ Services.scriptloader.loadSubScript(url, sandbox);
+ return sandbox;
+ },
+
+ /**
+ * Get country address data and fallback to US if not found.
+ * See AddressDataLoader._loadData for more details of addressData structure.
+ *
+ * @param {string} [country=FormAutofill.DEFAULT_REGION]
+ * The country code for requesting specific country's metadata. It'll be
+ * default region if parameter is not set.
+ * @param {string} [level1=null]
+ * Return address level 1/level 2 metadata if parameter is set.
+ * @returns {object|null}
+ * Return metadata of specific region with default locale and other supported
+ * locales. We need to return a default country metadata for layout format
+ * and collator, but for sub-region metadata we'll just return null if not found.
+ */
+ getCountryAddressRawData(
+ country = FormAutofill.DEFAULT_REGION,
+ level1 = null
+ ) {
+ let metadata = AddressDataLoader.getData(country, level1);
+ if (!metadata) {
+ if (level1) {
+ return null;
+ }
+ // Fallback to default region if we couldn't get data from given country.
+ if (country != FormAutofill.DEFAULT_REGION) {
+ metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION);
+ }
+ }
+
+ // TODO: Now we fallback to US if we couldn't get data from default region,
+ // but it could be removed in bug 1423464 if it's not necessary.
+ if (!metadata) {
+ metadata = AddressDataLoader.getData("US");
+ }
+ return metadata;
+ },
+
+ /**
+ * Get country address data with default locale.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {object|null} Return metadata of specific region with default locale.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressData(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && metadata.defaultLocale;
+ },
+
+ /**
+ * Get country address data with all locales.
+ *
+ * @param {string} country
+ * @param {string} level1
+ * @returns {Array<object> | null}
+ * Return metadata of specific region with all the locales.
+ * NOTE: The returned data may be for a default region if the
+ * specified one cannot be found. Callers who only want the specific
+ * region should check the returned country code.
+ */
+ getCountryAddressDataWithLocales(country, level1) {
+ let metadata = this.getCountryAddressRawData(country, level1);
+ return metadata && [metadata.defaultLocale, ...metadata.locales];
+ },
+
+ /**
+ * Get the collators based on the specified country.
+ *
+ * @param {string} country The specified country.
+ * @param {object} [options = {}] a list of options for this method
+ * @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored.
+ * @param {string} [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values
+ * @param {string} [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings
+ * @returns {Array} An array containing several collator objects.
+ */
+ getSearchCollators(
+ country,
+ { ignorePunctuation = true, sensitivity = "base", usage = "search" } = {}
+ ) {
+ // TODO: Only one language should be used at a time per country. The locale
+ // of the page should be taken into account to do this properly.
+ // We are going to support more countries in bug 1370193 and this
+ // should be addressed when we start to implement that bug.
+
+ if (!this._collators[country]) {
+ let dataset = this.getCountryAddressData(country);
+ let languages = dataset.languages || [dataset.lang];
+ let options = {
+ ignorePunctuation,
+ sensitivity,
+ usage,
+ };
+ this._collators[country] = languages.map(
+ lang => new Intl.Collator(lang, options)
+ );
+ }
+ return this._collators[country];
+ },
+
+ // Based on the list of fields abbreviations in
+ // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
+ FIELDS_LOOKUP: {
+ N: "name",
+ O: "organization",
+ A: "street-address",
+ S: "address-level1",
+ C: "address-level2",
+ D: "address-level3",
+ Z: "postal-code",
+ n: "newLine",
+ },
+
+ /**
+ * Parse a country address format string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, format string "%A%n%C, %S" should return:
+ * [
+ * {fieldId: "street-address", newLine: true},
+ * {fieldId: "address-level2"},
+ * {fieldId: "address-level1"},
+ * ]
+ *
+ * @param {string} fmt Country address format string
+ * @returns {Array<object>} List of fields
+ */
+ parseAddressFormat(fmt) {
+ if (!fmt) {
+ throw new Error("fmt string is missing.");
+ }
+
+ return fmt.match(/%[^%]/g).reduce((parsed, part) => {
+ // Take the first letter of each segment and try to identify it
+ let fieldId = this.FIELDS_LOOKUP[part[1]];
+ // Early return if cannot identify part.
+ if (!fieldId) {
+ return parsed;
+ }
+ // If a new line is detected, add an attribute to the previous field.
+ if (fieldId == "newLine") {
+ let size = parsed.length;
+ if (size) {
+ parsed[size - 1].newLine = true;
+ }
+ return parsed;
+ }
+ return parsed.concat({ fieldId });
+ }, []);
+ },
+
+ /**
+ * Used to populate dropdowns in the UI (e.g. FormAutofill preferences).
+ * Use findAddressSelectOption for matching a value to a region.
+ *
+ * @param {string[]} subKeys An array of regionCode strings
+ * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
+ * @param {string[]} subNames An array of regionName strings
+ * @param {string[]} subLnames An array of latinised regionName strings
+ * @returns {Map?} Returns null if subKeys or subNames are not truthy.
+ * Otherwise, a Map will be returned mapping keys -> names.
+ */
+ buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
+ // Not all regions have sub_keys. e.g. DE
+ if (
+ !subKeys ||
+ !subKeys.length ||
+ (!subNames && !subLnames) ||
+ (subNames && subKeys.length != subNames.length) ||
+ (subLnames && subKeys.length != subLnames.length)
+ ) {
+ return null;
+ }
+
+ // Overwrite subKeys with subIsoids, when available
+ if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
+ for (let i = 0; i < subIsoids.length; i++) {
+ if (subIsoids[i]) {
+ subKeys[i] = subIsoids[i];
+ }
+ }
+ }
+
+ // Apply sub_lnames if sub_names does not exist
+ let names = subNames || subLnames;
+ return new Map(subKeys.map((key, index) => [key, names[index]]));
+ },
+
+ /**
+ * Parse a require string and outputs an array of fields.
+ * Spaces, commas, and other literals are ignored in this implementation.
+ * For example, a require string "ACS" should return:
+ * ["street-address", "address-level2", "address-level1"]
+ *
+ * @param {string} requireString Country address require string
+ * @returns {Array<string>} List of fields
+ */
+ parseRequireString(requireString) {
+ if (!requireString) {
+ throw new Error("requireString string is missing.");
+ }
+
+ return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
+ },
+
+ /**
+ * Use address data and alternative country name list to identify a country code from a
+ * specified country name.
+ *
+ * @param {string} countryName A country name to be identified
+ * @param {string} [countrySpecified] A country code indicating that we only
+ * search its alternative names if specified.
+ * @returns {string} The matching country code.
+ */
+ identifyCountryCode(countryName, countrySpecified) {
+ if (!countryName) {
+ return null;
+ }
+
+ if (AddressDataLoader.getData(countryName)) {
+ return countryName;
+ }
+
+ const countries = countrySpecified
+ ? [countrySpecified]
+ : [...FormAutofill.countries.keys()];
+
+ for (const country of countries) {
+ let collators = this.getSearchCollators(country);
+ let metadata = this.getCountryAddressData(country);
+ if (country != metadata.key) {
+ // We hit the fallback logic in getCountryAddressRawData so ignore it as
+ // it's not related to `country` and use the name from l10n instead.
+ metadata = {
+ id: `data/${country}`,
+ key: country,
+ name: FormAutofill.countries.get(country),
+ };
+ }
+ let alternativeCountryNames = metadata.alternative_names || [
+ metadata.name,
+ ];
+ let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
+ if (!reAlternativeCountryNames) {
+ reAlternativeCountryNames = this._reAlternativeCountryNames[country] =
+ [];
+ }
+
+ if (countryName.length == 3) {
+ if (this.strCompare(metadata.alpha_3_code, countryName, collators)) {
+ return country;
+ }
+ }
+
+ for (let i = 0; i < alternativeCountryNames.length; i++) {
+ let name = alternativeCountryNames[i];
+ let reName = reAlternativeCountryNames[i];
+ if (!reName) {
+ reName = reAlternativeCountryNames[i] = new RegExp(
+ "\\b" + this.escapeRegExp(name) + "\\b",
+ "i"
+ );
+ }
+
+ if (
+ this.strCompare(name, countryName, collators) ||
+ reName.test(countryName)
+ ) {
+ return country;
+ }
+ }
+ }
+
+ return null;
+ },
+
+ findSelectOption(selectEl, record, fieldName) {
+ if (this.isAddressField(fieldName)) {
+ return this.findAddressSelectOption(selectEl, record, fieldName);
+ }
+ if (this.isCreditCardField(fieldName)) {
+ return this.findCreditCardSelectOption(selectEl, record, fieldName);
+ }
+ return null;
+ },
+
+ /**
+ * Try to find the abbreviation of the given sub-region name
+ *
+ * @param {string[]} subregionValues A list of inferable sub-region values.
+ * @param {string} [country] A country name to be identified.
+ * @returns {string} The matching sub-region abbreviation.
+ */
+ getAbbreviatedSubregionName(subregionValues, country) {
+ let values = Array.isArray(subregionValues)
+ ? subregionValues
+ : [subregionValues];
+
+ let collators = this.getSearchCollators(country);
+ for (let metadata of this.getCountryAddressDataWithLocales(country)) {
+ let {
+ sub_keys: subKeys,
+ sub_names: subNames,
+ sub_lnames: subLnames,
+ } = metadata;
+ if (!subKeys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ subNames = subNames || subLnames;
+
+ let speculatedSubIndexes = [];
+ for (const val of values) {
+ let identifiedValue = this.identifyValue(
+ subKeys,
+ subNames,
+ val,
+ collators
+ );
+ if (identifiedValue) {
+ return identifiedValue;
+ }
+
+ // Predict the possible state by partial-matching if no exact match.
+ [subKeys, subNames].forEach(sub => {
+ speculatedSubIndexes.push(
+ sub.findIndex(token => {
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(token) + "\\b"
+ );
+
+ return pattern.test(val);
+ })
+ );
+ });
+ }
+ let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
+ if (subKey) {
+ return subKey;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Find the option element from select element.
+ * 1. Try to find the locale using the country from address.
+ * 2. First pass try to find exact match.
+ * 3. Second pass try to identify values from address value and options,
+ * and look for a match.
+ *
+ * @param {DOMElement} selectEl
+ * @param {object} address
+ * @param {string} fieldName
+ * @returns {DOMElement}
+ */
+ findAddressSelectOption(selectEl, address, fieldName) {
+ if (selectEl.options.length > 512) {
+ // Allow enough space for all countries (roughly 300 distinct values) and all
+ // timezones (roughly 400 distinct values), plus some extra wiggle room.
+ return null;
+ }
+ let value = address[fieldName];
+ if (!value) {
+ return null;
+ }
+
+ let collators = this.getSearchCollators(address.country);
+
+ for (let option of selectEl.options) {
+ if (
+ this.strCompare(value, option.value, collators) ||
+ this.strCompare(value, option.text, collators)
+ ) {
+ return option;
+ }
+ }
+
+ switch (fieldName) {
+ case "address-level1": {
+ let { country } = address;
+ let identifiedValue = this.getAbbreviatedSubregionName(
+ [value],
+ country
+ );
+ // No point going any further if we cannot identify value from address level 1
+ if (!identifiedValue) {
+ return null;
+ }
+ for (let dataset of this.getCountryAddressDataWithLocales(country)) {
+ let keys = dataset.sub_keys;
+ if (!keys) {
+ // Not all regions have sub_keys. e.g. DE
+ continue;
+ }
+ // Apply sub_lnames if sub_names does not exist
+ let names = dataset.sub_names || dataset.sub_lnames;
+
+ // Go through options one by one to find a match.
+ // Also check if any option contain the address-level1 key.
+ let pattern = new RegExp(
+ "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
+ "i"
+ );
+ for (let option of selectEl.options) {
+ let optionValue = this.identifyValue(
+ keys,
+ names,
+ option.value,
+ collators
+ );
+ let optionText = this.identifyValue(
+ keys,
+ names,
+ option.text,
+ collators
+ );
+ if (
+ identifiedValue === optionValue ||
+ identifiedValue === optionText ||
+ pattern.test(option.value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ case "country": {
+ if (this.getCountryAddressData(value)) {
+ for (let option of selectEl.options) {
+ if (
+ this.identifyCountryCode(option.text, value) ||
+ this.identifyCountryCode(option.value, value)
+ ) {
+ return option;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ findCreditCardSelectOption(selectEl, creditCard, fieldName) {
+ let oneDigitMonth = creditCard["cc-exp-month"]
+ ? creditCard["cc-exp-month"].toString()
+ : null;
+ let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
+ let fourDigitsYear = creditCard["cc-exp-year"]
+ ? creditCard["cc-exp-year"].toString()
+ : null;
+ let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
+ let options = Array.from(selectEl.options);
+
+ switch (fieldName) {
+ case "cc-exp-month": {
+ if (!oneDigitMonth) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(s => {
+ let result = /[1-9]\d*/.exec(s);
+ return result && result[0] == oneDigitMonth;
+ })
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp-year": {
+ if (!fourDigitsYear) {
+ return null;
+ }
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => s == twoDigitsYear || s == fourDigitsYear
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-exp": {
+ if (!oneDigitMonth || !fourDigitsYear) {
+ return null;
+ }
+ let patterns = [
+ oneDigitMonth + "/" + twoDigitsYear, // 8/22
+ oneDigitMonth + "/" + fourDigitsYear, // 8/2022
+ twoDigitsMonth + "/" + twoDigitsYear, // 08/22
+ twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
+ oneDigitMonth + "-" + twoDigitsYear, // 8-22
+ oneDigitMonth + "-" + fourDigitsYear, // 8-2022
+ twoDigitsMonth + "-" + twoDigitsYear, // 08-22
+ twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
+ twoDigitsYear + "-" + twoDigitsMonth, // 22-08
+ fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
+ fourDigitsYear + "/" + oneDigitMonth, // 2022/8
+ twoDigitsMonth + twoDigitsYear, // 0822
+ twoDigitsYear + twoDigitsMonth, // 2208
+ ];
+
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(str =>
+ patterns.some(pattern => str.includes(pattern))
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ case "cc-type": {
+ let network = creditCard["cc-type"] || "";
+ for (let option of options) {
+ if (
+ [option.text, option.label, option.value].some(
+ s => lazy.CreditCard.getNetworkFromName(s) == network
+ )
+ ) {
+ return option;
+ }
+ }
+ break;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Try to match value with keys and names, but always return the key.
+ *
+ * @param {Array<string>} keys
+ * @param {Array<string>} names
+ * @param {string} value
+ * @param {Array} collators
+ * @returns {string}
+ */
+ identifyValue(keys, names, value, collators) {
+ let resultKey = keys.find(key => this.strCompare(value, key, collators));
+ if (resultKey) {
+ return resultKey;
+ }
+
+ let index = names.findIndex(name =>
+ this.strCompare(value, name, collators)
+ );
+ if (index !== -1) {
+ return keys[index];
+ }
+
+ return null;
+ },
+
+ /**
+ * Compare if two strings are the same.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @param {Array} collators
+ * @returns {boolean}
+ */
+ strCompare(a = "", b = "", collators) {
+ return collators.some(collator => !collator.compare(a, b));
+ },
+
+ /**
+ * Determine whether one string(b) may be found within another string(a)
+ *
+ * @param {string} a
+ * @param {string} b
+ * @param {Array} collators
+ * @returns {boolean} True if the string is found
+ */
+ strInclude(a = "", b = "", collators) {
+ const len = a.length - b.length;
+ for (let i = 0; i <= len; i++) {
+ if (this.strCompare(a.substring(i, i + b.length), b, collators)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Escaping user input to be treated as a literal string within a regular
+ * expression.
+ *
+ * @param {string} string
+ * @returns {string}
+ */
+ escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ },
+
+ /**
+ * Get formatting information of a given country
+ *
+ * @param {string} country
+ * @returns {object}
+ * {
+ * {string} addressLevel3L10nId
+ * {string} addressLevel2L10nId
+ * {string} addressLevel1L10nId
+ * {string} postalCodeL10nId
+ * {object} fieldsOrder
+ * {string} postalCodePattern
+ * }
+ */
+ getFormFormat(country) {
+ let dataset = this.getCountryAddressData(country);
+ // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
+ if (country != dataset.key) {
+ // Use a sparse object so the below default values take effect.
+ dataset = {
+ /**
+ * Even though data/ZZ only has address-level2, include the other levels
+ * in case they are needed for unknown countries. Users can leave the
+ * unnecessary fields blank which is better than forcing users to enter
+ * the data in incorrect fields.
+ */
+ fmt: "%N%n%O%n%A%n%C %S %Z",
+ };
+ }
+ return {
+ // When particular values are missing for a country, the
+ // data/ZZ value should be used instead:
+ // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
+ addressLevel3L10nId: this.getAddressFieldL10nId(
+ dataset.sublocality_name_type || "suburb"
+ ),
+ addressLevel2L10nId: this.getAddressFieldL10nId(
+ dataset.locality_name_type || "city"
+ ),
+ addressLevel1L10nId: this.getAddressFieldL10nId(
+ dataset.state_name_type || "province"
+ ),
+ addressLevel1Options: this.buildRegionMapIfAvailable(
+ dataset.sub_keys,
+ dataset.sub_isoids,
+ dataset.sub_names,
+ dataset.sub_lnames
+ ),
+ countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
+ fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
+ postalCodeL10nId: this.getAddressFieldL10nId(
+ dataset.zip_name_type || "postal-code"
+ ),
+ postalCodePattern: dataset.zip,
+ };
+ },
+
+ getAddressFieldL10nId(type) {
+ return "autofill-address-" + type.replace(/_/g, "-");
+ },
+
+ CC_FATHOM_NONE: 0,
+ CC_FATHOM_JS: 1,
+ CC_FATHOM_NATIVE: 2,
+ isFathomCreditCardsEnabled() {
+ return this.ccHeuristicsMode != this.CC_FATHOM_NONE;
+ },
+
+ /**
+ * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl)
+ * to fathom recognized field type.
+ *
+ * @param {string} key key from FormAutofillConfidences dictionary
+ * @returns {string} fathom field type
+ */
+ formAutofillConfidencesKeyToCCFieldType(key) {
+ const MAP = {
+ ccNumber: "cc-number",
+ ccName: "cc-name",
+ ccType: "cc-type",
+ ccExp: "cc-exp",
+ ccExpMonth: "cc-exp-month",
+ ccExpYear: "cc-exp-year",
+ };
+ return MAP[key];
+ },
+};
+
+XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://formautofill/locale/formautofill.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "_reauthEnabledByUser",
+ "extensions.formautofill.reauth.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccHeuristicsMode",
+ "extensions.formautofill.creditCards.heuristics.mode",
+ 0
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomHighConfidenceThreshold",
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "ccFathomTestConfidence",
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ null,
+ null,
+ pref => parseFloat(pref)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "visibilityCheckThreshold",
+ "extensions.formautofill.heuristics.visibilityCheckThreshold",
+ 200
+);
+
+// This is only used in iOS
+XPCOMUtils.defineLazyPreferenceGetter(
+ FormAutofillUtils,
+ "focusOnAutofill",
+ "extensions.formautofill.focusOnAutofill",
+ true
+);
diff --git a/toolkit/components/formautofill/shared/FormStateManager.sys.mjs b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs
new file mode 100644
index 0000000000..18f5a2f05b
--- /dev/null
+++ b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs
@@ -0,0 +1,154 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+ FormAutofillHandler:
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs",
+});
+
+export class FormStateManager {
+ constructor(onSubmit, onAutofillCallback) {
+ /**
+ * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects.
+ */
+ this._formsDetails = new WeakMap();
+ /**
+ * @type {object} The object where to store the active items, e.g. element,
+ * handler, section, and field detail.
+ */
+ this._activeItems = {};
+
+ this.onSubmit = onSubmit;
+
+ this.onAutofillCallback = onAutofillCallback;
+ }
+
+ /**
+ * Get the active input's information from cache which is created after page
+ * identified.
+ *
+ * @returns {object | null}
+ * Return the active input's information that cloned from content cache
+ * (or return null if the information is not found in the cache).
+ */
+ get activeFieldDetail() {
+ if (!this._activeItems.fieldDetail) {
+ let formDetails = this.activeFormDetails;
+ if (!formDetails) {
+ return null;
+ }
+ for (let detail of formDetails) {
+ let detailElement = detail.elementWeakRef.get();
+ if (detailElement && this.activeInput == detailElement) {
+ this._activeItems.fieldDetail = detail;
+ break;
+ }
+ }
+ }
+ return this._activeItems.fieldDetail;
+ }
+
+ /**
+ * Get the active form's information from cache which is created after page
+ * identified.
+ *
+ * @returns {Array<object> | null}
+ * Return target form's information from content cache
+ * (or return null if the information is not found in the cache).
+ *
+ */
+ get activeFormDetails() {
+ let formHandler = this.activeHandler;
+ return formHandler ? formHandler.fieldDetails : null;
+ }
+
+ get activeInput() {
+ let elementWeakRef = this._activeItems.elementWeakRef;
+ return elementWeakRef ? elementWeakRef.get() : null;
+ }
+
+ get activeHandler() {
+ const activeInput = this.activeInput;
+ if (!activeInput) {
+ return null;
+ }
+
+ // XXX: We are recomputing the activeHandler every time to avoid keeping a
+ // reference on the active element. This might be called quite frequently
+ // so if _getFormHandler/findRootForField become more costly, we should
+ // look into caching this result (eg by adding a weakmap).
+ let handler = this._getFormHandler(activeInput);
+ if (handler) {
+ handler.focusedInput = activeInput;
+ }
+ return handler;
+ }
+
+ get activeSection() {
+ let formHandler = this.activeHandler;
+ return formHandler ? formHandler.activeSection : null;
+ }
+
+ /**
+ * Get the form's handler from cache which is created after page identified.
+ *
+ * @param {HTMLInputElement} element Focused input which triggered profile searching
+ * @returns {Array<object> | null}
+ * Return target form's handler from content cache
+ * (or return null if the information is not found in the cache).
+ *
+ */
+ _getFormHandler(element) {
+ if (!element) {
+ return null;
+ }
+ let rootElement = lazy.FormLikeFactory.findRootForField(element);
+ return this._formsDetails.get(rootElement);
+ }
+
+ identifyAutofillFields(element) {
+ let formHandler = this._getFormHandler(element);
+ if (!formHandler) {
+ let formLike = lazy.FormLikeFactory.createFromField(element);
+ formHandler = new lazy.FormAutofillHandler(
+ formLike,
+ this.onSubmit,
+ this.onAutofillCallback
+ );
+ } else if (!formHandler.updateFormIfNeeded(element)) {
+ return formHandler.fieldDetails;
+ }
+ this._formsDetails.set(formHandler.form.rootElement, formHandler);
+ return formHandler.collectFormFields();
+ }
+
+ updateActiveInput(element) {
+ if (!element) {
+ this._activeItems = {};
+ return;
+ }
+ this._activeItems = {
+ elementWeakRef: Cu.getWeakReference(element),
+ fieldDetail: null,
+ };
+ }
+
+ getRecords(formElement, handler) {
+ handler = handler || this._formsDetails.get(formElement);
+ const records = handler?.createRecords();
+
+ if (
+ !handler ||
+ !records ||
+ !Object.values(records).some(typeRecords => typeRecords.length)
+ ) {
+ return null;
+ }
+ return records;
+ }
+}
+
+export default FormStateManager;
diff --git a/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs
new file mode 100644
index 0000000000..9df83d5cd8
--- /dev/null
+++ b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs
@@ -0,0 +1,620 @@
+/* eslint-disable no-useless-concat */
+/* 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/. */
+
+// prettier-ignore
+export const HeuristicsRegExp = {
+ RULES: {
+ email: undefined,
+ tel: undefined,
+ organization: undefined,
+ "street-address": undefined,
+ "address-line1": undefined,
+ "address-line2": undefined,
+ "address-line3": undefined,
+ "address-level2": undefined,
+ "address-level1": undefined,
+ "postal-code": undefined,
+ country: undefined,
+ // Note: We place the `cc-name` field for Credit Card first, because
+ // it is more specific than the `name` field below and we want to check
+ // for it before we catch the more generic one.
+ "cc-name": undefined,
+ name: undefined,
+ "given-name": undefined,
+ "additional-name": undefined,
+ "family-name": undefined,
+ "cc-number": undefined,
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ "cc-exp": undefined,
+ "cc-type": undefined,
+ },
+
+ RULE_SETS: [
+ //=========================================================================
+ // Firefox-specific rules
+ {
+ "address-line1": "addrline1|address_1",
+ "address-line2": "addrline2|address_2",
+ "address-line3": "addrline3|address_3",
+ "address-level1": "land", // de-DE
+ "additional-name": "apellido.?materno|lastlastname",
+ "cc-name":
+ "accountholdername" +
+ "|titulaire", // fr-FR
+ "cc-number":
+ "(cc|kk)nr", // de-DE
+ "cc-exp":
+ "ważna.*do" + // pl-PL
+ "|data.*ważności", // pl-PL
+ "cc-exp-month":
+ "month" +
+ "|(cc|kk)month" + // de-DE
+ "|miesiąc", // pl-PL
+ "cc-exp-year":
+ "year" +
+ "|(cc|kk)year" + // de-DE
+ "|rok", // pl-PL
+ "cc-type":
+ "type" +
+ "|kartenmarke" + // de-DE
+ "|typ.*karty", // pl-PL
+ },
+
+ //=========================================================================
+ // These are the rules used by Bitwarden [0], converted into RegExp form.
+ // [0] https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436
+ {
+ email: "(^e-?mail$)|(^email-?address$)",
+
+ tel:
+ "(^phone$)" +
+ "|(^mobile$)" +
+ "|(^mobile-?phone$)" +
+ "|(^tel$)" +
+ "|(^telephone$)" +
+ "|(^phone-?number$)",
+
+ organization:
+ "(^company$)" +
+ "|(^company-?name$)" +
+ "|(^organization$)" +
+ "|(^organization-?name$)",
+
+ "street-address":
+ "(^address$)" +
+ "|(^street-?address$)" +
+ "|(^addr$)" +
+ "|(^street$)" +
+ "|(^mailing-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^billing-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^mail-?addr(ess)?$)" + // Modified to not grab lines, below
+ "|(^bill-?addr(ess)?$)", // Modified to not grab lines, below
+
+ "address-line1":
+ "(^address-?1$)" +
+ "|(^address-?line-?1$)" +
+ "|(^addr-?1$)" +
+ "|(^street-?1$)",
+
+ "address-line2":
+ "(^address-?2$)" +
+ "|(^address-?line-?2$)" +
+ "|(^addr-?2$)" +
+ "|(^street-?2$)",
+
+ "address-line3":
+ "(^address-?3$)" +
+ "|(^address-?line-?3$)" +
+ "|(^addr-?3$)" +
+ "|(^street-?3$)",
+
+ "address-level2":
+ "(^city$)" +
+ "|(^town$)" +
+ "|(^address-?level-?2$)" +
+ "|(^address-?city$)" +
+ "|(^address-?town$)",
+
+ "address-level1":
+ "(^state$)" +
+ "|(^province$)" +
+ "|(^provence$)" +
+ "|(^address-?level-?1$)" +
+ "|(^address-?state$)" +
+ "|(^address-?province$)",
+
+ "postal-code":
+ "(^postal$)" +
+ "|(^zip$)" +
+ "|(^zip2$)" +
+ "|(^zip-?code$)" +
+ "|(^postal-?code$)" +
+ "|(^post-?code$)" +
+ "|(^address-?zip$)" +
+ "|(^address-?postal$)" +
+ "|(^address-?code$)" +
+ "|(^address-?postal-?code$)" +
+ "|(^address-?zip-?code$)",
+
+ country:
+ "(^country$)" +
+ "|(^country-?code$)" +
+ "|(^country-?name$)" +
+ "|(^address-?country$)" +
+ "|(^address-?country-?name$)" +
+ "|(^address-?country-?code$)",
+
+ name: "(^name$)|full-?name|your-?name",
+
+ "given-name":
+ "(^f-?name$)" +
+ "|(^first-?name$)" +
+ "|(^given-?name$)" +
+ "|(^first-?n$)",
+
+ "additional-name":
+ "(^m-?name$)" +
+ "|(^middle-?name$)" +
+ "|(^additional-?name$)" +
+ "|(^middle-?initial$)" +
+ "|(^middle-?n$)" +
+ "|(^middle-?i$)",
+
+ "family-name":
+ "(^l-?name$)" +
+ "|(^last-?name$)" +
+ "|(^s-?name$)" +
+ "|(^surname$)" +
+ "|(^family-?name$)" +
+ "|(^family-?n$)" +
+ "|(^last-?n$)",
+
+ "cc-name":
+ "cc-?name" +
+ "|card-?name" +
+ "|cardholder-?name" +
+ "|cardholder" +
+ // "|(^name$)" + // Removed to avoid overwriting "name", above.
+ "|(^nom$)",
+
+ "cc-number":
+ "cc-?number" +
+ "|cc-?num" +
+ "|card-?number" +
+ "|card-?num" +
+ "|(^number$)" +
+ "|(^cc$)" +
+ "|cc-?no" +
+ "|card-?no" +
+ "|(^credit-?card$)" +
+ "|numero-?carte" +
+ "|(^carte$)" +
+ "|(^carte-?credit$)" +
+ "|num-?carte" +
+ "|cb-?num",
+
+ "cc-exp":
+ "(^cc-?exp$)" +
+ "|(^card-?exp$)" +
+ "|(^cc-?expiration$)" +
+ "|(^card-?expiration$)" +
+ "|(^cc-?ex$)" +
+ "|(^card-?ex$)" +
+ "|(^card-?expire$)" +
+ "|(^card-?expiry$)" +
+ "|(^validite$)" +
+ "|(^expiration$)" +
+ "|(^expiry$)" +
+ "|mm-?yy" +
+ "|mm-?yyyy" +
+ "|yy-?mm" +
+ "|yyyy-?mm" +
+ "|expiration-?date" +
+ "|payment-?card-?expiration" +
+ "|(^payment-?cc-?date$)",
+
+ "cc-exp-month":
+ "(^exp-?month$)" +
+ "|(^cc-?exp-?month$)" +
+ "|(^cc-?month$)" +
+ "|(^card-?month$)" +
+ "|(^cc-?mo$)" +
+ "|(^card-?mo$)" +
+ "|(^exp-?mo$)" +
+ "|(^card-?exp-?mo$)" +
+ "|(^cc-?exp-?mo$)" +
+ "|(^card-?expiration-?month$)" +
+ "|(^expiration-?month$)" +
+ "|(^cc-?mm$)" +
+ "|(^cc-?m$)" +
+ "|(^card-?mm$)" +
+ "|(^card-?m$)" +
+ "|(^card-?exp-?mm$)" +
+ "|(^cc-?exp-?mm$)" +
+ "|(^exp-?mm$)" +
+ "|(^exp-?m$)" +
+ "|(^expire-?month$)" +
+ "|(^expire-?mo$)" +
+ "|(^expiry-?month$)" +
+ "|(^expiry-?mo$)" +
+ "|(^card-?expire-?month$)" +
+ "|(^card-?expire-?mo$)" +
+ "|(^card-?expiry-?month$)" +
+ "|(^card-?expiry-?mo$)" +
+ "|(^mois-?validite$)" +
+ "|(^mois-?expiration$)" +
+ "|(^m-?validite$)" +
+ "|(^m-?expiration$)" +
+ "|(^expiry-?date-?field-?month$)" +
+ "|(^expiration-?date-?month$)" +
+ "|(^expiration-?date-?mm$)" +
+ "|(^exp-?mon$)" +
+ "|(^validity-?mo$)" +
+ "|(^exp-?date-?mo$)" +
+ "|(^cb-?date-?mois$)" +
+ "|(^date-?m$)",
+
+ "cc-exp-year":
+ "(^exp-?year$)" +
+ "|(^cc-?exp-?year$)" +
+ "|(^cc-?year$)" +
+ "|(^card-?year$)" +
+ "|(^cc-?yr$)" +
+ "|(^card-?yr$)" +
+ "|(^exp-?yr$)" +
+ "|(^card-?exp-?yr$)" +
+ "|(^cc-?exp-?yr$)" +
+ "|(^card-?expiration-?year$)" +
+ "|(^expiration-?year$)" +
+ "|(^cc-?yy$)" +
+ "|(^cc-?y$)" +
+ "|(^card-?yy$)" +
+ "|(^card-?y$)" +
+ "|(^card-?exp-?yy$)" +
+ "|(^cc-?exp-?yy$)" +
+ "|(^exp-?yy$)" +
+ "|(^exp-?y$)" +
+ "|(^cc-?yyyy$)" +
+ "|(^card-?yyyy$)" +
+ "|(^card-?exp-?yyyy$)" +
+ "|(^cc-?exp-?yyyy$)" +
+ "|(^expire-?year$)" +
+ "|(^expire-?yr$)" +
+ "|(^expiry-?year$)" +
+ "|(^expiry-?yr$)" +
+ "|(^card-?expire-?year$)" +
+ "|(^card-?expire-?yr$)" +
+ "|(^card-?expiry-?year$)" +
+ "|(^card-?expiry-?yr$)" +
+ "|(^an-?validite$)" +
+ "|(^an-?expiration$)" +
+ "|(^annee-?validite$)" +
+ "|(^annee-?expiration$)" +
+ "|(^expiry-?date-?field-?year$)" +
+ "|(^expiration-?date-?year$)" +
+ "|(^cb-?date-?ann$)" +
+ "|(^expiration-?date-?yy$)" +
+ "|(^expiration-?date-?yyyy$)" +
+ "|(^validity-?year$)" +
+ "|(^exp-?date-?year$)" +
+ "|(^date-?y$)",
+
+ "cc-type":
+ "(^cc-?type$)" +
+ "|(^card-?type$)" +
+ "|(^card-?brand$)" +
+ "|(^cc-?brand$)" +
+ "|(^cb-?type$)",
+ },
+
+ //=========================================================================
+ // These rules are from Chromium source codes [1]. Most of them
+ // converted to JS format have the same meaning with the original ones except
+ // the first line of "address-level1".
+ // [1] https://source.chromium.org/chromium/chromium/src/+/master:components/autofill/core/common/autofill_regex_constants.cc
+ {
+ // ==== Email ====
+ email:
+ "e.?mail" +
+ "|courriel" + // fr
+ "|correo.*electr(o|ó)nico" + // es-ES
+ "|メールアドレス" + // ja-JP
+ "|Электронной.?Почты" + // ru
+ "|邮件|邮箱" + // zh-CN
+ "|電郵地址" + // zh-TW
+ "|ഇ-മെയില്‍|ഇലക്ട്രോണിക്.?" +
+ "മെയിൽ" + // ml
+ "|ایمیل|پست.*الکترونیک" + // fa
+ "|ईमेल|इलॅक्ट्रॉनिक.?मेल" + // hi
+ "|(\\b|_)eposta(\\b|_)" + // tr
+ "|(?:이메일|전자.?우편|[Ee]-?mail)(.?주소)?", // ko-KR
+
+ // ==== Telephone ====
+ tel:
+ "phone|mobile|contact.?number" +
+ "|telefonnummer" + // de-DE
+ "|telefono|teléfono" + // es
+ "|telfixe" + // fr-FR
+ "|電話" + // ja-JP
+ "|telefone|telemovel" + // pt-BR, pt-PT
+ "|телефон" + // ru
+ "|मोबाइल" + // hi for mobile
+ "|(\\b|_|\\*)telefon(\\b|_|\\*)" + // tr
+ "|电话" + // zh-CN
+ "|മൊബൈല്‍" + // ml for mobile
+ "|(?:전화|핸드폰|휴대폰|휴대전화)(?:.?번호)?", // ko-KR
+
+ // ==== Address Fields ====
+ organization:
+ "company|business|organization|organisation" +
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>confirma)" +
+ "|firma|firmenname" + // de-DE
+ "|empresa" + // es
+ "|societe|société" + // fr-FR
+ "|ragione.?sociale" + // it-IT
+ "|会社" + // ja-JP
+ "|название.?компании" + // ru
+ "|单位|公司" + // zh-CN
+ "|شرکت" + // fa
+ "|회사|직장", // ko-KR
+
+ "street-address": "streetaddress|street-address",
+ "address-line1":
+ "^address$|address[_-]?line(one)?|address1|addr1|street" +
+ "|(?:shipping|billing)address$" +
+ "|strasse|straße|hausnummer|housenumber" + // de-DE
+ "|house.?name" + // en-GB
+ "|direccion|dirección" + // es
+ "|adresse" + // fr-FR
+ "|indirizzo" + // it-IT
+ "|^住所$|住所1" + // ja-JP
+ "|morada" + // pt-BR, pt-PT
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>identificação do endereço)" +
+ "|(endereço)" + // pt-BR, pt-PT
+ "|Адрес" + // ru
+ "|地址" + // zh-CN
+ "|(\\b|_)adres(?! (başlığı(nız)?|tarifi))(\\b|_)" + // tr
+ "|^주소.?$|주소.?1", // ko-KR
+
+ "address-line2":
+ "address[_-]?line(2|two)|address2|addr2|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State`
+ "|adresszusatz|ergänzende.?angaben" + // de-DE
+ "|direccion2|colonia|adicional" + // es
+ "|addresssuppl|complementnom|appartement" + // fr-FR
+ "|indirizzo2" + // it-IT
+ "|住所2" + // ja-JP
+ "|complemento|addrcomplement" + // pt-BR, pt-PT
+ "|Улица" + // ru
+ "|地址2" + // zh-CN
+ "|주소.?2", // ko-KR
+
+ "address-line3":
+ "address[_-]?line(3|three)|address3|addr3|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State`
+ "|adresszusatz|ergänzende.?angaben" + // de-DE
+ "|direccion3|colonia|adicional" + // es
+ "|addresssuppl|complementnom|appartement" + // fr-FR
+ "|indirizzo3" + // it-IT
+ "|住所3" + // ja-JP
+ "|complemento|addrcomplement" + // pt-BR, pt-PT
+ "|Улица" + // ru
+ "|地址3" + // zh-CN
+ "|주소.?3", // ko-KR
+
+ "address-level2":
+ "city|town" +
+ "|\\bort\\b|stadt" + // de-DE
+ "|suburb" + // en-AU
+ "|ciudad|provincia|localidad|poblacion" + // es
+ "|ville|commune" + // fr-FR
+ "|localita" + // it-IT
+ "|市区町村" + // ja-JP
+ "|cidade" + // pt-BR, pt-PT
+ "|Город" + // ru
+ "|市" + // zh-CN
+ "|分區" + // zh-TW
+ "|شهر" + // fa
+ "|शहर" + // hi for city
+ "|ग्राम|गाँव" + // hi for village
+ "|നഗരം|ഗ്രാമം" + // ml for town|village
+ "|((\\b|_|\\*)([İii̇]l[cç]e(miz|niz)?)(\\b|_|\\*))" + // tr
+ "|^시[^도·・]|시[·・]?군[·・]?구", // ko-KR
+
+ "address-level1":
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "(?<neg>united?.state|hist?.state|history?.state)" +
+ "|state|county|region|province" +
+ "|principality" + // en-UK
+ "|都道府県" + // ja-JP
+ "|estado|provincia" + // pt-BR, pt-PT
+ "|область" + // ru
+ "|省" + // zh-CN
+ "|地區" + // zh-TW
+ "|സംസ്ഥാനം" + // ml
+ "|استان" + // fa
+ "|राज्य" + // hi
+ "|((\\b|_|\\*)(eyalet|[şs]ehir|[İii̇]l(imiz)?|kent)(\\b|_|\\*))" + // tr
+ "|^시[·・]?도", // ko-KR
+
+ "postal-code":
+ "zip|postal|post.*code|pcode" +
+ "|pin.?code" + // en-IN
+ "|postleitzahl" + // de-DE
+ "|\\bcp\\b" + // es
+ "|\\bcdp\\b" + // fr-FR
+ "|\\bcap\\b" + // it-IT
+ "|郵便番号" + // ja-JP
+ "|codigo|codpos|\\bcep\\b" + // pt-BR, pt-PT
+ "|Почтовый.?Индекс" + // ru
+ "|पिन.?कोड" + // hi
+ "|പിന്‍കോഡ്" + // ml
+ "|邮政编码|邮编" + // zh-CN
+ "|郵遞區號" + // zh-TW
+ "|(\\b|_)posta kodu(\\b|_)" + // tr
+ "|우편.?번호", // ko-KR
+
+ country:
+ "country|countries" +
+ "|país|pais" + // es
+ "|(\\b|_)land(\\b|_)(?!.*(mark.*))" + // de-DE landmark is a type in india.
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>入国|出国)" +
+ "|国" + // ja-JP
+ "|国家" + // zh-CN
+ "|국가|나라" + // ko-KR
+ "|(\\b|_)(ülke|ulce|ulke)(\\b|_)" + // tr
+ "|کشور", // fa
+
+ // ==== Name Fields ====
+ "cc-name":
+ "card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" +
+ "|(?:card|cc).?name|cc.?full.?name" +
+ "|karteninhaber" + // de-DE
+ "|nombre.*tarjeta" + // es
+ "|nom.*carte" + // fr-FR
+ "|nome.*cart" + // it-IT
+ "|名前" + // ja-JP
+ "|Имя.*карты" + // ru
+ "|信用卡开户名|开户名|持卡人姓名" + // zh-CN
+ "|持卡人姓名", // zh-TW
+
+ name:
+ "^name|full.?name|your.?name|customer.?name|bill.?name|ship.?name" +
+ "|name.*first.*last|firstandlastname" +
+ "|nombre.*y.*apellidos" + // es
+ "|^nom(?!bre)" + // fr-FR
+ "|お名前|氏名" + // ja-JP
+ "|^nome" + // pt-BR, pt-PT
+ "|نام.*نام.*خانوادگی" + // fa
+ "|姓名" + // zh-CN
+ "|(\\b|_|\\*)ad[ı]? soyad[ı]?(\\b|_|\\*)" + // tr
+ "|성명", // ko-KR
+
+ "given-name":
+ "first.*name|initials|fname|first$|given.*name" +
+ "|vorname" + // de-DE
+ "|nombre" + // es
+ "|forename|prénom|prenom" + // fr-FR
+ "|名" + // ja-JP
+ "|nome" + // pt-BR, pt-PT
+ "|Имя" + // ru
+ "|نام" + // fa
+ "|이름" + // ko-KR
+ "|പേര്" + // ml
+ "|(\\b|_|\\*)(isim|ad|ad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr
+ "|नाम", // hi
+
+ "additional-name":
+ "middle.*name|mname|middle$|middle.*initial|m\\.i\\.|mi$|\\bmi\\b",
+
+ "family-name":
+ "last.*name|lname|surname|last$|secondname|family.*name" +
+ "|nachname" + // de-DE
+ "|apellidos?" + // es
+ "|famille|^nom(?!bre)" + // fr-FR
+ "|cognome" + // it-IT
+ "|姓" + // ja-JP
+ "|apelidos|surename|sobrenome" + // pt-BR, pt-PT
+ "|Фамилия" + // ru
+ "|نام.*خانوادگی" + // fa
+ "|उपनाम" + // hi
+ "|മറുപേര്" + // ml
+ "|(\\b|_|\\*)(soyisim|soyad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr
+ "|\\b성(?:[^명]|\\b)", // ko-KR
+
+ // ==== Credit Card Fields ====
+ // Note: `cc-name` expression has been moved up, above `name`, in
+ // order to handle specialization through ordering.
+ "cc-number":
+ "(add)?(?:card|cc|acct).?(?:number|#|no|num|field)" +
+ // In order to support webkit we convert all negative lookbehinds to a capture group
+ // (?<!not)word -> (?<neg>notword)|word
+ // TODO: Bug 1829583
+ "|(?<neg>telefonnummer|hausnummer|personnummer|fødselsnummer)" + // de-DE, sv-SE, no
+ "|nummer" +
+ "|カード番号" + // ja-JP
+ "|Номер.*карты" + // ru
+ "|信用卡号|信用卡号码" + // zh-CN
+ "|信用卡卡號" + // zh-TW
+ "|카드" + // ko-KR
+ // es/pt/fr
+ "|(numero|número|numéro)(?!.*(document|fono|phone|réservation))",
+
+ "cc-exp-month":
+ "expir|exp.*mo|exp.*date|ccmonth|cardmonth|addmonth" +
+ "|gueltig|gültig|monat" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты" + // ru
+ "|月", // zh-CN
+
+ "cc-exp-year":
+ "exp|^/|(add)?year" +
+ "|ablaufdatum|gueltig|gültig|jahr" + // de-DE
+ "|fecha" + // es
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты" + // ru
+ "|年|有效期", // zh-CN
+
+ "cc-exp":
+ "expir|exp.*date|^expfield$" +
+ "|gueltig|gültig" + // de-DE
+ "|fecha" + // es
+ "|date.*exp" + // fr-FR
+ "|scadenza" + // it-IT
+ "|有効期限" + // ja-JP
+ "|validade" + // pt-BR, pt-PT
+ "|Срок действия карты", // ru
+ },
+ ],
+
+ _getRule(name) {
+ let rules = [];
+ this.RULE_SETS.forEach(set => {
+ if (set[name]) {
+ // Add the rule.
+ // We make the regex lower case so that we can match it against the
+ // lower-cased field name and get a rough equivalent of a case-insensitive
+ // match. This avoids a performance cliff with the "iu" flag on regular
+ // expressions.
+ rules.push(`(${set[name].toLowerCase()})`.normalize("NFKC"));
+ }
+ });
+
+ const value = new RegExp(rules.join("|"), "gu");
+ Object.defineProperty(this.RULES, name, { get: undefined });
+ Object.defineProperty(this.RULES, name, { value });
+ return value;
+ },
+
+ getRules() {
+ Object.keys(this.RULES).forEach(field =>
+ Object.defineProperty(this.RULES, field, {
+ get() {
+ return HeuristicsRegExp._getRule(field);
+ },
+ })
+ );
+
+ return this.RULES;
+ },
+};
+
+export default HeuristicsRegExp;
diff --git a/toolkit/components/formautofill/shared/LabelUtils.sys.mjs b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs
new file mode 100644
index 0000000000..9bfedee105
--- /dev/null
+++ b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs
@@ -0,0 +1,120 @@
+/* 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/. */
+
+/**
+ * This is a utility object to work with HTML labels in web pages,
+ * including finding label elements and label text extraction.
+ */
+export const LabelUtils = {
+ // The tag name list is from Chromium except for "STYLE":
+ // eslint-disable-next-line max-len
+ // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e
+ EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"],
+
+ // A map object, whose keys are the id's of form fields and each value is an
+ // array consisting of label elements correponding to the id.
+ // @type {Map<string, array>}
+ _mappedLabels: null,
+
+ // An array consisting of label elements whose correponding form field doesn't
+ // have an id attribute.
+ // @type {Array<[HTMLLabelElement, HTMLElement]>}
+ _unmappedLabelControls: null,
+
+ // A weak map consisting of label element and extracted strings pairs.
+ // @type {WeakMap<HTMLLabelElement, array>}
+ _labelStrings: null,
+
+ /**
+ * Extract all strings of an element's children to an array.
+ * "element.textContent" is a string which is merged of all children nodes,
+ * and this function provides an array of the strings contains in an element.
+ *
+ * @param {object} element
+ * A DOM element to be extracted.
+ * @returns {Array}
+ * All strings in an element.
+ */
+ extractLabelStrings(element) {
+ if (this._labelStrings.has(element)) {
+ return this._labelStrings.get(element);
+ }
+ let strings = [];
+ let _extractLabelStrings = el => {
+ if (this.EXCLUDED_TAGS.includes(el.tagName)) {
+ return;
+ }
+
+ if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) {
+ let trimmedText = el.textContent.trim();
+ if (trimmedText) {
+ strings.push(trimmedText);
+ }
+ return;
+ }
+
+ for (let node of el.childNodes) {
+ let nodeType = node.nodeType;
+ if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) {
+ continue;
+ }
+ _extractLabelStrings(node);
+ }
+ };
+ _extractLabelStrings(element);
+ this._labelStrings.set(element, strings);
+ return strings;
+ },
+
+ generateLabelMap(doc) {
+ this._mappedLabels = new Map();
+ this._unmappedLabelControls = [];
+ this._labelStrings = new WeakMap();
+
+ for (let label of doc.querySelectorAll("label")) {
+ let id = label.htmlFor;
+ let control;
+ if (!id) {
+ control = label.control;
+ if (!control) {
+ continue;
+ }
+ id = control.id;
+ }
+ if (id) {
+ let labels = this._mappedLabels.get(id);
+ if (labels) {
+ labels.push(label);
+ } else {
+ this._mappedLabels.set(id, [label]);
+ }
+ } else {
+ // control must be non-empty here
+ this._unmappedLabelControls.push({ label, control });
+ }
+ }
+ },
+
+ clearLabelMap() {
+ this._mappedLabels = null;
+ this._unmappedLabelControls = null;
+ this._labelStrings = null;
+ },
+
+ findLabelElements(element) {
+ if (!this._mappedLabels) {
+ this.generateLabelMap(element.ownerDocument);
+ }
+
+ let id = element.id;
+ if (!id) {
+ return this._unmappedLabelControls
+ .filter(lc => lc.control == element)
+ .map(lc => lc.label);
+ }
+ return this._mappedLabels.get(id) || [];
+ },
+};
+
+export default LabelUtils;