From 2e6b1fa2935c15f8c4d3abfe0c373deb43e6607f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 00:13:25 +0000 Subject: [PATCH] Refactor [vXXX] auto update credential provider script --- .../CC_Script/AutofillTelemetry.sys.mjs | 332 ++--- .../Client/Assets/CC_Script/Constants.ios.mjs | 2 + .../Assets/CC_Script/FieldScanner.sys.mjs | 102 +- .../Assets/CC_Script/FormAutofill.sys.mjs | 8 + .../CC_Script/FormAutofillChild.ios.sys.mjs | 167 ++- .../CC_Script/FormAutofillHandler.sys.mjs | 990 ++++++++++--- .../CC_Script/FormAutofillHeuristics.sys.mjs | 314 ++-- .../CC_Script/FormAutofillSection.sys.mjs | 1286 ++++------------- .../CC_Script/FormAutofillUtils.sys.mjs | 116 +- .../Assets/CC_Script/FormLikeFactory.sys.mjs | 98 +- .../Assets/CC_Script/FormStateManager.sys.mjs | 144 +- .../Client/Assets/CC_Script/Helpers.ios.mjs | 22 +- .../Assets/CC_Script/HeuristicsRegExp.sys.mjs | 10 +- .../Client/Assets/CC_Script/Overrides.ios.js | 2 + 14 files changed, 1779 insertions(+), 1814 deletions(-) diff --git a/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs index 6a1fa974cc9cb..1f236d110d942 100644 --- a/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs @@ -3,7 +3,6 @@ * 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; @@ -41,7 +40,7 @@ class AutofillTelemetryBase { * or `address_form` event and the Glean event `cc_form`, and `address_form`. * It indicates the detected credit card or address fields and which method (autocomplete property, regular expression heuristics or fathom) identified them. * - * @param {object} section Using section.fieldDetails to extract which fields were identified and how + * @param {Array} fieldDetails fieldDetails to extract which fields were identified and how * @param {string} undetected Default value when a field is not detected: 'undetected' (Glean) and 'false' in (Legacy) * @param {string} autocomplete Value when a field is identified with autocomplete property: 'autocomplete' (Glean), 'true' (Legacy) * @param {string} regexp Value when a field is identified with regex expression heuristics: 'regexp' (Glean), '0' (Legacy) @@ -49,7 +48,7 @@ class AutofillTelemetryBase { * @returns {object} Extra keys to include in the form event */ #buildFormDetectedEventExtra( - section, + fieldDetails, undetected, autocomplete, regexp, @@ -58,7 +57,7 @@ class AutofillTelemetryBase { let extra = this.#initFormEventExtra(undetected); let identified = new Set(); - section.fieldDetails.forEach(detail => { + fieldDetails.forEach(detail => { identified.add(detail.fieldName); if (detail.reason == "autocomplete") { @@ -86,93 +85,106 @@ class AutofillTelemetryBase { return extra; } - recordFormDetected(section) { + recordFormDetected(flowId, fieldDetails) { this.recordFormEvent( "detected", - section.flowId, - this.#buildFormDetectedEventExtra(section, "false", "true", "0", false) + flowId, + this.#buildFormDetectedEventExtra( + fieldDetails, + "false", + "true", + "0", + false + ) ); this.recordGleanFormEvent( "formDetected", - section.flowId, + flowId, this.#buildFormDetectedEventExtra( - section, + fieldDetails, "undetected", "autocomplete", "regexp", true ) ); + + try { + this.recordIframeLayoutDetection(flowId, fieldDetails); + } catch {} } - recordPopupShown(section, fieldName) { - const extra = { field_name: fieldName }; - this.recordFormEvent("popup_shown", section.flowId, extra); - this.recordGleanFormEvent("formPopupShown", section.flowId, extra); + recordPopupShown(flowId, fieldDetails) { + const extra = { field_name: fieldDetails[0].fieldName }; + this.recordFormEvent("popup_shown", flowId, extra); + this.recordGleanFormEvent("formPopupShown", flowId, extra); } - recordFormFilled(section, profile) { + recordFormFilled(flowId, fieldDetails, data) { // Calculate values for telemetry - let extra = this.#initFormEventExtra("unavailable"); - - for (let fieldDetail of section.fieldDetails) { - let element = fieldDetail.element; - 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"; + const extra = this.#initFormEventExtra("unavailable"); + + for (const fieldDetail of fieldDetails) { + // It is possible that we don't autofill a field because it is cross-origin. + // When that happens, the data will not include that element. + let { filledState, filledValue } = data.get(fieldDetail.elementId) ?? {}; + switch (filledState) { + case FIELD_STATES.AUTO_FILLED: + filledState = "filled"; + break; + case FIELD_STATES.NORMAL: + default: + filledState = + fieldDetail.localName == "select" || filledValue?.length + ? "user_filled" + : "not_filled"; + break; } - this.#setFormEventExtra(extra, fieldDetail.fieldName, state); + this.#setFormEventExtra(extra, fieldDetail.fieldName, filledState); } - this.recordFormEvent("filled", section.flowId, extra); - this.recordGleanFormEvent("formFilled", section.flowId, extra); + this.recordFormEvent("filled", flowId, extra); + this.recordGleanFormEvent("formFilled", flowId, extra); } - recordFilledModified(section, fieldName) { - const extra = { field_name: fieldName }; - this.recordFormEvent("filled_modified", section.flowId, extra); - this.recordGleanFormEvent("formFilledModified", section.flowId, extra); + recordFilledModified(flowId, fieldDetails) { + const extra = { field_name: fieldDetails[0].fieldName }; + this.recordFormEvent("filled_modified", flowId, extra); + this.recordGleanFormEvent("formFilledModified", flowId, extra); } - recordFormSubmitted(section, record, _form) { - let extra = this.#initFormEventExtra("unavailable"); + recordFormSubmitted(flowId, fieldDetails, data) { + const 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"); - } + for (const fieldDetail of fieldDetails) { + let { filledState, filledValue } = data.get(fieldDetail.elementId); + switch (filledState) { + case FIELD_STATES.AUTO_FILLED: + filledState = "autofilled"; + break; + case FIELD_STATES.NORMAL: + default: + filledState = + fieldDetail.localName == "select" || filledValue.length + ? "user_filled" + : "not_filled"; + break; } - } else { - Object.keys(record.record).forEach(fieldName => - this.#setFormEventExtra(extra, fieldName, "user_filled") - ); + this.#setFormEventExtra(extra, fieldDetail.fieldName, filledState); } - this.recordFormEvent("submitted", section.flowId, extra); - this.recordGleanFormEvent("formSubmitted", section.flowId, extra); + this.recordFormEvent("submitted", flowId, extra); + this.recordGleanFormEvent("formSubmitted", flowId, extra); } - recordFormCleared(section, fieldName) { - const extra = { field_name: fieldName }; + recordFormCleared(flowId, fieldDetails) { + const extra = { field_name: fieldDetails[0].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); - this.recordGleanFormEvent("formCleared", section.flowId, extra); + this.recordFormEvent("cleared", flowId, extra); + this.recordGleanFormEvent("formCleared", flowId, extra); } recordFormEvent(method, flowId, extra) { @@ -189,27 +201,23 @@ class AutofillTelemetryBase { throw new Error("Not implemented."); } - recordFormInteractionEvent( - method, - section, - { fieldName, profile, record, form } = {} - ) { + recordFormInteractionEvent(method, flowId, fieldDetails, data) { if (!this.EVENT_OBJECT_FORM_INTERACTION) { return undefined; } switch (method) { case "detected": - return this.recordFormDetected(section); + return this.recordFormDetected(flowId, fieldDetails); case "popup_shown": - return this.recordPopupShown(section, fieldName); + return this.recordPopupShown(flowId, fieldDetails); case "filled": - return this.recordFormFilled(section, profile); + return this.recordFormFilled(flowId, fieldDetails, data); case "filled_modified": - return this.recordFilledModified(section, fieldName); + return this.recordFilledModified(flowId, fieldDetails); case "submitted": - return this.recordFormSubmitted(section, record, form); + return this.recordFormSubmitted(flowId, fieldDetails, data); case "cleared": - return this.recordFormCleared(section, fieldName); + return this.recordFormCleared(flowId, fieldDetails); } return undefined; } @@ -252,6 +260,44 @@ class AutofillTelemetryBase { histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed); } } + + recordIframeLayoutDetection(flowId, fieldDetails) { + const fieldsInMainFrame = []; + const fieldsInIframe = []; + const fieldsInSandboxedIframe = []; + const fieldsInCrossOrignIframe = []; + + const iframes = new Set(); + for (const fieldDetail of fieldDetails) { + const bc = BrowsingContext.get(fieldDetail.browsingContextId); + if (bc.top == bc) { + fieldsInMainFrame.push(fieldDetail); + continue; + } + + iframes.add(bc); + fieldsInIframe.push(fieldDetail); + if (bc.sandboxFlags != 0) { + fieldsInSandboxedIframe.push(fieldDetail); + } + + if (!FormAutofillUtils.isBCSameOriginWithTop(bc)) { + fieldsInCrossOrignIframe.push(fieldDetail); + } + } + + const extra = { + category: this.EVENT_CATEGORY, + flow_id: flowId, + iframe_count: iframes.size, + main_frame: fieldsInMainFrame.map(f => f.fieldName).toString(), + iframe: fieldsInIframe.map(f => f.fieldName).toString(), + cross_origin: fieldsInCrossOrignIframe.map(f => f.fieldName).toString(), + sandboxed: fieldsInSandboxedIframe.map(f => f.fieldName).toString(), + }; + + Glean.formautofill.iframeLayoutDetection.record(extra); + } } export class AddressTelemetry extends AutofillTelemetryBase { @@ -374,128 +420,11 @@ class CreditCardTelemetry extends AutofillTelemetryBase { "cc-exp-year": "cc_exp_year", }; - recordLegacyFormEvent(method, flowId, extra = null) { - Services.telemetry.recordEvent( - this.EVENT_CATEGORY, - method, - "cc_form", - flowId, - extra - ); - } - recordGleanFormEvent(eventName, flowId, extra) { extra.flow_id = flowId; Glean.formautofillCreditcards[eventName].record(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.element; - 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} 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); @@ -526,10 +455,10 @@ export class AutofillTelemetry { static ADDRESS = "address"; static CREDIT_CARD = "creditcard"; - static #getTelemetryBySection(section) { - return section instanceof FormAutofillCreditCardSection - ? this.#creditCardTelemetry - : this.#addressTelemetry; + static #getTelemetryByFieldDetail(fieldDetail) { + return FormAutofillUtils.isAddressField(fieldDetail.fieldName) + ? this.#addressTelemetry + : this.#creditCardTelemetry; } static #getTelemetryByType(type) { @@ -572,21 +501,12 @@ export class AutofillTelemetry { * Utility functions for form event (defined in Events.yaml) * * Category: address or creditcard - * Event name: cc_form, cc_form_v2, or address_form + * Event name: 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, - }); + static recordFormInteractionEvent(method, flowId, fieldDetails, data) { + const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]); + telemetry.recordFormInteractionEvent(method, flowId, fieldDetails, data); } /** @@ -595,13 +515,13 @@ export class AutofillTelemetry { * Category: formautofill.creditCards or formautofill.addresses * Scalar name: submitted_sections_count */ - static recordDetectedSectionCount(section) { - const telemetry = this.#getTelemetryBySection(section); + static recordDetectedSectionCount(fieldDetails) { + const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]); telemetry.recordDetectedSectionCount(); } - static recordSubmittedSectionCount(type, count) { - const telemetry = this.#getTelemetryByType(type); + static recordSubmittedSectionCount(fieldDetails, count) { + const telemetry = this.#getTelemetryByFieldDetail(fieldDetails[0]); telemetry.recordSubmittedSectionCount(count); } diff --git a/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs b/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs index 290e690ea6497..3001325c1df4f 100644 --- a/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs +++ b/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs @@ -32,6 +32,8 @@ const IOS_DEFAULT_PREFERENCES = { "extensions.formautofill.heuristics.captureOnPageNavigation": false, "extensions.formautofill.focusOnAutofill": false, "extensions.formautofill.test.ignoreVisibilityCheck": false, + "extensions.formautofill.heuristics.autofillSameOriginWithTop": false, + "signon.generation.confidenceThreshold": 0.75, }; // Used Mimic the behavior of .getAutocompleteInfo() diff --git a/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs index 2118de3de890d..cb6ed83df1227 100644 --- a/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs @@ -15,10 +15,25 @@ export class FieldDetail { // Reference to the elemenet elementWeakRef = null; - // id/name. This is only used for debugging + // The identifier generated via ContentDOMReference for the associated DOM element + // of this field + elementId = null; + + // The identifier generated via ContentDOMReference for the root element of + // this field + rootElementId = null; + + // If the element is an iframe, it is the id of the BrowsingContext of the iframe, + // Otherwise, it is the id of the BrowsingContext the element is in + browsingContextId = null; + + // string with `${element.id}/{element.name}`. This is only used for debugging. identifier = ""; - // The inferred field name for this element + // tag name attribute of the element + localName = null; + + // The inferred field name for this element. fieldName = null; // The approach we use to infer the information for this element @@ -50,41 +65,81 @@ export class FieldDetail { constructor( element, + form, fieldName = null, { autocompleteInfo = {}, confidence = null } = {} ) { this.elementWeakRef = new WeakRef(element); + this.elementId = lazy.FormAutofillUtils.getElementIdentifier(element); + this.rootElementId = lazy.FormAutofillUtils.getElementIdentifier( + form.rootElement + ); this.identifier = `${element.id}/${element.name}`; - this.fieldName = fieldName; + this.localName = element.localName; - if (autocompleteInfo) { + if (Array.isArray(fieldName)) { + this.fieldName = fieldName[0]; + this.alternativeFieldName = fieldName[1]; + } else { + this.fieldName = fieldName; + } + + if (!this.fieldName) { + this.reason = "unknown"; + } else if (autocompleteInfo) { this.reason = "autocomplete"; this.section = autocompleteInfo.section; this.addressType = autocompleteInfo.addressType; this.contactType = autocompleteInfo.contactType; this.credentialType = autocompleteInfo.credentialType; + this.sectionName = this.section || this.addressType; } else if (confidence) { this.reason = "fathom"; this.confidence = confidence; + + // TODO: This should be removed once we support reference field info across iframe. + // 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. + this.isOnlyVisibleFieldWithHighConfidence = false; + if ( + this.confidence > lazy.FormAutofillUtils.ccFathomHighConfidenceThreshold + ) { + const root = element.form || element.ownerDocument; + const inputs = root.querySelectorAll("input:not([type=hidden])"); + if (inputs.length == 1 && inputs[0] == element) { + this.isOnlyVisibleFieldWithHighConfidence = true; + } + } } else { this.reason = "regex-heuristic"; } + + try { + this.browsingContextId = + element.localName == "iframe" + ? element.browsingContext.id + : BrowsingContext.getFromWindow(element.ownerGlobal).id; + } catch { + /* unit test doesn't have ownerGlobal */ + } + + this.isVisible = lazy.FormAutofillUtils.isFieldVisible(this.element); } get element() { return this.elementWeakRef.deref(); } - get sectionName() { - return this.section || this.addressType; - } - - #isVisible = null; - get isVisible() { - if (this.#isVisible == null) { - this.#isVisible = lazy.FormAutofillUtils.isFieldVisible(this.element); - } - return this.#isVisible; + /** + * Convert FieldDetail class to an object that is suitable for + * sending over IPC. Avoid using this in other case. + */ + toVanillaObject() { + const json = { ...this }; + delete json.elementWeakRef; + return json; } } @@ -96,6 +151,7 @@ export class FieldDetail { * `inferFieldInfo` function. */ export class FieldScanner { + #form = null; #elementsWeakRef = null; #inferFieldInfoFn = null; @@ -107,12 +163,16 @@ export class FieldScanner { * Create a FieldScanner based on form elements with the existing * fieldDetails. * - * @param {Array.DOMElement} elements - * The elements from a form for each parser. + * @param {FormLike} form * @param {Funcion} inferFieldInfoFn * The callback function that is used to infer the field info of a given element */ - constructor(elements, inferFieldInfoFn) { + constructor(form, inferFieldInfoFn) { + const elements = Array.from(form.elements).filter(element => + lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) + ); + + this.#form = form; this.#elementsWeakRef = new WeakRef(elements); this.#inferFieldInfoFn = inferFieldInfoFn; } @@ -189,9 +249,11 @@ export class FieldScanner { 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, { + const [fieldName, autocompleteInfo, confidence] = this.#inferFieldInfoFn( + element, + this.#elements + ); + const fieldDetail = new FieldDetail(element, this.#form, fieldName, { autocompleteInfo, confidence, }); diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs index 08b6acaee1830..751d1dacaa61c 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs @@ -37,12 +37,15 @@ const ENABLED_AUTOFILL_CAPTURE_ON_FORM_REMOVAL_PREF = "extensions.formautofill.heuristics.captureOnFormRemoval"; const ENABLED_AUTOFILL_CAPTURE_ON_PAGE_NAVIGATION_PREF = "extensions.formautofill.heuristics.captureOnPageNavigation"; +const ENABLED_AUTOFILL_SAME_ORIGIN_WITH_TOP = + "extensions.formautofill.heuristics.autofillSameOriginWithTop"; export const FormAutofill = { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, ENABLED_AUTOFILL_CAPTURE_ON_FORM_REMOVAL_PREF, ENABLED_AUTOFILL_CAPTURE_ON_PAGE_NAVIGATION_PREF, + ENABLED_AUTOFILL_SAME_ORIGIN_WITH_TOP, ENABLED_AUTOFILL_CREDITCARDS_PREF, AUTOFILL_CREDITCARDS_REAUTH_PREF, AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF, @@ -284,6 +287,11 @@ XPCOMUtils.defineLazyPreferenceGetter( null, val => val?.split(",").filter(v => !!v) ); +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofill, + "autofillSameOriginWithTop", + ENABLED_AUTOFILL_SAME_ORIGIN_WITH_TOP +); XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillChild.ios.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillChild.ios.sys.mjs index 3183319fd9376..053c65ffe1be2 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillChild.ios.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillChild.ios.sys.mjs @@ -3,10 +3,15 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* eslint-disable no-undef,mozilla/balanced-listeners */ +import { AddressRecord } from "resource://gre/modules/shared/AddressRecord.sys.mjs"; import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; import { FormStateManager } from "resource://gre/modules/shared/FormStateManager.sys.mjs"; import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs"; -import { AddressRecord } from "resource://gre/modules/shared/AddressRecord.sys.mjs"; +import { + FormAutofillAddressSection, + FormAutofillCreditCardSection, + FormAutofillSection, +} from "resource://gre/modules/shared/FormAutofillSection.sys.mjs"; export class FormAutofillChild { /** @@ -26,36 +31,16 @@ export class FormAutofillChild { this.callbacks = callbacks; - this.fieldDetailsManager = new FormStateManager(); - - document.addEventListener("focusin", this.onFocusIn); - document.addEventListener("submit", this.onSubmit); - } - - _doIdentifyAutofillFields(element) { - this.fieldDetailsManager.updateActiveInput(element); - this.fieldDetailsManager.identifyAutofillFields(element); - - const activeFieldName = - this.fieldDetailsManager.activeFieldDetail?.fieldName; - - const activeFieldDetails = - this.fieldDetailsManager.activeSection?.fieldDetails; - - // Only ping swift if current field is either a cc or address field - if (!activeFieldDetails?.find(field => field.element === element)) { - return; - } + this.fieldDetailsManager = new FormStateManager(fieldDetail => + // Collect field_modified telemetry + this.activeSection?.onFilledModified(fieldDetail.elementId) + ); - const fieldNamesWithValues = - this.transformToFieldNamesWithValues(activeFieldDetails); - if (FormAutofillUtils.isAddressField(activeFieldName)) { - this.callbacks.address.autofill(fieldNamesWithValues); - } else if (FormAutofillUtils.isCreditCardField(activeFieldName)) { - // Normalize record format so we always get a consistent - // credit card record format: {cc-number, cc-name, cc-exp-month, cc-exp-year} - CreditCardRecord.normalizeFields(fieldNamesWithValues); - this.callbacks.creditCard.autofill(fieldNamesWithValues); + try { + document.addEventListener("focusin", this.onFocusIn); + document.addEventListener("submit", this.onSubmit); + } catch { + // We don't have `document` when running in xpcshell-test } } @@ -69,35 +54,117 @@ export class FormAutofillChild { ); } - onFocusIn(evt) { - const element = evt.target; - this.fieldDetailsManager.updateActiveInput(element); + _doIdentifyAutofillFields(element) { + if (this.#focusedElement == element) { + return; + } + this.#focusedElement = element; + if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) { return; } + + // Find the autofill handler for this form and identify all the fields. + const { handler, newFieldsIdentified } = + this.fieldDetailsManager.identifyAutofillFields(element); + + // If we found newly identified fields, run section classification heuristic + if (newFieldsIdentified) { + this.#sections = FormAutofillSection.classifySections( + handler.fieldDetails + ); + + // For telemetry + this.#sections.forEach(section => section.onDetected()); + } + } + + #focusedElement = null; + + // This is a cache contains the classified section for the active form. + #sections = null; + + get activeSection() { + const elementId = this.activeFieldDetail?.elementId; + return this.#sections?.find(section => + section.getFieldDetailByElementId(elementId) + ); + } + + // active field detail only exists if we identified its field name + get activeFieldDetail() { + return this.activeHandler?.getFieldDetailByElement(this.#focusedElement); + } + + get activeHandler() { + return this.fieldDetailsManager.getFormHandler(this.#focusedElement); + } + + onFocusIn(evt) { + const element = evt.target; + this._doIdentifyAutofillFields(element); + + // Only ping swift if current field is either a cc or address field + if (!this.activeFieldDetail) { + return; + } + + const fieldNamesWithValues = this.transformToFieldNamesWithValues( + this.activeSection.fieldDetails + ); + + if (FormAutofillUtils.isAddressField(this.activeFieldDetail.fieldName)) { + this.callbacks.address.autofill(fieldNamesWithValues); + } else if ( + FormAutofillUtils.isCreditCardField(this.activeFieldDetail.fieldName) + ) { + // Normalize record format so we always get a consistent + // credit card record format: {cc-number, cc-name, cc-exp-month, cc-exp-year} + CreditCardRecord.normalizeFields(fieldNamesWithValues); + this.callbacks.creditCard.autofill(fieldNamesWithValues); + } } onSubmit(_event) { - if (!this.fieldDetailsManager.activeHandler) { + if (!this.activeHandler) { return; } - this.fieldDetailsManager.activeHandler.onFormSubmitted(); - const records = this.fieldDetailsManager.activeHandler.createRecords(); + // Get filled value for the form + const formFilledData = this.activeHandler.collectFormFilledData(); + + // Should reference `_onFormSubmit` in `FormAutofillParent.sys.mjs` + const creditCard = []; + + for (const section of this.#sections) { + const secRecord = section.createRecord(formFilledData); + if (!secRecord) { + continue; + } + + if (section instanceof FormAutofillAddressSection) { + // TODO(FXSP-133 Phase 3): Support address capture + // this.callbacks.address.submit(); + continue; + } else if (section instanceof FormAutofillCreditCardSection) { + creditCard.push(secRecord); + } else { + throw new Error("Unknown section type"); + } + + section.onSubmitted(formFilledData); + } - if (records.creditCard.length) { + if (creditCard.length) { // Normalize record format so we always get a consistent // credit card record format: {cc-number, cc-name, cc-exp-month, cc-exp-year} - const creditCardRecords = records.creditCard.map(entry => { + const creditCardRecords = creditCard.map(entry => { CreditCardRecord.normalizeFields(entry.record); return entry.record; }); this.callbacks.creditCard.submit(creditCardRecords); } - - // TODO(FXSP-133 Phase 3): Support address capture - // this.callbacks.address.submit(); } fillFormFields(payload) { @@ -105,14 +172,20 @@ export class FormAutofillChild { // all additional data must be computed. On Desktop, computed fields are handled in FormAutofillStorageBase.sys.mjs at the time of saving. Ideally, we should centralize // all transformations, computations, and normalization processes within AddressRecord.sys.mjs to maintain a unified implementation across both platforms. // This will be addressed in FXCM-810, aiming to simplify our data representation for both credit cards and addresses. - if ( - FormAutofillUtils.isAddressField( - this.fieldDetailsManager.activeFieldDetail?.fieldName - ) - ) { + + if (FormAutofillUtils.isAddressField(this.activeFieldDetail?.fieldName)) { AddressRecord.computeFields(payload); } - this.fieldDetailsManager.activeHandler.autofillFormFields(payload); + + this.activeHandler.fillFields( + FormAutofillUtils.getElementIdentifier(this.#focusedElement), + this.activeSection.fieldDetails.map(f => f.elementId), + payload + ); + + // For telemetry + const formFilledData = this.activeHandler.collectFormFilledData(); + this.activeSection.onFilled(formFilledData); } } diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs index 49f79be77a937..b39f05d946d1e 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs @@ -7,14 +7,15 @@ import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUti const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - FormAutofillAddressSection: - "resource://gre/modules/shared/FormAutofillSection.sys.mjs", - FormAutofillCreditCardSection: - "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + AutofillFormFactory: + "resource://gre/modules/shared/AutofillFormFactory.sys.mjs", + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FieldDetail: "resource://gre/modules/shared/FieldScanner.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", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; @@ -26,23 +27,15 @@ 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(); + + // An object that caches the current selected option, keyed by element. + #matchingSelectOption = null; + /** * 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 @@ -56,47 +49,27 @@ export class FormAutofillHandler { * 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; + fieldDetails = []; /** * 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 - * four arguments: (1) a FormLike for the form being - * submitted, (2) the reason for infering the form - * submission (3) the corresponding Window, and (4) - * the responsible FormAutofillHandler. - * @param {Function} onAutofillCallback Function that can be invoked + * @param {Function} onFilledModifiedCallback Function that can be invoked * when we want to suggest autofill on a form. */ - constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) { + constructor(form, onFilledModifiedCallback = () => {}) { 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 = formSubmissionReason => { - onFormSubmitted(this.form, formSubmissionReason, this.window, this); - }; + this.onFilledModifiedCallback = onFilledModifiedCallback; - this.onAutofillCallback = onAutofillCallback; + // The identifier generated via ContentDOMReference for the root element. + this.rootElementId = FormAutofillUtils.getElementIdentifier( + form.rootElement + ); ChromeUtils.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "FormAutofillHandler") @@ -109,67 +82,40 @@ export class FormAutofillHandler { 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(); - } + // This uses the #filledStateByElement map instead of + // autofillState as the state has already been cleared by the time + // the input event fires. + const fieldDetail = this.getFieldDetailByElement(event.target); + const previousState = this.getFilledStateByElement(event.target); + const newState = FIELD_STATES.NORMAL; - if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) { - return; + if (previousState != newState) { + this.changeFieldState(fieldDetail, newState); } - this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); - const section = this.getSectionByElement(targetFieldDetail.element); - section?.clearFilled(targetFieldDetail); + this.onFilledModifiedCallback?.(fieldDetail, previousState, newState); } } } - set focusedInput(element) { - const section = this.getSectionByElement(element); - if (!section) { - return; - } - - this.#focusedSection = section; - this.#focusedSection.focusedInput = element; + getFieldDetailByName(fieldName) { + return this.fieldDetails.find(detail => detail.fieldName == fieldName); } - 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) { + return this.fieldDetails.find(detail => detail.element == element); } - getFieldDetailByElement(element) { - for (const section of this.sections) { - const detail = section.getFieldDetailByElement(element); - if (detail) { - return detail; - } - } - return null; + getFieldDetailByElementId(elementId) { + return this.fieldDetails.find(detail => detail.elementId == elementId); } - get activeSection() { - return this.#focusedSection; + /** + * Only use this API within handleEvent + */ + getFilledStateByElement(element) { + return this.#filledStateByElement.get(element); } /** @@ -191,12 +137,12 @@ export class FormAutofillHandler { let _formLike; const getFormLike = () => { if (!_formLike) { - _formLike = lazy.FormLikeFactory.createFromField(element); + _formLike = lazy.AutofillFormFactory.createFromField(element); } return _formLike; }; - const currentForm = element.form ?? getFormLike(); + const currentForm = getFormLike(); if (currentForm.elements.length != this.form.elements.length) { this.log.debug("The count of form elements is changed."); this._updateForm(getFormLike()); @@ -221,65 +167,23 @@ export class FormAutofillHandler { _updateForm(form) { this.form = form; - this.fieldDetails = null; - - this.sections = []; - this.#cachedSectionByElement = new WeakMap(); + this.fieldDetails = []; } /** * Set fieldDetails from the form about fields that can be autofilled. * + * @param {boolean} ignoreUnknown + * True to only keep fields that have a field name + * * @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) { - // We don't support csc field, so remove csc fields from section - const fieldDetails = section.fieldDetails.filter( - f => !["cc-csc"].includes(f.fieldName) - ); - if (!fieldDetails.length) { - continue; - } - - let autofillableSection; - if (section.type == lazy.FormSection.ADDRESS) { - autofillableSection = new lazy.FormAutofillAddressSection( - fieldDetails, - this - ); - } else { - autofillableSection = new lazy.FormAutofillCreditCardSection( - fieldDetails, - this - ); - } - - // Do not include section that is either disabled or invalid. - // We only include invalid section for testing purpose. - if ( - !autofillableSection.isEnabled() || - (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); + collectFormFields(ignoreUnknown = true) { + const fields = lazy.FormAutofillHeuristics.getFormInfo(this.form) ?? []; + this.fieldDetails = ignoreUnknown + ? fields.filter(field => field.fieldName) + : fields; + return this.fieldDetails; } /** @@ -287,10 +191,10 @@ export class FormAutofillHandler { * * @param {object} fieldDetail * A fieldDetail of which its element is about to update the state. - * @param {string} nextState - * Used to determine the next state + * @param {string} state + * The state to apply. */ - changeFieldState(fieldDetail, nextState) { + changeFieldState(fieldDetail, state) { const element = fieldDetail.element; if (!element) { this.log.warn( @@ -299,7 +203,8 @@ export class FormAutofillHandler { ); return; } - if (!(nextState in this.FIELD_STATE_ENUM)) { + + if (!Object.values(FIELD_STATES).includes(state)) { this.log.warn( fieldDetail.fieldName, "is trying to change to an invalid state" @@ -307,105 +212,770 @@ export class FormAutofillHandler { return; } - if (this.#filledStateByElement.get(element) == nextState) { - return; + element.autofillState = state; + this.#filledStateByElement.set(element, state); + + if (state == FIELD_STATES.AUTO_FILLED) { + element.addEventListener("input", this, { mozSystemGroup: true }); } + } + + /** + * Populates result to the preview layers with given profile. + * + * @param {Array} elementIds + * @param {object} profile + * A profile to be previewed with + */ + previewFields(elementIds, profile) { + this.getAdaptedProfiles([profile]); + + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.element; + + // Skip the field if it is null or readonly or disabled + if ( + !elementIds.includes(fieldDetail.elementId) || + !FormAutofillUtils.isFieldAutofillable(element) + ) { + continue; + } - 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) { + let value = this.getFilledValueFromProfile(fieldDetail, profile); + if (!value) { + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); continue; } - if (state == nextState) { - nextStateValue = mmStateValue; + if (HTMLInputElement.isInstance(element)) { + 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; + } + } else if (HTMLSelectElement.isInstance(element)) { + // Unlike text input, select element is always previewed even if + // the option is already selected. + const option = this.matchSelectOptions(fieldDetail, profile); + value = option?.text ?? ""; } else { - this.winUtils.removeManuallyManagedState(element, mmStateValue); + continue; } - } - - if (nextStateValue) { - this.winUtils.addManuallyManagedState(element, nextStateValue); - } - if (nextState == FIELD_STATES.AUTO_FILLED) { - element.addEventListener("input", this, { mozSystemGroup: true }); + element.previewValue = value?.toString().replaceAll("*", "•"); + this.changeFieldState(fieldDetail, FIELD_STATES.PREVIEW); } - - this.#filledStateByElement.set(element, nextState); } /** * Processes form fields that can be autofilled, and populates them with the * profile provided by backend. * + * @param {string} focusedId + * The id of the element that triggers autofilling. + * @param {Array} elementIds + * An array of IDs for the elements that should be autofilled. * @param {object} profile - * A profile to be filled in. + * The data profile containing the values to be autofilled into the form fields. */ - async autofillFormFields(profile) { - const noFilledSectionsPreviously = !this.#hasFilledSection(); - await this.activeSection.autofillFields(profile); + fillFields(focusedId, elementIds, profile) { + this.getAdaptedProfiles([profile]); + + for (const fieldDetail of this.fieldDetails) { + const { element, elementId } = fieldDetail; + + if ( + !elementIds.includes(elementId) || + !FormAutofillUtils.isFieldAutofillable(element) + ) { + continue; + } - const onChangeHandler = e => { + element.previewValue = ""; + + if (HTMLInputElement.isInstance(element)) { + // 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 (!value) { + continue; + } + + // 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 ( + elementId == focusedId || + !element.value || + element.value == element.defaultValue || + element.autofillState == FIELD_STATES.AUTO_FILLED + ) { + FormAutofillHandler.fillFieldValue(element, value); + this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } else if (HTMLSelectElement.isInstance(element)) { + const option = this.matchSelectOptions(fieldDetail, profile); + 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; + FormAutofillHandler.fillFieldValue(element, option.value); + } + // Autofill highlight appears regardless if value is changed or not + this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } else { + continue; + } + } + + FormAutofillUtils.getElementByIdentifier(focusedId)?.focus({ + preventScroll: true, + }); + + this.registerFormChangeHandler(); + } + + registerFormChangeHandler() { + if (this.onChangeHandler) { + return; + } + + this.log.debug("register change handler for filled form:", this.form); + + this.onChangeHandler = e => { if (!e.isTrusted) { return; } if (e.type == "reset") { - this.sections.map(section => section.resetFieldStates()); + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.element; + element.removeEventListener("input", this, { mozSystemGroup: true }); + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } } + // 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 ( + this.fieldDetails.every( + detail => detail.element.autofillState != FIELD_STATES.AUTO_FILLED + ) + ) { + this.form.rootElement.removeEventListener( + "input", + this.onChangeHandler, + { + mozSystemGroup: true, + } + ); + this.form.rootElement.removeEventListener( + "reset", + this.onChangeHandler, + { + mozSystemGroup: true, + } + ); + this.onChangeHandler = null; } }; - 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, + // 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", this.onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.addEventListener("reset", this.onChangeHandler, { + mozSystemGroup: true, + }); + } + + computeFillingValue(fieldDetail) { + const element = fieldDetail.element; + if (!element) { + return null; + } + + let value = element.value.trim(); + switch (fieldDetail.fieldName) { + case "address-level1": + if (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 { + const text = element.selectedOptions[0].text.trim(); + value = + FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || + text; + } + } + break; + case "country": + // This is a temporary fix. Ideally we should have either case-insensitive comparison of country codes + // or handle this elsewhere see Bug 1889234 for more context. + value = value.toUpperCase(); + break; + case "cc-type": + if ( + HTMLSelectElement.isInstance(element) && + !lazy.CreditCard.isValidNetwork(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) { + const selectedOption = element.selectedOptions[0]; + const networkType = + lazy.CreditCard.getNetworkFromName(selectedOption.text) ?? + lazy.CreditCard.getNetworkFromName(selectedOption.value); + if (networkType) { + value = networkType; + } + } + } + break; + } + + return value; + } + + /* + * Apply both address and credit card related transformers. + * + * @param {Object} profile + * A profile for adjusting credit card related value. + * @override + */ + applyTransformers(profile) { + this.addressTransformer(profile); + this.telTransformer(profile); + this.creditCardExpiryDateTransformer(profile); + this.creditCardExpMonthAndYearTransformer(profile); + this.creditCardNameTransformer(profile); + this.adaptFieldMaxLength(profile); + } + + getAdaptedProfiles(originalProfiles) { + for (let profile of originalProfiles) { + this.applyTransformers(profile); + } + return originalProfiles; + } + + /** + * Match the select option for a field if we autofill with the given profile. + * This function caches the matching result in the `#matchingSelectionOption` + * variable. + * + * @param {FieldDetail} fieldDetail + * The field information of the matching element. + * @param {object} profile + * The profile used for autofill. + * + * @returns {Option} + * The matched option, or undefined if no matching option is found. + */ + matchSelectOptions(fieldDetail, profile) { + if (!this.#matchingSelectOption) { + this.#matchingSelectOption = new WeakMap(); + } + + const { element, fieldName } = fieldDetail; + if (!HTMLSelectElement.isInstance(element)) { + return undefined; + } + + const cache = this.#matchingSelectOption.get(element) || {}; + const value = profile[fieldName]; + + let option = cache[value]?.deref(); + if (!option) { + option = FormAutofillUtils.findSelectOption(element, profile, fieldName); + + if (option) { + cache[value] = new WeakRef(option); + this.#matchingSelectOption.set(element, cache); + } else if (cache[value]) { + delete cache[value]; + this.#matchingSelectOption.set(element, cache); + } + } + + return option; + } + + adaptFieldMaxLength(profile) { + for (let key in profile) { + let detail = this.getFieldDetailByName(key); + if (!detail || detail.part) { + continue; + } + + let element = detail.element; + 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 4 or 5, then we + // assume it is intended to hold an expiration of the + // form "MMYY" or "MM/YY". + if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) { + const month2Digits = ( + "0" + profile["cc-exp-month"].toString() + ).slice(-2); + const year2Digits = profile["cc-exp-year"].toString().slice(-2); + const separator = maxLength == 5 ? "/" : ""; + profile[key] = `${month2Digits}${separator}${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`]; + } + } + } + + /** + * Handles credit card expiry date transformation when + * the expiry date exists in a cc-exp field. + * + * @param {object} profile + */ + creditCardExpiryDateTransformer(profile) { + if (!profile["cc-exp"]) { + return; + } + + const element = this.getFieldDetailByName("cc-exp")?.element; + if (!element) { + return; + } + + function updateExpiry(_string, _month, _year) { + // Bug 1687681: This is a short term fix to other locales having + // different characters to represent year. + // - FR locales may use "A" to represent year. + // - DE locales may use "J" to represent year. + // - PL locales may use "R" to represent year. + // This approach will not scale well and should be investigated in a follow up bug. + const monthChars = "m"; + const yearChars = "yy|aa|jj|rr"; + const expiryDateFormatRegex = (firstChars, secondChars) => + new RegExp( + "(?:\\b|^)((?:[" + + firstChars + + "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + + secondChars + + "]{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"] + let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string); + if (result) { + return ( + _month.padStart(result[1].length, "0") + + result[2] + + _year.substr(-1 * result[3].length) + ); + } + + // 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 = expiryDateFormatRegex(yearChars, monthChars).exec(_string); + if (result) { + return ( + _year.substr(-1 * result[1].length) + + result[2] + + _month.padStart(result[3].length, "0") + ); + } + return null; + } + + let newExpiryString = null; + const month = profile["cc-exp-month"].toString(); + const year = profile["cc-exp-year"].toString(); + if (element.localName == "input") { + // Use the placeholder or label to determine the expiry string format. + const possibleExpiryStrings = []; + if (element.placeholder) { + possibleExpiryStrings.push(element.placeholder); + } + const labels = lazy.LabelUtils.findLabelElements(element); + if (labels) { + // Not consider multiple lable for now. + possibleExpiryStrings.push(element.labels[0]?.textContent); + } + if (element.previousElementSibling?.localName == "label") { + possibleExpiryStrings.push(element.previousElementSibling.textContent); + } + + possibleExpiryStrings.some(string => { + newExpiryString = updateExpiry(string, month, year); + return !!newExpiryString; }); } + + // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the + // preferred presentation format for credit card expiry dates. + profile["cc-exp"] = newExpiryString ?? `${month.padStart(2, "0")}/${year}`; + } + + /** + * 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 + */ + creditCardExpMonthAndYearTransformer(profile) { + const getInputElementByField = (field, self) => { + if (!field) { + return null; + } + const detail = self.getFieldDetailByName(field); + if (!detail) { + return null; + } + const element = detail.element; + return element.localName === "input" ? element : null; + }; + const 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"); + } + const 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) { + const placeholder = year.placeholder; + + // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits. + const result = /\b(yy|aa|jj|rr)\b/i.test(placeholder); + if (result) { + profile["cc-exp-year-formatted"] = profile["cc-exp-year"] + ?.toString() + .substring(2); + } + } + } + + /** + * Handles credit card name transformation when the name exists in + * the separate cc-given-name, cc-middle-name, and cc-family name fields + * + * @param {object} profile + */ + creditCardNameTransformer(profile) { + const name = profile["cc-name"]; + if (!name) { + return; + } + + const given = this.getFieldDetailByName("cc-given-name"); + const middle = this.getFieldDetailByName("cc-middle-name"); + const family = this.getFieldDetailByName("cc-family-name"); + if (given || middle || family) { + const nameParts = lazy.FormAutofillNameUtils.splitName(name); + if (given && nameParts.given) { + profile["cc-given-name"] = nameParts.given; + } + if (middle && nameParts.middle) { + profile["cc-middle-name"] = nameParts.middle; + } + if (family && nameParts.family) { + profile["cc-family-name"] = nameParts.family; + } + } + } + + addressTransformer(profile) { + if (profile["street-address"]) { + // "-moz-street-address-one-line" is used by the labels in + // ProfileAutoCompleteResult. + profile["-moz-street-address-one-line"] = + FormAutofillUtils.toOneLineAddress(profile["street-address"]); + let streetAddressDetail = this.getFieldDetailByName("street-address"); + if ( + streetAddressDetail && + HTMLInputElement.isInstance(streetAddressDetail.element) + ) { + 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 = []; + } + } + } } /** - * Collect the filled sections within submitted form and convert all the valid - * field data into multiple records. + * Replace tel with tel-national if tel violates the input element's + * restriction. * - * @returns {object} records - * {Array.} records.address - * {Array.} records.creditCard + * @param {object} profile + * A profile to be converted. */ - createRecords() { - const records = { - address: [], - creditCard: [], + telTransformer(profile) { + if (!profile.tel || !profile["tel-national"]) { + return; + } + + let detail = this.getFieldDetailByName("tel"); + if (!detail) { + return; + } + + let element = detail.element; + 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; + } + } - for (const section of this.sections) { - const secRecord = section.createRecord(); - if (!secRecord) { + 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"]; + } + } + } + + /** + * + * @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) { + let value = + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName]; + + if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) { + const part = fieldDetail.part; + return value.slice((part - 1) * 4, part * 4); + } + return value; + } + /** + * Fills the provided element with the specified value. + * + * @param {HTMLInputElement| HTMLSelectElement} element - The form field element to be filled. + * @param {string} value - The value to be filled into the form field. + */ + static 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 }) + ); + } + } + + clearPreviewedFields(elementIds) { + for (const elementId of elementIds) { + const fieldDetail = this.getFieldDetailByElementId(elementId); + const element = fieldDetail?.element; + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); 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"); + + element.previewValue = ""; + if (element.autofillState == FIELD_STATES.AUTO_FILLED) { + continue; } + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } + } + + clearFilledFields(elementIds) { + const fieldDetails = elementIds.map(id => + this.getFieldDetailByElementId(id) + ); + for (const fieldDetail of fieldDetails) { + const element = fieldDetail?.element; + if (!element) { + this.log.warn(fieldDetail?.fieldName, "is unreachable"); + continue; + } - return records; + if (element.autofillState == 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); + } + } + } + } + + /** + * Resets a . - 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.element; - this._resetSelectElementValue(element); - this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); - } - this.filledRecordGUID = null; + createRecord(formFilledData) { + if (!this.fieldDetails.length) { + return {}; } - } - /** - * Clear preview text and background highlight of all fields. - */ - clearPreviewedFormFields() { - this.log.debug("clear previewed fields"); + const data = { + flowId: this.flowId, + record: {}, + }; - for (const fieldDetail of this.fieldDetails) { - let element = fieldDetail.element; - if (!element) { - this.log.warn(fieldDetail.fieldName, "is unreachable"); + for (const detail of this.fieldDetails) { + // Do not save security code. + if (detail.fieldName == "cc-csc") { continue; } - element.previewValue = ""; + const { filledValue } = formFilledData.get(detail.elementId); - // We keep the state if this field has - // already been auto-filled. if ( - this.handler.getFilledStateByElement(element) == - FIELD_STATES.AUTO_FILLED + !filledValue || + filledValue.length > lazy.FormAutofillUtils.MAX_FIELD_VALUE_LENGTH ) { - continue; + // Keep the property and preserve more information for updating + data.record[detail.fieldName] = ""; + } else if (detail.part > 1) { + // If there are multiple parts for the same field, concatenate the values. + // This is now used in cases where the credit card number field + // is split into multiple fields. + data.record[detail.fieldName] += filledValue; + } else { + data.record[detail.fieldName] = filledValue; } + } - this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + if (!this.isRecordCreatable(data.record)) { + return null; } + + return data; } /** - * Clear value and highlight style of all filled fields. + * Heuristics to determine which fields to autofill when a section contains + * multiple fields of the same type. */ - clearPopulatedForm() { - for (let fieldDetail of this.fieldDetails) { - let element = fieldDetail.element; - if (!element) { - this.log.warn(fieldDetail.fieldName, "is unreachable"); - continue; + getAutofillFields() { + return this.fieldDetails.filter(fieldDetail => { + // We don't save security code, but if somehow the profile has securty code, + // make sure we don't autofill it. + if (fieldDetail.fieldName == "cc-csc") { + return false; } - 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); - } + // When both visible and invisible . + if (fieldDetail.localName == "select" && !fieldDetail.isVisible) { + return !this.fieldDetails.some( + field => field.fieldName == fieldDetail.fieldName && field.isVisible + ); } - } + return true; + }); } - resetFieldStates() { - for (const fieldDetail of this.fieldDetails) { - const element = fieldDetail.element; - element.removeEventListener("input", this, { mozSystemGroup: true }); - this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + /* + * For telemetry + */ + onDetected() { + if (!this.isValidSection()) { + return; } - this.filledRecordGUID = null; - } - isFilled() { - return !!this.filledRecordGUID; + lazy.AutofillTelemetry.recordDetectedSectionCount(this.fieldDetails); + lazy.AutofillTelemetry.recordFormInteractionEvent( + "detected", + this.flowId, + this.fieldDetails + ); } - /** - * Condenses multiple credit card number fields into one fieldDetail - * in order to submit the credit card record correctly. - * - * @param {Array.} 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].element?.value + - condensedDetails[i + 1].element?.value + - condensedDetails[i + 2].element?.value + - condensedDetails[i + 3].element?.value; - condensedDetails.splice(i + 1, 3); - } - } else { - countOfCCNumbers = 0; - } - } + onPopupOpened(elementId) { + const fieldDetail = this.getFieldDetailByElementId(elementId); + lazy.AutofillTelemetry.recordFormInteractionEvent( + "popup_shown", + this.flowId, + [fieldDetail] + ); } - /** - * 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.element; - // Remove the unnecessary spaces - let value = detail.fieldValue ?? (element && element.value.trim()); - value = this.computeFillingValue(value, detail, element); + onFilled(filledResult) { + lazy.AutofillTelemetry.recordFormInteractionEvent( + "filled", + this.flowId, + this.fieldDetails, + filledResult + ); + } - if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { - // Keep the property and preserve more information for updating - data.record[detail.fieldName] = ""; - return; - } + onFilledModified(elementId) { + const fieldDetail = this.getFieldDetailByElementId(elementId); + lazy.AutofillTelemetry.recordFormInteractionEvent( + "filled_modified", + this.flowId, + [fieldDetail] + ); + } - data.record[detail.fieldName] = value; + onSubmitted(formFilledData) { + this.submitted = true; - if ( - this.handler.getFilledStateByElement(element) == - FIELD_STATES.AUTO_FILLED - ) { - data.untouchedFields.push(detail.fieldName); - } - }); - - const telFields = this.fieldDetails.filter( - f => FormAutofillUtils.getCategoryFromFieldName(f.fieldName) == "tel" + lazy.AutofillTelemetry.recordSubmittedSectionCount(this.fieldDetails, 1); + lazy.AutofillTelemetry.recordFormInteractionEvent( + "submitted", + this.flowId, + this.fieldDetails, + formFilledData ); - if ( - telFields.length && - telFields.every(f => data.untouchedFields.includes(f.fieldName)) - ) { - // No need to verify it if none of related fields are modified after autofilling. - if (!data.untouchedFields.includes("tel")) { - data.untouchedFields.push("tel"); - } - } + } - if (!this.isRecordCreatable(data.record)) { - return null; - } + onCleared(elementId) { + const fieldDetail = this.getFieldDetailByElementId(elementId); + lazy.AutofillTelemetry.recordFormInteractionEvent("cleared", this.flowId, [ + fieldDetail, + ]); + } - return data; + /** + * Utility functions + */ + getFieldDetailByElementId(elementId) { + return this.fieldDetails.find(detail => detail.elementId == elementId); } /** - * Resets a // not in a
are one LoginForm but this + * shouldn't be relied upon as the heuristics may change to detect multiple + * "forms" (e.g. registration and login) on one page with a . * - * @param {HTMLInputElement|HTMLSelectElement} aField - an or //,