diff options
Diffstat (limited to 'mobile/android/actors/GeckoViewAutoFillChild.sys.mjs')
-rw-r--r-- | mobile/android/actors/GeckoViewAutoFillChild.sys.mjs | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs b/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs new file mode 100644 index 0000000000..bd49b7aaf3 --- /dev/null +++ b/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs @@ -0,0 +1,395 @@ +/* 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 { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", + LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", +}); + +export class GeckoViewAutoFillChild extends GeckoViewActorChild { + constructor() { + super(); + + this._autofillElements = undefined; + this._autofillInfos = undefined; + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "DOMFormHasPassword": { + this.addElement( + lazy.FormLikeFactory.createFromForm(aEvent.composedTarget) + ); + break; + } + case "DOMInputPasswordAdded": { + const input = aEvent.composedTarget; + if (!input.form) { + this.addElement(lazy.FormLikeFactory.createFromField(input)); + } + break; + } + case "focusin": { + const element = aEvent.composedTarget; + if (!this.contentWindow.HTMLInputElement.isInstance(element)) { + break; + } + GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => { + if (Cu.isDeadWrapper(element)) { + // Focus element is removed or document is navigated to new page. + return; + } + const focusedElement = + Services.focus.focusedElement || + element.ownerDocument?.activeElement; + if (element == focusedElement) { + this.onFocus(focusedElement); + } + }); + break; + } + case "focusout": { + if ( + this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget) + ) { + this.onFocus(null); + } + break; + } + case "pagehide": { + if (aEvent.target === this.document) { + this.clearElements(this.browsingContext); + } + break; + } + case "pageshow": { + if (aEvent.target === this.document) { + this.scanDocument(this.document); + } + break; + } + case "PasswordManager:ShowDoorhanger": { + const { form: formLike } = aEvent.detail; + this.commitAutofill(formLike); + break; + } + } + } + + /** + * Process an auto-fillable form and send the relevant details of the form + * to Java. Multiple calls within a short time period for the same form are + * coalesced, so that, e.g., if multiple inputs are added to a form in + * succession, we will only perform one processing pass. Note that for inputs + * without forms, FormLikeFactory treats the document as the "form", but + * there is no difference in how we process them. + * + * @param aFormLike A FormLike object produced by FormLikeFactory. + */ + async addElement(aFormLike) { + debug`Adding auto-fill ${aFormLike.rootElement.tagName}`; + + const window = aFormLike.rootElement.ownerGlobal; + // Get password field to get better form data via LoginManagerChild. + let passwordField; + for (const field of aFormLike.elements) { + if ( + ChromeUtils.getClassName(field) === "HTMLInputElement" && + field.type == "password" + ) { + passwordField = field; + break; + } + } + + const loginManagerChild = lazy.LoginManagerChild.forWindow(window); + const docState = loginManagerChild.stateForDocument( + passwordField.ownerDocument + ); + const [usernameField] = docState.getUserNameAndPasswordFields( + passwordField || aFormLike.elements[0] + ); + + const focusedElement = aFormLike.rootElement.ownerDocument.activeElement; + let sendFocusEvent = aFormLike.rootElement === focusedElement; + + const rootInfo = this._getInfo( + aFormLike.rootElement, + null, + undefined, + null + ); + + rootInfo.rootUuid = rootInfo.uuid; + rootInfo.children = aFormLike.elements + .filter( + element => + element.type != "hidden" && + (!usernameField || + element.type != "text" || + element == usernameField || + (element.getAutocompleteInfo() && + element.getAutocompleteInfo().fieldName == "email")) + ) + .map(element => { + sendFocusEvent |= element === focusedElement; + return this._getInfo( + element, + rootInfo.uuid, + rootInfo.uuid, + usernameField + ); + }); + + try { + // We don't await here so that we can send a focus event immediately + // after this as the app might not know which element is focused. + const responsePromise = this.sendQuery("Add", { + node: rootInfo, + }); + + if (sendFocusEvent) { + // We might have missed sending a focus event for the active element. + this.onFocus(aFormLike.ownerDocument.activeElement); + } + + const responses = await responsePromise; + // `responses` is an object with global IDs as keys. + debug`Performing auto-fill ${Object.keys(responses)}`; + + const AUTOFILL_STATE = "autofill"; + const winUtils = window.windowUtils; + + for (const uuid in responses) { + const entry = + this._autofillElements && this._autofillElements.get(uuid); + const element = entry && entry.get(); + const value = responses[uuid] || ""; + + if ( + window.HTMLInputElement.isInstance(element) && + !element.disabled && + element.parentElement + ) { + element.setUserInput(value); + if (winUtils && element.value === value) { + // Add highlighting for autofilled fields. + winUtils.addManuallyManagedState(element, AUTOFILL_STATE); + + // Remove highlighting when the field is changed. + element.addEventListener( + "input", + _ => winUtils.removeManuallyManagedState(element, AUTOFILL_STATE), + { mozSystemGroup: true, once: true } + ); + } + } else if (element) { + warn`Don't know how to auto-fill ${element.tagName}`; + } + } + } catch (error) { + warn`Cannot perform autofill ${error}`; + } + } + + _getInfo(aElement, aParent, aRoot, aUsernameField) { + if (!this._autofillInfos) { + this._autofillInfos = new WeakMap(); + this._autofillElements = new Map(); + } + + let info = this._autofillInfos.get(aElement); + if (info) { + return info; + } + + const window = aElement.ownerGlobal; + const bounds = aElement.getBoundingClientRect(); + const isInputElement = window.HTMLInputElement.isInstance(aElement); + + info = { + isInputElement, + uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces + parentUuid: aParent, + rootUuid: aRoot, + tag: aElement.tagName, + type: isInputElement ? aElement.type : null, + value: isInputElement ? aElement.value : null, + editable: + isInputElement && + [ + "color", + "date", + "datetime-local", + "email", + "month", + "number", + "password", + "range", + "search", + "tel", + "text", + "time", + "url", + "week", + ].includes(aElement.type), + disabled: isInputElement ? aElement.disabled : null, + attributes: Object.assign( + {}, + ...Array.from(aElement.attributes) + .filter(attr => attr.localName !== "value") + .map(attr => ({ [attr.localName]: attr.value })) + ), + origin: aElement.ownerDocument.location.origin, + autofillhint: "", + bounds: { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + }, + }; + + if (aElement === aUsernameField) { + info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME + } else if (isInputElement) { + // Using autocomplete attribute if it is email. + const autocompleteInfo = aElement.getAutocompleteInfo(); + if (autocompleteInfo) { + const autocompleteAttr = autocompleteInfo.fieldName; + if (autocompleteAttr == "email") { + info.type = "email"; + } + } + } + + this._autofillInfos.set(aElement, info); + this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement)); + return info; + } + + _updateInfoValues(aElements) { + if (!this._autofillInfos) { + return []; + } + + const updated = []; + for (const element of aElements) { + const info = this._autofillInfos.get(element); + + if (!info?.isInputElement || info.value === element.value) { + continue; + } + debug`Updating value ${info.value} to ${element.value}`; + + info.value = element.value; + this._autofillInfos.set(element, info); + updated.push(info); + } + return updated; + } + + /** + * Called when an auto-fillable field is focused or blurred. + * + * @param aTarget Focused element, or null if an element has lost focus. + */ + onFocus(aTarget) { + debug`Auto-fill focus on ${aTarget && aTarget.tagName}`; + + const info = aTarget && this._autofillInfos?.get(aTarget); + if (info) { + const bounds = aTarget.getBoundingClientRect(); + const screenRect = lazy.LayoutUtils.rectToScreenRect( + aTarget.ownerGlobal, + bounds + ); + info.screenRect = { + left: screenRect.left, + top: screenRect.top, + right: screenRect.right, + bottom: screenRect.bottom, + }; + } + + if (!aTarget || info) { + this.sendAsyncMessage("Focus", { + node: info, + }); + } + } + + commitAutofill(aFormLike) { + if (!aFormLike) { + throw new Error("null-form on autofill commit"); + } + + debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`; + + const updatedNodeInfos = this._updateInfoValues([ + aFormLike.rootElement, + ...aFormLike.elements, + ]); + + for (const updatedInfo of updatedNodeInfos) { + debug`Updating node ${updatedInfo}`; + this.sendAsyncMessage("Update", { + node: updatedInfo, + }); + } + + const info = this._getInfo(aFormLike.rootElement); + if (info) { + debug`Committing node ${info}`; + this.sendAsyncMessage("Commit", { + node: info, + }); + } + } + + /** + * Clear all tracked auto-fill forms and notify Java. + */ + clearElements(browsingContext) { + this._autofillInfos = undefined; + this._autofillElements = undefined; + + if (browsingContext === browsingContext.top) { + this.sendAsyncMessage("Clear"); + } + } + + /** + * Scan for auto-fillable forms and add them if necessary. Called when a page + * is navigated to through history, in which case we don't get our typical + * "input added" notifications. + * + * @param aDoc Document to scan. + */ + scanDocument(aDoc) { + // Add forms first; only check forms with password inputs. + const inputs = aDoc.querySelectorAll("input[type=password]"); + let inputAdded = false; + for (let i = 0; i < inputs.length; i++) { + if (inputs[i].form) { + // Let addElement coalesce multiple calls for the same form. + this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form)); + } else if (!inputAdded) { + // Treat inputs without forms as one unit, and process them only once. + inputAdded = true; + this.addElement(lazy.FormLikeFactory.createFromField(inputs[i])); + } + } + } +} + +const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill"); |