Bug 1891710: part 2) Enable <Element-outerHTML.html> WPT for Trusted Types. r=smaug
[gecko.git] / mobile / android / actors / GeckoViewAutoFillChild.sys.mjs
blobbd49b7aaf38b2f3f31950140bb04c9e6e091da86
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs";
6 import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11   FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
12   LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
13   LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs",
14 });
16 export class GeckoViewAutoFillChild extends GeckoViewActorChild {
17   constructor() {
18     super();
20     this._autofillElements = undefined;
21     this._autofillInfos = undefined;
22   }
24   // eslint-disable-next-line complexity
25   handleEvent(aEvent) {
26     debug`handleEvent: ${aEvent.type}`;
27     switch (aEvent.type) {
28       case "DOMFormHasPassword": {
29         this.addElement(
30           lazy.FormLikeFactory.createFromForm(aEvent.composedTarget)
31         );
32         break;
33       }
34       case "DOMInputPasswordAdded": {
35         const input = aEvent.composedTarget;
36         if (!input.form) {
37           this.addElement(lazy.FormLikeFactory.createFromField(input));
38         }
39         break;
40       }
41       case "focusin": {
42         const element = aEvent.composedTarget;
43         if (!this.contentWindow.HTMLInputElement.isInstance(element)) {
44           break;
45         }
46         GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => {
47           if (Cu.isDeadWrapper(element)) {
48             // Focus element is removed or document is navigated to new page.
49             return;
50           }
51           const focusedElement =
52             Services.focus.focusedElement ||
53             element.ownerDocument?.activeElement;
54           if (element == focusedElement) {
55             this.onFocus(focusedElement);
56           }
57         });
58         break;
59       }
60       case "focusout": {
61         if (
62           this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget)
63         ) {
64           this.onFocus(null);
65         }
66         break;
67       }
68       case "pagehide": {
69         if (aEvent.target === this.document) {
70           this.clearElements(this.browsingContext);
71         }
72         break;
73       }
74       case "pageshow": {
75         if (aEvent.target === this.document) {
76           this.scanDocument(this.document);
77         }
78         break;
79       }
80       case "PasswordManager:ShowDoorhanger": {
81         const { form: formLike } = aEvent.detail;
82         this.commitAutofill(formLike);
83         break;
84       }
85     }
86   }
88   /**
89    * Process an auto-fillable form and send the relevant details of the form
90    * to Java. Multiple calls within a short time period for the same form are
91    * coalesced, so that, e.g., if multiple inputs are added to a form in
92    * succession, we will only perform one processing pass. Note that for inputs
93    * without forms, FormLikeFactory treats the document as the "form", but
94    * there is no difference in how we process them.
95    *
96    * @param aFormLike A FormLike object produced by FormLikeFactory.
97    */
98   async addElement(aFormLike) {
99     debug`Adding auto-fill ${aFormLike.rootElement.tagName}`;
101     const window = aFormLike.rootElement.ownerGlobal;
102     // Get password field to get better form data via LoginManagerChild.
103     let passwordField;
104     for (const field of aFormLike.elements) {
105       if (
106         ChromeUtils.getClassName(field) === "HTMLInputElement" &&
107         field.type == "password"
108       ) {
109         passwordField = field;
110         break;
111       }
112     }
114     const loginManagerChild = lazy.LoginManagerChild.forWindow(window);
115     const docState = loginManagerChild.stateForDocument(
116       passwordField.ownerDocument
117     );
118     const [usernameField] = docState.getUserNameAndPasswordFields(
119       passwordField || aFormLike.elements[0]
120     );
122     const focusedElement = aFormLike.rootElement.ownerDocument.activeElement;
123     let sendFocusEvent = aFormLike.rootElement === focusedElement;
125     const rootInfo = this._getInfo(
126       aFormLike.rootElement,
127       null,
128       undefined,
129       null
130     );
132     rootInfo.rootUuid = rootInfo.uuid;
133     rootInfo.children = aFormLike.elements
134       .filter(
135         element =>
136           element.type != "hidden" &&
137           (!usernameField ||
138             element.type != "text" ||
139             element == usernameField ||
140             (element.getAutocompleteInfo() &&
141               element.getAutocompleteInfo().fieldName == "email"))
142       )
143       .map(element => {
144         sendFocusEvent |= element === focusedElement;
145         return this._getInfo(
146           element,
147           rootInfo.uuid,
148           rootInfo.uuid,
149           usernameField
150         );
151       });
153     try {
154       // We don't await here so that we can send a focus event immediately
155       // after this as the app might not know which element is focused.
156       const responsePromise = this.sendQuery("Add", {
157         node: rootInfo,
158       });
160       if (sendFocusEvent) {
161         // We might have missed sending a focus event for the active element.
162         this.onFocus(aFormLike.ownerDocument.activeElement);
163       }
165       const responses = await responsePromise;
166       // `responses` is an object with global IDs as keys.
167       debug`Performing auto-fill ${Object.keys(responses)}`;
169       const AUTOFILL_STATE = "autofill";
170       const winUtils = window.windowUtils;
172       for (const uuid in responses) {
173         const entry =
174           this._autofillElements && this._autofillElements.get(uuid);
175         const element = entry && entry.get();
176         const value = responses[uuid] || "";
178         if (
179           window.HTMLInputElement.isInstance(element) &&
180           !element.disabled &&
181           element.parentElement
182         ) {
183           element.setUserInput(value);
184           if (winUtils && element.value === value) {
185             // Add highlighting for autofilled fields.
186             winUtils.addManuallyManagedState(element, AUTOFILL_STATE);
188             // Remove highlighting when the field is changed.
189             element.addEventListener(
190               "input",
191               _ => winUtils.removeManuallyManagedState(element, AUTOFILL_STATE),
192               { mozSystemGroup: true, once: true }
193             );
194           }
195         } else if (element) {
196           warn`Don't know how to auto-fill ${element.tagName}`;
197         }
198       }
199     } catch (error) {
200       warn`Cannot perform autofill ${error}`;
201     }
202   }
204   _getInfo(aElement, aParent, aRoot, aUsernameField) {
205     if (!this._autofillInfos) {
206       this._autofillInfos = new WeakMap();
207       this._autofillElements = new Map();
208     }
210     let info = this._autofillInfos.get(aElement);
211     if (info) {
212       return info;
213     }
215     const window = aElement.ownerGlobal;
216     const bounds = aElement.getBoundingClientRect();
217     const isInputElement = window.HTMLInputElement.isInstance(aElement);
219     info = {
220       isInputElement,
221       uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces
222       parentUuid: aParent,
223       rootUuid: aRoot,
224       tag: aElement.tagName,
225       type: isInputElement ? aElement.type : null,
226       value: isInputElement ? aElement.value : null,
227       editable:
228         isInputElement &&
229         [
230           "color",
231           "date",
232           "datetime-local",
233           "email",
234           "month",
235           "number",
236           "password",
237           "range",
238           "search",
239           "tel",
240           "text",
241           "time",
242           "url",
243           "week",
244         ].includes(aElement.type),
245       disabled: isInputElement ? aElement.disabled : null,
246       attributes: Object.assign(
247         {},
248         ...Array.from(aElement.attributes)
249           .filter(attr => attr.localName !== "value")
250           .map(attr => ({ [attr.localName]: attr.value }))
251       ),
252       origin: aElement.ownerDocument.location.origin,
253       autofillhint: "",
254       bounds: {
255         left: bounds.left,
256         top: bounds.top,
257         right: bounds.right,
258         bottom: bounds.bottom,
259       },
260     };
262     if (aElement === aUsernameField) {
263       info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME
264     } else if (isInputElement) {
265       // Using autocomplete attribute if it is email.
266       const autocompleteInfo = aElement.getAutocompleteInfo();
267       if (autocompleteInfo) {
268         const autocompleteAttr = autocompleteInfo.fieldName;
269         if (autocompleteAttr == "email") {
270           info.type = "email";
271         }
272       }
273     }
275     this._autofillInfos.set(aElement, info);
276     this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement));
277     return info;
278   }
280   _updateInfoValues(aElements) {
281     if (!this._autofillInfos) {
282       return [];
283     }
285     const updated = [];
286     for (const element of aElements) {
287       const info = this._autofillInfos.get(element);
289       if (!info?.isInputElement || info.value === element.value) {
290         continue;
291       }
292       debug`Updating value ${info.value} to ${element.value}`;
294       info.value = element.value;
295       this._autofillInfos.set(element, info);
296       updated.push(info);
297     }
298     return updated;
299   }
301   /**
302    * Called when an auto-fillable field is focused or blurred.
303    *
304    * @param aTarget Focused element, or null if an element has lost focus.
305    */
306   onFocus(aTarget) {
307     debug`Auto-fill focus on ${aTarget && aTarget.tagName}`;
309     const info = aTarget && this._autofillInfos?.get(aTarget);
310     if (info) {
311       const bounds = aTarget.getBoundingClientRect();
312       const screenRect = lazy.LayoutUtils.rectToScreenRect(
313         aTarget.ownerGlobal,
314         bounds
315       );
316       info.screenRect = {
317         left: screenRect.left,
318         top: screenRect.top,
319         right: screenRect.right,
320         bottom: screenRect.bottom,
321       };
322     }
324     if (!aTarget || info) {
325       this.sendAsyncMessage("Focus", {
326         node: info,
327       });
328     }
329   }
331   commitAutofill(aFormLike) {
332     if (!aFormLike) {
333       throw new Error("null-form on autofill commit");
334     }
336     debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`;
338     const updatedNodeInfos = this._updateInfoValues([
339       aFormLike.rootElement,
340       ...aFormLike.elements,
341     ]);
343     for (const updatedInfo of updatedNodeInfos) {
344       debug`Updating node ${updatedInfo}`;
345       this.sendAsyncMessage("Update", {
346         node: updatedInfo,
347       });
348     }
350     const info = this._getInfo(aFormLike.rootElement);
351     if (info) {
352       debug`Committing node ${info}`;
353       this.sendAsyncMessage("Commit", {
354         node: info,
355       });
356     }
357   }
359   /**
360    * Clear all tracked auto-fill forms and notify Java.
361    */
362   clearElements(browsingContext) {
363     this._autofillInfos = undefined;
364     this._autofillElements = undefined;
366     if (browsingContext === browsingContext.top) {
367       this.sendAsyncMessage("Clear");
368     }
369   }
371   /**
372    * Scan for auto-fillable forms and add them if necessary. Called when a page
373    * is navigated to through history, in which case we don't get our typical
374    * "input added" notifications.
375    *
376    * @param aDoc Document to scan.
377    */
378   scanDocument(aDoc) {
379     // Add forms first; only check forms with password inputs.
380     const inputs = aDoc.querySelectorAll("input[type=password]");
381     let inputAdded = false;
382     for (let i = 0; i < inputs.length; i++) {
383       if (inputs[i].form) {
384         // Let addElement coalesce multiple calls for the same form.
385         this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form));
386       } else if (!inputAdded) {
387         // Treat inputs without forms as one unit, and process them only once.
388         inputAdded = true;
389         this.addElement(lazy.FormLikeFactory.createFromField(inputs[i]));
390       }
391     }
392   }
395 const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill");