summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/FormAutofillHandler.jsm
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /toolkit/components/formautofill/FormAutofillHandler.jsm
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/FormAutofillHandler.jsm')
-rw-r--r--toolkit/components/formautofill/FormAutofillHandler.jsm1676
1 files changed, 1676 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillHandler.jsm b/toolkit/components/formautofill/FormAutofillHandler.jsm
new file mode 100644
index 0000000000..b44ae1a9ee
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillHandler.jsm
@@ -0,0 +1,1676 @@
+/* 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/. */
+
+/*
+ * Defines a handler object to represent forms that autofill can handle.
+ */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["FormAutofillHandler", "FormAutofillCreditCardSection"];
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { FormAutofill } = ChromeUtils.import(
+ "resource://autofill/FormAutofill.jsm"
+);
+
+const { FormAutofillUtils } = ChromeUtils.import(
+ "resource://autofill/FormAutofillUtils.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.jsm",
+ FormAutofillHeuristics: "resource://autofill/FormAutofillHeuristics.jsm",
+});
+
+const formFillController = Cc[
+ "@mozilla.org/satchel/form-fill-controller;1"
+].getService(Ci.nsIFormFillController);
+
+XPCOMUtils.defineLazyGetter(lazy, "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(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, EXPORTED_SYMBOLS[0])
+);
+
+const { FIELD_STATES } = FormAutofillUtils;
+
+class FormAutofillSection {
+ constructor(fieldDetails, winUtils) {
+ this.fieldDetails = fieldDetails;
+ this.filledRecordGUID = null;
+ this.winUtils = winUtils;
+
+ /**
+ * 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",
+ };
+
+ if (!this.isValidSection()) {
+ this.fieldDetails = [];
+ lazy.log.debug(
+ `Ignoring ${this.constructor.name} related fields since it is an invalid section`
+ );
+ }
+
+ this._cacheValue = {
+ allFieldNames: null,
+ matchingSelectOption: null,
+ };
+
+ // Identifier used to correlate events relating to the same form
+ this.flowId = Services.uuid.generateUUID().toString();
+ lazy.log.debug(
+ "Creating new credit card section with flowId =",
+ this.flowId
+ );
+
+ lazy.AutofillTelemetry.recordDetectedSectionCount(this);
+ lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this);
+ }
+
+ /*
+ * 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 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 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 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._focusedDetail = this.getFieldDetailByElement(element);
+ }
+
+ getFieldDetailByElement(element) {
+ return this.fieldDetails.find(
+ detail => detail.elementWeakRef.get() == element
+ );
+ }
+
+ get allFieldNames() {
+ if (!this._cacheValue.allFieldNames) {
+ this._cacheValue.allFieldNames = this.fieldDetails.map(
+ record => record.fieldName
+ );
+ }
+ return this._cacheValue.allFieldNames;
+ }
+
+ getFieldDetailByName(fieldName) {
+ return this.fieldDetails.find(detail => detail.fieldName == fieldName);
+ }
+
+ 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`];
+ }
+ }
+ }
+
+ 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) {
+ let focusedDetail = this._focusedDetail;
+ if (!focusedDetail) {
+ throw new Error("No fieldDetail for the focused input.");
+ }
+
+ if (!(await this.prepareFillingProfile(profile))) {
+ lazy.log.debug("profile cannot be filled");
+ return false;
+ }
+
+ let focusedInput = focusedDetail.elementWeakRef.get();
+
+ this.filledRecordGUID = profile.guid;
+ for (let 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
+
+ let 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.
+ let value =
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName];
+
+ // Bug 1688607: The transform function allows us to handle the multiple credit card number fields case
+ if (fieldDetail.transform) {
+ value = fieldDetail.transform(value);
+ }
+ 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 == focusedInput ||
+ (element != focusedInput &&
+ (!element.value || element.value == element.defaultValue)) ||
+ fieldDetail.state == FIELD_STATES.AUTO_FILLED
+ ) {
+ element.focus({ preventScroll: true });
+ element.setUserInput(value);
+ this._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;
+ element.focus({ preventScroll: true });
+ // Set the value of the select element so that web event handlers can react accordingly
+ element.value = option.value;
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("input", { bubbles: true })
+ );
+ element.dispatchEvent(
+ new element.ownerGlobal.Event("change", { bubbles: true })
+ );
+ }
+ // Autofill highlight appears regardless if value is changed or not
+ this._changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
+ }
+ }
+ 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 (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ let value =
+ profile[`${fieldDetail.fieldName}-formatted`] ||
+ profile[fieldDetail.fieldName] ||
+ "";
+
+ // Skip the field if it is null or readonly or disabled
+ if (!FormAutofillUtils.isFieldAutofillable(element)) {
+ continue;
+ }
+
+ if (HTMLSelectElement.isInstance(element)) {
+ // Unlike text input, select element is always previewed even if
+ // the option is already selected.
+ if (value) {
+ let cache = this._cacheValue.matchingSelectOption.get(element) || {};
+ let option = cache[value] && cache[value].get();
+ if (option) {
+ value = option.text || "";
+ } else {
+ value = "";
+ }
+ }
+ } 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._changeFieldState(
+ fieldDetail,
+ value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL
+ );
+ }
+ }
+
+ /**
+ * Clear preview text and background highlight of all fields.
+ */
+ clearPreviewedFormFields() {
+ lazy.log.debug("clear previewed fields");
+
+ for (let fieldDetail of this.fieldDetails) {
+ let element = fieldDetail.elementWeakRef.get();
+ if (!element) {
+ lazy.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ element.previewValue = "";
+
+ // We keep the state if this field has
+ // already been auto-filled.
+ if (fieldDetail.state == FIELD_STATES.AUTO_FILLED) {
+ continue;
+ }
+
+ this._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) {
+ lazy.log.warn(fieldDetail.fieldName, "is unreachable");
+ continue;
+ }
+
+ if (fieldDetail.state == 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);
+ }
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ let element = fieldDetail.elementWeakRef.get();
+
+ if (!element) {
+ lazy.log.warn(
+ fieldDetail.fieldName,
+ "is unreachable while changing state"
+ );
+ return;
+ }
+ if (!(nextState in this._FIELD_STATE_ENUM)) {
+ lazy.log.warn(
+ fieldDetail.fieldName,
+ "is trying to change to an invalid state"
+ );
+ return;
+ }
+ if (fieldDetail.state == nextState) {
+ return;
+ }
+
+ let nextStateValue = null;
+ for (let [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 });
+ }
+
+ fieldDetail.state = nextState;
+ }
+
+ resetFieldStates() {
+ for (let fieldDetail of this.fieldDetails) {
+ const element = fieldDetail.elementWeakRef.get();
+ element.removeEventListener("input", this, { mozSystemGroup: true });
+ this._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 => {
+ let 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 (detail.state == FIELD_STATES.AUTO_FILLED) {
+ data.untouchedFields.push(detail.fieldName);
+ }
+ });
+
+ this.createNormalizedRecord(data);
+
+ if (!this.isRecordCreatable(data.record)) {
+ return null;
+ }
+
+ return data;
+ }
+
+ 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 === ""
+ ) {
+ formFillController.showPopup();
+ }
+
+ if (targetFieldDetail.state == FIELD_STATES.NORMAL) {
+ return;
+ }
+
+ this._changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL);
+
+ lazy.AutofillTelemetry.recordFormInteractionEvent(
+ "filled_modified",
+ this,
+ {
+ fieldName: targetFieldDetail.fieldName,
+ }
+ );
+
+ let isAutofilled = false;
+ let 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 |= fieldDetail.state == 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._changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
+ }
+ this.filledRecordGUID = null;
+ }
+ break;
+ }
+ }
+ }
+ /**
+ * 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 })
+ );
+ }
+}
+
+class FormAutofillAddressSection extends FormAutofillSection {
+ constructor(fieldDetails, winUtils) {
+ super(fieldDetails, winUtils);
+
+ this._cacheValue.oneLineStreetAddress = null;
+ }
+
+ isValidSection() {
+ return (
+ this.fieldDetails.length >= 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.
+ lazy.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 = "";
+ }
+ }
+ }
+ }
+}
+
+class FormAutofillCreditCardSection extends FormAutofillSection {
+ /**
+ * Credit Card Section Constructor
+ *
+ * @param {object} fieldDetails
+ * The fieldDetail objects for the fields in this section
+ * @param {object} winUtils
+ * A WindowUtils reference for the Window the section appears in
+ * @param {object} handler
+ * The FormAutofillHandler responsible for this section
+ */
+ constructor(fieldDetails, winUtils, handler) {
+ super(fieldDetails, winUtils);
+
+ this.handler = handler;
+
+ if (!this.isValidSection()) {
+ return;
+ }
+
+ // 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) {
+ lazy.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)
+ );
+ lazy.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?.confidence > 0) {
+ if (ccNameDetail || ccExpiryDetail) {
+ return true;
+ }
+ } else if (ccNameDetail?.confidence > 0) {
+ 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 = /(?<!.)(yy|aa|jj)(?!.)/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);
+ }
+
+ 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"],
+ lazy.reauthPasswordPromptMessage
+ );
+
+ if (!decrypted) {
+ // Early return if the decrypted is empty or undefined
+ return false;
+ }
+
+ profile["cc-number"] = decrypted;
+ }
+ return true;
+ }
+
+ async autofillFields(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;
+ }
+ }
+}
+
+/**
+ * Handles profile autofill for a DOM Form element.
+ */
+class FormAutofillHandler {
+ /**
+ * 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.
+ */
+ constructor(form, onFormSubmitted = () => {}) {
+ this._updateForm(form);
+
+ /**
+ * The window to which this form belongs
+ */
+ this.window = this.form.rootElement.ownerGlobal;
+
+ /**
+ * A WindowUtils reference of which Window the form belongs
+ */
+ this.winUtils = this.window.windowUtils;
+
+ /**
+ * 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);
+ };
+ }
+
+ set focusedInput(element) {
+ let section = this._sectionCache.get(element);
+ if (!section) {
+ section = this.sections.find(s => s.getFieldDetailByElement(element));
+ this._sectionCache.set(element, section);
+ }
+
+ this._focusedSection = section;
+
+ if (section) {
+ section.focusedInput = element;
+ }
+ }
+
+ 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;
+ let getFormLike = () => {
+ if (!_formLike) {
+ _formLike = lazy.FormLikeFactory.createFromField(element);
+ }
+ return _formLike;
+ };
+
+ let currentForm = element.form;
+ if (!currentForm) {
+ currentForm = getFormLike();
+ }
+
+ if (currentForm.elements.length != this.form.elements.length) {
+ lazy.log.debug("The count of form elements is changed.");
+ this._updateForm(getFormLike());
+ return true;
+ }
+
+ if (!this.form.elements.includes(element)) {
+ lazy.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) {
+ /**
+ * DOM Form element to which this object is attached.
+ */
+ this.form = form;
+
+ /**
+ * 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.
+ */
+ this.fieldDetails = null;
+
+ this.sections = [];
+ this._sectionCache = new WeakMap();
+ }
+
+ /**
+ * Set fieldDetails from the form about fields that can be autofilled.
+ *
+ * @param {boolean} allowDuplicates
+ * true to remain any duplicated field details otherwise to remove the
+ * duplicated ones.
+ * @returns {Array} The valid address and credit card details.
+ */
+ collectFormFields(allowDuplicates = false) {
+ let sections = lazy.FormAutofillHeuristics.getFormInfo(
+ this.form,
+ allowDuplicates
+ );
+ let allValidDetails = [];
+ for (let { fieldDetails, type } of sections) {
+ let section;
+ if (type == FormAutofillUtils.SECTION_TYPES.ADDRESS) {
+ section = new FormAutofillAddressSection(fieldDetails, this.winUtils);
+ } else if (type == FormAutofillUtils.SECTION_TYPES.CREDIT_CARD) {
+ section = new FormAutofillCreditCardSection(
+ fieldDetails,
+ this.winUtils,
+ this
+ );
+ } else {
+ throw new Error("Unknown field type.");
+ }
+ this.sections.push(section);
+ allValidDetails.push(...section.fieldDetails);
+ }
+
+ this.fieldDetails = allValidDetails;
+ return allValidDetails;
+ }
+
+ _hasFilledSection() {
+ return this.sections.some(section => section.isFilled());
+ }
+
+ /**
+ * 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) {
+ let noFilledSectionsPreviously = !this._hasFilledSection();
+ await this.activeSection.autofillFields(profile);
+
+ const onChangeHandler = e => {
+ if (!e.isTrusted) {
+ return;
+ }
+ if (e.type == "reset") {
+ for (let section of this.sections) {
+ 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.
+ lazy.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 FormAutofillAddressSection) {
+ records.address.push(secRecord);
+ } else if (section instanceof FormAutofillCreditCardSection) {
+ records.creditCard.push(secRecord);
+ } else {
+ throw new Error("Unknown section type");
+ }
+ }
+
+ return records;
+ }
+}