diff --git a/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs index f4dca18dde145..a903c5810bd58 100644 --- a/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AddressMetaDataExtension.sys.mjs @@ -342,6 +342,8 @@ export const AddressMetaDataExtension = { }, "data/JP": { alpha_3_code: "JPN", + fmt: "〒%Z%n%S%C%n%A%n%O%n%N", + require: "ACSZ", }, "data/JE": { alpha_3_code: "JEY", diff --git a/firefox-ios/Client/Assets/CC_Script/AddressRecord.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AddressRecord.sys.mjs index 599a802dcd130..5b4a04920c711 100644 --- a/firefox-ios/Client/Assets/CC_Script/AddressRecord.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AddressRecord.sys.mjs @@ -7,6 +7,7 @@ import { FormAutofillNameUtils } from "resource://gre/modules/shared/FormAutofil import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; import { PhoneNumber } from "resource://gre/modules/shared/PhoneNumber.sys.mjs"; import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { AddressParser } from "resource://gre/modules/shared/AddressParser.sys.mjs"; /** * The AddressRecord class serves to handle and normalize internal address records. @@ -32,6 +33,7 @@ export class AddressRecord { static computeFields(address) { this.#computeNameFields(address); this.#computeAddressLineFields(address); + this.#computeStreetAndHouseNumberFields(address); this.#computeCountryFields(address); this.#computeTelFields(address); } @@ -66,6 +68,17 @@ export class AddressRecord { } } + static #computeStreetAndHouseNumberFields(address) { + if (!("address-housenumber" in address) && "street-address" in address) { + let streetAddress = AddressParser.parseStreetAddress( + address["street-address"] + ); + if (streetAddress) { + address["address-housenumber"] = streetAddress.street_number; + } + } + } + static #computeCountryFields(address) { // Compute country name if (!("country-name" in address)) { diff --git a/firefox-ios/Client/Assets/CC_Script/AutofillFormFactory.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AutofillFormFactory.sys.mjs index 68db143640172..450e8e96fad29 100644 --- a/firefox-ios/Client/Assets/CC_Script/AutofillFormFactory.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AutofillFormFactory.sys.mjs @@ -39,4 +39,8 @@ export const AutofillFormFactory = { } return lazy.FormLikeFactory.createFromField(aField, { ignoreForm }); }, + + createFromDocumentRoot(aDocRoot) { + return lazy.FormLikeFactory.createFromDocumentRoot(aDocRoot); + }, }; diff --git a/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs b/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs index 9825aeacfe845..15e4f952342d5 100644 --- a/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/AutofillTelemetry.sys.mjs @@ -12,10 +12,6 @@ class AutofillTelemetryBase { EVENT_CATEGORY = null; EVENT_OBJECT_FORM_INTERACTION = null; - HISTOGRAM_NUM_USES = null; - HISTOGRAM_PROFILE_NUM_USES = null; - HISTOGRAM_PROFILE_NUM_USES_KEY = null; - #initFormEventExtra(value) { let extra = {}; for (const field of Object.values(this.SUPPORTED_FIELDS)) { @@ -183,17 +179,6 @@ class AutofillTelemetryBase { throw new Error("Not implemented."); } - recordNumberOfUse(records) { - let histogram = Services.telemetry.getKeyedHistogramById( - this.HISTOGRAM_PROFILE_NUM_USES - ); - histogram.clear(); - - for (let record of records) { - histogram.add(this.HISTOGRAM_PROFILE_NUM_USES_KEY, record.timesUsed); - } - } - recordIframeLayoutDetection(flowId, fieldDetails) { const fieldsInMainFrame = []; const fieldsInIframe = []; @@ -238,9 +223,6 @@ export class AddressTelemetry extends AutofillTelemetryBase { EVENT_OBJECT_FORM_INTERACTION = "AddressForm"; EVENT_OBJECT_FORM_INTERACTION_EXT = "AddressFormExt"; - HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES"; - HISTOGRAM_PROFILE_NUM_USES_KEY = "address"; - // Fields that are recorded in `address_form` and `address_form_ext` telemetry SUPPORTED_FIELDS = { "street-address": "street_address", @@ -316,10 +298,6 @@ class CreditCardTelemetry extends AutofillTelemetryBase { EVENT_CATEGORY = "creditcard"; EVENT_OBJECT_FORM_INTERACTION = "CcFormV2"; - HISTOGRAM_NUM_USES = "CREDITCARD_NUM_USES"; - HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES"; - HISTOGRAM_PROFILE_NUM_USES_KEY = "credit_card"; - // Mapping of field name used in formautofill code to the field name // used in the telemetry. SUPPORTED_FIELDS = { @@ -369,23 +347,6 @@ class CreditCardTelemetry extends AutofillTelemetryBase { } } - recordNumberOfUse(records) { - super.recordNumberOfUse(records); - - if (!this.HISTOGRAM_NUM_USES) { - return; - } - - let histogram = Services.telemetry.getHistogramById( - this.HISTOGRAM_NUM_USES - ); - histogram.clear(); - - for (let record of records) { - histogram.add(record.timesUsed); - } - } - recordAutofillProfileCount(count) { Glean.formautofillCreditcards.autofillProfilesCount.set(count); } @@ -463,14 +424,6 @@ export class AutofillTelemetry { telemetry.recordAutofillProfileCount(count); } - /** - * Utility functions for address/credit card number of use - */ - static recordNumberOfUse(type, records) { - const telemetry = this.#getTelemetryByType(type); - telemetry.recordNumberOfUse(records); - } - static recordFormSubmissionHeuristicCount(label) { Glean.formautofill.formSubmissionHeuristic[label].add(1); } diff --git a/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs b/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs index 1281ac259f914..cd73ef59d48e1 100644 --- a/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs +++ b/firefox-ios/Client/Assets/CC_Script/Constants.ios.mjs @@ -5,7 +5,6 @@ const IOS_DEFAULT_PREFERENCES = { "extensions.formautofill.creditCards.heuristics.mode": 1, "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold": 0.5, - "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold": 0.95, "extensions.formautofill.creditCards.heuristics.fathom.testConfidence": 0, "extensions.formautofill.creditCards.heuristics.fathom.types": "cc-number,cc-name", @@ -32,11 +31,11 @@ const IOS_DEFAULT_PREFERENCES = { "extensions.formautofill.heuristics.captureOnPageNavigation": false, "extensions.formautofill.heuristics.detectDynamicFormChanges": false, "extensions.formautofill.heuristics.fillOnDynamicFormChanges": false, + "extensions.formautofill.heuristics.refillOnSiteClearingFields": false, "extensions.formautofill.focusOnAutofill": false, "extensions.formautofill.test.ignoreVisibilityCheck": false, "extensions.formautofill.heuristics.autofillSameOriginWithTop": false, "signon.generation.confidenceThreshold": 0.75, - "extensions.formautofill.ml.experiment.enabled": false, }; // Used Mimic the behavior of .getAutocompleteInfo() diff --git a/firefox-ios/Client/Assets/CC_Script/CreditCard.sys.mjs b/firefox-ios/Client/Assets/CC_Script/CreditCard.sys.mjs index 622c371d76ad8..0b52d96f7ed7a 100644 --- a/firefox-ios/Client/Assets/CC_Script/CreditCard.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/CreditCard.sys.mjs @@ -217,6 +217,7 @@ export class CreditCard { /** * Normalizes a credit card number. + * * @param {string} number * @return {string | null} * @memberof CreditCard @@ -346,8 +347,8 @@ export class CreditCard { } /** - * * Please use getLabelInfo above, as it allows for localization. + * * @deprecated */ static getLabel({ number, name }) { diff --git a/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs index 7d6a87d255cce..1110070fb3c8e 100644 --- a/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FieldScanner.sys.mjs @@ -4,9 +4,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { - FormAutofill: "resource://autofill/FormAutofill.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", - MLAutofill: "resource://autofill/MLAutofill.sys.mjs", }); /** @@ -42,6 +40,10 @@ export class FieldDetail { // The possible values are "autocomplete", "fathom", and "regex-heuristic" reason = null; + // This field could be a lookup field, for example, one that could be used to + // search for an address or postal code and fill in other fields. + isLookup = false; + /* * The "section", "addressType", and "contactType" values are * used to identify the exact field when the serializable data is received @@ -96,11 +98,9 @@ export class FieldDetail { fieldName = null, { autocompleteInfo = null, - fathomLabel = null, fathomConfidence = null, isVisible = true, - mlHeaderInput = null, - mlButtonInput = null, + isLookup = false, } = {} ) { const fieldDetail = new FieldDetail(element); @@ -133,23 +133,6 @@ export class FieldDetail { } else if (fathomConfidence) { fieldDetail.reason = "fathom"; fieldDetail.confidence = fathomConfidence; - - // 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"; } @@ -168,16 +151,7 @@ export class FieldDetail { // Info required by heuristics fieldDetail.maxLength = element.maxLength; - if ( - lazy.FormAutofill.isMLExperimentEnabled && - ["input", "select"].includes(element.localName) - ) { - fieldDetail.mlinput = lazy.MLAutofill.getMLMarkup(fieldDetail.element); - fieldDetail.mlHeaderInput = mlHeaderInput; - fieldDetail.mlButtonInput = mlButtonInput; - fieldDetail.fathomLabel = fathomLabel; - fieldDetail.fathomConfidence = fathomConfidence; - } + fieldDetail.isLookup = isLookup; return fieldDetail; } @@ -251,6 +225,43 @@ export class FieldScanner { return this.#fieldDetails[index]; } + getFieldsMatching(matchFn, includeInvisible = false) { + let fields = []; + + for (let idx = 0; this.elementExisting(idx); idx++) { + let field = this.#fieldDetails[idx]; + if ((includeInvisible || field.isVisible) && matchFn(field)) { + fields.push(field); + } + } + + return fields; + } + + /** + * Return the index of the first visible field found with the given name. + * + * @param {string} fieldName + * The field name to find. + * @param {string} includeInvisible + * Whether to find non-visible fields. + * @returns {number} + * The index of the element or -1 if not found. + */ + getFieldIndexByName(fieldName, includeInvisible = false) { + for (let idx = 0; this.elementExisting(idx); idx++) { + let field = this.#fieldDetails[idx]; + if ( + field.fieldName == fieldName && + (includeInvisible || field.isVisible) + ) { + return idx; + } + } + + return -1; + } + /** * When a field detail should be changed its fieldName after parsing, use * this function to update the fieldName which is at a specific index. diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs index fb4e49a0fb84c..f7cd9fe232fc3 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofill.sys.mjs @@ -24,8 +24,8 @@ const ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF = "extensions.formautofill.addresses.supportedCountries"; const ENABLED_AUTOFILL_CREDITCARDS_PREF = "extensions.formautofill.creditCards.enabled"; -const AUTOFILL_CREDITCARDS_REAUTH_PREF = - "extensions.formautofill.creditCards.reauth.optout"; +const AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF = + "extensions.formautofill.creditCards.os-auth.locked.enabled"; const AUTOFILL_CREDITCARDS_HIDE_UI_PREF = "extensions.formautofill.creditCards.hideui"; const FORM_AUTOFILL_SUPPORT_RTL_PREF = "extensions.formautofill.supportRTL"; @@ -45,6 +45,10 @@ const AUTOFILL_FILL_ON_DYNAMIC_FORM_CHANGES_TIMEOUT_PREF = "extensions.formautofill.heuristics.fillOnDynamicFormChanges.timeout"; const AUTOFILL_FILL_ON_DYNAMIC_FORM_CHANGES_PREF = "extensions.formautofill.heuristics.fillOnDynamicFormChanges"; +const AUTOFILL_REFILL_ON_SITE_CLEARING_VALUE_PREF = + "extensions.formautofill.heuristics.refillOnSiteClearingFields"; +const AUTOFILL_REFILL_ON_SITE_CLEARING_VALUE_TIMEOUT_PREF = + "extensions.formautofill.heuristics.refillOnSiteClearingFields.timeout"; export const FormAutofill = { ENABLED_AUTOFILL_ADDRESSES_PREF, @@ -54,11 +58,13 @@ export const FormAutofill = { ENABLED_AUTOFILL_SAME_ORIGIN_WITH_TOP, ENABLED_AUTOFILL_CREDITCARDS_PREF, ENABLED_AUTOFILL_DETECT_DYNAMIC_FORM_CHANGES_PREF, - AUTOFILL_CREDITCARDS_REAUTH_PREF, + AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF, AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF, AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF, AUTOFILL_FILL_ON_DYNAMIC_FORM_CHANGES_PREF, AUTOFILL_FILL_ON_DYNAMIC_FORM_CHANGES_TIMEOUT_PREF, + AUTOFILL_REFILL_ON_SITE_CLEARING_VALUE_PREF, + AUTOFILL_REFILL_ON_SITE_CLEARING_VALUE_TIMEOUT_PREF, _region: null, @@ -212,10 +218,6 @@ export const FormAutofill = { prefix: logPrefix, }); }, - - get isMLExperimentEnabled() { - return FormAutofill._isMLEnabled && FormAutofill._isMLExperimentEnabled; - }, }; // TODO: Bug 1747284. Use Region.home instead of reading "browser.serach.region" @@ -326,43 +328,36 @@ XPCOMUtils.defineLazyPreferenceGetter( XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, - "_isMLEnabled", - "browser.ml.enable", + "detectDynamicFormChanges", + "extensions.formautofill.heuristics.detectDynamicFormChanges", false ); XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, - "_isMLExperimentEnabled", - "extensions.formautofill.ml.experiment.enabled", + "fillOnDynamicFormChanges", + "extensions.formautofill.heuristics.fillOnDynamicFormChanges", false ); XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, - "MLModelRevision", - "extensions.formautofill.ml.experiment.modelRevision", - null -); - -XPCOMUtils.defineLazyPreferenceGetter( - FormAutofill, - "detectDynamicFormChanges", - "extensions.formautofill.heuristics.detectDynamicFormChanges", - false + "fillOnDynamicFormChangeTimeout", + "extensions.formautofill.heuristics.fillOnDynamicFormChanges.timeout", + 0 ); XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, - "fillOnDynamicFormChanges", - "extensions.formautofill.heuristics.fillOnDynamicFormChanges", + "refillOnSiteClearingFields", + "extensions.formautofill.heuristics.refillOnSiteClearingFields", false ); XPCOMUtils.defineLazyPreferenceGetter( FormAutofill, - "fillOnDynamicFormChangeTimeout", - "extensions.formautofill.heuristics.fillOnDynamicFormChanges.timeout", + "refillOnSiteClearingFieldsTimeout", + "extensions.formautofill.heuristics.refillOnSiteClearingFields.timeout", 0 ); diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs index 4b03f37653810..dda3101cb529d 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillHandler.sys.mjs @@ -17,6 +17,8 @@ ChromeUtils.defineESModuleGetters(lazy, { FormAutofillNameUtils: "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; @@ -24,6 +26,7 @@ const { FIELD_STATES } = FormAutofillUtils; export const FORM_CHANGE_REASON = { NODES_ADDED: "nodes-added", NODES_REMOVED: "nodes-removed", + SELECT_OPTIONS_CHANGED: "select-options-changed", ELEMENT_INVISIBLE: "visible-element-became-invisible", ELEMENT_VISIBLE: "invisible-element-became-visible", }; @@ -67,13 +70,14 @@ export class FormAutofillHandler { #formMutationObserver = null; + #visibilityObserver = null; #visibilityStateObserverByElement = new WeakMap(); /** * * fillOnFormChangeData.isWithinDynamicFormChangeThreshold: * Flags if a "form-change" event is received within the timeout threshold - * (see lazy.FormAutofill.fillOnDynamicFormChangeTimeout), that we set + * (see FormAutofill.fillOnDynamicFormChangeTimeout), that we set * in order to consider newly detected fields for filling. * fillOnFormChangeData.previouslyUsedProfile * The previously used profile from the latest autocompletion. @@ -81,10 +85,16 @@ export class FormAutofillHandler { * The previously focused element id from the latest autocompletion * * This is used for any following form changes and is cleared after a time threshold - * set by lazy.FormAutofill.fillOnDynamicFormChangeTimeout. + * set by FormAutofill.fillOnDynamicFormChangeTimeout. */ #fillOnFormChangeData = new Map(); + /** + * Caching the refill timeout id to cancel it once we know that we're about to fill + * on form change, because this sets up another refill timeout. + */ + #refillTimeoutId = null; + /** * Flag to indicate whethere there is an ongoing autofilling/clearing process. */ @@ -212,20 +222,10 @@ export class FormAutofillHandler { return this.#filledStateByElement.get(element); } - isVisiblityStateObserverSetUpByElement(element) { - return this.#visibilityStateObserverByElement.has(element); - } - - setVisibilityStateObserverByElement(element, observer) { - this.#visibilityStateObserverByElement.set(element, observer); - } - - clearVisibilityStateObserverByElement(element) { - if (this.isVisiblityStateObserverSetUpByElement(element)) { - const observer = this.#visibilityStateObserverByElement.get(element); - observer.disconnect(); - this.#visibilityStateObserverByElement.delete(element); - } + #clearVisibilityObserver() { + this.#visibilityObserver.disconnect(); + this.#visibilityObserver = null; + this.#visibilityStateObserverByElement = new WeakMap(); } /** @@ -268,6 +268,11 @@ export class FormAutofillHandler { return false; } + updateFormByElement(element) { + const formLike = lazy.AutofillFormFactory.createFromField(element); + this._updateForm(formLike); + } + /** * Update the form with a new FormLike, and the related fields should be * updated or clear to ensure the data consistency. @@ -344,7 +349,7 @@ export class FormAutofillHandler { } /** - * Resetting the state element's fieldDetail after it was removed from the form + * Resetting the filled state after an element was removed from the form * Todo: We'll need to update this.filledResult in FormAutofillParent (Bug 1948077). * * @param {HTMLElement} element that was removed @@ -353,8 +358,7 @@ export class FormAutofillHandler { if (this.getFilledStateByElement(element) != FIELD_STATES.AUTO_FILLED) { return; } - const fieldDetail = this.getFieldDetailByElement(element); - this.#filledStateByElement.delete(fieldDetail); + this.#filledStateByElement.delete(element); } /** @@ -414,7 +418,11 @@ export class FormAutofillHandler { let value = this.getFilledValueFromProfile(fieldDetail, profile); if (!value) { - this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + // A field could have been filled by a previous fill, so only + // clear when in the preview state. + if (element.autofillState == FIELD_STATES.PREVIEW) { + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } continue; } @@ -450,9 +458,12 @@ export class FormAutofillHandler { * The data profile containing the values to be autofilled into the form fields. */ fillFields(focusedId, elementIds, profile) { + this.cancelRefillOnSiteClearingFieldsAction(); + this.#isAutofillInProgress = true; this.getAdaptedProfiles([profile]); + const filledValuesByElement = new Map(); for (const fieldDetail of this.fieldDetails) { const { element, elementId } = fieldDetail; @@ -489,10 +500,19 @@ export class FormAutofillHandler { ) { FormAutofillHandler.fillFieldValue(element, value); this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + filledValuesByElement.set(element, value); } } else if (HTMLSelectElement.isInstance(element)) { const option = this.matchSelectOptions(fieldDetail, profile); if (!option) { + if ( + this.getFilledStateByElement(element) == FIELD_STATES.AUTO_FILLED + ) { + // The select element was previously autofilled, but there + // is no matching option under the current set of options anymore. + // Changing the state will also remove the highlighting from the element + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } continue; } @@ -504,6 +524,7 @@ export class FormAutofillHandler { } // Autofill highlight appears regardless if value is changed or not this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + filledValuesByElement.set(element, option.value); } else { continue; } @@ -513,6 +534,8 @@ export class FormAutofillHandler { this.#isAutofillInProgress = false; this.registerFormChangeHandler(); + + this.reassignValuesIfModified(filledValuesByElement, false); } registerFormChangeHandler() { @@ -527,6 +550,7 @@ export class FormAutofillHandler { return; } if (e.type == "reset") { + this.cancelRefillOnSiteClearingFieldsAction(); for (const fieldDetail of this.fieldDetails) { const element = fieldDetail.element; element.removeEventListener("input", this, { mozSystemGroup: true }); @@ -567,6 +591,59 @@ export class FormAutofillHandler { }); } + /** + * After a refill or clear action, the website might adjust the value of an + * element immediately afterwards. If this happens, fill or clear the value + * a second time to avoid having elements that are empty but highlighted, or + * vice versa. + * + * @param {Map} filledValuesByElement + * @param {boolean} onClear true for a clear action + */ + reassignValuesIfModified(filledValuesByElement, onClear) { + if (!FormAutofill.refillOnSiteClearingFields) { + return; + } + + this.#refillTimeoutId = lazy.setTimeout(() => { + for (let [e, v] of filledValuesByElement) { + if (onClear) { + if (e.autofillState != FIELD_STATES.NORMAL || e.value !== v) { + // Only reclear if the value was changed back to the original value. + continue; + } + } else if (e.autofillState == FIELD_STATES.NORMAL || e.value) { + // Nothing to do if the autofilled value wasn't cleared or the + // element's autofill state has changed to NORMAL in the meantime + continue; + } + + this.#isAutofillInProgress = true; + FormAutofillHandler.fillFieldValue(e, onClear ? "" : v, { + ignoreFocus: true, + }); + // Although the field should already be in the autofilled state at this point, + // still setting autofilled state to re-highlight the element. + e.autofillState = onClear + ? FIELD_STATES.NORMAL + : FIELD_STATES.AUTO_FILLED; + this.#isAutofillInProgress = false; + } + + this.#refillTimeoutId = null; + }, FormAutofill.refillOnSiteClearingFieldsTimeout); + } + + cancelRefillOnSiteClearingFieldsAction() { + if (!FormAutofill.refillOnSiteClearingFields) { + return; + } + if (this.#refillTimeoutId) { + lazy.clearTimeout(this.#refillTimeoutId); + this.#refillTimeoutId = null; + } + } + /** * Listens for dynamic form changes by setting up two observer types: * 1. IntersectionObserver(s) that observe(s) intersections between @@ -586,87 +663,93 @@ export class FormAutofillHandler { this.setUpFormNodesMutationObserver(); } - /** - * Iterates through handler.form.elements and sets up an IntersectionObserver for each (in-)visible - * address/cc input element that is not observed yet (see handler.#visibilityStateObserverByElement). - * The observer notifies of intersections between the (in-)visible element and the intersection target (handler.form). - * This is the case if e.g. a visible element becomes invisible or an invisible element becomes visible. - * If a visibility state change is observed, a "form-changes" event is dispatched. - */ - setUpElementVisibilityObserver() { - const VISIBILITY_STATE = { - VISIBLE: true, - INVISIBLE: false, - }; + #initializeIntersectionObserver() { + this.#visibilityObserver ??= new this.window.IntersectionObserver( + (entries, _observer) => { + const nowVisible = []; + const nowInvisible = []; + entries.forEach(entry => { + let observedElement = entry.target; + + let oldState = + this.#visibilityStateObserverByElement.get(observedElement); + let newState = FormAutofillUtils.isFieldVisible(observedElement); + if (oldState == newState) { + return; + } - // Setting up an observer for an element's changing visibility state - const setUpIntersectionObserver = (element, visibilityState) => { - const visibilityStateObserver = new this.window.IntersectionObserver( - (entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting != visibilityState) { - return; - } - if ( - entry.target.checkVisibility({ - checkOpacity: true, - checkVisibilityCSS: true, - }) != visibilityState - ) { - // The observer notified that the element reached the intersection threshold - // (meaning the element's visibility state changed to either visible or invisible. - // But checkVisibility doesn't confirm that. - // For these mismatches we disconnect the observer to avoid an infinite loop. - observer.disconnect(); - return; - } - const changes = {}; - const reason = - visibilityState == VISIBILITY_STATE.VISIBLE - ? FORM_CHANGE_REASON.ELEMENT_VISIBLE - : FORM_CHANGE_REASON.ELEMENT_INVISIBLE; - changes[reason] = [entry.target]; - - const formChangedEvent = new CustomEvent("form-changed", { - detail: { - form: this.form.rootElement, - changes, - }, - bubbles: true, - }); - this.form.ownerDocument.dispatchEvent(formChangedEvent); - - this.clearVisibilityStateObserverByElement(element); - observer.disconnect(); - }); - }, - { - root: this.form.rootElement, - // intersection reatio between 0.0 (invisible element) and 1.0 (visible element) - threshold: visibilityState === VISIBILITY_STATE.INVISIBLE ? 0 : 1, + if (newState) { + nowVisible.push(observedElement); + } else { + nowInvisible.push(observedElement); + } + }); + + if (!nowVisible.length && !nowInvisible.length) { + return; } - ); - visibilityStateObserver.observe(element); - this.setVisibilityStateObserverByElement( - element, - visibilityStateObserver - ); - }; + let changes = {}; + if (nowVisible.length) { + changes[FORM_CHANGE_REASON.ELEMENT_VISIBLE] = nowVisible; + } + if (nowInvisible.length) { + changes[FORM_CHANGE_REASON.ELEMENT_INVISIBLE] = nowInvisible; + } + + // Clear all of the observer state. The notification will add a new + // observer if needed. + this.#clearVisibilityObserver(); + + const formChangedEvent = new CustomEvent("form-changed", { + detail: { + form: this.form.rootElement, + changes, + }, + bubbles: true, + }); + this.form.ownerDocument.dispatchEvent(formChangedEvent); + }, + { + root: this.form.rootElement, + // intersection ratio between 0.0 (invisible element) and 1.0 (visible element) + threshold: [0, 1], + } + ); + } + + /** + * Sets up an IntersectionObserver to handle each (in-)visible address/cc input element + * in a form. The observer notifies of intersections between the (in-)visible element and + * the intersection target (handler.form). This is the case if e.g. a visible element becomes + * invisible or an invisible element becomes visible. If a visibility state change is observed, + * a "form-changes" event is dispatched. + */ + setUpElementVisibilityObserver() { for (let element of this.form.elements) { if (!FormAutofillUtils.isCreditCardOrAddressFieldType(element)) { continue; } - if (this.isVisiblityStateObserverSetUpByElement(element)) { + + if (this.#visibilityStateObserverByElement.has(element)) { continue; } - if (FormAutofillUtils.isFieldVisible(element)) { - // Setting up an observer that notifies when the visible element becomes invisible - setUpIntersectionObserver(element, VISIBILITY_STATE.INVISIBLE); - } else { - // Setting up an observer that notifies when the invisible element becomes visible - setUpIntersectionObserver(element, VISIBILITY_STATE.VISIBLE); + + let state = FormAutofillUtils.isFieldVisible(element); + if (state) { + // We don't care about visibility state changes for fields that are not recognized + // by our heuristics. We only handle this for visible fields because we currently + // don't run field detection heuristics for invisible fields. + const fieldDetail = this.getFieldDetailByElement(element); + if (!fieldDetail.fieldName) { + continue; + } } + + this.#initializeIntersectionObserver(); + + this.#visibilityObserver.observe(element); + this.#visibilityStateObserverByElement.set(element, state); } } @@ -684,18 +767,21 @@ export class FormAutofillHandler { const mutationObserver = new this.window.MutationObserver( (mutations, _) => { const collectMutatedNodes = mutations => { - let removedNodes = []; - let addedNodes = []; + let removedNodes = new Set(); + let addedNodes = new Set(); + let changedSelectElements = new Set(); mutations.forEach(mutation => { if (mutation.type == "childList") { - if (mutation.addedNodes.length) { - addedNodes.push(...mutation.addedNodes); + if (HTMLSelectElement.isInstance(mutation.target)) { + changedSelectElements.add(mutation.target); + } else if (mutation.addedNodes.length) { + addedNodes.add(...mutation.addedNodes); } else if (mutation.removedNodes.length) { - removedNodes.push(...mutation.removedNodes); + removedNodes.add(...mutation.removedNodes); } } }); - return [addedNodes, removedNodes]; + return [addedNodes, removedNodes, changedSelectElements]; }; const collectAllSubtreeElements = node => { @@ -715,22 +801,35 @@ export class FormAutofillHandler { ); }; - let [addedNodes, removedNodes] = collectMutatedNodes(mutations); - let relevantAddedElements = getCCAndAddressElements(addedNodes); - // We only care about removed elements that might change the - // currently detected fieldDetails - let relevantRemovedElements = getCCAndAddressElements( - removedNodes - ).filter( + const [addedNodes, removedNodes, changedSelectElements] = + collectMutatedNodes(mutations); + let relevantAddedElements = getCCAndAddressElements([...addedNodes]); + // We only care about removed elements and changed select options + // from the current set of detected fieldDetails + let relevantRemovedElements = getCCAndAddressElements([ + ...removedNodes, + ]).filter( + element => + this.#fieldDetails && !!this.getFieldDetailByElement(element) + ); + let relevantChangedSelectElements = [...changedSelectElements].filter( element => this.#fieldDetails && !!this.getFieldDetailByElement(element) ); - if (!relevantRemovedElements.length && !relevantAddedElements.length) { + if ( + !relevantRemovedElements.length && + !relevantAddedElements.length && + !relevantChangedSelectElements.length + ) { return; } let changes = {}; + if (relevantChangedSelectElements.length) { + changes[FORM_CHANGE_REASON.SELECT_OPTIONS_CHANGED] = + relevantChangedSelectElements; + } if (relevantRemovedElements.length) { changes[FORM_CHANGE_REASON.NODES_REMOVED] = relevantRemovedElements; } @@ -764,9 +863,7 @@ export class FormAutofillHandler { return; } // Disconnect intersection observers - for (let element of this.form.elements) { - this.clearVisibilityStateObserverByElement(element); - } + this.#clearVisibilityObserver(); // Disconnect mutation observer this.#formMutationObserver.disconnect(); this.#isObservingFormMutations = false; @@ -825,29 +922,6 @@ export class FormAutofillHandler { return value; } - /* - * Apply both address and credit card related transformers. - * - * @param {Object} profile - * A profile for adjusting credit card related value. - * @override - */ - applyTransformers(profile) { - this.addressTransformer(profile); - this.telTransformer(profile); - this.creditCardExpiryDateTransformer(profile); - this.creditCardExpMonthAndYearTransformer(profile); - this.creditCardNameTransformer(profile); - this.adaptFieldMaxLength(profile); - } - - getAdaptedProfiles(originalProfiles) { - for (let profile of originalProfiles) { - this.applyTransformers(profile); - } - return originalProfiles; - } - /** * Match the select option for a field if we autofill with the given profile. * This function caches the matching result in the `#matchingSelectionOption` @@ -875,7 +949,8 @@ export class FormAutofillHandler { const value = profile[fieldName]; let option = cache[value]?.deref(); - if (!option) { + + if (!option || !option.isConnected) { option = FormAutofillUtils.findSelectOption(element, profile, fieldName); if (option) { @@ -890,8 +965,270 @@ export class FormAutofillHandler { return option; } - adaptFieldMaxLength(profile) { - for (let key in profile) { + getAdaptedProfiles(originalProfiles) { + for (let profile of originalProfiles) { + let transformer = new ProfileTransformer(this, profile); + transformer.applyTransformers(); + } + return originalProfiles; + } + + /** + * + * @param {object} fieldDetail A fieldDetail of the related element. + * @param {object} profile The profile to fill. + * @returns {string} The value to fill for the given field. + */ + getFilledValueFromProfile(fieldDetail, profile) { + let value = + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName]; + + if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) { + const part = fieldDetail.part; + return value.slice((part - 1) * 4, part * 4); + } + return value; + } + /** + * Fills the provided element with the specified value. + * + * @param {HTMLElement} element - The form field element to be filled. + * @param {string} value - The value to be filled into the form field. + * @param {object} options + * @param {boolean} [options.ignoreFocus] - Whether to ignore focusing the field that is filled. + * True - When an autofilled field get's refilled after + * its value was cleared + * False - Default + */ + static fillFieldValue(element, value, { ignoreFocus = false } = {}) { + // Ignoring to focus the field if it gets refilled (after the site cleared its value), + // because it was already focused on the previous autofill action and we want to avoid + // re-triggering any event listener callbacks or autocomplete dropdowns + if (FormAutofillUtils.focusOnAutofill && !ignoreFocus) { + element.focus({ preventScroll: true }); + } + if (FormAutofillUtils.isTextControl(element)) { + element.setUserInput(value); + } else if (HTMLSelectElement.isInstance(element)) { + // Set the value of the select element so that web event handlers can react accordingly + element.value = value; + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } + } + + clearPreviewedFields(elementIds) { + for (const elementId of elementIds) { + const fieldDetail = this.getFieldDetailByElementId(elementId); + const element = fieldDetail?.element; + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + element.previewValue = ""; + if (element.autofillState == FIELD_STATES.AUTO_FILLED) { + continue; + } + this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + } + + clearFilledFields(focusedId, elementIds) { + this.cancelRefillOnSiteClearingFieldsAction(); + this.#isAutofillInProgress = true; + const fieldDetails = elementIds.map(id => + this.getFieldDetailByElementId(id) + ); + + const filledValuesByElement = new Map(); + + for (const fieldDetail of fieldDetails) { + const element = fieldDetail?.element; + if (!element) { + this.log.warn(fieldDetail?.fieldName, "is unreachable"); + continue; + } + + if (element.autofillState == FIELD_STATES.AUTO_FILLED) { + let value = ""; + if (HTMLSelectElement.isInstance(element)) { + if (!element.options.length) { + continue; + } + // Resets a element to its selected option or the first - // option if there is none selected. - const selected = [...element.options].find(option => - option.hasAttribute("selected") - ); - value = selected ? selected.value : element.options[0].value; - } - FormAutofillHandler.fillFieldValue(element, value); - this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); - } - } - - this.focusPreviouslyFocusedElement(focusedId); - this.#isAutofillInProgress = false; - } - - focusPreviouslyFocusedElement(focusedId) { - let focusedElement = FormAutofillUtils.getElementByIdentifier(focusedId); - if (FormAutofillUtils.focusOnAutofill && focusedElement) { - focusedElement.focus({ preventScroll: true }); - } - } - - /** - * 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 (FormAutofillUtils.isTextControl(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 0953b0e6703db..090beab7d6141 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillHeuristics.sys.mjs @@ -13,7 +13,6 @@ ChromeUtils.defineESModuleGetters(lazy, { FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs", FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", - MLAutofill: "resource://autofill/MLAutofill.sys.mjs", }); /** @@ -122,6 +121,40 @@ export const FormAutofillHeuristics = { ); }, + /** + * Return a set of additonal attributes related to a field. + * + * @param {Element} element + * Form element to examine. + * @param {list} fieldNames + * String or list of field names for the element. + * @returns {map} + * Returns a map of extra attributes. + */ + parseAdditionalAttributes(element, fieldNames) { + let attributes = { isLookup: false }; + const INTERESTED_FIELDS = [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + "postal-code", + ]; + + if (typeof fieldNames == "string") { + fieldNames = [fieldNames]; + } + + if (fieldNames?.some(fieldName => INTERESTED_FIELDS.includes(fieldName))) { + const regExpLookup = HeuristicsRegExp.getExtraRules("lookup"); + if (this._matchRegexp(element, regExpLookup)) { + attributes.isLookup = true; + } + } + + return attributes; + }, + /** * This function handles the case when two adjacent fields are incorrectly * identified with the same field name. Currently, only given-name and @@ -160,6 +193,16 @@ export const FormAutofillHeuristics = { .length ) { scanner.updateFieldName(idx - 1, otherFieldName); + } else { + // If there are two given name or family name fields, yet only one + // of the other type of name field, assume that the second field + // is meant to be an additional name. + let fields = scanner.getFieldsMatching( + field => field.fieldName == otherFieldName + ); + if (fields.length == 1) { + scanner.updateFieldName(idx, "additional-name"); + } } scanner.parsingIndex++; @@ -378,6 +421,9 @@ export const FormAutofillHeuristics = { // Store the index of fields that are recognized as 'address-housenumber' let houseNumberFields = []; + // The number of address-related lookup fields found. + let lookupFieldsCount = 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. @@ -387,11 +433,12 @@ export const FormAutofillHeuristics = { 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. + // but we'll skip over them all anyway. Only check the alternate field + // name if it wasn't already changed by an earlier step. if ( - [detail?.fieldName, detail?.alternativeFieldName].includes( - "address-housenumber" - ) + detail?.fieldName == "address-housenumber" || + (detail?.reason == "regex-heuristic" && + detail?.alternativeFieldName == "address-housenumber") ) { houseNumberFields.push(idx); continue; @@ -400,6 +447,12 @@ export const FormAutofillHeuristics = { if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { break; } + + if (detail?.isLookup) { + lookupFieldsCount++; + continue; // Skip address lookup fields + } + fields.push(detail); fieldIndicies.push(idx); } @@ -477,50 +530,51 @@ export const FormAutofillHeuristics = { for (const idx of houseNumberFields) { scanner.updateFieldName(idx, "address-housenumber"); } - scanner.parsingIndex += fields.length + houseNumberFields.length; + scanner.parsingIndex += + fields.length + houseNumberFields.length + lookupFieldsCount; return true; }, _parseAddressFields(scanner, fieldDetail) { - const INTERESTED_FIELDS = ["address-level1", "address-level2"]; + let fieldFound = false; - if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) { - return false; + // If there is an address-level3 field but no address-level2 field, + // modify to be address-level2. + if ( + fieldDetail.fieldName == "address-level3" && + scanner.getFieldIndexByName("address-level2") == -1 + ) { + scanner.updateFieldName(scanner.parsingIndex, "address-level2"); + fieldFound = true; } - const fields = []; - for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) { - const detail = scanner.getFieldDetailByIndex(idx); - if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { - break; + // State & City(address-level2) + if ( + fieldDetail.fieldName == "address-level2" && + scanner.getFieldIndexByName("address-level1") == -1 + ) { + const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1); + 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 && next.localName == "select") { + scanner.updateFieldName(scanner.parsingIndex + 1, "address-level1"); + scanner.parsingIndex += 2; + return true; } - fields.push(detail); - } - if (!fields.length) { - return false; + fieldFound = true; } - // State & City(address-level2) - if (fields.length == 1) { - if (fields[0].fieldName == "address-level2") { - const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1); - 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 && next.localName == "select") { - scanner.updateFieldName(scanner.parsingIndex + 1, "address-level1"); - scanner.parsingIndex += 2; - return true; - } - } + if (fieldFound) { + scanner.parsingIndex++; + return true; } - scanner.parsingIndex += fields.length; - return true; + return false; }, /** @@ -786,13 +840,6 @@ export const FormAutofillHeuristics = { lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) ); - let closestHeaders; - let closestButtons; - if (FormAutofill.isMLExperimentEnabled && elements.length) { - closestHeaders = lazy.MLAutofill.closestHeaderAbove(elements); - closestButtons = lazy.MLAutofill.closestButtonBelow(elements); - } - const fieldDetails = []; for (let idx = 0; idx < elements.length; idx++) { const element = elements[idx]; @@ -812,30 +859,14 @@ export const FormAutofillHeuristics = { } const [fieldName, inferInfo] = this.inferFieldInfo(element, elements); - - // For cases where the heuristic has determined the field name without - // running Fathom, still run Fathom so we can compare the results between - // Fathom and the ML model. Note that this is only enabled when the ML experiment - // is enabled. - if ( - FormAutofill.isMLExperimentEnabled && - inferInfo.fathomConfidence == undefined - ) { - let fields = this._getPossibleFieldNames(element); - fields = fields.filter(r => lazy.CreditCardRulesets.types.includes(r)); - const [label, score] = this.getFathomField(element, fields, elements); - inferInfo.fathomLabel = label; - inferInfo.fathomConfidence = score; - } + const attributes = this.parseAdditionalAttributes(element, fieldName); fieldDetails.push( lazy.FieldDetail.create(element, formLike, fieldName, { autocompleteInfo: inferInfo.autocompleteInfo, - fathomLabel: inferInfo.fathomLabel, fathomConfidence: inferInfo.fathomConfidence, isVisible, - mlHeaderInput: closestHeaders?.[idx] ?? null, - mlButtonInput: closestButtons?.[idx] ?? null, + isLookup: attributes.isLookup, }) ); } @@ -974,7 +1005,7 @@ export const FormAutofillHeuristics = { * @returns {Array} - An array containing: * [0]the inferred field name * [1]information collected during the inference process. The possible values includes: - * 'autocompleteInfo', 'fathomLabel', and 'fathomConfidence'. + * 'autocompleteInfo' and 'fathomConfidence'. */ inferFieldInfo(element, elements = []) { const inferredInfo = {}; @@ -984,7 +1015,8 @@ export const FormAutofillHeuristics = { // needs to find the field name. if ( autocompleteInfo?.fieldName && - !["on", "off"].includes(autocompleteInfo.fieldName) + !["on", "off"].includes(autocompleteInfo.fieldName) && + !lazy.FormAutofillUtils.isUnsupportedField(autocompleteInfo.fieldName) ) { inferredInfo.autocompleteInfo = autocompleteInfo; return [autocompleteInfo.fieldName, inferredInfo]; @@ -1000,6 +1032,9 @@ export const FormAutofillHeuristics = { return ["email", inferredInfo]; } + let fathomFoundType; + let matchedFieldNames = []; + if (lazy.FormAutofillUtils.isFathomCreditCardsEnabled()) { // We don't care fields that are not supported by fathom const fathomFields = fields.filter(r => @@ -1011,12 +1046,18 @@ export const FormAutofillHeuristics = { elements ); if (confidence != null) { - inferredInfo.fathomLabel = matchedFieldName; inferredInfo.fathomConfidence = confidence; } // At this point, use fathom's recommendation if it has one if (matchedFieldName) { - return [matchedFieldName, inferredInfo]; + // If the name was matched, fall through and try to detect if the + // field also matches an address type, which may be a better match. + if (matchedFieldName != "cc-name") { + return [matchedFieldName, inferredInfo]; + } + + matchedFieldNames = [matchedFieldName]; + fathomFoundType = CC_TYPE; } // Continue to run regex-based heuristics even when fathom doesn't recognize @@ -1067,7 +1108,18 @@ export const FormAutofillHeuristics = { } // Find a matched field name using regexp-based heuristics - const matchedFieldNames = this._findMatchedFieldNames(element, fields); + const heuristicMatchedFieldNames = this._findMatchedFieldNames( + element, + fields, + fathomFoundType + ); + matchedFieldNames.push(...heuristicMatchedFieldNames); + + // If regular expression based heuristics doesn't find any matched field name, + // and the input type is "tel", just use "tel" as the field name. + if (!matchedFieldNames.length && element.type == "tel") { + return ["tel", inferredInfo]; + } return [matchedFieldNames, inferredInfo]; }, @@ -1269,7 +1321,7 @@ export const FormAutofillHeuristics = { * @param {Array} fieldNames An array of field names to compare against. * @returns {Array} An array of the matching field names. */ - _findMatchedFieldNames(element, fieldNames) { + _findMatchedFieldNames(element, fieldNames, foundType = "") { if (!fieldNames.length) { return []; } @@ -1280,7 +1332,6 @@ export const FormAutofillHeuristics = { lazy.FormAutofillUtils.isCreditCardField(name) ? CC_TYPE : ADDR_TYPE, ]); - let foundType; let attribute = true; let matchedFieldNames = []; diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs index 4bc28af326c08..5234f3cf03957 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillSection.sys.mjs @@ -83,35 +83,32 @@ export class FormAutofillSection { 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. * * @returns {boolean} True for a valid section, otherwise false - * */ isValidSection() { throw new TypeError("isValidSection method must be overrided"); } - /* + /** * Examine the section is an enabled section type or not based on its * preferences. This method must be overrided. * * @returns {boolean} True for an enabled section type, otherwise false - * */ isEnabled() { throw new TypeError("isEnabled method must be overrided"); } - /* + /** * Examine the section is createable for storing the profile. This method * must be overrided. * - * @param {Object} _record The record for examining createable + * @param {object} _record The record for examining createable * @returns {boolean} True for the record is createable, otherwise false - * */ isRecordCreatable(_record) { throw new TypeError("isRecordCreatable method must be overridden"); @@ -247,7 +244,11 @@ export class FormAutofillSection { // email field, an invisible field that appears next to the user-visible field, // and simple cases where a page error where a field name is reused twice. let dupIndex = candidateSection.fieldDetails.findIndex( - f => f.fieldName == cur.fieldName && f.isVisible && cur.isVisible + f => + f.fieldName == cur.fieldName && + f.isVisible && + cur.isVisible && + !f.isLookup ); let isDuplicate = dupIndex != -1; @@ -333,27 +334,63 @@ export class FormAutofillSection { return data; } + shouldAutofillField(fieldDetail) { + // We don't save security code, but if somehow the profile has securty code, + // make sure we don't autofill it. + if (fieldDetail.fieldName == "cc-csc") { + return false; + } + + // When both visible and invisible elements exist, we only autofill the + // visible element. + if (!fieldDetail.isVisible) { + return !this.fieldDetails.some( + field => field.fieldName == fieldDetail.fieldName && field.isVisible + ); + } + + // Only fill a street address lookup field if it is the only street + // address related field in this section. Similarly, for postal code + // fields. + if (fieldDetail.isLookup) { + const STREET_FIELDS = [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + ]; + + let INTERESTED_FIELDS = []; + if (STREET_FIELDS.includes(fieldDetail.fieldName)) { + INTERESTED_FIELDS = STREET_FIELDS; + } else if (fieldDetail.fieldName == "postal-code") { + INTERESTED_FIELDS = ["postal-code"]; + } + + if ( + INTERESTED_FIELDS.length && + this.fieldDetails.some( + field => + INTERESTED_FIELDS.includes(field.fieldName) && + field.isVisible && + !field.isLookup + ) + ) { + return false; + } + } + + return true; + } + /** * Heuristics to determine which fields to autofill when a section contains * multiple fields of the same type. */ getAutofillFields() { - return this.fieldDetails.filter(fieldDetail => { - // We don't save security code, but if somehow the profile has securty code, - // make sure we don't autofill it. - if (fieldDetail.fieldName == "cc-csc") { - return false; - } - - // When both visible and invisible elements exist, we only autofill the - // visible element. - if (!fieldDetail.isVisible) { - return !this.fieldDetails.some( - field => field.fieldName == fieldDetail.fieldName && field.isVisible - ); - } - return true; - }); + return this.fieldDetails.filter(fieldDetail => + this.shouldAutofillField(fieldDetail) + ); } /* @@ -505,22 +542,16 @@ export class FormAutofillCreditCardSection extends FormAutofillSection { /** * Determine whether a set of cc fields identified by our heuristics form a * valid credit card section. - * There are 4 different cases when a field is considered a credit card field + * There are 3 different cases when a field is considered a credit card field * 1. Identified by autocomplete attribute. ex - * 2. Identified by fathom and fathom is pretty confident (when confidence - * value is higher than `highConfidenceThreshold`) - * 3. Identified by fathom. Confidence value is between `fathom.confidenceThreshold` - * and `fathom.highConfidenceThreshold` - * 4. Identified by regex-based heurstic. There is no confidence value in thise case. + * 2. Identified by fathom. + * 3. Identified by regex-based heurstic. There is no confidence value in thise case. * * A form is considered a valid credit card form when one of the following condition * is met: - * A. One of the cc field is identified by autocomplete (case 1) + * A. One of the cc field is identified by autocomplete (case 1). * B. One of the cc field is identified by fathom (case 2 or 3), and there is also - * another cc field found by any of our heuristic (case 2, 3, or 4) - * C. Only one cc field is found in the section, but fathom is very confident (Case 2). - * Currently we add an extra restriction to this rule to decrease the false-positive - * rate. See comments below for details. + * another cc field found by any of our heuristic (case 2, 3). * * @returns {boolean} True for a valid section, otherwise false */ @@ -571,14 +602,6 @@ export class FormAutofillCreditCardSection extends FormAutofillSection { } } - // Condition C. - if ( - ccNumberDetail?.isOnlyVisibleFieldWithHighConfidence || - ccNameDetail?.isOnlyVisibleFieldWithHighConfidence - ) { - return true; - } - return false; } @@ -645,7 +668,6 @@ export class FormAutofillCreditCardSection extends FormAutofillSection { result = decrypted ? "success" : "fail_user_canceled"; } catch (ex) { result = "fail_error"; - throw ex; } finally { Glean.formautofill.promptShownOsReauth.record({ trigger: "autofill", @@ -662,31 +684,10 @@ export class FormAutofillCreditCardSection extends FormAutofillSection { } async getDecryptedString(cipherText, reauth) { - if ( - !lazy.FormAutofillUtils.getOSAuthEnabled( - lazy.FormAutofill.AUTOFILL_CREDITCARDS_REAUTH_PREF - ) - ) { + if (!lazy.FormAutofillUtils.getOSAuthEnabled()) { this.log.debug("Reauth is disabled"); reauth = false; } - let string; - let errorResult = 0; - try { - string = await lazy.OSKeyStore.decrypt(cipherText, reauth); - } catch (e) { - errorResult = e.result; - if (e.result != Cr.NS_ERROR_ABORT) { - throw e; - } - this.log.warn("User canceled encryption login"); - } finally { - Glean.creditcard.osKeystoreDecrypt.record({ - isDecryptSuccess: errorResult === 0, - errorResult, - trigger: "autofill", - }); - } - return string; + return await lazy.OSKeyStore.decrypt(cipherText, "formautofill_cc", reauth); } } diff --git a/firefox-ios/Client/Assets/CC_Script/FormAutofillUtils.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormAutofillUtils.sys.mjs index 2951e720fac6d..2a821eff20ec9 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormAutofillUtils.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormAutofillUtils.sys.mjs @@ -27,19 +27,12 @@ ChromeUtils.defineLazyGetter( ) ); -XPCOMUtils.defineLazyServiceGetter( - lazy, - "Crypto", - "@mozilla.org/login-manager/crypto/SDR;1", - "nsILoginManagerCrypto" -); - export let FormAutofillUtils; const ADDRESSES_COLLECTION_NAME = "addresses"; const CREDITCARDS_COLLECTION_NAME = "creditCards"; -const AUTOFILL_CREDITCARDS_REAUTH_PREF = - FormAutofill.AUTOFILL_CREDITCARDS_REAUTH_PREF; +const AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF = + FormAutofill.AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF; const MANAGE_ADDRESSES_L10N_IDS = [ "autofill-add-address-title", "autofill-manage-addresses-title", @@ -122,7 +115,7 @@ FormAutofillUtils = { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME, - AUTOFILL_CREDITCARDS_REAUTH_PREF, + AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF, MANAGE_ADDRESSES_L10N_IDS, EDIT_ADDRESS_L10N_IDS, MANAGE_CREDITCARDS_L10N_IDS, @@ -174,6 +167,13 @@ FormAutofillUtils = { "cc-csc": "creditCard", }, + // This list includes autocomplete attributes that indicate that the field + // is an address or credit-card field, but the field name is not one we + // currently support for autofill. In these cases, we ignore the field + // name so that our heuristic can still classify the field using a + // supported field name. + _unsupportedFieldNameInfo: ["address-level4"], + _collators: {}, _reAlternativeCountryNames: {}, @@ -187,6 +187,12 @@ FormAutofillUtils = { return this._fieldNameInfo?.[fieldName] == "creditCard"; }, + // Returns true if the field is one we don't fill handle via the autocomplete + // attribute. It should be identified using heuristics. + isUnsupportedField(fieldName) { + return this._unsupportedFieldNameInfo.includes(fieldName); + }, + isCCNumber(ccNumber) { return ccNumber && lazy.CreditCard.isValidNumber(ccNumber); }, @@ -206,65 +212,35 @@ FormAutofillUtils = { }, /** - * Get the decrypted value for a string pref. + * Get whether the OSAuth is enabled or not. * - * @param {string} prefName -> The pref whose value is needed. - * @param {string} safeDefaultValue -> Value to be returned incase the pref is not yet set. - * @returns {string} + * @returns {boolean} Whether or not OS Auth is enabled. */ - getSecurePref(prefName, safeDefaultValue) { - if (Services.prefs.getBoolPref("security.nocertdb", false)) { + getOSAuthEnabled() { + if (!lazy.OSKeyStore.canReauth()) { return false; } - try { - const encryptedValue = Services.prefs.getStringPref(prefName, ""); - return encryptedValue === "" - ? safeDefaultValue - : lazy.Crypto.decrypt(encryptedValue); - } catch { - return safeDefaultValue; - } - }, - /** - * Set the pref to the encrypted form of the value. - * - * @param {string} prefName -> The pref whose value is to be set. - * @param {string} value -> The value to be set in its encrypted form. - */ - setSecurePref(prefName, value) { - if (Services.prefs.getBoolPref("security.nocertdb", false)) { - return; - } - if (value) { - const encryptedValue = lazy.Crypto.encrypt(value); - Services.prefs.setStringPref(prefName, encryptedValue); - } else { - Services.prefs.clearUserPref(prefName); - } - }, + // We need to unlock the pref here to retrieve it's true value, otherwise + // the default (false) will be returned. + const prefName = AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF; + Services.prefs.unlockPref(prefName); + const isEnabled = Services.prefs.getBoolPref(prefName, true); + Services.prefs.lockPref(prefName); - /** - * Get whether the OSAuth is enabled or not. - * - * @param {string} prefName -> The name of the pref (creditcards or addresses) - * @returns {boolean} - */ - getOSAuthEnabled(prefName) { - return ( - lazy.OSKeyStore.canReauth() && - this.getSecurePref(prefName, "") !== "opt out" - ); + return isEnabled; }, /** * Set whether the OSAuth is enabled or not. * - * @param {string} prefName -> The pref to encrypt. * @param {boolean} enable -> Whether the pref is to be enabled. */ - setOSAuthEnabled(prefName, enable) { - this.setSecurePref(prefName, enable ? null : "opt out"); + setOSAuthEnabled(enable) { + const prefName = AUTOFILL_CREDITCARDS_OS_AUTH_LOCKED_PREF; + Services.prefs.setBoolPref(prefName, enable); + Services.prefs.lockPref(prefName); + Services.obs.notifyObservers(null, "OSAuthEnabledChange"); }, async verifyUserOSAuth( @@ -456,7 +432,9 @@ FormAutofillUtils = { * @returns {boolean} true if the element can be autofilled */ isFieldAutofillable(element) { - return element && !element.readOnly && !element.disabled; + return ( + element && !element.readOnly && !element.disabled && element.isConnected + ); }, /** @@ -684,16 +662,26 @@ FormAutofillUtils = { */ buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) { // Not all regions have sub_keys. e.g. DE - if ( - !subKeys || - !subKeys.length || - (!subNames && !subLnames) || - (subNames && subKeys.length != subNames.length) || - (subLnames && subKeys.length != subLnames.length) - ) { + if (!subKeys?.length) { return null; } + let names; + if (!subNames && !subLnames) { + // Use the keys if sub_names does not exist + names = [...subKeys]; + } else { + if ( + (subNames && subKeys.length != subNames.length) || + (subLnames && subKeys.length != subLnames.length) + ) { + return null; + } + + // Apply sub_lnames if sub_names does not exist + names = subNames || subLnames; + } + // Overwrite subKeys with subIsoids, when available if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) { for (let i = 0; i < subIsoids.length; i++) { @@ -703,8 +691,6 @@ FormAutofillUtils = { } } - // Apply sub_lnames if sub_names does not exist - let names = subNames || subLnames; return new Map(subKeys.map((key, index) => [key, names[index]])); }, @@ -831,7 +817,7 @@ FormAutofillUtils = { continue; } // Apply sub_lnames if sub_names does not exist - subNames = subNames || subLnames; + subNames = subNames || subLnames || subKeys; let speculatedSubIndexes = []; for (const val of values) { @@ -867,6 +853,53 @@ FormAutofillUtils = { return null; }, + /** + * Attempts to find the full sub-region name from an abbreviated / ISO code, + * using the address metadata for the specified country. + * + * @param {string} abbreviatedValue A short sub-region code (e.g. "B"). + * @param {string} country The country code (e.g. "AR"). + * @returns {string|null} The full sub-region name (e.g. "Buenos Aires") or null. + */ + getFullSubregionName(abbreviatedValue, country) { + if (!abbreviatedValue || !country) { + return null; + } + + const collators = this.getSearchCollators(country); + for (const metadata of this.getCountryAddressDataWithLocales(country)) { + const { + sub_keys: subKeys, + sub_names: subNames, + sub_lnames: subLnames, + sub_isoids: subIsoids, + } = metadata; + if (!subKeys) { + continue; + } + + // Use latin names if available, otherwise use native names. + const targetNames = subLnames || subNames || subKeys; + + // Check if the abbreviatedValue matches an ISO ID (e.g. "B") or a key (which may also be an ISO ID). + let matchIndex = subKeys.findIndex(key => + this.strCompare(abbreviatedValue, key, collators) + ); + + if (matchIndex === -1 && subIsoids) { + matchIndex = subIsoids.findIndex(isoid => + this.strCompare(abbreviatedValue, isoid, collators) + ); + } + + if (matchIndex !== -1 && targetNames.length > matchIndex) { + // Return the full or latin name from the targetNames list at the matching index. + return targetNames[matchIndex]; + } + } + return null; + }, + /** * Find the option element from select element. * 1. Try to find the locale using the country from address. @@ -919,7 +952,8 @@ FormAutofillUtils = { continue; } // Apply sub_lnames if sub_names does not exist - let names = dataset.sub_names || dataset.sub_lnames; + let names = + dataset.sub_names || dataset.sub_lnames || dataset.sub_keys; let isoids = dataset.sub_isoids; // Go through options one by one to find a match. @@ -1119,7 +1153,7 @@ FormAutofillUtils = { ? this.strInclude(value, name, collators) : this.strCompare(value, name, collators) ); - if (index === -1) { + if (index === -1 && isoids) { index = isoids.findIndex(isoid => inexactMatch ? this.strInclude(value, isoid, collators) @@ -1463,15 +1497,6 @@ XPCOMUtils.defineLazyPreferenceGetter( pref => parseFloat(pref) ); -XPCOMUtils.defineLazyPreferenceGetter( - FormAutofillUtils, - "ccFathomHighConfidenceThreshold", - "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", - null, - null, - pref => parseFloat(pref) -); - XPCOMUtils.defineLazyPreferenceGetter( FormAutofillUtils, "ccFathomTestConfidence", diff --git a/firefox-ios/Client/Assets/CC_Script/FormLikeFactory.sys.mjs b/firefox-ios/Client/Assets/CC_Script/FormLikeFactory.sys.mjs index 3da14260a8bde..6716469824aeb 100644 --- a/firefox-ios/Client/Assets/CC_Script/FormLikeFactory.sys.mjs +++ b/firefox-ios/Client/Assets/CC_Script/FormLikeFactory.sys.mjs @@ -80,7 +80,7 @@ export let FormLikeFactory = { * "forms" (e.g. registration and login) on one page with a
. * * @param {HTMLElement} aDocumentRoot - * @param {Object} aOptions + * @param {object} aOptions * @param {boolean} [aOptions.ignoreForm = false] * True to always use owner document as the `form` * @return {formLike} @@ -136,7 +136,7 @@ export let FormLikeFactory = { * * @param {HTMLInputElement|HTMLSelectElement} aField * an ,