mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	This mirrors the code which refills values after a form fill if a site immediately clears them. Differential Revision: https://phabricator.services.mozilla.com/D250833
		
			
				
	
	
		
			1461 lines
		
	
	
	
		
			49 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1461 lines
		
	
	
	
		
			49 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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, {
 | 
						|
  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",
 | 
						|
  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;
 | 
						|
 | 
						|
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",
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * Handles profile autofill for a DOM Form element.
 | 
						|
 */
 | 
						|
export class FormAutofillHandler {
 | 
						|
  // The window to which this form belongs
 | 
						|
  window = null;
 | 
						|
 | 
						|
  // DOM Form element to which this object is attached
 | 
						|
  form = null;
 | 
						|
 | 
						|
  // 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
 | 
						|
   * 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;
 | 
						|
 | 
						|
  /**
 | 
						|
   * Flags if the MutationObserver (this.#formMutationObserver) that is observing
 | 
						|
   * node additions/removals for the root element has been set up
 | 
						|
   */
 | 
						|
  #isObservingFormMutations = false;
 | 
						|
 | 
						|
  #formMutationObserver = null;
 | 
						|
 | 
						|
  #visibilityObserver = null;
 | 
						|
  #visibilityStateObserverByElement = new WeakMap();
 | 
						|
 | 
						|
  /**
 | 
						|
   *
 | 
						|
   * fillOnFormChangeData.isWithinDynamicFormChangeThreshold:
 | 
						|
   *              Flags if a "form-change" event is received within the timeout threshold
 | 
						|
   *              (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.
 | 
						|
   * fillOnFormChangeData.previouslyFocusedId
 | 
						|
   *              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 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.
 | 
						|
   */
 | 
						|
  #isAutofillInProgress = false;
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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} onFilledModifiedCallback Function that can be invoked
 | 
						|
   *                   when we want to suggest autofill on a form.
 | 
						|
   */
 | 
						|
  constructor(form, onFilledModifiedCallback = () => {}) {
 | 
						|
    this._updateForm(form);
 | 
						|
 | 
						|
    this.window = this.form.rootElement.ownerGlobal;
 | 
						|
 | 
						|
    this.onFilledModifiedCallback = onFilledModifiedCallback;
 | 
						|
 | 
						|
    // The identifier generated via ContentDOMReference for the root element.
 | 
						|
    this.rootElementId = FormAutofillUtils.getElementIdentifier(
 | 
						|
      form.rootElement
 | 
						|
    );
 | 
						|
 | 
						|
    ChromeUtils.defineLazyGetter(this, "log", () =>
 | 
						|
      FormAutofill.defineLogGetter(this, "FormAutofillHandler")
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  get fillOnFormChangeData() {
 | 
						|
    return this.#fillOnFormChangeData;
 | 
						|
  }
 | 
						|
 | 
						|
  clearFillOnFormChangeData() {
 | 
						|
    this.#fillOnFormChangeData = new Map();
 | 
						|
    this.#fillOnFormChangeData.isWithinDynamicFormChangeThreshold = false;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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<FieldDetail>}
 | 
						|
   *          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<FieldDetail>} 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;
 | 
						|
  }
 | 
						|
 | 
						|
  get isAutofillInProgress() {
 | 
						|
    return this.#isAutofillInProgress;
 | 
						|
  }
 | 
						|
 | 
						|
  handleEvent(event) {
 | 
						|
    switch (event.type) {
 | 
						|
      case "input": {
 | 
						|
        if (!event.isTrusted || this.isAutofillInProgress) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        // 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 (previousState != newState) {
 | 
						|
          this.changeFieldState(fieldDetail, newState);
 | 
						|
        }
 | 
						|
 | 
						|
        this.onFilledModifiedCallback?.(fieldDetail, previousState, newState);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  getFieldDetailByName(fieldName) {
 | 
						|
    return this.fieldDetails.find(detail => detail.fieldName == fieldName);
 | 
						|
  }
 | 
						|
 | 
						|
  getFieldDetailByElement(element) {
 | 
						|
    return this.fieldDetails.find(detail => detail.element == element);
 | 
						|
  }
 | 
						|
 | 
						|
  getFieldDetailByElementId(elementId) {
 | 
						|
    return this.fieldDetails.find(detail => detail.elementId == elementId);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Only use this API within handleEvent
 | 
						|
   */
 | 
						|
  getFilledStateByElement(element) {
 | 
						|
    return this.#filledStateByElement.get(element);
 | 
						|
  }
 | 
						|
 | 
						|
  #clearVisibilityObserver() {
 | 
						|
    this.#visibilityObserver.disconnect();
 | 
						|
    this.#visibilityObserver = null;
 | 
						|
    this.#visibilityStateObserverByElement = new WeakMap();
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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.AutofillFormFactory.createFromField(element);
 | 
						|
      }
 | 
						|
      return _formLike;
 | 
						|
    };
 | 
						|
 | 
						|
    const currentForm = 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;
 | 
						|
  }
 | 
						|
 | 
						|
  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.
 | 
						|
   *
 | 
						|
   * @param {FormLike} form a new FormLike to replace the original one.
 | 
						|
   */
 | 
						|
  _updateForm(form) {
 | 
						|
    this.form = form;
 | 
						|
 | 
						|
    this.#fieldDetails = null;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Collect <input>, <select>, and <iframe> elements from the specified form
 | 
						|
   * and return the correspond 'FieldDetail' objects.
 | 
						|
   *
 | 
						|
   * @param {formLike} formLike
 | 
						|
   *        The form that we collect information from.
 | 
						|
   * @param {boolean} includeIframe
 | 
						|
   *        True to add <iframe> to the returned FieldDetails array.
 | 
						|
   * @param {boolean} ignoreInvisibleInput
 | 
						|
   *        True to NOT run heuristics on invisible <input> fields.
 | 
						|
   *
 | 
						|
   * @returns {Array<FieldDeail>}
 | 
						|
   *        An array containing eligible fields for autofill, also
 | 
						|
   *        including iframe.
 | 
						|
   */
 | 
						|
  static collectFormFieldDetails(
 | 
						|
    formLike,
 | 
						|
    includeIframe,
 | 
						|
    ignoreInvisibleInput = true
 | 
						|
  ) {
 | 
						|
    const fieldDetails =
 | 
						|
      lazy.FormAutofillHeuristics.getFormInfo(formLike, ignoreInvisibleInput) ??
 | 
						|
      [];
 | 
						|
 | 
						|
    // 'FormLike' only contains <input> & <select>, so in order to include <iframe>
 | 
						|
    // in the list of 'FieldDetails', we need to search for <iframe> in the form.
 | 
						|
    if (!includeIframe) {
 | 
						|
      return fieldDetails;
 | 
						|
    }
 | 
						|
 | 
						|
    // Insert <iframe> elements into the fieldDetails array, maintaining the element order.
 | 
						|
    const elements = formLike.rootElement.querySelectorAll("iframe");
 | 
						|
 | 
						|
    let startIndex = 0;
 | 
						|
 | 
						|
    // eslint-disable-next-line no-labels
 | 
						|
    outer: for (const element of elements) {
 | 
						|
      if (FormAutofillUtils.isFieldVisible(element)) {
 | 
						|
        const iframeFd = lazy.FieldDetail.create(element, formLike, "iframe");
 | 
						|
 | 
						|
        for (let index = startIndex; index < fieldDetails.length; index++) {
 | 
						|
          let position = element.compareDocumentPosition(
 | 
						|
            fieldDetails[index]?.element
 | 
						|
          );
 | 
						|
          if (
 | 
						|
            position &
 | 
						|
            (Node.DOCUMENT_POSITION_FOLLOWING |
 | 
						|
              Node.DOCUMENT_POSITION_CONTAINED_BY)
 | 
						|
          ) {
 | 
						|
            fieldDetails.splice(index, 0, iframeFd);
 | 
						|
            startIndex = index; // start from this index for later iframes
 | 
						|
            // eslint-disable-next-line no-labels
 | 
						|
            continue outer;
 | 
						|
          }
 | 
						|
        }
 | 
						|
 | 
						|
        fieldDetails.push(iframeFd);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return fieldDetails;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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
 | 
						|
   */
 | 
						|
  resetFieldStateWhenRemoved(element) {
 | 
						|
    if (this.getFilledStateByElement(element) != FIELD_STATES.AUTO_FILLED) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    this.#filledStateByElement.delete(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} state
 | 
						|
   *        The state to apply.
 | 
						|
   */
 | 
						|
  changeFieldState(fieldDetail, state) {
 | 
						|
    const element = fieldDetail.element;
 | 
						|
    if (!element) {
 | 
						|
      this.log.warn(
 | 
						|
        fieldDetail.fieldName,
 | 
						|
        "is unreachable while changing state"
 | 
						|
      );
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (!Object.values(FIELD_STATES).includes(state)) {
 | 
						|
      this.log.warn(
 | 
						|
        fieldDetail.fieldName,
 | 
						|
        "is trying to change to an invalid state"
 | 
						|
      );
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    element.autofillState = state;
 | 
						|
    this.#filledStateByElement.set(element, state);
 | 
						|
 | 
						|
    if (state == FIELD_STATES.AUTO_FILLED) {
 | 
						|
      element.addEventListener("input", this, { mozSystemGroup: true });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Populates result to the preview layers with given profile.
 | 
						|
   *
 | 
						|
   * @param {Array} elementIds
 | 
						|
   * @param {object} profile
 | 
						|
   *        A profile to be previewed with
 | 
						|
   */
 | 
						|
  previewFields(elementIds, profile) {
 | 
						|
    this.getAdaptedProfiles([profile]);
 | 
						|
 | 
						|
    for (const fieldDetail of this.fieldDetails) {
 | 
						|
      const element = fieldDetail.element;
 | 
						|
 | 
						|
      // Skip the field if it is null or readonly or disabled
 | 
						|
      if (
 | 
						|
        !elementIds.includes(fieldDetail.elementId) ||
 | 
						|
        !FormAutofillUtils.isFieldAutofillable(element)
 | 
						|
      ) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      let value = this.getFilledValueFromProfile(fieldDetail, profile);
 | 
						|
      if (!value) {
 | 
						|
        this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      if (FormAutofillUtils.isTextControl(element)) {
 | 
						|
        if (element.value && element.value != element.defaultValue) {
 | 
						|
          // Skip the field if the user has already entered text and that text
 | 
						|
          // is not the site prefilled value.
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
      } else if (HTMLSelectElement.isInstance(element)) {
 | 
						|
        // Unlike text input, select element is always previewed even if
 | 
						|
        // the option is already selected.
 | 
						|
        const option = this.matchSelectOptions(fieldDetail, profile);
 | 
						|
        value = option?.text ?? "";
 | 
						|
      } else {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      element.previewValue = value?.toString().replaceAll("*", "•");
 | 
						|
      this.changeFieldState(fieldDetail, FIELD_STATES.PREVIEW);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Processes form fields that can be autofilled, and populates them with the
 | 
						|
   * profile provided by backend.
 | 
						|
   *
 | 
						|
   * @param {string} focusedId
 | 
						|
   *        The id of the element that triggers autofilling.
 | 
						|
   * @param {Array} elementIds
 | 
						|
   *        An array of IDs for the elements that should be autofilled.
 | 
						|
   * @param {object} profile
 | 
						|
   *        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;
 | 
						|
 | 
						|
      if (
 | 
						|
        !elementIds.includes(elementId) ||
 | 
						|
        !FormAutofillUtils.isFieldAutofillable(element)
 | 
						|
      ) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      element.previewValue = "";
 | 
						|
 | 
						|
      if (FormAutofillUtils.isTextControl(element)) {
 | 
						|
        // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field
 | 
						|
        // that is generated when presentation ready data doesn't fit into the autofilling element.
 | 
						|
        // For example, autofilling expiration month into an input element will not work as expected if
 | 
						|
        // the month is less than 10, since the input is expected a zero-padded string.
 | 
						|
        // See Bug 1722941 for follow up.
 | 
						|
        const value = this.getFilledValueFromProfile(fieldDetail, profile);
 | 
						|
        if (!value) {
 | 
						|
          continue;
 | 
						|
        }
 | 
						|
 | 
						|
        // For the focused input element, it will be filled with a valid value
 | 
						|
        // anyway.
 | 
						|
        // For the others, the fields should be only filled when their values are empty
 | 
						|
        // or their values are equal to the site prefill value
 | 
						|
        // or are the result of an earlier auto-fill.
 | 
						|
        if (
 | 
						|
          elementId == focusedId ||
 | 
						|
          !element.value ||
 | 
						|
          element.value == element.defaultValue ||
 | 
						|
          element.autofillState == FIELD_STATES.AUTO_FILLED
 | 
						|
        ) {
 | 
						|
          FormAutofillHandler.fillFieldValue(element, value);
 | 
						|
          this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
 | 
						|
          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;
 | 
						|
        }
 | 
						|
 | 
						|
        // Do not change value or dispatch events if the option is already selected.
 | 
						|
        // Use case for multiple select is not considered here.
 | 
						|
        if (!option.selected) {
 | 
						|
          option.selected = true;
 | 
						|
          FormAutofillHandler.fillFieldValue(element, option.value);
 | 
						|
        }
 | 
						|
        // Autofill highlight appears regardless if value is changed or not
 | 
						|
        this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
 | 
						|
        filledValuesByElement.set(element, option.value);
 | 
						|
      } else {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    this.focusPreviouslyFocusedElement(focusedId);
 | 
						|
    this.#isAutofillInProgress = false;
 | 
						|
 | 
						|
    this.registerFormChangeHandler();
 | 
						|
 | 
						|
    this.reassignValuesIfModified(filledValuesByElement, false);
 | 
						|
  }
 | 
						|
 | 
						|
  registerFormChangeHandler() {
 | 
						|
    if (this.onChangeHandler) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this.log.debug("register change handler for filled form:", this.form);
 | 
						|
 | 
						|
    this.onChangeHandler = e => {
 | 
						|
      if (!e.isTrusted) {
 | 
						|
        return;
 | 
						|
      }
 | 
						|
      if (e.type == "reset") {
 | 
						|
        this.cancelRefillOnSiteClearingFieldsAction();
 | 
						|
        for (const fieldDetail of this.fieldDetails) {
 | 
						|
          const element = fieldDetail.element;
 | 
						|
          element.removeEventListener("input", this, { mozSystemGroup: true });
 | 
						|
          this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      // Unregister listeners once no field is in AUTO_FILLED state.
 | 
						|
      if (
 | 
						|
        this.fieldDetails.every(
 | 
						|
          detail => detail.element.autofillState != FIELD_STATES.AUTO_FILLED
 | 
						|
        )
 | 
						|
      ) {
 | 
						|
        this.form.rootElement.removeEventListener(
 | 
						|
          "input",
 | 
						|
          this.onChangeHandler,
 | 
						|
          {
 | 
						|
            mozSystemGroup: true,
 | 
						|
          }
 | 
						|
        );
 | 
						|
        this.form.rootElement.removeEventListener(
 | 
						|
          "reset",
 | 
						|
          this.onChangeHandler,
 | 
						|
          {
 | 
						|
            mozSystemGroup: true,
 | 
						|
          }
 | 
						|
        );
 | 
						|
        this.onChangeHandler = null;
 | 
						|
      }
 | 
						|
    };
 | 
						|
 | 
						|
    // Handle the highlight style resetting caused by user's correction afterward.
 | 
						|
    this.form.rootElement.addEventListener("input", this.onChangeHandler, {
 | 
						|
      mozSystemGroup: true,
 | 
						|
    });
 | 
						|
    this.form.rootElement.addEventListener("reset", this.onChangeHandler, {
 | 
						|
      mozSystemGroup: true,
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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<HTMLElement,string>} 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.AUTO_FILLED &&
 | 
						|
          e.value === v
 | 
						|
        ) {
 | 
						|
          // 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
 | 
						|
   *         (in-)visibile elements and an intersection target (the form/document of interest).
 | 
						|
   *         (see this.setUpElementVisibilityObserver)
 | 
						|
   *      2. MutationsObserver that observes child node additions and removals
 | 
						|
   *         in the form/document of interest (see this.setUpNodesObserver)
 | 
						|
   * If a form change is observed, a "form-changed" event gets dispatched transfering
 | 
						|
   * the changed fields and the reason for the form change (see FORM_CHANGE_REASON).
 | 
						|
   */
 | 
						|
  setUpDynamicFormChangeObserver() {
 | 
						|
    if (!FormAutofill.detectDynamicFormChanges) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    this.setUpElementVisibilityObserver();
 | 
						|
    this.setUpFormNodesMutationObserver();
 | 
						|
  }
 | 
						|
 | 
						|
  #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;
 | 
						|
          }
 | 
						|
 | 
						|
          if (newState) {
 | 
						|
            nowVisible.push(observedElement);
 | 
						|
          } else {
 | 
						|
            nowInvisible.push(observedElement);
 | 
						|
          }
 | 
						|
        });
 | 
						|
 | 
						|
        if (!nowVisible.length && !nowInvisible.length) {
 | 
						|
          return;
 | 
						|
        }
 | 
						|
 | 
						|
        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.#visibilityStateObserverByElement.has(element)) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      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);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Sets up a MutationObserver for the form or document (if form-less) of interest
 | 
						|
   * in order to be notified about child nodes additions or removals.
 | 
						|
   * If any of the added/removed nodes (including the nodes in the node's subtree)
 | 
						|
   * are of an address of cc type, a "form-changed" event is dispatched.
 | 
						|
   */
 | 
						|
  setUpFormNodesMutationObserver() {
 | 
						|
    if (this.#isObservingFormMutations) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const mutationObserver = new this.window.MutationObserver(
 | 
						|
      (mutations, _) => {
 | 
						|
        const collectMutatedNodes = mutations => {
 | 
						|
          let removedNodes = new Set();
 | 
						|
          let addedNodes = new Set();
 | 
						|
          let changedSelectElements = new Set();
 | 
						|
          mutations.forEach(mutation => {
 | 
						|
            if (mutation.type == "childList") {
 | 
						|
              if (HTMLSelectElement.isInstance(mutation.target)) {
 | 
						|
                changedSelectElements.add(mutation.target);
 | 
						|
              } else if (mutation.addedNodes.length) {
 | 
						|
                addedNodes.add(...mutation.addedNodes);
 | 
						|
              } else if (mutation.removedNodes.length) {
 | 
						|
                removedNodes.add(...mutation.removedNodes);
 | 
						|
              }
 | 
						|
            }
 | 
						|
          });
 | 
						|
          return [addedNodes, removedNodes, changedSelectElements];
 | 
						|
        };
 | 
						|
 | 
						|
        const collectAllSubtreeElements = node => {
 | 
						|
          if (!node.childNodes.length) {
 | 
						|
            return node;
 | 
						|
          }
 | 
						|
          return Array.from(node.childNodes).flatMap(childNode =>
 | 
						|
            collectAllSubtreeElements(childNode)
 | 
						|
          );
 | 
						|
        };
 | 
						|
 | 
						|
        const getCCAndAddressElements = nodes => {
 | 
						|
          return nodes
 | 
						|
            .flatMap(node => collectAllSubtreeElements(node))
 | 
						|
            .filter(element =>
 | 
						|
              FormAutofillUtils.isCreditCardOrAddressFieldType(element)
 | 
						|
            );
 | 
						|
        };
 | 
						|
 | 
						|
        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 &&
 | 
						|
          !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;
 | 
						|
        }
 | 
						|
        if (relevantAddedElements.length) {
 | 
						|
          changes[FORM_CHANGE_REASON.NODES_ADDED] = relevantAddedElements;
 | 
						|
        }
 | 
						|
 | 
						|
        const formChangedEvent = new CustomEvent("form-changed", {
 | 
						|
          detail: {
 | 
						|
            form: this.form.rootElement,
 | 
						|
            changes,
 | 
						|
          },
 | 
						|
          bubbles: true,
 | 
						|
        });
 | 
						|
        this.form.ownerDocument.dispatchEvent(formChangedEvent);
 | 
						|
      }
 | 
						|
    );
 | 
						|
    const config = { childList: true, subtree: true };
 | 
						|
    this.#formMutationObserver = mutationObserver;
 | 
						|
    this.#formMutationObserver.observe(this.form.rootElement, config);
 | 
						|
    this.#isObservingFormMutations = true;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * After the form was submitted, disconnect all IntersectionObserver that
 | 
						|
   * are still observing form's elements and disconnect the MutationsOberver
 | 
						|
   * that is observing the form.
 | 
						|
   */
 | 
						|
  clearFormChangeObservers() {
 | 
						|
    if (!this.#isObservingFormMutations) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    // Disconnect intersection observers
 | 
						|
    this.#clearVisibilityObserver();
 | 
						|
    // Disconnect mutation observer
 | 
						|
    this.#formMutationObserver.disconnect();
 | 
						|
    this.#isObservingFormMutations = false;
 | 
						|
  }
 | 
						|
 | 
						|
  computeFillingValue(fieldDetail) {
 | 
						|
    const element = fieldDetail.element;
 | 
						|
    if (!element) {
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
 | 
						|
    let value = element.value.trim();
 | 
						|
    switch (fieldDetail.fieldName) {
 | 
						|
      case "address-level1":
 | 
						|
        if (HTMLSelectElement.isInstance(element)) {
 | 
						|
          // Don't save the record when the option value is empty *OR* there
 | 
						|
          // are multiple options being selected. The empty option is usually
 | 
						|
          // assumed to be default along with a meaningless text to users.
 | 
						|
          if (!value || element.selectedOptions.length != 1) {
 | 
						|
            // Keep the property and preserve more information for address updating
 | 
						|
            value = "";
 | 
						|
          } else {
 | 
						|
            const text = element.selectedOptions[0].text.trim();
 | 
						|
            value =
 | 
						|
              FormAutofillUtils.getAbbreviatedSubregionName([value, text]) ||
 | 
						|
              text;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        break;
 | 
						|
      case "country":
 | 
						|
        // This is a temporary fix. Ideally we should have either case-insensitive comparison of country codes
 | 
						|
        // or handle this elsewhere see Bug 1889234 for more context.
 | 
						|
        value = value.toUpperCase();
 | 
						|
        break;
 | 
						|
      case "cc-type":
 | 
						|
        if (
 | 
						|
          HTMLSelectElement.isInstance(element) &&
 | 
						|
          !lazy.CreditCard.isValidNetwork(value)
 | 
						|
        ) {
 | 
						|
          // Don't save the record when the option value is empty *OR* there
 | 
						|
          // are multiple options being selected. The empty option is usually
 | 
						|
          // assumed to be default along with a meaningless text to users.
 | 
						|
          if (value && element.selectedOptions.length == 1) {
 | 
						|
            const selectedOption = element.selectedOptions[0];
 | 
						|
            const networkType =
 | 
						|
              lazy.CreditCard.getNetworkFromName(selectedOption.text) ??
 | 
						|
              lazy.CreditCard.getNetworkFromName(selectedOption.value);
 | 
						|
            if (networkType) {
 | 
						|
              value = networkType;
 | 
						|
            }
 | 
						|
          }
 | 
						|
        }
 | 
						|
        break;
 | 
						|
    }
 | 
						|
 | 
						|
    return value;
 | 
						|
  }
 | 
						|
 | 
						|
  /*
 | 
						|
   * Apply both address and credit card related transformers.
 | 
						|
   *
 | 
						|
   * @param {Object} profile
 | 
						|
   *        A profile for adjusting credit card related value.
 | 
						|
   * @override
 | 
						|
   */
 | 
						|
  applyTransformers(profile) {
 | 
						|
    this.addressTransformer(profile);
 | 
						|
    this.telTransformer(profile);
 | 
						|
    this.creditCardExpiryDateTransformer(profile);
 | 
						|
    this.creditCardExpMonthAndYearTransformer(profile);
 | 
						|
    this.creditCardNameTransformer(profile);
 | 
						|
    this.adaptFieldMaxLength(profile);
 | 
						|
  }
 | 
						|
 | 
						|
  getAdaptedProfiles(originalProfiles) {
 | 
						|
    for (let profile of originalProfiles) {
 | 
						|
      this.applyTransformers(profile);
 | 
						|
    }
 | 
						|
    return originalProfiles;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Match the select option for a field if we autofill with the given profile.
 | 
						|
   * This function caches the matching result in the `#matchingSelectionOption`
 | 
						|
   * variable.
 | 
						|
   *
 | 
						|
   * @param {FieldDetail} fieldDetail
 | 
						|
   *        The field information of the matching element.
 | 
						|
   * @param {object} profile
 | 
						|
   *        The profile used for autofill.
 | 
						|
   *
 | 
						|
   * @returns {Option}
 | 
						|
   *        The matched option, or undefined if no matching option is found.
 | 
						|
   */
 | 
						|
  matchSelectOptions(fieldDetail, profile) {
 | 
						|
    if (!this.#matchingSelectOption) {
 | 
						|
      this.#matchingSelectOption = new WeakMap();
 | 
						|
    }
 | 
						|
 | 
						|
    const { element, fieldName } = fieldDetail;
 | 
						|
    if (!HTMLSelectElement.isInstance(element)) {
 | 
						|
      return undefined;
 | 
						|
    }
 | 
						|
 | 
						|
    const cache = this.#matchingSelectOption.get(element) || {};
 | 
						|
    const value = profile[fieldName];
 | 
						|
 | 
						|
    let option = cache[value]?.deref();
 | 
						|
 | 
						|
    if (!option || !option.isConnected) {
 | 
						|
      option = FormAutofillUtils.findSelectOption(element, profile, fieldName);
 | 
						|
 | 
						|
      if (option) {
 | 
						|
        cache[value] = new WeakRef(option);
 | 
						|
        this.#matchingSelectOption.set(element, cache);
 | 
						|
      } else if (cache[value]) {
 | 
						|
        delete cache[value];
 | 
						|
        this.#matchingSelectOption.set(element, cache);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return option;
 | 
						|
  }
 | 
						|
 | 
						|
  adaptFieldMaxLength(profile) {
 | 
						|
    for (let key in profile) {
 | 
						|
      let detail = this.getFieldDetailByName(key);
 | 
						|
      if (!detail || detail.part) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      let element = detail.element;
 | 
						|
      if (!element) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      let maxLength = element.maxLength;
 | 
						|
      if (
 | 
						|
        maxLength === undefined ||
 | 
						|
        maxLength < 0 ||
 | 
						|
        profile[key].toString().length <= maxLength
 | 
						|
      ) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
 | 
						|
      if (maxLength) {
 | 
						|
        switch (typeof profile[key]) {
 | 
						|
          case "string":
 | 
						|
            // If this is an expiration field and our previous
 | 
						|
            // adaptations haven't resulted in a string that is
 | 
						|
            // short enough to satisfy the field length, and the
 | 
						|
            // field is constrained to a length of 4 or 5, then we
 | 
						|
            // assume it is intended to hold an expiration of the
 | 
						|
            // form "MMYY" or "MM/YY".
 | 
						|
            if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) {
 | 
						|
              const month2Digits = (
 | 
						|
                "0" + profile["cc-exp-month"].toString()
 | 
						|
              ).slice(-2);
 | 
						|
              const year2Digits = profile["cc-exp-year"].toString().slice(-2);
 | 
						|
              const separator = maxLength == 5 ? "/" : "";
 | 
						|
              profile[key] = `${month2Digits}${separator}${year2Digits}`;
 | 
						|
            } else if (key == "cc-number") {
 | 
						|
              // We want to show the last four digits of credit card so that
 | 
						|
              // the masked credit card previews correctly and appears correctly
 | 
						|
              // in the autocomplete menu
 | 
						|
              profile[key] = profile[key].substr(
 | 
						|
                profile[key].length - maxLength
 | 
						|
              );
 | 
						|
            } else {
 | 
						|
              profile[key] = profile[key].substr(0, maxLength);
 | 
						|
            }
 | 
						|
            break;
 | 
						|
          case "number":
 | 
						|
            // There's no way to truncate a number smaller than a
 | 
						|
            // single digit.
 | 
						|
            if (maxLength < 1) {
 | 
						|
              maxLength = 1;
 | 
						|
            }
 | 
						|
            // The only numbers we store are expiration month/year,
 | 
						|
            // and if they truncate, we want the final digits, not
 | 
						|
            // the initial ones.
 | 
						|
            profile[key] = profile[key] % Math.pow(10, maxLength);
 | 
						|
            break;
 | 
						|
          default:
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        delete profile[key];
 | 
						|
        delete profile[`${key}-formatted`];
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Handles credit card expiry date transformation when
 | 
						|
   * the expiry date exists in a cc-exp field.
 | 
						|
   *
 | 
						|
   * @param {object} profile
 | 
						|
   */
 | 
						|
  creditCardExpiryDateTransformer(profile) {
 | 
						|
    if (!profile["cc-exp"]) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const element = this.getFieldDetailByName("cc-exp")?.element;
 | 
						|
    if (!element) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    function updateExpiry(_string, _month, _year) {
 | 
						|
      // Bug 1687681: This is a short term fix to other locales having
 | 
						|
      // different characters to represent year.
 | 
						|
      // - FR locales may use "A" to represent year.
 | 
						|
      // - DE locales may use "J" to represent year.
 | 
						|
      // - PL locales may use "R" to represent year.
 | 
						|
      // This approach will not scale well and should be investigated in a follow up bug.
 | 
						|
      const monthChars = "m";
 | 
						|
      const yearChars = "yy|aa|jj|rr";
 | 
						|
      const expiryDateFormatRegex = (firstChars, secondChars) =>
 | 
						|
        new RegExp(
 | 
						|
          "(?:\\b|^)((?:[" +
 | 
						|
            firstChars +
 | 
						|
            "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
 | 
						|
            secondChars +
 | 
						|
            "]{2}){1,2})(?:\\b|$)",
 | 
						|
          "i"
 | 
						|
        );
 | 
						|
 | 
						|
      // If the month first check finds a result, where placeholder is "mm - yyyy",
 | 
						|
      // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"]
 | 
						|
      let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string);
 | 
						|
      if (result) {
 | 
						|
        return (
 | 
						|
          _month.padStart(result[1].length, "0") +
 | 
						|
          result[2] +
 | 
						|
          _year.substr(-1 * result[3].length)
 | 
						|
        );
 | 
						|
      }
 | 
						|
 | 
						|
      // If the year first check finds a result, where placeholder is "yyyy mm",
 | 
						|
      // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"]
 | 
						|
      result = expiryDateFormatRegex(yearChars, monthChars).exec(_string);
 | 
						|
      if (result) {
 | 
						|
        return (
 | 
						|
          _year.substr(-1 * result[1].length) +
 | 
						|
          result[2] +
 | 
						|
          _month.padStart(result[3].length, "0")
 | 
						|
        );
 | 
						|
      }
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
 | 
						|
    let newExpiryString = null;
 | 
						|
    const month = profile["cc-exp-month"].toString();
 | 
						|
    const year = profile["cc-exp-year"].toString();
 | 
						|
    if (element.localName == "input") {
 | 
						|
      // Use the placeholder or label to determine the expiry string format.
 | 
						|
      const possibleExpiryStrings = [];
 | 
						|
      if (element.placeholder) {
 | 
						|
        possibleExpiryStrings.push(element.placeholder);
 | 
						|
      }
 | 
						|
      const labels = lazy.LabelUtils.findLabelElements(element);
 | 
						|
      if (labels) {
 | 
						|
        // Not consider multiple lable for now.
 | 
						|
        possibleExpiryStrings.push(element.labels[0]?.textContent);
 | 
						|
      }
 | 
						|
      if (element.previousElementSibling?.localName == "label") {
 | 
						|
        possibleExpiryStrings.push(element.previousElementSibling.textContent);
 | 
						|
      }
 | 
						|
 | 
						|
      possibleExpiryStrings.some(string => {
 | 
						|
        newExpiryString = updateExpiry(string, month, year);
 | 
						|
        return !!newExpiryString;
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
 | 
						|
    // preferred presentation format for credit card expiry dates.
 | 
						|
    profile["cc-exp"] = newExpiryString ?? `${month.padStart(2, "0")}/${year}`;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Handles credit card expiry date transformation when the expiry date exists in
 | 
						|
   * the separate cc-exp-month and cc-exp-year fields
 | 
						|
   *
 | 
						|
   * @param {object} profile
 | 
						|
   */
 | 
						|
  creditCardExpMonthAndYearTransformer(profile) {
 | 
						|
    const getInputElementByField = (field, self) => {
 | 
						|
      if (!field) {
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      const detail = self.getFieldDetailByName(field);
 | 
						|
      if (!detail) {
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      const element = detail.element;
 | 
						|
      return element.localName === "input" ? element : null;
 | 
						|
    };
 | 
						|
    const month = getInputElementByField("cc-exp-month", this);
 | 
						|
    if (month) {
 | 
						|
      // Transform the expiry month to MM since this is a common format needed for filling.
 | 
						|
      profile["cc-exp-month-formatted"] = profile["cc-exp-month"]
 | 
						|
        ?.toString()
 | 
						|
        .padStart(2, "0");
 | 
						|
    }
 | 
						|
    const year = getInputElementByField("cc-exp-year", this);
 | 
						|
    // If the expiration year element is an input,
 | 
						|
    // then we examine any placeholder to see if we should format the expiration year
 | 
						|
    // as a zero padded string in order to autofill correctly.
 | 
						|
    if (year) {
 | 
						|
      const placeholder = year.placeholder;
 | 
						|
 | 
						|
      // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits.
 | 
						|
      const result = /\b(yy|aa|jj|rr)\b/i.test(placeholder);
 | 
						|
      if (result) {
 | 
						|
        profile["cc-exp-year-formatted"] = profile["cc-exp-year"]
 | 
						|
          ?.toString()
 | 
						|
          .substring(2);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Handles credit card name transformation when the name exists in
 | 
						|
   * the separate cc-given-name, cc-middle-name, and cc-family name fields
 | 
						|
   *
 | 
						|
   * @param {object} profile
 | 
						|
   */
 | 
						|
  creditCardNameTransformer(profile) {
 | 
						|
    const name = profile["cc-name"];
 | 
						|
    if (!name) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const given = this.getFieldDetailByName("cc-given-name");
 | 
						|
    const middle = this.getFieldDetailByName("cc-middle-name");
 | 
						|
    const family = this.getFieldDetailByName("cc-family-name");
 | 
						|
    if (given || middle || family) {
 | 
						|
      const nameParts = lazy.FormAutofillNameUtils.splitName(name);
 | 
						|
      if (given && nameParts.given) {
 | 
						|
        profile["cc-given-name"] = nameParts.given;
 | 
						|
      }
 | 
						|
      if (middle && nameParts.middle) {
 | 
						|
        profile["cc-middle-name"] = nameParts.middle;
 | 
						|
      }
 | 
						|
      if (family && nameParts.family) {
 | 
						|
        profile["cc-family-name"] = nameParts.family;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  addressTransformer(profile) {
 | 
						|
    if (profile["street-address"]) {
 | 
						|
      // "-moz-street-address-one-line" is used by the labels in
 | 
						|
      // ProfileAutoCompleteResult.
 | 
						|
      profile["-moz-street-address-one-line"] =
 | 
						|
        FormAutofillUtils.toOneLineAddress(profile["street-address"]);
 | 
						|
      let streetAddressDetail = this.getFieldDetailByName("street-address");
 | 
						|
      if (
 | 
						|
        streetAddressDetail &&
 | 
						|
        FormAutofillUtils.isTextControl(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 = [];
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // If a house number field exists, split the address up into house number
 | 
						|
    // and street name.
 | 
						|
    if (this.getFieldDetailByName("address-housenumber")) {
 | 
						|
      let address = lazy.AddressParser.parseStreetAddress(
 | 
						|
        profile["street-address"]
 | 
						|
      );
 | 
						|
      if (address) {
 | 
						|
        profile["address-housenumber"] = address.street_number;
 | 
						|
        let field = this.getFieldDetailByName("address-line1")
 | 
						|
          ? "address-line1"
 | 
						|
          : "street-address";
 | 
						|
        profile[field] = address.street_name;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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"];
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   *
 | 
						|
   * @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 <select> 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;
 | 
						|
        } else {
 | 
						|
          filledValuesByElement.set(element, element.value);
 | 
						|
        }
 | 
						|
 | 
						|
        FormAutofillHandler.fillFieldValue(element, value);
 | 
						|
        this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    this.focusPreviouslyFocusedElement(focusedId);
 | 
						|
    this.#isAutofillInProgress = false;
 | 
						|
 | 
						|
    this.reassignValuesIfModified(filledValuesByElement, true);
 | 
						|
  }
 | 
						|
 | 
						|
  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);
 | 
						|
  }
 | 
						|
}
 |