diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs')
-rw-r--r-- | toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs | 411 |
1 files changed, 411 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs new file mode 100644 index 0000000000..49f79be77a --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs @@ -0,0 +1,411 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofillAddressSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillCreditCardSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillHeuristics: + "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", +}); + +const { FIELD_STATES } = FormAutofillUtils; + +/** + * Handles profile autofill for a DOM Form element. + */ +export class FormAutofillHandler { + // The window to which this form belongs + window = null; + + // A WindowUtils reference of which Window the form belongs + winUtils = null; + + // DOM Form element to which this object is attached + form = null; + + // An array of section that are found in this form + sections = []; + + // The section contains the focused input + #focusedSection = null; + + // Caches the element to section mapping + #cachedSectionByElement = new WeakMap(); + + // Keeps track of filled state for all identified elements + #filledStateByElement = new WeakMap(); + /** + * Array of collected data about relevant form fields. Each item is an object + * storing the identifying details of the field and a reference to the + * originally associated element from the form. + * + * The "section", "addressType", "contactType", and "fieldName" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + * + * A direct reference to the associated element cannot be sent to the user + * interface because processing may be done in the parent process. + */ + fieldDetails = null; + + /** + * Initialize the form from `FormLike` object to handle the section or form + * operations. + * + * @param {FormLike} form Form that need to be auto filled + * @param {Function} onFormSubmitted Function that can be invoked + * to simulate form submission. Function is passed + * 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 + * when we want to suggest autofill on a form. + */ + constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) { + this._updateForm(form); + + this.window = this.form.rootElement.ownerGlobal; + this.winUtils = this.window.windowUtils; + + // Enum for form autofill MANUALLY_MANAGED_STATES values + this.FIELD_STATE_ENUM = { + // not themed + [FIELD_STATES.NORMAL]: null, + // highlighted + [FIELD_STATES.AUTO_FILLED]: "autofill", + // highlighted && grey color text + [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", + }; + + /** + * This function is used if the form handler (or one of its sections) + * determines that it needs to act as if the form had been submitted. + */ + this.onFormSubmitted = formSubmissionReason => { + onFormSubmitted(this.form, formSubmissionReason, this.window, this); + }; + + this.onAutofillCallback = onAutofillCallback; + + ChromeUtils.defineLazyGetter(this, "log", () => + FormAutofill.defineLogGetter(this, "FormAutofillHandler") + ); + } + + handleEvent(event) { + switch (event.type) { + case "input": { + if (!event.isTrusted) { + return; + } + const target = event.target; + const targetFieldDetail = this.getFieldDetailByElement(target); + const isCreditCardField = FormAutofillUtils.isCreditCardField( + targetFieldDetail.fieldName + ); + + // If the user manually blanks a credit card field, then + // we want the popup to be activated. + if ( + !HTMLSelectElement.isInstance(target) && + isCreditCardField && + target.value === "" + ) { + this.onAutofillCallback(); + } + + if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) { + return; + } + + this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); + const section = this.getSectionByElement(targetFieldDetail.element); + section?.clearFilled(targetFieldDetail); + } + } + } + + set focusedInput(element) { + const section = this.getSectionByElement(element); + if (!section) { + return; + } + + this.#focusedSection = section; + this.#focusedSection.focusedInput = element; + } + + getSectionByElement(element) { + const section = + this.#cachedSectionByElement.get(element) ?? + this.sections.find(s => s.getFieldDetailByElement(element)); + if (!section) { + return null; + } + + this.#cachedSectionByElement.set(element, section); + return section; + } + + getFieldDetailByElement(element) { + for (const section of this.sections) { + const detail = section.getFieldDetailByElement(element); + if (detail) { + return detail; + } + } + return null; + } + + get activeSection() { + return this.#focusedSection; + } + + /** + * Check the form is necessary to be updated. This function should be able to + * detect any changes including all control elements in the form. + * + * @param {HTMLElement} element The element supposed to be in the form. + * @returns {boolean} FormAutofillHandler.form is updated or not. + */ + updateFormIfNeeded(element) { + // When the following condition happens, FormAutofillHandler.form should be + // updated: + // * The count of form controls is changed. + // * When the element can not be found in the current form. + // + // However, we should improve the function to detect the element changes. + // e.g. a tel field is changed from type="hidden" to type="tel". + + let _formLike; + const getFormLike = () => { + if (!_formLike) { + _formLike = lazy.FormLikeFactory.createFromField(element); + } + return _formLike; + }; + + const currentForm = element.form ?? getFormLike(); + if (currentForm.elements.length != this.form.elements.length) { + this.log.debug("The count of form elements is changed."); + this._updateForm(getFormLike()); + return true; + } + + if (!this.form.elements.includes(element)) { + this.log.debug("The element can not be found in the current form."); + this._updateForm(getFormLike()); + return true; + } + + return false; + } + + /** + * Update the form with a new FormLike, and the related fields should be + * updated or clear to ensure the data consistency. + * + * @param {FormLike} form a new FormLike to replace the original one. + */ + _updateForm(form) { + this.form = form; + + this.fieldDetails = null; + + this.sections = []; + this.#cachedSectionByElement = new WeakMap(); + } + + /** + * Set fieldDetails from the form about fields that can be autofilled. + * + * @returns {Array} The valid address and credit card details. + */ + collectFormFields(ignoreInvalid = true) { + const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form); + const allValidDetails = []; + for (const section of sections) { + // We don't support csc field, so remove csc fields from section + const fieldDetails = section.fieldDetails.filter( + f => !["cc-csc"].includes(f.fieldName) + ); + if (!fieldDetails.length) { + continue; + } + + let autofillableSection; + if (section.type == lazy.FormSection.ADDRESS) { + autofillableSection = new lazy.FormAutofillAddressSection( + fieldDetails, + this + ); + } else { + autofillableSection = new lazy.FormAutofillCreditCardSection( + fieldDetails, + this + ); + } + + // Do not include section that is either disabled or invalid. + // We only include invalid section for testing purpose. + if ( + !autofillableSection.isEnabled() || + (ignoreInvalid && !autofillableSection.isValidSection()) + ) { + continue; + } + + this.sections.push(autofillableSection); + allValidDetails.push(...autofillableSection.fieldDetails); + } + + this.fieldDetails = allValidDetails; + return allValidDetails; + } + + #hasFilledSection() { + return this.sections.some(section => section.isFilled()); + } + + getFilledStateByElement(element) { + return this.#filledStateByElement.get(element); + } + + /** + * Change the state of a field to correspond with different presentations. + * + * @param {object} fieldDetail + * A fieldDetail of which its element is about to update the state. + * @param {string} nextState + * Used to determine the next state + */ + changeFieldState(fieldDetail, nextState) { + const element = fieldDetail.element; + if (!element) { + this.log.warn( + fieldDetail.fieldName, + "is unreachable while changing state" + ); + return; + } + if (!(nextState in this.FIELD_STATE_ENUM)) { + this.log.warn( + fieldDetail.fieldName, + "is trying to change to an invalid state" + ); + return; + } + + if (this.#filledStateByElement.get(element) == nextState) { + return; + } + + let nextStateValue = null; + for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) { + // The NORMAL state is simply the absence of other manually + // managed states so we never need to add or remove it. + if (!mmStateValue) { + continue; + } + + if (state == nextState) { + nextStateValue = mmStateValue; + } else { + this.winUtils.removeManuallyManagedState(element, mmStateValue); + } + } + + if (nextStateValue) { + this.winUtils.addManuallyManagedState(element, nextStateValue); + } + + if (nextState == FIELD_STATES.AUTO_FILLED) { + element.addEventListener("input", this, { mozSystemGroup: true }); + } + + this.#filledStateByElement.set(element, nextState); + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {object} profile + * A profile to be filled in. + */ + async autofillFormFields(profile) { + const noFilledSectionsPreviously = !this.#hasFilledSection(); + await this.activeSection.autofillFields(profile); + + const onChangeHandler = e => { + if (!e.isTrusted) { + return; + } + if (e.type == "reset") { + this.sections.map(section => section.resetFieldStates()); + } + // Unregister listeners once no field is in AUTO_FILLED state. + if (!this.#hasFilledSection()) { + this.form.rootElement.removeEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.removeEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + }; + + if (noFilledSectionsPreviously) { + // Handle the highlight style resetting caused by user's correction afterward. + this.log.debug("register change handler for filled form:", this.form); + this.form.rootElement.addEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.addEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + } + + /** + * Collect the filled sections within submitted form and convert all the valid + * field data into multiple records. + * + * @returns {object} records + * {Array.<Object>} records.address + * {Array.<Object>} records.creditCard + */ + createRecords() { + const records = { + address: [], + creditCard: [], + }; + + for (const section of this.sections) { + const secRecord = section.createRecord(); + if (!secRecord) { + continue; + } + if (section instanceof lazy.FormAutofillAddressSection) { + records.address.push(secRecord); + } else if (section instanceof lazy.FormAutofillCreditCardSection) { + records.creditCard.push(secRecord); + } else { + throw new Error("Unknown section type"); + } + } + + return records; + } +} |