Source code

Revision control

Copy as Markdown

Other Tools

/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
AutofillFormFactory:
"resource://gre/modules/shared/AutofillFormFactory.sys.mjs",
AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs",
CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
GenericAutocompleteItem: "resource://gre/modules/FillHelpers.sys.mjs",
InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs",
FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
FormAutofillHandler:
"resource://gre/modules/shared/FormAutofillHandler.sys.mjs",
FORM_CHANGE_REASON:
"resource://gre/modules/shared/FormAutofillHandler.sys.mjs",
FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
FORM_SUBMISSION_REASON: "resource://gre/actors/FormHandlerChild.sys.mjs",
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
setTimeout: "resource://gre/modules/Timer.sys.mjs",
});
/**
* Handles content's interactions for the frame.
*/
export class FormAutofillChild extends JSWindowActorChild {
/**
* Keep track of autofill handlers that are waiting for the parent process
* to send back the identified result.
*/
#handlerWaitingForDetectedComplete = new Set();
/**
* Keep track of handler that are waiting for the
* notification to re-fill fields after a form change
*/
#handlerWaitingForFillOnFormChangeComplete = new Set();
constructor() {
super();
this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillChild");
this.debug("init");
this._hasDOMContentLoadedHandler = false;
this._hasRegisteredPageHide = new Set();
/**
* @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
*/
this._fieldDetailsManager = new lazy.FormStateManager(
this.onFilledModified.bind(this)
);
/**
* Tracks whether the last form submission was triggered by a form submit event,
* if so we'll ignore the page navigation that follows
*/
this.isFollowingSubmitEvent = false;
}
/**
* After the parent process finishes classifying the fields, the parent process
* informs all the child process of the classified field result. The child process
* then sets the updated result to the corresponding AutofillHandler
*
* @param {Array<FieldDetail>} fieldDetails
* An array of the identified fields.
* @param {boolean} isUpdate flags whether the field detection process
* is run due to a form change
*/
onFieldsDetectedComplete(fieldDetails, isUpdate = false) {
if (!fieldDetails.length) {
return;
}
const handler = this._fieldDetailsManager.getFormHandlerByRootElementId(
fieldDetails[0].rootElementId
);
this.#handlerWaitingForDetectedComplete.delete(handler);
if (isUpdate) {
handler.updateFormIfNeeded(fieldDetails[0].element);
this._fieldDetailsManager.addFormHandlerByElementEntries(handler);
}
handler.setIdentifiedFieldDetails(fieldDetails);
handler.setUpDynamicFormChangeObserver();
let addressFields = [];
let creditcardFields = [];
handler.fieldDetails.forEach(fd => {
if (lazy.FormAutofillUtils.isAddressField(fd.fieldName)) {
addressFields.push(fd);
} else if (lazy.FormAutofillUtils.isCreditCardField(fd.fieldName)) {
creditcardFields.push(fd);
}
});
// Bug 1905040. This is only a temporarily workaround for now to skip marking address fields
// autocompletable whenever we detect an address field. We only mark address field when
// it is a valid address section (This is done in the parent)
const addressFieldSet = new Set(addressFields.map(fd => fd.fieldName));
if (
addressFieldSet.size < lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD
) {
addressFields = [];
}
// Inform the autocomplete controller these fields are autofillable
[...addressFields, ...creditcardFields].forEach(fieldDetail => {
this.#markAsAutofillField(fieldDetail);
if (
fieldDetail.element == lazy.FormAutofillContent.focusedInput &&
!isUpdate
) {
this.showPopupIfEmpty(fieldDetail.element, fieldDetail.fieldName);
}
});
if (isUpdate) {
// The fields detection was re-run because of a form change, this means
// FormAutofillChild already registered its interest in form submissions
// in the initial field detection process
return;
}
// Do not need to listen to form submission event because if the address fields do not contain
// 'street-address' or `address-linx`, we will not save the address.
if (
creditcardFields.length ||
(addressFields.length &&
[
"street-address",
"address-line1",
"address-line2",
"address-line3",
].some(fieldName => addressFieldSet.has(fieldName)))
) {
this.manager
.getActor("FormHandler")
.registerFormSubmissionInterest(this, {
includesFormRemoval: lazy.FormAutofill.captureOnFormRemoval,
includesPageNavigation: lazy.FormAutofill.captureOnPageNavigation,
});
// TODO (Bug 1901486): Integrate pagehide to FormHandler.
if (!this._hasRegisteredPageHide.has(handler)) {
this.registerPageHide(handler);
this._hasRegisteredPageHide.add(true);
}
}
}
/**
* Filling the fields again, because a form change was detected by this or
* another FormAutofillChild immediately after an autocompletion process
* (see handler.fillOnFormChangeData.isWithinDynamicFormChangeThreshold).
*
* @param {string} focusedId element id of focused element that triggered
* the initial autocompletion process
* @param {Array<string>} ids element ids of detected fields that will be filled
* @param {object} profile profile that was used on first autcompletion process
*
* @returns {object} filled fields
*/
fillFieldsOnFormChange(focusedId, ids, profile) {
const result = this.fillFields(focusedId, ids, profile, true);
const handler = this.#getHandlerByElementId(ids[0]);
this.#handlerWaitingForFillOnFormChangeComplete.delete(handler);
return result;
}
/**
* Identifies elements that are in the associated form of the passed element.
*
* @param {Element} element
* The element to be identified.
*
* @returns {FormAutofillHandler}
* The autofill handler instance for the form that is associated with the
* passed element.
*/
identifyFieldsWhenFocused(element) {
this.debug(
`identifyFieldsWhenFocused: ${element.ownerDocument.location?.hostname}`
);
const handler = this._fieldDetailsManager.getOrCreateFormHandler(element);
// If the child process is still waiting for the parent to send to
// `onFieldsDetectedComplete` or `onFieldsUpdatedComplete` message, bail out.
if (
this.#handlerWaitingForDetectedComplete.has(handler) ||
this.#handlerWaitingForFillOnFormChangeComplete.has(handler)
) {
return;
}
// Bail out if there is nothing changed since last time we identified this element
// or there is no interested fields.
if (handler.hasIdentifiedFields() && !handler.updateFormIfNeeded(element)) {
// This is for testing purposes only. It sends a notification to indicate that the
// form has been identified and is ready to open the popup.
// If new fields are detected, the message will be sent to the parent
// once the parent finishes collecting information from sub-frames if they exist.
this.sendAsyncMessage("FormAutofill:FieldsIdentified");
const fieldName =
handler.getFieldDetailByElement(element)?.fieldName ?? "";
this.showPopupIfEmpty(element, fieldName);
} else {
const includeIframe = this.browsingContext == this.browsingContext.top;
let detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails(
handler.form,
includeIframe
);
// If none of the detected fields are credit card or address fields,
// there's no need to notify the parent because nothing will change.
if (
!detectedFields.some(
fd =>
lazy.FormAutofillUtils.isCreditCardField(fd.fieldName) ||
lazy.FormAutofillUtils.isAddressField(fd.fieldName)
)
) {
handler.setIdentifiedFieldDetails(detectedFields);
return;
}
this.sendAsyncMessage(
"FormAutofill:OnFieldsDetected",
detectedFields.map(field => field.toVanillaObject())
);
// Notify the parent about the newly identified fields because
// the autofill section information is maintained on the parent side.
this.#handlerWaitingForDetectedComplete.add(handler);
}
}
/**
* This function is called by the parent when a field is detected in another
* frame. The parent uses this function to collect field information from frames
* that are part of the same form as the detected field.
*
* @param {string} focusedBCId
* The browsing context ID of the top-level iframe
* that contains the detected field.
* Note that this value is set only when the current frame is the top-level.
*
* @returns {Array}
* Array of FieldDetail objects of identified fields (including iframes).
*/
identifyFields(focusedBCId) {
const isTop = this.browsingContext == this.browsingContext.top;
let element;
if (isTop) {
// Find the focused iframe
element = BrowsingContext.get(focusedBCId).embedderElement;
} else {
// Ignore form as long as the frame is not the top-level, which means
// we can just pick any of the eligible elements to identify.
element = this.document.querySelector("input, select, iframe");
}
if (!element) {
return [];
}
const handler = this._fieldDetailsManager.getOrCreateFormHandler(element);
// We don't have to call 'updateFormIfNeeded' like we do in
// 'identifyFieldsWhenFocused' because 'collectFormFieldDetails' doesn't use cached
// result.
const includeIframe = isTop;
const detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails(
handler.form,
includeIframe
);
if (detectedFields.length) {
// This actor should receive `onFieldsDetectedComplete`message after
// `idenitfyFields` is called
this.#handlerWaitingForDetectedComplete.add(handler);
}
return detectedFields;
}
showPopupIfEmpty(element, fieldName) {
if (element?.value?.length !== 0) {
this.debug(`Not opening popup because field is not empty.`);
return;
}
if (fieldName.startsWith("cc-") || AppConstants.platform === "android") {
lazy.FormAutofillContent.showPopup();
}
}
/**
* We received a form-submission-detected event because
* the page was navigated.
*/
onPageNavigation() {
if (!lazy.FormAutofill.captureOnPageNavigation) {
return;
}
if (this.isFollowingSubmitEvent) {
// The next page navigation should be handled as form submission again
this.isFollowingSubmitEvent = false;
return;
}
const formSubmissionReason = lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
const weakIdentifiedForms =
this._fieldDetailsManager.getWeakIdentifiedForms();
for (const form of weakIdentifiedForms) {
// Disconnected forms are captured by the form removal heuristic
if (!form.isConnected) {
continue;
}
this.formSubmitted(form, formSubmissionReason);
}
}
/**
* We received a form-submission-detected event because
* a form was removed from the DOM after a successful
* xhr/fetch request
*
* @param {Event} form form to be submitted
*/
onFormRemoval(form) {
if (!lazy.FormAutofill.captureOnFormRemoval) {
return;
}
const formSubmissionReason =
lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;
this.formSubmitted(form, formSubmissionReason);
this.manager.getActor("FormHandler").unregisterFormRemovalInterest(this);
}
registerPageHide(handler) {
// Check whether the section is in an <iframe>; and, if so,
// watch for the <iframe> to pagehide.
if (this.browsingContext != this.browsingContext.top) {
this.debug(
"Address/Credit card form is in an iframe -- watching for pagehide"
);
handler.window.addEventListener(
"pagehide",
() => {
this.debug("Credit card subframe is pagehiding", handler.form);
const reason = lazy.FORM_SUBMISSION_REASON.IFRAME_PAGEHIDE;
this.formSubmitted(handler.form.rootElement, reason, handler);
this._hasRegisteredPageHide.delete(handler);
},
{ once: true }
);
}
}
shouldIgnoreFormAutofillEvent(event) {
if (!event.isTrusted) {
return true;
}
if (
!lazy.FormAutofill.isAutofillCreditCardsAvailable &&
!lazy.FormAutofill.isAutofillAddressesAvailable
) {
return true;
}
const nodePrincipal = event.target.nodePrincipal;
return nodePrincipal.isSystemPrincipal || nodePrincipal.schemeIs("about");
}
handleEvent(evt) {
if (
!lazy.FormAutofill.isAutofillEnabled ||
this.shouldIgnoreFormAutofillEvent(evt)
) {
return;
}
if (!this.windowContext) {
// !this.windowContext must not be null, because we need the
// windowContext and/or docShell to (un)register form submission listeners
return;
}
switch (evt.type) {
case "focusin": {
this.onFocusIn(evt.target);
break;
}
case "form-changed": {
const { form, changes } = evt.detail;
this.onFormChange(form, changes);
break;
}
case "form-submission-detected": {
const { form, reason } = evt.detail;
this.onFormSubmission(form, reason);
break;
}
default: {
throw new Error("Unexpected event type");
}
}
}
onFocusIn(element) {
const handler = this._fieldDetailsManager.getFormHandler(element);
// When autofilling and clearing a field, we focus on the element before modifying the value.
// (See FormAutofillHandler.fillFieldValue and FormAutofillHandler.clearFilledFields).
// We ignore the focus event for those case to avoid showing popup while autofilling or clearing.
if (
!lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) ||
handler?.isAutofillInProgress
) {
return;
}
const doc = element.ownerDocument;
if (doc.readyState === "loading") {
// For auto-focused input, we might receive focus event before document becomes ready.
// When this happens, run field identification after receiving `DOMContentLoaded` event
if (!this._hasDOMContentLoadedHandler) {
this._hasDOMContentLoadedHandler = true;
doc.addEventListener(
"DOMContentLoaded",
() => this.onFocusIn(lazy.FormAutofillContent.focusedInput),
{ once: true }
);
}
return;
}
if (
AppConstants.MOZ_GECKOVIEW ||
!lazy.FormAutofillContent.savedFieldNames
) {
this.debug("onFocusIn: savedFieldNames are not known yet");
// Init can be asynchronous because we don't need anything from the parent
// at this point.
this.sendAsyncMessage("FormAutofill:InitStorage");
}
this.identifyFieldsWhenFocused(element);
}
/**
* A "form-changed" event was dispatched, because the observed document/form
* added or removed child nodes or an observed element changed its visibility state.
* A new field detection process will be initiated in the parent, if the collected fieldDetails
* from the current form/document differ from the previous state.
*
* @param {HTMLFormElement|HTMLDocument} form or document (if form-less) that contains the
* elements that were added/removed/became (in-)visible
* @param {object} changes Change details keyed by lazy.FORM_CHANGE_REASON:
* - NODES_ADDED: HTMLElement[] - nodes added
* - NODES_REMOVED: HTMLElement[] - nodes removed
* - ELEMENT_VISIBLE: HTMLElement[] - elements that became visible
* - ELEMENT_INVISIBLE: HTMLElement[] - elements that became invisible
* A form-change event is single-reasoned for visibility changes and can be multi-reasoned for mutations.
*/
async onFormChange(form, changes) {
if (!lazy.FormAutofill.detectDynamicFormChanges) {
return;
}
this.debug(
`Handling form change - infered by reason(s): ${Object.keys(changes)}`
);
// Ignore "form-changed" events with reason "visibile-element-became-invisible" if
// the affected element is disconnected. This element change is already handled by a
// "form-changed" event with reason "nodes-removed".
const invisibleElement =
changes[lazy.FORM_CHANGE_REASON.ELEMENT_INVISIBLE]?.[0];
if (invisibleElement && !invisibleElement.isConnected) {
return;
}
const formRootElementId = lazy.FormAutofillUtils.getElementIdentifier(form);
const handler =
this._fieldDetailsManager.getFormHandlerByRootElementId(
formRootElementId
);
// Not resetting the field state for elements that became invisible because the handler
// keeps tracking them if they were previously autocompleted. Their field state
// will be updated on a clearing action
const removedElements = changes[lazy.FORM_CHANGE_REASON.NODES_REMOVED];
removedElements?.forEach(element => {
handler.resetFieldStateWhenRemoved(element);
});
if (
this.#handlerWaitingForDetectedComplete.has(handler) ||
this.#handlerWaitingForFillOnFormChangeComplete.has(handler)
) {
// The child is still waiting for the parent to complete
// a previous fields detection or a previous re-filling on form change.
return;
}
// createFromField needs an input, select or iframe element
const anchorElement = handler.form.elements.find(
element =>
HTMLInputElement.isInstance(element) ||
HTMLSelectElement.isInstance(element) ||
HTMLIFrameElement.isInstance(element)
);
const currentForm = lazy.AutofillFormFactory.createFromField(anchorElement);
const currentFields =
lazy.FormAutofillHandler.collectFormFieldDetails(currentForm);
if (
currentFields.length == handler.fieldDetails.length &&
currentFields.every(
(field, idx) => field.element === handler.fieldDetails[idx].element
)
) {
// The detected form fields remain unchanged,
// so we don't notify the parent and the subtree children
return;
}
// Merging previous fields with current fields to preserve the previous element ids
// which are needed for the parent to not capture duplicates in filledResult.
const mergedFields = currentFields.map(currentField => {
const prevField = handler.getFieldDetailByElement(currentField.element);
return prevField ?? currentField;
});
this._fieldDetailsManager.removeFormHandlerByElementEntries(handler);
this.sendAsyncMessage(
"FormAutofill:OnFieldsUpdated",
mergedFields.map(field => field.toVanillaObject())
);
this.#handlerWaitingForDetectedComplete.add(handler);
if (
lazy.FormAutofill.fillOnDynamicFormChanges &&
handler.fillOnFormChangeData.isWithinDynamicFormChangeThreshold &&
!this.#handlerWaitingForFillOnFormChangeComplete.has(handler)
) {
this.#handlerWaitingForFillOnFormChangeComplete.add(handler);
this.sendAsyncMessage("FormAutofill:FillFieldsOnFormChange", {
elementId: handler.fillOnFormChangeData.previouslyFocusedId,
profile: handler.fillOnFormChangeData.previouslyUsedProfile,
});
}
}
/**
* Handle form-submission-detected event (dispatched by FormHandlerChild)
*
* Depending on the heuristic that detected the form submission,
* the form that is submitted is retrieved differently
*
* @param {HTMLFormElement} form that is being submitted
* @param {string} reason heuristic that detected the form submission
* (see FormHandlerChild.FORM_SUBMISSION_REASON)
*/
onFormSubmission(form, reason) {
switch (reason) {
case lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION:
this.onPageNavigation();
break;
case lazy.FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT:
this.formSubmitted(form, reason);
break;
case lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH:
this.onFormRemoval(form);
break;
}
}
async receiveMessage(message) {
switch (message.name) {
case "FormAutofill:FillFields": {
const { focusedId, ids, profile } = message.data;
const result = this.fillFields(focusedId, ids, profile);
this.prepareFillingFieldsOnFormChange(focusedId, ids, profile);
// Return the autofilled result to the parent. The result
// is used by both tests and telemetry.
return result;
}
case "FormAutofill:FillFieldsOnFormChange": {
const { focusedId, ids, profile } = message.data;
const result = this.fillFieldsOnFormChange(focusedId, ids, profile);
// Not preparing for another filling on form change to avoid infinite loops
return result;
}
case "FormAutofill:ClearFilledFields": {
const { focusedId, ids } = message.data;
this.clearFields(focusedId, ids);
break;
}
case "FormAutofill:PreviewFields": {
const { ids, profile } = message.data;
const handler = this.#getHandlerByElementId(ids[0]);
if (profile) {
handler?.previewFields(ids, profile);
} else {
handler?.clearPreviewedFields(ids);
}
break;
}
case "FormAutofill:IdentifyFields": {
const { focusedBCId } = message.data ?? {};
return this.identifyFields(focusedBCId).map(fieldDetail =>
fieldDetail.toVanillaObject()
);
}
case "FormAutofill:GetFilledInfo": {
const { rootElementId } = message.data;
const handler =
this._fieldDetailsManager.getFormHandlerByRootElementId(
rootElementId
);
return handler?.collectFormFilledData();
}
case "FormAutofill:InspectFields": {
const fieldDetails = this.inspectFields();
return fieldDetails.map(field => field.toVanillaObject());
}
case "FormAutofill:onFieldsDetectedComplete": {
const { fds } = message.data;
const fieldDetails = fds.map(fd =>
lazy.FieldDetail.fromVanillaObject(fd)
);
this.onFieldsDetectedComplete(fieldDetails);
break;
}
case "FormAutofill:onFieldsUpdatedComplete": {
const { fds } = message.data;
const fieldDetails = fds.map(fd =>
lazy.FieldDetail.fromVanillaObject(fd)
);
const isUpdate = true;
this.onFieldsDetectedComplete(fieldDetails, isUpdate);
break;
}
}
return true;
}
/**
* Handle a form submission and early return when:
* 1. In private browsing mode.
* 2. Could not map any autofill handler by form element.
* 3. Number of filled fields is less than autofill threshold
*
* @param {HTMLElement} formElement Root element which receives submit event.
* @param {string} formSubmissionReason Reason for invoking the form submission
* (see options for FORM_SUBMISSION_REASON in FormAutofillUtils))
* @param {object} handler FormAutofillHander, if known by caller
*/
formSubmitted(formElement, formSubmissionReason, handler = undefined) {
this.debug(`Handling form submission - infered by ${formSubmissionReason}`);
lazy.AutofillTelemetry.recordFormSubmissionHeuristicCount(
formSubmissionReason
);
if (!lazy.FormAutofill.isAutofillEnabled) {
this.debug("Form Autofill is disabled");
return;
}
// The `domWin` truthiness test is used by unit tests to bypass this check.
const domWin = formElement.ownerGlobal;
if (!domWin) {
return;
}
if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
this.debug("Ignoring submission in a private window");
return;
}
handler = handler || this._fieldDetailsManager.getFormHandler(formElement);
if (!handler) {
this.debug("Form element could not map to an existing handler");
return;
}
const formFilledData = handler.collectFormFilledData();
if (!formFilledData) {
this.debug("Form handler could not obtain filled data");
return;
}
// After a form submit event follows (most likely) a page navigation, so we set this flag
// to not handle the following one as form submission in order to avoid re-submitting the same form.
// Ideally, we should keep a record of the last submitted form details and based on that we
// should decide if we want to submit a form (bug 1895437)
this.isFollowingSubmitEvent = true;
this.sendAsyncMessage("FormAutofill:OnFormSubmit", {
rootElementId: handler.rootElementId,
formFilledData,
});
}
/**
* This is called by FormAutofillHandler
*/
onFilledModified(fieldDetail, previousState, newState) {
const element = fieldDetail.element;
if (HTMLInputElement.isInstance(element)) {
// If the user manually blanks a credit card field, then
// we want the popup to be activated.
if (
lazy.FormAutofillUtils.isCreditCardField(fieldDetail.fieldName) &&
element.value === ""
) {
lazy.FormAutofillContent.showPopup();
}
}
if (
previousState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED &&
newState == lazy.FormAutofillUtils.FIELD_STATES.NORMAL
) {
this.sendAsyncMessage(
"FormAutofill:FieldFilledModified",
fieldDetail.elementId
);
}
}
clearFields(focusedId, elementIds) {
const handler = this.#getHandlerByElementId(elementIds[0]);
handler?.clearFilledFields(focusedId, elementIds);
// Explicitly calling showPopupIfEmpty here, because FormAutofillChild is ignoring
// all focus events during the autofilling/clearing process.
const focusedElement =
lazy.FormAutofillUtils.getElementByIdentifier(focusedId);
const fieldName =
handler.getFieldDetailByElement(focusedElement)?.fieldName ?? "";
this.showPopupIfEmpty(focusedElement, fieldName);
}
async fillFields(focusedId, elementIds, profile) {
let result = new Map();
let handler;
try {
Services.obs.notifyObservers(null, "autofill-fill-starting");
handler = this.#getHandlerByElementId(elementIds[0]);
handler.fillFields(focusedId, elementIds, profile);
// Return the autofilled result to the parent. The result
// is used by both tests and telemetry.
result = handler.collectFormFilledData();
Services.obs.notifyObservers(null, "autofill-fill-complete");
} catch {}
return result;
}
/**
* Caches necessary data in handler.fillOnFormChangeData in order to fill any fields that
* are additonally detected after a form changed dynamically. This data is cleared after
* a predefined timeout threshold (see lazy.FormAutofill.fillOnDynamicFormChangeTimeout).
* The timeout gets cancelled early and the data cleared if a "click" or "keydown" event
* is dispatched on the form.
*/
prepareFillingFieldsOnFormChange(focusedId, elementIds, profile) {
if (!lazy.FormAutofill.fillOnDynamicFormChanges) {
return;
}
const handler = this.#getHandlerByElementId(elementIds[0]);
// TODO bug 1953231:
// FormAutofillParent should keep of which profile is used for which section, e.g. by introducing profile
// ids. It's not ideal that we are cachine the whole used profile data in the child and then send it back to
// the parent when filling after a form change. The parent should let the child know what profile to use.
handler.fillOnFormChangeData.previouslyUsedProfile = profile;
handler.fillOnFormChangeData.previouslyFocusedId = focusedId;
handler.fillOnFormChangeData.isWithinDynamicFormChangeThreshold = true;
const clearFillOnFormChangeTimeoutID = lazy.setTimeout(
() => {
handler.clearFillOnFormChangeData();
try {
userActedEvents.forEach(event => {
handler.form.rootElement.removeEventListener(
event,
onUserInteractionListener
);
});
} catch (e) {
// handler.form.rootElement might already be a dead object by now
}
},
// Note: The longer the timeout, the higher the possibility that all dynamic form
// changes have occured. Default timeout is 1000ms and should not be increased
// to avoid accidentially filling on non-script/user actions.
lazy.FormAutofill.fillOnDynamicFormChangeTimeout
);
const onUserInteractionListener = () => {
// User interacted with the form after it was filled
lazy.clearTimeout(clearFillOnFormChangeTimeoutID);
handler.clearFillOnFormChangeData();
};
const userActedEvents = ["click", "keydown"];
userActedEvents.forEach(event => {
handler.form.rootElement.addEventListener(
event,
onUserInteractionListener,
{ once: true }
);
});
}
/**
* Returns all the identified fields for this document.
* This function is only used by the autofill developer tool extension.
*/
inspectFields() {
const isTop = this.browsingContext == this.browsingContext.top;
const elements = isTop
? Array.from(this.document.querySelectorAll("input, select, iframe"))
: Array.from(this.document.querySelectorAll("input, select"));
// Unlike the case when users click on a field and we only run our heuristic
// on fields within the same form as the focused field, for inspection,
// we want to inspect all the forms in this page.
const roots = new Set();
let fieldDetails = [];
for (const element of elements) {
const formLike = lazy.FormLikeFactory.createFromField(element);
if (roots.has(formLike.rootElement)) {
continue;
}
roots.add(formLike.rootElement);
const handler = new lazy.FormAutofillHandler(formLike);
// Fields that cannot be recognized will still be reported with this API.
const includeIframe = isTop;
const fields = lazy.FormAutofillHandler.collectFormFieldDetails(
handler.form,
includeIframe,
false
);
fieldDetails.push(...fields);
}
// The 'fieldDetails' array are grouped by form so might not follow their
// order in the DOM tree. We rebuild the array based on their order in
// the document.
fieldDetails = elements
.map(element => fieldDetails.find(field => field.element == element))
.filter(field => !!field && field.element);
// Add a data attribute with a unique identifier to allow the inspector
// to link the element with its associated 'FieldDetail' information.
for (const fd of fieldDetails) {
const INSPECT_ATTRIBUTE = "data-moz-autofill-inspect-id";
fd.inspectId = fd.element.getAttribute(INSPECT_ATTRIBUTE);
}
return fieldDetails;
}
#markAsAutofillField(fieldDetail) {
const element = fieldDetail.element;
// Since Form Autofill popup is only for input element, any non-Input
// element should be excluded here.
if (!HTMLInputElement.isInstance(element)) {
return;
}
this.manager
.getActor("AutoComplete")
?.markAsAutoCompletableField(element, this);
}
get actorName() {
return "FormAutofill";
}
/**
* Get the search options when searching for autocomplete entries in the parent
*
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @returns {object} the search options for the input
*/
getAutoCompleteSearchOption(input) {
const fieldDetail = this._fieldDetailsManager
.getFormHandler(input)
?.getFieldDetailByElement(input);
const scenarioName = lazy.FormScenarios.detect({ input }).signUpForm
? "SignUpFormScenario"
: "";
return {
fieldName: fieldDetail?.fieldName,
elementId: fieldDetail?.elementId,
scenarioName,
};
}
/**
* Ask the provider whether it might have autocomplete entry to show
* for the given input.
*
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @returns {boolean} true if we shold search for autocomplete entries
*/
shouldSearchForAutoComplete(input) {
const fieldDetail = this._fieldDetailsManager
.getFormHandler(input)
?.getFieldDetailByElement(input);
if (!fieldDetail) {
return false;
}
const fieldName = fieldDetail.fieldName;
const isAddressField = lazy.FormAutofillUtils.isAddressField(fieldName);
const searchPermitted = isAddressField
? lazy.FormAutofill.isAutofillAddressesEnabled
: lazy.FormAutofill.isAutofillCreditCardsEnabled;
// If the specified autofill feature is pref off, do not search
if (!searchPermitted) {
return false;
}
// No profile can fill the currently-focused input.
if (!lazy.FormAutofillContent.savedFieldNames.has(fieldName)) {
return false;
}
return true;
}
/**
* Convert the search result to autocomplete results
*
* @param {string} searchString - The string to search for
* @param {HTMLInputElement} input - The input element to search for autocomplete entries
* @param {Array<object>} records - autocomplete records
* @returns {AutocompleteResult}
*/
searchResultToAutoCompleteResult(searchString, input, records) {
if (!records) {
return null;
}
const handler = this._fieldDetailsManager.getFormHandler(input);
const fieldDetail = handler?.getFieldDetailByElement(input);
if (!fieldDetail) {
return null;
}
const adaptedRecords = handler.getAdaptedProfiles(records.records);
const isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
const isInputAutofilled =
input.autofillState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED;
let AutocompleteResult;
// TODO: This should be calculated in the parent
// The field categories will be filled if the corresponding profile is
// used for autofill. We don't display this information for credit
// cards, so this is only calculated for address fields.
let fillCategories;
if (lazy.FormAutofillUtils.isAddressField(fieldDetail.fieldName)) {
AutocompleteResult = lazy.AddressResult;
fillCategories = adaptedRecords.map(profile => {
const fields = Object.keys(profile).filter(fieldName => {
const detail = handler.getFieldDetailByName(fieldName);
return detail ? handler.isFieldAutofillable(detail, profile) : false;
});
return lazy.FormAutofillUtils.getCategoriesFromFieldNames(fields);
});
} else {
AutocompleteResult = lazy.CreditCardResult;
}
const acResult = new AutocompleteResult(
searchString,
fieldDetail,
records.allFieldNames,
adaptedRecords,
fillCategories,
{ isSecure, isInputAutofilled }
);
const externalEntries = records.externalEntries;
acResult.externalEntries.push(
...externalEntries.map(
entry =>
new lazy.GenericAutocompleteItem(
entry.image,
entry.label,
entry.secondary,
entry.fillMessageName,
entry.fillMessageData
)
)
);
return acResult;
}
#getHandlerByElementId(elementId) {
const element = lazy.FormAutofillUtils.getElementByIdentifier(elementId);
return this._fieldDetailsManager.getFormHandler(element);
}
}