From 60d697d08d1d87b78d619a561feeec22f7b611d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Oct 2024 00:14:50 +0000 Subject: [PATCH] Refactor [vXXX] auto update credential provider script --- .../AddressMetaDataExtension.sys.mjs | 1 + .../CC_Script/AutofillTelemetry.sys.mjs | 384 ++--- .../Client/Assets/CC_Script/Constants.ios.mjs | 2 + .../CC_Script/CreditCardRuleset.sys.mjs | 4 +- .../Assets/CC_Script/FieldScanner.sys.mjs | 204 +-- .../Assets/CC_Script/FormAutofill.sys.mjs | 8 + .../CC_Script/FormAutofillChild.ios.sys.mjs | 182 ++- .../CC_Script/FormAutofillHandler.sys.mjs | 1037 ++++++++++--- .../CC_Script/FormAutofillHeuristics.sys.mjs | 484 ++++--- .../CC_Script/FormAutofillSection.sys.mjs | 1287 ++++------------- .../CC_Script/FormAutofillUtils.sys.mjs | 129 +- .../Assets/CC_Script/FormLikeFactory.sys.mjs | 98 +- .../Assets/CC_Script/FormStateManager.sys.mjs | 169 +-- .../Client/Assets/CC_Script/Helpers.ios.mjs | 96 +- .../Assets/CC_Script/HeuristicsRegExp.sys.mjs | 22 +- .../Assets/CC_Script/LabelUtils.sys.mjs | 11 +- .../Client/Assets/CC_Script/Overrides.ios.js | 2 + 17 files changed, 2162 insertions(+), 1958 deletions(-) diff --git a/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs index da13b667849d3..f4dca18dde145 100644 --- a/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs @@ -254,6 +254,7 @@ export const AddressMetaDataExtension = { }, "data/DE": { alpha_3_code: "DEU", + address_reversed: true, }, "data/GH": { alpha_3_code: "GHA", diff --git a/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs index 6a1fa974cc9cb..2987ec2c15cff 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,140 +85,148 @@ 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) { - Services.telemetry.recordEvent( - this.EVENT_CATEGORY, - method, - this.EVENT_OBJECT_FORM_INTERACTION, - flowId, - extra - ); + recordFormEvent(_method, _flowId, _extra) { + throw new Error("Not implemented."); } recordGleanFormEvent(_eventName, _flowId, _extra) { 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; } recordDoorhangerEvent(method, object, flowId) { - Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, object, flowId); + const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c => + c[1].toUpperCase() + ); + Glean[this.EVENT_CATEGORY][eventName]?.record({ value: flowId }); } recordManageEvent(method) { - Services.telemetry.recordEvent(this.EVENT_CATEGORY, method, "manage"); + const eventName = + method.replace(/(_[a-z])/g, c => c[1].toUpperCase()) + "Manage"; + Glean[this.EVENT_CATEGORY][eventName]?.record(); } recordAutofillProfileCount(_count) { @@ -252,12 +259,50 @@ 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 { EVENT_CATEGORY = "address"; - EVENT_OBJECT_FORM_INTERACTION = "address_form"; - EVENT_OBJECT_FORM_INTERACTION_EXT = "address_form_ext"; + EVENT_OBJECT_FORM_INTERACTION = "AddressForm"; + EVENT_OBJECT_FORM_INTERACTION_EXT = "AddressFormExt"; SCALAR_DETECTED_SECTION_COUNT = "formautofill.addresses.detected_sections_count"; @@ -326,22 +371,16 @@ export class AddressTelemetry extends AutofillTelemetryBase { } } - Services.telemetry.recordEvent( - this.EVENT_CATEGORY, - method, - this.EVENT_OBJECT_FORM_INTERACTION, - flowId, - extra - ); + const eventMethod = method.replace(/(_[a-z])/g, c => c[1].toUpperCase()); + Glean.address[eventMethod + this.EVENT_OBJECT_FORM_INTERACTION]?.record({ + value: flowId, + ...extra, + }); if (Object.keys(extExtra).length) { - Services.telemetry.recordEvent( - this.EVENT_CATEGORY, - method, - this.EVENT_OBJECT_FORM_INTERACTION_EXT, - flowId, - extExtra - ); + Glean.address[ + eventMethod + this.EVENT_OBJECT_FORM_INTERACTION_EXT + ]?.record({ value: flowId, ...extExtra }); } } @@ -352,7 +391,7 @@ export class AddressTelemetry extends AutofillTelemetryBase { class CreditCardTelemetry extends AutofillTelemetryBase { EVENT_CATEGORY = "creditcard"; - EVENT_OBJECT_FORM_INTERACTION = "cc_form_v2"; + EVENT_OBJECT_FORM_INTERACTION = "CcFormV2"; SCALAR_DETECTED_SECTION_COUNT = "formautofill.creditCards.detected_sections_count"; @@ -374,126 +413,18 @@ 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); + recordFormEvent(method, flowId, aExtra) { + // Don't modify the passed-in aExtra as it's reused. + const extra = Object.assign({ value: flowId }, aExtra); + const eventMethod = method.replace(/(_[a-z])/g, c => c[1].toUpperCase()); + Glean.creditcard[eventMethod + this.EVENT_OBJECT_FORM_INTERACTION]?.record( + extra + ); } recordNumberOfUse(records) { @@ -526,10 +457,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 +503,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 +517,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/CreditCardRuleset.sys.mjs b/firefox-ios/Client/Assets/CC_Script/CreditCardRuleset.sys.mjs index 26651fe65ab59..72a75cf27aa01 100644 --- a/firefox-ios/Client/Assets/CC_Script/CreditCardRuleset.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/CreditCardRuleset.sys.mjs @@ -1207,7 +1207,9 @@ export var CreditCardRulesets = { ); for (const type of this.types) { - this[type] = makeRuleset([...coefficients[type]], biases); + if (type) { + this[type] = makeRuleset([...coefficients[type]], biases); + } } }, diff --git a/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs index 2118de3de890d..b1d4b3754904c 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 @@ -48,43 +63,103 @@ export class FieldDetail { // Confidence value when the field name is inferred by "fathom" confidence = null; - constructor( - element, - fieldName = null, - { autocompleteInfo = {}, confidence = null } = {} - ) { + constructor(element) { this.elementWeakRef = new WeakRef(element); - this.identifier = `${element.id}/${element.name}`; - this.fieldName = fieldName; - - if (autocompleteInfo) { - this.reason = "autocomplete"; - this.section = autocompleteInfo.section; - this.addressType = autocompleteInfo.addressType; - this.contactType = autocompleteInfo.contactType; - this.credentialType = autocompleteInfo.credentialType; - } else if (confidence) { - this.reason = "fathom"; - this.confidence = confidence; - } else { - this.reason = "regex-heuristic"; - } } get element() { return this.elementWeakRef.deref(); } - get sectionName() { - return this.section || this.addressType; + /** + * 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; } - #isVisible = null; - get isVisible() { - if (this.#isVisible == null) { - this.#isVisible = lazy.FormAutofillUtils.isFieldVisible(this.element); + static fromVanillaObject(obj) { + const element = lazy.FormAutofillUtils.getElementByIdentifier( + obj.elementId + ); + return element ? Object.assign(new FieldDetail(element), obj) : null; + } + + static create( + element, + form, + fieldName = null, + { autocompleteInfo = {}, confidence = null, isVisible = true } = {} + ) { + const fieldDetail = new FieldDetail(element); + + fieldDetail.elementId = + lazy.FormAutofillUtils.getElementIdentifier(element); + fieldDetail.rootElementId = lazy.FormAutofillUtils.getElementIdentifier( + form.rootElement + ); + fieldDetail.identifier = `${element.id}/${element.name}`; + fieldDetail.localName = element.localName; + + if (Array.isArray(fieldName)) { + fieldDetail.fieldName = fieldName[0] ?? ""; + fieldDetail.alternativeFieldName = fieldName[1] ?? ""; + } else { + fieldDetail.fieldName = fieldName; + } + + if (!fieldDetail.fieldName) { + fieldDetail.reason = "unknown"; + } else if (autocompleteInfo) { + fieldDetail.reason = "autocomplete"; + fieldDetail.section = autocompleteInfo.section; + fieldDetail.addressType = autocompleteInfo.addressType; + fieldDetail.contactType = autocompleteInfo.contactType; + fieldDetail.credentialType = autocompleteInfo.credentialType; + fieldDetail.sectionName = + autocompleteInfo.section || autocompleteInfo.addressType; + } else if (confidence) { + fieldDetail.reason = "fathom"; + fieldDetail.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. + fieldDetail.isOnlyVisibleFieldWithHighConfidence = false; + if ( + fieldDetail.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) { + fieldDetail.isOnlyVisibleFieldWithHighConfidence = true; + } + } + } else { + fieldDetail.reason = "regex-heuristic"; } - return this.#isVisible; + + try { + fieldDetail.browsingContextId = + element.localName == "iframe" + ? element.browsingContext.id + : BrowsingContext.getFromWindow(element.ownerGlobal).id; + } catch { + /* unit test doesn't have ownerGlobal */ + } + + fieldDetail.isVisible = isVisible; + + // Info required by heuristics + fieldDetail.maxLength = element.maxLength; + + return fieldDetail; } } @@ -96,29 +171,19 @@ export class FieldDetail { * `inferFieldInfo` function. */ export class FieldScanner { - #elementsWeakRef = null; - #inferFieldInfoFn = null; - #parsingIndex = 0; - fieldDetails = []; + #fieldDetails = []; /** * Create a FieldScanner based on form elements with the existing * fieldDetails. * - * @param {Array.DOMElement} elements - * The elements from a form for each parser. - * @param {Funcion} inferFieldInfoFn - * The callback function that is used to infer the field info of a given element + * @param {Array} fieldDetails + * An array of fieldDetail object to be scanned. */ - constructor(elements, inferFieldInfoFn) { - this.#elementsWeakRef = new WeakRef(elements); - this.#inferFieldInfoFn = inferFieldInfoFn; - } - - get #elements() { - return this.#elementsWeakRef.deref(); + constructor(fieldDetails) { + this.#fieldDetails = fieldDetails; } /** @@ -132,7 +197,7 @@ export class FieldScanner { } get parsingFinished() { - return this.parsingIndex >= this.#elements.length; + return this.parsingIndex >= this.#fieldDetails.length; } /** @@ -143,7 +208,7 @@ export class FieldScanner { * The latest index of elements waiting for parsing. */ set parsingIndex(index) { - if (index > this.#elements.length) { + if (index > this.#fieldDetails.length) { throw new Error("The parsing index is out of range."); } this.#parsingIndex = index; @@ -159,44 +224,11 @@ export class FieldScanner { * The field detail at the specific index. */ getFieldDetailByIndex(index) { - if (index >= this.#elements.length) { + if (index >= this.#fieldDetails.length) { return null; } - if (index < this.fieldDetails.length) { - return this.fieldDetails[index]; - } - - for (let i = this.fieldDetails.length; i < index + 1; i++) { - this.pushDetail(); - } - - return this.fieldDetails[index]; - } - - /** - * This function retrieves the first unparsed element and obtains its - * information by invoking the `inferFieldInfoFn` callback function. - * The field information is then stored in a FieldDetail object and - * appended to the `fieldDetails` array. - * - * Any element without the related detail will be used for adding the detail - * to the end of field details. - */ - pushDetail() { - const elementIndex = this.fieldDetails.length; - if (elementIndex >= this.#elements.length) { - throw new Error("Try to push the non-existing element info."); - } - const element = this.#elements[elementIndex]; - const [fieldName, autocompleteInfo, confidence] = - this.#inferFieldInfoFn(element); - const fieldDetail = new FieldDetail(element, fieldName, { - autocompleteInfo, - confidence, - }); - - this.fieldDetails.push(fieldDetail); + return this.#fieldDetails[index]; } /** @@ -212,11 +244,11 @@ export class FieldScanner { * autocomplete attribute */ updateFieldName(index, fieldName, ignoreAutocomplete = false) { - if (index >= this.fieldDetails.length) { + if (index >= this.#fieldDetails.length) { throw new Error("Try to update the non-existing field detail."); } - const fieldDetail = this.fieldDetails[index]; + const fieldDetail = this.#fieldDetails[index]; if (fieldDetail.fieldName == fieldName) { return; } @@ -225,12 +257,12 @@ export class FieldScanner { return; } - this.fieldDetails[index].fieldName = fieldName; - this.fieldDetails[index].reason = "update-heuristic"; + fieldDetail.fieldName = fieldName; + fieldDetail.reason = "update-heuristic"; } elementExisting(index) { - return index < this.#elements.length; + return index < this.#fieldDetails.length; } } 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..b0a956a193042 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,17 @@ * 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 { FormAutofillHandler } from "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"; +import { FormAutofillHeuristics } from "resource://gre/modules/shared/FormAutofillHeuristics.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 +33,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 +56,130 @@ export class FormAutofillChild { ); } + identifyFieldsWhenFocused(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 = this.fieldDetailsManager.getOrCreateFormHandler(element); + + if (!handler.hasIdentifiedFields() || handler.updateFormIfNeeded(element)) { + // If we found newly identified fields, run section classification heuristic + const detectedFields = FormAutofillHandler.collectFormFields( + handler.form + ); + + FormAutofillHeuristics.parseAndUpdateFieldNamesParent(detectedFields); + handler.setIdentifiedFieldDetails(detectedFields); + + 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.fieldDetailsManager.updateActiveInput(element); - if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) { + + this.identifyFieldsWhenFocused(element); + + // Only ping swift if current field is either a cc or address field + if (!this.activeFieldDetail) { return; } - this._doIdentifyAutofillFields(element); + + // Since iOS doesn't support cross frame autofill, + // we should only call the autofill callback if the section is valid. + // TODO(issam): This will change when we have cross frame fill support. + if (!this.activeSection.isValidSection()) { + 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 +187,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..a7aec3ac1f648 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs @@ -7,14 +7,16 @@ 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", + AddressParser: "resource://gre/modules/shared/AddressParser.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 +28,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,120 +50,115 @@ 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 = null; /** * Initialize the form from `FormLike` object to handle the section or form * operations. * * @param {FormLike} form Form that need to be auto filled - * @param {Function} onFormSubmitted Function that can be invoked - * to simulate form submission. Function is passed - * 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") ); } + /** + * Retrieves the 'fieldDetails' property, ensuring it has been initialized by + * `setIdentifiedFieldDetails`. Throws an error if accessed before initialization. + * + * This is because 'fieldDetail'' contains information that need to be computed + * in the parent side first. + * + * @throws {Error} If `setIdentifiedFieldDetails` has not been called. + * @returns {Array} + * The list of autofillable field details for this form. + */ + get fieldDetails() { + if (!this.#fieldDetails) { + throw new Error( + `Should only use 'fieldDetails' after 'setIdentifiedFieldDetails' is called` + ); + } + return this.#fieldDetails; + } + + /** + * Sets the list of 'FieldDetail' objects for autofillable fields within the form. + * + * @param {Array} fieldDetails + * An array of field details that has been computed on the parent side. + * This method should be called before accessing `fieldDetails`. + */ + setIdentifiedFieldDetails(fieldDetails) { + this.#fieldDetails = fieldDetails; + } + + /** + * Determines whether 'setIdentifiedFieldDetails' has been called and the + * `fieldDetails` have been initialized. + * + * @returns {boolean} + * True if 'fieldDetails' has been initialized; otherwise, False. + */ + hasIdentifiedFields() { + return !!this.#fieldDetails; + } + handleEvent(event) { switch (event.type) { case "input": { if (!event.isTrusted) { return; } - const target = event.target; - const targetFieldDetail = this.getFieldDetailByElement(target); - const isCreditCardField = FormAutofillUtils.isCreditCardField( - targetFieldDetail.fieldName - ); - // If the user manually blanks a credit card field, then - // we want the popup to be activated. - if ( - !HTMLSelectElement.isInstance(target) && - isCreditCardField && - target.value === "" - ) { - this.onAutofillCallback(); - } + // 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 +180,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 +210,40 @@ export class FormAutofillHandler { _updateForm(form) { this.form = form; - this.fieldDetails = null; - - this.sections = []; - this.#cachedSectionByElement = new WeakMap(); + this.#fieldDetails = null; } /** - * Set fieldDetails from the form about fields that can be autofilled. + * Collect , element to its selected option or the first option if there is none selected. + * + * @param {HTMLElement} element + */ + #resetSelectElementValue(element) { + if (!element.options.length) { + return; } + const 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 }) + ); + } - return records; + /** + * Return the record that is keyed by element id and value is the normalized value + * done by computeFillingValue + * + * @returns {object} An object keyed by element id, and the value is + * an object that includes the following properties: + * filledState: The autofill state of the element + * filledvalue: The value of the element + */ + collectFormFilledData() { + const filledData = new Map(); + + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.element; + filledData.set(fieldDetail.elementId, { + filledState: element.autofillState, + filledValue: this.computeFillingValue(fieldDetail), + }); + } + return filledData; + } + + isFieldAutofillable(fieldDetail, profile) { + if (HTMLInputElement.isInstance(fieldDetail.element)) { + return !!profile[fieldDetail.fieldName]; + } + return !!this.matchSelectOptions(fieldDetail, profile); } } diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs index fb96e47caefdf..db68e36d32b86 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs @@ -9,26 +9,12 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CreditCard: "resource://gre/modules/CreditCard.sys.mjs", CreditCardRulesets: "resource://gre/modules/shared/CreditCardRuleset.sys.mjs", + FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs", FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", }); -/** - * To help us classify sections, we want to know what fields can appear - * multiple times in a row. - * Such fields, like `address-line{X}`, should not break sections. - */ -const MULTI_FIELD_NAMES = [ - "address-level3", - "address-level2", - "address-level1", - "tel", - "postal-code", - "email", - "street-address", -]; - /** * To help us classify sections that can appear only N times in a row. * For example, the only time multiple cc-number fields are valid is when @@ -39,44 +25,8 @@ const MULTI_N_FIELD_NAMES = { "cc-number": 4, }; -export class FormSection { - static ADDRESS = "address"; - static CREDIT_CARD = "creditCard"; - - #fieldDetails = []; - - #name = ""; - - constructor(fieldDetails) { - if (!fieldDetails.length) { - throw new TypeError("A section should contain at least one field"); - } - - fieldDetails.forEach(field => this.addField(field)); - - const fieldName = fieldDetails[0].fieldName; - if (lazy.FormAutofillUtils.isAddressField(fieldName)) { - this.type = FormSection.ADDRESS; - } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) { - this.type = FormSection.CREDIT_CARD; - } else { - throw new Error("Unknown field type to create a section."); - } - } - - get fieldDetails() { - return this.#fieldDetails; - } - - get name() { - return this.#name; - } - - addField(fieldDetail) { - this.#name ||= fieldDetail.sectionName; - this.#fieldDetails.push(fieldDetail); - } -} +const CC_TYPE = 1; +const ADDR_TYPE = 2; /** * Returns the autocomplete information of fields according to heuristics. @@ -171,6 +121,67 @@ export const FormAutofillHeuristics = { ); }, + /** + * In some languages such French (nom) and German (Name), name can mean either family name or + * full name in a form, depending on the context. We want to be sure that if "name" is + * detected in the context of "family-name" or "given-name", it is updated accordingly. + * + * Look for "given-name", "family-name", and "name" fields. If any two of those fields are detected + * and one of them is "name", then replace "name" with "family-name" if "name" is accompanied by + * "given-name" or vise-versa. + * + * @param {FieldScanner} scanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if any field is recognized and updated, otherwise false. + */ + _parseNameFields(scanner, fieldDetail) { + const TARGET_FIELDS = ["name", "given-name", "family-name"]; + + if (!TARGET_FIELDS.includes(fieldDetail.fieldName)) { + return false; + } + + const fields = []; + let nameIndex = -1; + + for (let idx = scanner.parsingIndex; ; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!TARGET_FIELDS.includes(detail?.fieldName)) { + break; + } + if (detail.fieldName === "name") { + nameIndex = idx; + } + fields.push(detail); + } + + if (nameIndex != -1 && fields.length == 2) { + //if name is detected and the other of the two fields detected is 'given-name' + //then update name to 'name' to 'family-name' + if ( + fields[0].fieldName == "given-name" || + fields[1].fieldName == "given-name" + ) { + scanner.updateFieldName(nameIndex, "family-name"); + //if name is detected and the other of the two fields detected is 'family-name' + //then update name to 'name' to 'given-name' + } else if ( + fields[0].fieldName == "family-name" || + fields[1].fieldName == "family-name" + ) { + scanner.updateFieldName(nameIndex, "given-name"); + } else { + return false; + } + + scanner.parsingIndex += fields.length; + return true; + } + + return false; + }, + /** * Try to match the telephone related fields to the grammar * list to see if there is any valid telephone set and correct their @@ -285,13 +296,28 @@ export const FormAutofillHeuristics = { "address-line3", ]; + let houseNumberFields = 0; + + // We need to build a list of the address fields. A list of the indicies + // is also needed as the fields with a given name can change positions + // during the update. const fields = []; + const fieldIndicies = []; for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) { const detail = scanner.getFieldDetailByIndex(idx); + + // Skip over any house number fields. There should only be zero or one, + // but we'll skip over them all anyway. + if (detail?.fieldName == "address-housenumber") { + houseNumberFields++; + continue; + } + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { break; } fields.push(detail); + fieldIndicies.push(idx); } if (!fields.length) { @@ -304,7 +330,7 @@ export const FormAutofillHeuristics = { fields[0].reason != "autocomplete" && ["address-line2", "address-line3"].includes(fields[0].fieldName) ) { - scanner.updateFieldName(scanner.parsingIndex, "address-line1"); + scanner.updateFieldName(fieldIndicies[0], "address-line1"); } break; case 2: @@ -314,27 +340,22 @@ export const FormAutofillHeuristics = { (fields[1].fieldName == "address-line2" || fields[1].reason != "autocomplete") ) { - scanner.updateFieldName( - scanner.parsingIndex, - "address-line1", - true - ); + scanner.updateFieldName(fieldIndicies[0], "address-line1", true); } } else { - scanner.updateFieldName(scanner.parsingIndex, "address-line1"); + scanner.updateFieldName(fieldIndicies[0], "address-line1"); } - - scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2"); + scanner.updateFieldName(fieldIndicies[1], "address-line2"); break; case 3: default: - scanner.updateFieldName(scanner.parsingIndex, "address-line1"); - scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2"); - scanner.updateFieldName(scanner.parsingIndex + 2, "address-line3"); + scanner.updateFieldName(fieldIndicies[0], "address-line1"); + scanner.updateFieldName(fieldIndicies[1], "address-line2"); + scanner.updateFieldName(fieldIndicies[2], "address-line3"); break; } - scanner.parsingIndex += fields.length; + scanner.parsingIndex += fields.length + houseNumberFields; return true; }, @@ -362,21 +383,13 @@ export const FormAutofillHeuristics = { if (fields.length == 1) { if (fields[0].fieldName == "address-level2") { const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1); - if ( - prev && - !prev.fieldName && - HTMLSelectElement.isInstance(prev.element) - ) { + if (prev && !prev.fieldName && prev.localName == "select") { scanner.updateFieldName(scanner.parsingIndex - 1, "address-level1"); scanner.parsingIndex += 1; return true; } const next = scanner.getFieldDetailByIndex(scanner.parsingIndex + 1); - if ( - next && - !next.fieldName && - HTMLSelectElement.isInstance(next.element) - ) { + if (next && !next.fieldName && next.localName == "select") { scanner.updateFieldName(scanner.parsingIndex + 1, "address-level1"); scanner.parsingIndex += 2; return true; @@ -462,6 +475,44 @@ export const FormAutofillHeuristics = { return false; }, + _parseCreditCardNumberFields(scanner, fieldDetail) { + const INTERESTED_FIELDS = ["cc-number"]; + + if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) { + return false; + } + + const fieldDetails = []; + for (let idx = scanner.parsingIndex; ; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { + break; + } + fieldDetails.push(detail); + } + + // This rule only applies when all the fields are visible + if (fieldDetails.some(field => !field.isVisible)) { + scanner.parsingIndex += fieldDetails.length; + return true; + } + + // This is the heuristic to handle special cases where we can have multiple + // fields in one section, but only if the field has appeared N times in a row. + // For example, websites can use 4 consecutive 4-digit `cc-number` fields + // instead of one 16-digit `cc-number` field. + const N = MULTI_N_FIELD_NAMES["cc-number"]; + if (fieldDetails.length == N) { + fieldDetails.forEach((fd, index) => { + // part starts with 1 + fd.part = index + 1; + }); + scanner.parsingIndex += fieldDetails.length; + return true; + } + + return false; + }, /** * Look for cc-*-name fields when *-name field is present * @@ -536,6 +587,33 @@ export const FormAutofillHeuristics = { return false; }, + /** + * If the given field is of a different type than the previous + * field, use the alternate field name instead. + */ + _checkForAlternateField(scanner, fieldDetail) { + if (fieldDetail.alternativeFieldName) { + const previousField = scanner.getFieldDetailByIndex( + scanner.parsingIndex - 1 + ); + if (previousField) { + const preIsCC = lazy.FormAutofillUtils.isCreditCardField( + previousField.fieldName + ); + const curIsCC = lazy.FormAutofillUtils.isCreditCardField( + fieldDetail.fieldName + ); + + // If the current type is different from the previous element's type, use + // the alternative fieldname instead. + if (preIsCC != curIsCC) { + fieldDetail.fieldName = fieldDetail.alternativeFieldName; + fieldDetail.reason = "update-heuristic-alternate"; + } + } + } + }, + /** * This function should provide all field details of a form which are placed * in the belonging section. The details contain the autocomplete info @@ -551,9 +629,50 @@ export const FormAutofillHeuristics = { lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) ); - const scanner = new lazy.FieldScanner(elements, element => - this.inferFieldInfo(element, elements) - ); + const fieldDetails = []; + for (const element of elements) { + // Ignore invisible , we still keep invisible + // to store the value. + const isVisible = lazy.FormAutofillUtils.isFieldVisible(element); + if (!HTMLSelectElement.isInstance(element) && !isVisible) { + continue; + } + + const [fieldName, autocompleteInfo, confidence] = this.inferFieldInfo( + element, + elements + ); + fieldDetails.push( + lazy.FieldDetail.create(element, form, fieldName, { + autocompleteInfo, + confidence, + isVisible, + }) + ); + } + + this.parseAndUpdateFieldNamesContent(fieldDetails); + + lazy.LabelUtils.clearLabelMap(); + + return fieldDetails; + }, + + /** + * Similar to `parseAndUpdateFieldNamesParent`. The difference is that + * the parsing heuristics used in this function are based on information + * not currently passed to the parent process. For example, + * text strings from associated labels. + * + * Note that the heuristics run in this function will not be able + * to reference field information across frames. + * + * @param {Array} fieldDetails + * An array of the identified fields. + */ + parseAndUpdateFieldNamesContent(fieldDetails) { + const scanner = new lazy.FieldScanner(fieldDetails); while (!scanner.parsingFinished) { const savedIndex = scanner.parsingIndex; @@ -561,136 +680,50 @@ export const FormAutofillHeuristics = { // First, we get the inferred field info const fieldDetail = scanner.getFieldDetailByIndex(scanner.parsingIndex); - if ( - this._parsePhoneFields(scanner, fieldDetail) || - this._parseStreetAddressFields(scanner, fieldDetail) || - this._parseAddressFields(scanner, fieldDetail) || - this._parseCreditCardExpiryFields(scanner, fieldDetail) || - this._parseCreditCardNameFields(scanner, fieldDetail) - ) { + if (this._parsePhoneFields(scanner, fieldDetail)) { continue; } - // If there is no field parsed, the parsing cursor can be moved - // forward to the next one. if (savedIndex == scanner.parsingIndex) { scanner.parsingIndex++; } } - - lazy.LabelUtils.clearLabelMap(); - - const fields = scanner.fieldDetails; - const sections = [ - ...this._classifySections( - fields.filter(f => lazy.FormAutofillUtils.isAddressField(f.fieldName)) - ), - ...this._classifySections( - fields.filter(f => - lazy.FormAutofillUtils.isCreditCardField(f.fieldName) - ) - ), - ]; - - return sections.sort( - (a, b) => - fields.indexOf(a.fieldDetails[0]) - fields.indexOf(b.fieldDetails[0]) - ); }, /** - * The result is an array contains the sections with its belonging field details. + * Iterates through the field details and updates the field names + * based on surrounding field information, using various parsing functions. * - * @param {Array} fieldDetails field detail array to be classified - * @returns {Array} The array with the sections. + * @param {Array} fieldDetails + * An array of the identified fields. */ - _classifySections(fieldDetails) { - let sections = []; - for (let i = 0; i < fieldDetails.length; i++) { - const cur = fieldDetails[i]; - const [currentSection] = sections.slice(-1); - - // The section this field might be placed into. - let candidateSection = null; - - // Use name group from autocomplete attribute (ex, section-xxx) to look for the section - // we might place this field into. - // If the field doesn't have a section name, the candidate section is the previous section. - if (!currentSection || !cur.sectionName) { - candidateSection = currentSection; - } else if (cur.sectionName) { - // If the field has a section name, the candidate section is the nearest section that - // either shares the same name or lacks a name. - for (let idx = sections.length - 1; idx >= 0; idx--) { - if (!sections[idx].name || sections[idx].name == cur.sectionName) { - candidateSection = sections[idx]; - break; - } - } - } + parseAndUpdateFieldNamesParent(fieldDetails) { + const scanner = new lazy.FieldScanner(fieldDetails); - if (candidateSection) { - let createNewSection = true; + while (!scanner.parsingFinished) { + const savedIndex = scanner.parsingIndex; - // We might create a new section instead of placing the field in the candiate section if - // the section already has a field with the same field name. - // We also check visibility for both the fields with the same field name because we don't - // wanht to create a new section for an invisible field. - if ( - candidateSection.fieldDetails.find( - f => f.fieldName == cur.fieldName && f.isVisible && cur.isVisible - ) - ) { - // For some field type, it is common to have multiple fields in one section, for example, - // email. In that case, we will not create a new section even when the candidate section - // already has a field with the same field name. - const [lastFieldDetail] = candidateSection.fieldDetails.slice(-1); - if (lastFieldDetail.fieldName == cur.fieldName) { - if (MULTI_FIELD_NAMES.includes(cur.fieldName)) { - createNewSection = false; - } else if (cur.fieldName in MULTI_N_FIELD_NAMES) { - // This is the heuristic to handle special cases where we can have multiple - // fields in one section, but only if the field has appeared N times in a row. - // For example, websites can use 4 consecutive 4-digit `cc-number` fields - // instead of one 16-digit `cc-number` field. - - const N = MULTI_N_FIELD_NAMES[cur.fieldName]; - if (lastFieldDetail.part) { - // If `part` is set, we have already identified this field can be - // merged previously - if (lastFieldDetail.part < N) { - createNewSection = false; - fieldDetails[i].part = lastFieldDetail.part + 1; - } - // If the next N fields are all the same field, we can merge them - } else if ( - N == 2 || - fieldDetails - .slice(i + 1, i + N - 1) - .every(f => f.fieldName == cur.fieldName) - ) { - lastFieldDetail.part = 1; - fieldDetails[i].part = 2; - createNewSection = false; - } - } - } - } else { - // The field doesn't exist in the candidate section, add it. - createNewSection = false; - } + const fieldDetail = scanner.getFieldDetailByIndex(scanner.parsingIndex); - if (!createNewSection) { - candidateSection.addField(fieldDetails[i]); - continue; - } + this._checkForAlternateField(scanner, fieldDetail); + + // Attempt to parse the field using different parsers. + if ( + this._parseNameFields(scanner, fieldDetail) || + this._parseStreetAddressFields(scanner, fieldDetail) || + this._parseAddressFields(scanner, fieldDetail) || + this._parseCreditCardExpiryFields(scanner, fieldDetail) || + this._parseCreditCardNameFields(scanner, fieldDetail) || + this._parseCreditCardNumberFields(scanner, fieldDetail) + ) { + continue; } - // Create a new section - sections.push(new FormSection([fieldDetails[i]])); + // Move the parsing cursor forward if no parser was applied. + if (savedIndex == scanner.parsingIndex) { + scanner.parsingIndex++; + } } - - return sections; }, _getPossibleFieldNames(element) { @@ -729,7 +762,7 @@ export const FormAutofillHeuristics = { * @param {Array} elements - See `getFathomField` for details * @returns {Array} - An array containing: * [0]the inferred field name - * [1]autocomplete information if the element has autocompelte attribute, null otherwise. + * [1]autocomplete information if the element has autocomplete attribute, null otherwise. * [2]fathom confidence if fathom considers it a cc field, null otherwise. */ inferFieldInfo(element, elements = []) { @@ -817,8 +850,8 @@ export const FormAutofillHeuristics = { } // Find a matched field name using regexp-based heuristics - const matchedFieldName = this._findMatchedFieldName(element, fields); - return [matchedFieldName, null, null]; + const matchedFieldNames = this._findMatchedFieldNames(element, fields); + return [matchedFieldNames, null, null]; }, /** @@ -998,40 +1031,71 @@ export const FormAutofillHeuristics = { }, /** - * Find the first matching field name from a given list of field names + * Find matching field names from a given list of field names * that matches an HTML element. * * The function first tries to match the element against a set of * pre-defined regular expression rules. If no match is found, it * then checks for label-specific rules, if they exist. * + * The return value can contain a maximum of two field names, the + * first item the first match found, and the second an alternate field + * name always of a different type, where the two type are credit card + * and address. + * * Note: For label rules, the keyword is often more general * (e.g., "^\\W*address"), hence they are only searched within labels * to reduce the occurrence of false positives. * * @param {HTMLElement} element The element to match. * @param {Array} fieldNames An array of field names to compare against. - * @returns {string|null} The name of the matched field, or null if no match was found. + * @returns {Array} An array of the matching field names. */ - _findMatchedFieldName(element, fieldNames) { + _findMatchedFieldNames(element, fieldNames) { if (!fieldNames.length) { - return null; + return []; } - // Attempt to match the element against the default set of rules - let matchedFieldName = fieldNames.find(fieldName => - this._matchRegexp(element, this.RULES[fieldName]) - ); + // The first element is the field name, and the second element is the type. + let fields = fieldNames.map(name => [ + name, + lazy.FormAutofillUtils.isCreditCardField(name) ? CC_TYPE : ADDR_TYPE, + ]); - // If no match is found, and if a label rule exists for the field, - // attempt to match against the label rules - if (!matchedFieldName) { - matchedFieldName = fieldNames.find(fieldName => { - const regexp = this.LABEL_RULES[fieldName]; - return this._matchRegexp(element, regexp, { attribute: false }); - }); + let foundType; + let attribute = true; + let matchedFieldNames = []; + + // Check RULES first, and only check LABEL_RULES if no match is found. + for (let rules of [this.RULES, this.LABEL_RULES]) { + // Attempt to match the element against the default set of rules. + if ( + fields.find(field => { + const [fieldName, type] = field; + + // The same type has been found already, so skip. + if (foundType == type) { + return false; + } + + if (!this._matchRegexp(element, rules[fieldName], { attribute })) { + return false; + } + + foundType = type; + matchedFieldNames.push(fieldName); + + return matchedFieldNames.length == 2; + }) + ) { + break; + } + + // Don't match attributes for label rules. + attribute = false; } - return matchedFieldName; + + return matchedFieldNames; }, /** diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs index aa4f79552188c..492cfb1e1b9b4 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs @@ -2,45 +2,81 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; -import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; - const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs", - CreditCard: "resource://gre/modules/CreditCard.sys.mjs", - FormAutofillNameUtils: - "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", - LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", }); -const { FIELD_STATES } = FormAutofillUtils; - -export class FormAutofillSection { - static SHOULD_FOCUS_ON_AUTOFILL = true; - #focusedInput = null; +/** + * To help us classify sections, we want to know what fields can appear + * multiple times in a row. + * Such fields, like `address-line{X}`, should not break sections. + */ +const MULTI_FIELD_NAMES = [ + "address-level3", + "address-level2", + "address-level1", + "tel", + "postal-code", + "email", + "street-address", +]; + +class FormSection { + static ADDRESS = "address"; + static CREDIT_CARD = "creditCard"; #fieldDetails = []; - constructor(fieldDetails, handler) { - this.#fieldDetails = fieldDetails; + #name = ""; - if (!this.isValidSection()) { - return; + constructor(fieldDetails) { + if (!fieldDetails.length) { + throw new TypeError("A section should contain at least one field"); } - this.handler = handler; - this.filledRecordGUID = null; + fieldDetails.forEach(field => this.addField(field)); + + const fieldName = fieldDetails[0].fieldName; + if (lazy.FormAutofillUtils.isAddressField(fieldName)) { + this.type = FormSection.ADDRESS; + } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) { + this.type = FormSection.CREDIT_CARD; + } else { + throw new Error("Unknown field type to create a section."); + } + } + + get fieldDetails() { + return this.#fieldDetails; + } + + get name() { + return this.#name; + } + + addField(fieldDetail) { + this.#name ||= fieldDetail.sectionName; + this.#fieldDetails.push(fieldDetail); + } +} + +export class FormAutofillSection { + /** + * Record information for fields that are in this section + */ + #fieldDetails = []; + + constructor(fieldDetails) { + this.#fieldDetails = fieldDetails; ChromeUtils.defineLazyGetter(this, "log", () => - FormAutofill.defineLogGetter(this, "FormAutofillHandler") + lazy.FormAutofill.defineLogGetter(this, "FormAutofillSection") ); - this._cacheValue = { - allFieldNames: null, - matchingSelectOption: null, - }; - // Identifier used to correlate events relating to the same form this.flowId = Services.uuid.generateUUID().toString(); this.log.debug( @@ -53,6 +89,10 @@ export class FormAutofillSection { return this.#fieldDetails; } + get allFieldNames() { + return this.fieldDetails.map(field => field.fieldName); + } + /* * Examine the section is a valid section or not based on its fieldDetails or * other information. This method must be overrided. @@ -87,22 +127,17 @@ export class FormAutofillSection { 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. + * @returns {boolean} Whether the profile should be previewed. */ - preparePreviewProfile(_profile) {} + preparePreviewProfile(_profile) { + return true; + } /** * Override this method if the profile is needed to be customized for filling @@ -117,581 +152,306 @@ export class FormAutofillSection { } /** - * Override this method if the profile is needed to be customized for filling - * values. + * The result is an array contains the sections with its belonging field details. * - * @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. + * @param {Array} fieldDetails field detail array to be classified + * @param {boolean} ignoreInvalid + * True to keep invalid section in the return array. Only used by tests now. + * @returns {Array} The array with the sections. */ - getFilledValueFromProfile(fieldDetail, profile) { - return ( - profile[`${fieldDetail.fieldName}-formatted`] || - profile[fieldDetail.fieldName] + static classifySections(fieldDetails, ignoreInvalid = false) { + const addressSections = FormAutofillSection.groupFields( + fieldDetails.filter(f => + lazy.FormAutofillUtils.isAddressField(f.fieldName) + ) + ); + const creditCardSections = FormAutofillSection.groupFields( + fieldDetails.filter(f => + lazy.FormAutofillUtils.isCreditCardField(f.fieldName) + ) ); - } - - /* - * Override this method if there is any field value needs to compute for a - * specific case. Return the original value in the default case. - * @param {String} value - * The original field value. - * @param {Object} _fieldName - * A fieldDetail of the related element. - * @param {HTMLElement} _element - * A element for checking converting value. - * - * @returns {String} - * A string of the converted value. - */ - computeFillingValue(value, _fieldName, _element) { - return value; - } - - set focusedInput(element) { - this.#focusedInput = element; - } - - getFieldDetailByElement(element) { - return this.fieldDetails.find(detail => detail.element == element); - } - - getFieldDetailByName(fieldName) { - return this.fieldDetails.find(detail => detail.fieldName == fieldName); - } - - get allFieldNames() { - if (!this._cacheValue.allFieldNames) { - this._cacheValue.allFieldNames = this.fieldDetails.map( - record => record.fieldName - ); - } - return this._cacheValue.allFieldNames; - } - - matchSelectOptions(profile) { - if (!this._cacheValue.matchingSelectOption) { - this._cacheValue.matchingSelectOption = new WeakMap(); - } - - for (const fieldName in profile) { - const fieldDetail = this.getFieldDetailByName(fieldName); - const element = fieldDetail?.element; - - if (!HTMLSelectElement.isInstance(element)) { - continue; - } - - const cache = this._cacheValue.matchingSelectOption.get(element) || {}; - const value = profile[fieldName]; - if (cache[value] && cache[value].deref()) { - continue; - } - - const option = FormAutofillUtils.findSelectOption( - element, - profile, - fieldName - ); - if (option) { - cache[value] = new WeakRef(option); - this._cacheValue.matchingSelectOption.set(element, cache); - } else { - if (cache[value]) { - delete cache[value]; - this._cacheValue.matchingSelectOption.set(element, cache); - } - // Skip removing cc-type since this is needed for displaying the icon for credit card network - // TODO(Bug 1874339): Cleanup transformation and normalization of data to not remove any - // fields and be more consistent - if (!["cc-type"].includes(fieldName)) { - // Delete the field so the phishing hint won't treat it as a "also fill" - // field. - delete profile[fieldName]; - } - } - } - } + const sections = [...addressSections, ...creditCardSections].sort( + (a, b) => + fieldDetails.indexOf(a.fieldDetails[0]) - + fieldDetails.indexOf(b.fieldDetails[0]) + ); - adaptFieldMaxLength(profile) { - for (let key in profile) { - let detail = this.getFieldDetailByName(key); - if (!detail) { + const autofillableSections = []; + for (const section of sections) { + if (!section.fieldDetails.length) { continue; } - let element = detail.element; - if (!element) { - continue; - } + const autofillableSection = + section.type == FormSection.ADDRESS + ? new FormAutofillAddressSection(section.fieldDetails) + : new FormAutofillCreditCardSection(section.fieldDetails); - let maxLength = element.maxLength; - if ( - maxLength === undefined || - maxLength < 0 || - profile[key].toString().length <= maxLength - ) { + if (ignoreInvalid && !autofillableSection.isValidSection()) { 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`]; - } - } - } - - fillFieldValue(element, value) { - if (FormAutofillUtils.focusOnAutofill) { - element.focus({ preventScroll: true }); - } - if (HTMLInputElement.isInstance(element)) { - element.setUserInput(value); - } else if (HTMLSelectElement.isInstance(element)) { - // Set the value of the select element so that web event handlers can react accordingly - element.value = value; - element.dispatchEvent( - new element.ownerGlobal.Event("input", { bubbles: true }) - ); - element.dispatchEvent( - new element.ownerGlobal.Event("change", { bubbles: true }) - ); - } - } - - getAdaptedProfiles(originalProfiles) { - for (let profile of originalProfiles) { - this.applyTransformers(profile); + autofillableSections.push(autofillableSection); } - return originalProfiles; + return autofillableSections; } /** - * Processes form fields that can be autofilled, and populates them with the - * profile provided by backend. + * Groups fields into sections based on: + * 1. Their `sectionName` attribute. + * 2. Whether the section already contains a field with the same `fieldName`, + * If so, a new section is created. * - * @param {object} profile - * A profile to be filled in. - * @returns {boolean} - * True if successful, false if failed + * @param {Array} fieldDetails An array of field detail objects. + * @returns {Array} An array of FormSection objects. */ - async autofillFields(profile) { - if (!this.#focusedInput) { - throw new Error("No focused input."); - } - - const focusedDetail = this.getFieldDetailByElement(this.#focusedInput); - if (!focusedDetail) { - throw new Error("No fieldDetail for the focused input."); - } - - this.getAdaptedProfiles([profile]); - if (!(await this.prepareFillingProfile(profile))) { - this.log.debug("profile cannot be filled"); - return false; - } - - this.filledRecordGUID = profile.guid; - for (const fieldDetail of this.fieldDetails) { - // Avoid filling field value in the following cases: - // 1. a non-empty input field for an unfocused input - // 2. the invalid value set - // 3. value already chosen in select element - - const element = fieldDetail.element; - // Skip the field if it is null or readonly or disabled - if (!FormAutofillUtils.isFieldAutofillable(element)) { - continue; + static groupFields(fieldDetails) { + let sections = []; + for (let i = 0; i < fieldDetails.length; i++) { + const cur = fieldDetails[i]; + const [currentSection] = sections.slice(-1); + + // The section this field might be placed into. + let candidateSection = null; + + // Use name group from autocomplete attribute (ex, section-xxx) to look for the section + // we might place this field into. + // If the field doesn't have a section name, the candidate section is the previous section. + if (!currentSection || !cur.sectionName) { + candidateSection = currentSection; + } else if (cur.sectionName) { + // If the field has a section name, the candidate section is the nearest section that + // either shares the same name or lacks a name. + for (let idx = sections.length - 1; idx >= 0; idx--) { + if (!sections[idx].name || sections[idx].name == cur.sectionName) { + candidateSection = sections[idx]; + break; + } + } } - element.previewValue = ""; - // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field - // that is generated when presentation ready data doesn't fit into the autofilling element. - // For example, autofilling expiration month into an input element will not work as expected if - // the month is less than 10, since the input is expected a zero-padded string. - // See Bug 1722941 for follow up. - const value = this.getFilledValueFromProfile(fieldDetail, profile); - if (HTMLInputElement.isInstance(element) && value) { - // For the focused input element, it will be filled with a valid value - // anyway. - // For the others, the fields should be only filled when their values are empty - // or their values are equal to the site prefill value - // or are the result of an earlier auto-fill. + if (candidateSection) { + let createNewSection = true; + + // We might create a new section instead of placing the field in the candidate section if + // the section already has a field with the same field name. + // We also check visibility for both the fields with the same field name because we don't + // want to create a new section for an invisible field. if ( - element == this.#focusedInput || - (element != this.#focusedInput && - (!element.value || element.value == element.defaultValue)) || - this.handler.getFilledStateByElement(element) == - FIELD_STATES.AUTO_FILLED + candidateSection.fieldDetails.find( + f => f.fieldName == cur.fieldName && f.isVisible && cur.isVisible + ) ) { - this.fillFieldValue(element, value); - this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + // For some field type, it is common to have multiple fields in one section, for example, + // email. In that case, we will not create a new section even when the candidate section + // already has a field with the same field name. + const [last] = candidateSection.fieldDetails.slice(-1); + if (last.fieldName == cur.fieldName) { + if ( + MULTI_FIELD_NAMES.includes(cur.fieldName) || + (last.part && last.part + 1 == cur.part) + ) { + createNewSection = false; + } + } + } else { + // The field doesn't exist in the candidate section, add it. + createNewSection = false; } - } else if (HTMLSelectElement.isInstance(element)) { - let cache = this._cacheValue.matchingSelectOption.get(element) || {}; - let option = cache[value] && cache[value].deref(); - if (!option) { + + if (!createNewSection) { + candidateSection.addField(fieldDetails[i]); continue; } - // Do not change value or dispatch events if the option is already selected. - // Use case for multiple select is not considered here. - if (!option.selected) { - option.selected = true; - this.fillFieldValue(element, option.value); - } - // Autofill highlight appears regardless if value is changed or not - this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); } - } - this.#focusedInput.focus({ preventScroll: true }); - lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, { - profile, - }); + // Create a new section + sections.push(new FormSection([fieldDetails[i]])); + } - return true; + return sections; } /** - * Populates result to the preview layers with given profile. + * Return the record that is converted from the element's value. + * The `valueByElementId` is passed by the child process. * - * @param {object} profile - * A profile to be previewed with + * @returns {object} object keyed by field name, and values are field values. */ - previewFormFields(profile) { - this.preparePreviewProfile(profile); - - for (const fieldDetail of this.fieldDetails) { - let element = fieldDetail.element; - // Skip the field if it is null or readonly or disabled - if (!FormAutofillUtils.isFieldAutofillable(element)) { - continue; - } - - let value = - profile[`${fieldDetail.fieldName}-formatted`] || - profile[fieldDetail.fieldName] || - ""; - - if (HTMLSelectElement.isInstance(element)) { - // Unlike text input, select element is always previewed even if - // the option is already selected. - if (value) { - const cache = - this._cacheValue.matchingSelectOption.get(element) ?? {}; - const option = cache[value]?.deref(); - value = option?.text ?? ""; - } - } else if (element.value && element.value != element.defaultValue) { - // Skip the field if the user has already entered text and that text is not the site prefilled value. - continue; - } - element.previewValue = value?.toString().replaceAll("*", "•"); - this.handler.changeFieldState( - fieldDetail, - value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL - ); - } - } - - /** - * Clear a previously autofilled field in this section - */ - clearFilled(fieldDetail) { - lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, { - fieldName: fieldDetail.fieldName, - }); - - let isAutofilled = false; - const dimFieldDetails = []; - for (const fieldDetail of this.fieldDetails) { - const element = fieldDetail.element; - - if (HTMLSelectElement.isInstance(element)) { - // Dim fields are those we don't attempt to revert their value - // when clear the target set, such as element to its selected option or the first option if there is none selected. + * Groups an array of field details by their browsing context IDs. + * + * @param {Array} fieldDetails + * Array of fieldDetails object * - * @param {HTMLElement} element - * @memberof FormAutofillSection + * @returns {object} + * An object keyed by BrowsingContext Id, value is an array that + * contains all fieldDetails with the same BrowsingContext id. */ - _resetSelectElementValue(element) { - if (!element.options.length) { - return; + static groupFieldDetailsByBrowsingContext(fieldDetails) { + const detailsByBC = {}; + for (const fieldDetail of fieldDetails) { + const bcid = fieldDetail.browsingContextId; + if (detailsByBC[bcid]) { + detailsByBC[bcid].push(fieldDetail); + } else { + detailsByBC[bcid] = [fieldDetail]; + } } - 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 }) - ); + return detailsByBC; } } export class FormAutofillAddressSection extends FormAutofillSection { - constructor(fieldDetails, handler) { - super(fieldDetails, handler); - - if (!this.isValidSection()) { - return; - } - - this._cacheValue.oneLineStreetAddress = null; - - lazy.AutofillTelemetry.recordDetectedSectionCount(this); - lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); - } - isValidSection() { const fields = new Set(this.fieldDetails.map(f => f.fieldName)); - return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; + return fields.size >= lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; } isEnabled() { - return FormAutofill.isAutofillAddressesEnabled; + return lazy.FormAutofill.isAutofillAddressesEnabled; } isRecordCreatable(record) { - const country = FormAutofillUtils.identifyCountryCode( + const country = lazy.FormAutofillUtils.identifyCountryCode( record.country || record["country-name"] ); if ( country && - !FormAutofill.isAutofillAddressesAvailableInCountry(country) + !lazy.FormAutofill.isAutofillAddressesAvailableInCountry(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. @@ -706,7 +466,7 @@ export class FormAutofillAddressSection extends FormAutofillSection { // the number of fields exceed the valid address secton threshold const categories = Object.entries(record) .filter(e => !!e[1]) - .map(e => FormAutofillUtils.getCategoryFromFieldName(e[0])); + .map(e => lazy.FormAutofillUtils.getCategoryFromFieldName(e[0])); return ( categories.reduce( @@ -715,192 +475,12 @@ export class FormAutofillAddressSection extends FormAutofillSection { ? acc : [...acc, category], [] - ).length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD + ).length >= lazy.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.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 = []; - } - } - } - } - - /** - * 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.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; - } - } - - 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; - } - } else if (fieldDetail.fieldName == "country") { - // This is a temporary fix. Ideally we should have either case-insensitive comparaison of country codes - // or handle this elsewhere see Bug 1889234 for more context. - value = value.toUpperCase(); - } - return value; - } } export class FormAutofillCreditCardSection extends FormAutofillSection { - /** - * Credit Card Section Constructor - * - * @param {Array} fieldDetails - * The fieldDetail objects for the fields in this section - * @param {Object} handler - * The handler responsible for this section - */ - constructor(fieldDetails, handler) { - super(fieldDetails, handler); - - if (!this.isValidSection()) { - return; - } - - lazy.AutofillTelemetry.recordDetectedSectionCount(this); - lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); - - // Check whether the section is in an