summaryrefslogtreecommitdiffstats
path: root/mobile/android/actors
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /mobile/android/actors
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/actors')
-rw-r--r--mobile/android/actors/ContentDelegateChild.sys.mjs167
-rw-r--r--mobile/android/actors/ContentDelegateParent.sys.mjs75
-rw-r--r--mobile/android/actors/GeckoViewAutoFillChild.sys.mjs395
-rw-r--r--mobile/android/actors/GeckoViewAutoFillParent.sys.mjs89
-rw-r--r--mobile/android/actors/GeckoViewContentChild.sys.mjs335
-rw-r--r--mobile/android/actors/GeckoViewContentParent.sys.mjs84
-rw-r--r--mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs69
-rw-r--r--mobile/android/actors/GeckoViewFormValidationChild.sys.mjs19
-rw-r--r--mobile/android/actors/GeckoViewPermissionChild.sys.mjs188
-rw-r--r--mobile/android/actors/GeckoViewPermissionParent.sys.mjs65
-rw-r--r--mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs191
-rw-r--r--mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs56
-rw-r--r--mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs7
-rw-r--r--mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs35
-rw-r--r--mobile/android/actors/GeckoViewPromptChild.sys.mjs24
-rw-r--r--mobile/android/actors/GeckoViewPrompterChild.sys.mjs94
-rw-r--r--mobile/android/actors/GeckoViewPrompterParent.sys.mjs167
-rw-r--r--mobile/android/actors/GeckoViewSettingsChild.sys.mjs26
-rw-r--r--mobile/android/actors/LoadURIDelegateChild.sys.mjs44
-rw-r--r--mobile/android/actors/LoadURIDelegateParent.sys.mjs8
-rw-r--r--mobile/android/actors/MediaControlDelegateChild.sys.mjs59
-rw-r--r--mobile/android/actors/MediaControlDelegateParent.sys.mjs8
-rw-r--r--mobile/android/actors/ProgressDelegateChild.sys.mjs26
-rw-r--r--mobile/android/actors/ProgressDelegateParent.sys.mjs7
-rw-r--r--mobile/android/actors/ScrollDelegateChild.sys.mjs31
-rw-r--r--mobile/android/actors/ScrollDelegateParent.sys.mjs8
-rw-r--r--mobile/android/actors/SelectionActionDelegateChild.sys.mjs442
-rw-r--r--mobile/android/actors/SelectionActionDelegateParent.sys.mjs72
-rw-r--r--mobile/android/actors/metrics.yaml11
-rw-r--r--mobile/android/actors/moz.build39
-rw-r--r--mobile/android/actors/tests/mochitests/head.js5
-rw-r--r--mobile/android/actors/tests/mochitests/mochitest.toml5
-rw-r--r--mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html108
33 files changed, 2959 insertions, 0 deletions
diff --git a/mobile/android/actors/ContentDelegateChild.sys.mjs b/mobile/android/actors/ContentDelegateChild.sys.mjs
new file mode 100644
index 0000000000..3fb9e4ff07
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateChild.sys.mjs
@@ -0,0 +1,167 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs",
+});
+
+export class ContentDelegateChild extends GeckoViewActorChild {
+ notifyParentOfViewportFit() {
+ if (this.triggerViewportFitChange) {
+ this.contentWindow.cancelIdleCallback(this.triggerViewportFitChange);
+ }
+ this.triggerViewportFitChange = this.contentWindow.requestIdleCallback(
+ () => {
+ this.triggerViewportFitChange = null;
+ const viewportFit = this.contentWindow.windowUtils.getViewportFitInfo();
+ if (this.lastViewportFit === viewportFit) {
+ return;
+ }
+ this.lastViewportFit = viewportFit;
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:DOMMetaViewportFit",
+ viewportfit: viewportFit,
+ });
+ }
+ );
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "contextmenu": {
+ if (aEvent.defaultPrevented) {
+ return;
+ }
+
+ function nearestParentAttribute(aNode, aAttribute) {
+ while (
+ aNode &&
+ aNode.hasAttribute &&
+ !aNode.hasAttribute(aAttribute)
+ ) {
+ aNode = aNode.parentNode;
+ }
+ return aNode && aNode.getAttribute && aNode.getAttribute(aAttribute);
+ }
+
+ function createAbsoluteUri(aBaseUri, aUri) {
+ if (!aUri || !aBaseUri || !aBaseUri.displaySpec) {
+ return null;
+ }
+ return Services.io.newURI(aUri, null, aBaseUri).displaySpec;
+ }
+
+ const node = aEvent.composedTarget;
+ const baseUri = node.ownerDocument.baseURIObject;
+ const uri = createAbsoluteUri(
+ baseUri,
+ nearestParentAttribute(node, "href")
+ );
+ const title = nearestParentAttribute(node, "title");
+ const alt = nearestParentAttribute(node, "alt");
+ const elementType = ChromeUtils.getClassName(node);
+ const isImage = elementType === "HTMLImageElement";
+ const isMedia =
+ elementType === "HTMLVideoElement" ||
+ elementType === "HTMLAudioElement";
+ let elementSrc = (isImage || isMedia) && (node.currentSrc || node.src);
+ if (elementSrc) {
+ const isBlob = elementSrc.startsWith("blob:");
+ if (isBlob && !URL.isValidObjectURL(elementSrc)) {
+ elementSrc = null;
+ }
+ }
+
+ if (uri || isImage || isMedia) {
+ const msg = {
+ type: "GeckoView:ContextMenu",
+ // We don't have full zoom on Android, so using CSS coordinates
+ // here is fine, since the CSS coordinate spaces match between the
+ // child and parent processes.
+ screenX: aEvent.screenX,
+ screenY: aEvent.screenY,
+ baseUri: (baseUri && baseUri.displaySpec) || null,
+ uri,
+ title,
+ alt,
+ elementType,
+ elementSrc: elementSrc || null,
+ textContent: node.textContent || null,
+ };
+
+ this.eventDispatcher.sendRequest(msg);
+ aEvent.preventDefault();
+ }
+ break;
+ }
+ case "MozDOMFullscreen:Request": {
+ this.sendAsyncMessage("GeckoView:DOMFullscreenRequest", {});
+ break;
+ }
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited":
+ // Content may change fullscreen state by itself, and we should ensure
+ // that the parent always exits fullscreen when content has left
+ // full screen mode.
+ if (this.contentWindow?.document.fullscreenElement) {
+ break;
+ }
+ // fall-through
+ case "MozDOMFullscreen:Exit":
+ this.sendAsyncMessage("GeckoView:DOMFullscreenExit", {});
+ break;
+ case "DOMMetaViewportFitChanged":
+ if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
+ this.notifyParentOfViewportFit();
+ }
+ break;
+ case "DOMContentLoaded": {
+ if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
+ // If loaded content doesn't have viewport-fit, parent still
+ // uses old value of previous content.
+ this.notifyParentOfViewportFit();
+ }
+ if (this.contentWindow !== this.contentWindow?.top) {
+ // Only check WebApp manifest on the top level window.
+ return;
+ }
+ this.contentWindow.requestIdleCallback(async () => {
+ const manifest = await lazy.ManifestObtainer.contentObtainManifest(
+ this.contentWindow
+ );
+ if (manifest) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:WebAppManifest",
+ manifest,
+ });
+ }
+ });
+ break;
+ }
+ case "MozFirstContentfulPaint": {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:FirstContentfulPaint",
+ });
+ break;
+ }
+ case "MozPaintStatusReset": {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PaintStatusReset",
+ });
+ break;
+ }
+ }
+ }
+}
+
+const { debug, warn } = ContentDelegateChild.initLogging(
+ "ContentDelegateChild"
+);
diff --git a/mobile/android/actors/ContentDelegateParent.sys.mjs b/mobile/android/actors/ContentDelegateParent.sys.mjs
new file mode 100644
index 0000000000..d621c80104
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateParent.sys.mjs
@@ -0,0 +1,75 @@
+/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+const { debug, warn } = GeckoViewUtils.initLogging("ContentDelegateParent");
+
+export class ContentDelegateParent extends GeckoViewActorParent {
+ didDestroy() {
+ this._didDestroy = true;
+ }
+
+ async receiveMessage(aMsg) {
+ debug`receiveMessage: ${aMsg.name}`;
+
+ switch (aMsg.name) {
+ case "GeckoView:DOMFullscreenExit": {
+ if (!this.#hasBeenDestroyed() && !this.#requestOrigin) {
+ this.#requestOrigin = this;
+ }
+ this.window.windowUtils.remoteFrameFullscreenReverted();
+ return null;
+ }
+
+ case "GeckoView:DOMFullscreenRequest": {
+ this.#requestOrigin = this;
+ this.window.windowUtils.remoteFrameFullscreenChanged(this.browser);
+ return null;
+ }
+ }
+
+ return super.receiveMessage(aMsg);
+ }
+
+ // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs
+ get #requestOrigin() {
+ const chromeBC = this.browsingContext.topChromeWindow?.browsingContext;
+ const requestOrigin = chromeBC?.fullscreenRequestOrigin;
+ return requestOrigin && requestOrigin.get();
+ }
+
+ // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs
+ set #requestOrigin(aActor) {
+ const chromeBC = this.browsingContext.topChromeWindow?.browsingContext;
+ if (!chromeBC) {
+ debug`not able to get browsingContext for chrome window.`;
+ return;
+ }
+
+ if (aActor) {
+ chromeBC.fullscreenRequestOrigin = Cu.getWeakReference(aActor);
+ return;
+ }
+ delete chromeBC.fullscreenRequestOrigin;
+ }
+
+ // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs
+ #hasBeenDestroyed() {
+ if (this._didDestroy) {
+ return true;
+ }
+
+ // The 'didDestroy' callback is not always getting called.
+ // So we can't rely on it here. Instead, we will try to access
+ // the browsing context to judge wether the actor has
+ // been destroyed or not.
+ try {
+ return !this.browsingContext;
+ } catch {
+ return true;
+ }
+ }
+}
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");
diff --git a/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs b/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs
new file mode 100644
index 0000000000..d7248d61fe
--- /dev/null
+++ b/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs
@@ -0,0 +1,89 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ gAutofillManager: "resource://gre/modules/GeckoViewAutofill.sys.mjs",
+});
+
+export class GeckoViewAutoFillParent extends GeckoViewActorParent {
+ constructor() {
+ super();
+ this.sessionId = Services.uuid.generateUUID().toString().slice(1, -1); // discard the surrounding curly braces
+ }
+
+ get rootActor() {
+ return this.browsingContext.top.currentWindowGlobal.getActor(
+ "GeckoViewAutoFill"
+ );
+ }
+
+ get autofill() {
+ return lazy.gAutofillManager.get(this.sessionId);
+ }
+
+ add(node) {
+ // We will start a new session if the current one does not exist.
+ const autofill = lazy.gAutofillManager.ensure(
+ this.sessionId,
+ this.eventDispatcher
+ );
+ return autofill?.add(node);
+ }
+
+ focus(node) {
+ this.autofill?.focus(node);
+ }
+
+ commit(node) {
+ this.autofill?.commit(node);
+ }
+
+ update(node) {
+ this.autofill?.update(node);
+ }
+
+ clear() {
+ lazy.gAutofillManager.delete(this.sessionId);
+ }
+
+ async receiveMessage(aMessage) {
+ const { name } = aMessage;
+ debug`receiveMessage ${name}`;
+
+ // We need to re-route all messages through the root actor to ensure that we
+ // have a consistent sessionId for the entire browsingContext tree.
+ switch (name) {
+ case "Add": {
+ return this.rootActor.add(aMessage.data.node);
+ }
+ case "Focus": {
+ this.rootActor.focus(aMessage.data.node);
+ break;
+ }
+ case "Update": {
+ this.rootActor.update(aMessage.data.node);
+ break;
+ }
+ case "Commit": {
+ this.rootActor.commit(aMessage.data.node);
+ break;
+ }
+ case "Clear": {
+ if (this.browsingContext === this.browsingContext.top) {
+ this.clear();
+ }
+ break;
+ }
+ }
+
+ return null;
+ }
+}
+
+const { debug, warn } =
+ GeckoViewAutoFillParent.initLogging("GeckoViewAutoFill");
diff --git a/mobile/android/actors/GeckoViewContentChild.sys.mjs b/mobile/android/actors/GeckoViewContentChild.sys.mjs
new file mode 100644
index 0000000000..97691c97fd
--- /dev/null
+++ b/mobile/android/actors/GeckoViewContentChild.sys.mjs
@@ -0,0 +1,335 @@
+/* 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";
+
+// This needs to match ScreenLength.java
+const SCREEN_LENGTH_TYPE_PIXEL = 0;
+const SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_WIDTH = 1;
+const SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_HEIGHT = 2;
+const SCREEN_LENGTH_DOCUMENT_WIDTH = 3;
+const SCREEN_LENGTH_DOCUMENT_HEIGHT = 4;
+
+// This need to match PanZoomController.java
+const SCROLL_BEHAVIOR_SMOOTH = 0;
+const SCROLL_BEHAVIOR_AUTO = 1;
+
+const SCREEN_ORIENTATION_PORTRAIT = 0;
+const SCREEN_ORIENTATION_LANDSCAPE = 1;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs",
+ SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+ Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs",
+});
+
+export class GeckoViewContentChild extends GeckoViewActorChild {
+ constructor() {
+ super();
+ this.lastOrientation = SCREEN_ORIENTATION_PORTRAIT;
+ }
+
+ actorCreated() {
+ super.actorCreated();
+
+ this.pageShow = new Promise(resolve => {
+ this.receivedPageShow = resolve;
+ });
+ }
+
+ toPixels(aLength, aType) {
+ const { contentWindow } = this;
+ if (aType === SCREEN_LENGTH_TYPE_PIXEL) {
+ return aLength;
+ } else if (aType === SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_WIDTH) {
+ return aLength * contentWindow.visualViewport.width;
+ } else if (aType === SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_HEIGHT) {
+ return aLength * contentWindow.visualViewport.height;
+ } else if (aType === SCREEN_LENGTH_DOCUMENT_WIDTH) {
+ return aLength * contentWindow.document.body.scrollWidth;
+ } else if (aType === SCREEN_LENGTH_DOCUMENT_HEIGHT) {
+ return aLength * contentWindow.document.body.scrollHeight;
+ }
+
+ return aLength;
+ }
+
+ toScrollBehavior(aBehavior) {
+ const { contentWindow } = this;
+ if (!contentWindow) {
+ return 0;
+ }
+ const { windowUtils } = contentWindow;
+ if (aBehavior === SCROLL_BEHAVIOR_SMOOTH) {
+ return windowUtils.SCROLL_MODE_SMOOTH;
+ } else if (aBehavior === SCROLL_BEHAVIOR_AUTO) {
+ return windowUtils.SCROLL_MODE_INSTANT;
+ }
+ return windowUtils.SCROLL_MODE_SMOOTH;
+ }
+
+ collectSessionState() {
+ const { docShell, contentWindow } = this;
+ const history = lazy.SessionHistory.collect(docShell);
+ let formdata = SessionStoreUtils.collectFormData(contentWindow);
+ let scrolldata = SessionStoreUtils.collectScrollPosition(contentWindow);
+
+ // Save the current document resolution.
+ let zoom = 1;
+ const domWindowUtils = contentWindow.windowUtils;
+ zoom = domWindowUtils.getResolution();
+ scrolldata = scrolldata || {};
+ scrolldata.zoom = {};
+ scrolldata.zoom.resolution = zoom;
+
+ // Save some data that'll help in adjusting the zoom level
+ // when restoring in a different screen orientation.
+ const displaySize = {};
+ const width = {},
+ height = {};
+ domWindowUtils.getDocumentViewerSize(width, height);
+
+ displaySize.width = width.value;
+ displaySize.height = height.value;
+
+ scrolldata.zoom.displaySize = displaySize;
+
+ formdata = lazy.PrivacyFilter.filterFormData(formdata || {});
+
+ return { history, formdata, scrolldata };
+ }
+
+ orientation() {
+ const currentOrientationType = this.contentWindow?.screen.orientation.type;
+ if (!currentOrientationType) {
+ // Unfortunately, we don't know current screen orientation.
+ // Return portrait as default.
+ return SCREEN_ORIENTATION_PORTRAIT;
+ }
+ if (currentOrientationType.startsWith("landscape")) {
+ return SCREEN_ORIENTATION_LANDSCAPE;
+ }
+ return SCREEN_ORIENTATION_PORTRAIT;
+ }
+
+ receiveMessage(message) {
+ const { name } = message;
+ debug`receiveMessage: ${name}`;
+
+ switch (name) {
+ case "GeckoView:DOMFullscreenEntered":
+ this.lastOrientation = this.orientation();
+ if (
+ !this.contentWindow?.windowUtils.handleFullscreenRequests() &&
+ !this.contentWindow?.document.fullscreenElement
+ ) {
+ // If we don't actually have any pending fullscreen request
+ // to handle, neither we have been in fullscreen, tell the
+ // parent to just exit.
+ const actor =
+ this.contentWindow?.windowGlobalChild?.getActor("ContentDelegate");
+ actor?.sendAsyncMessage("GeckoView:DOMFullscreenExit", {});
+ }
+ break;
+ case "GeckoView:DOMFullscreenExited":
+ // During fullscreen, window size is changed. So don't restore viewport size.
+ const restoreViewSize = this.orientation() == this.lastOrientation;
+ this.contentWindow?.windowUtils.exitFullscreen(!restoreViewSize);
+ break;
+ case "GeckoView:ZoomToInput": {
+ const { contentWindow } = this;
+ const dwu = contentWindow.windowUtils;
+
+ const zoomToFocusedInput = function () {
+ if (!dwu.flushApzRepaints()) {
+ dwu.zoomToFocusedInput();
+ return;
+ }
+ Services.obs.addObserver(function apzFlushDone() {
+ Services.obs.removeObserver(apzFlushDone, "apz-repaints-flushed");
+ dwu.zoomToFocusedInput();
+ }, "apz-repaints-flushed");
+ };
+
+ const { force } = message.data;
+
+ let gotResize = false;
+ const onResize = function () {
+ gotResize = true;
+ if (dwu.isMozAfterPaintPending) {
+ contentWindow.windowRoot.addEventListener(
+ "MozAfterPaint",
+ () => zoomToFocusedInput(),
+ { capture: true, once: true }
+ );
+ } else {
+ zoomToFocusedInput();
+ }
+ };
+
+ contentWindow.addEventListener("resize", onResize, { capture: true });
+
+ // When the keyboard is displayed, we can get one resize event,
+ // multiple resize events, or none at all. Try to handle all these
+ // cases by allowing resizing within a set interval, and still zoom to
+ // input if there is no resize event at the end of the interval.
+ contentWindow.setTimeout(() => {
+ contentWindow.removeEventListener("resize", onResize, {
+ capture: true,
+ });
+ if (!gotResize && force) {
+ onResize();
+ }
+ }, 500);
+ break;
+ }
+ case "RestoreSessionState": {
+ this.restoreSessionState(message);
+ break;
+ }
+ case "RestoreHistoryAndNavigate": {
+ const { history, switchId } = message.data;
+ if (history) {
+ lazy.SessionHistory.restore(this.docShell, history);
+ const historyIndex = history.requestedIndex - 1;
+ const webNavigation = this.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ );
+
+ if (!switchId) {
+ // TODO: Bug 1648158 This won't work for Fission or HistoryInParent.
+ webNavigation.sessionHistory.legacySHistory.reloadCurrentEntry();
+ } else {
+ webNavigation.resumeRedirectedLoad(switchId, historyIndex);
+ }
+ }
+ break;
+ }
+ case "GeckoView:UpdateInitData": {
+ // Provide a hook for native code to detect a transfer.
+ Services.obs.notifyObservers(
+ this.docShell,
+ "geckoview-content-global-transferred"
+ );
+ break;
+ }
+ case "GeckoView:ScrollBy": {
+ const x = {};
+ const y = {};
+ const { contentWindow } = this;
+ const { widthValue, widthType, heightValue, heightType, behavior } =
+ message.data;
+ contentWindow.windowUtils.getVisualViewportOffset(x, y);
+ contentWindow.windowUtils.scrollToVisual(
+ x.value + this.toPixels(widthValue, widthType),
+ y.value + this.toPixels(heightValue, heightType),
+ contentWindow.windowUtils.UPDATE_TYPE_MAIN_THREAD,
+ this.toScrollBehavior(behavior)
+ );
+ break;
+ }
+ case "GeckoView:ScrollTo": {
+ const { contentWindow } = this;
+ const { widthValue, widthType, heightValue, heightType, behavior } =
+ message.data;
+ contentWindow.windowUtils.scrollToVisual(
+ this.toPixels(widthValue, widthType),
+ this.toPixels(heightValue, heightType),
+ contentWindow.windowUtils.UPDATE_TYPE_MAIN_THREAD,
+ this.toScrollBehavior(behavior)
+ );
+ break;
+ }
+ case "CollectSessionState": {
+ return this.collectSessionState();
+ }
+ case "ContainsFormData": {
+ return this.containsFormData();
+ }
+ }
+
+ return null;
+ }
+
+ async containsFormData() {
+ const { contentWindow } = this;
+ let formdata = SessionStoreUtils.collectFormData(contentWindow);
+ formdata = lazy.PrivacyFilter.filterFormData(formdata || {});
+ if (formdata) {
+ return true;
+ }
+ return false;
+ }
+
+ async restoreSessionState(message) {
+ // Make sure we showed something before restoring scrolling and form data
+ await this.pageShow;
+
+ const { contentWindow } = this;
+ const { formdata, scrolldata } = message.data;
+
+ if (formdata) {
+ lazy.Utils.restoreFrameTreeData(
+ contentWindow,
+ formdata,
+ (frame, data) => {
+ // restore() will return false, and thus abort restoration for the
+ // current |frame| and its descendants, if |data.url| is given but
+ // doesn't match the loaded document's URL.
+ return SessionStoreUtils.restoreFormData(frame.document, data);
+ }
+ );
+ }
+
+ if (scrolldata) {
+ lazy.Utils.restoreFrameTreeData(
+ contentWindow,
+ scrolldata,
+ (frame, data) => {
+ if (data.scroll) {
+ SessionStoreUtils.restoreScrollPosition(frame, data);
+ }
+ }
+ );
+ }
+
+ if (scrolldata && scrolldata.zoom && scrolldata.zoom.displaySize) {
+ const utils = contentWindow.windowUtils;
+ // Restore zoom level.
+ utils.setRestoreResolution(
+ scrolldata.zoom.resolution,
+ scrolldata.zoom.displaySize.width,
+ scrolldata.zoom.displaySize.height
+ );
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "pageshow": {
+ this.receivedPageShow();
+ break;
+ }
+
+ case "mozcaretstatechanged":
+ if (
+ aEvent.reason === "presscaret" ||
+ aEvent.reason === "releasecaret"
+ ) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PinOnScreen",
+ pinned: aEvent.reason === "presscaret",
+ });
+ }
+ break;
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewContentChild.initLogging("GeckoViewContent");
diff --git a/mobile/android/actors/GeckoViewContentParent.sys.mjs b/mobile/android/actors/GeckoViewContentParent.sys.mjs
new file mode 100644
index 0000000000..354489054a
--- /dev/null
+++ b/mobile/android/actors/GeckoViewContentParent.sys.mjs
@@ -0,0 +1,84 @@
+/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewContentParent");
+
+export class GeckoViewContentParent extends GeckoViewActorParent {
+ async collectState() {
+ return this.sendQuery("CollectSessionState");
+ }
+
+ async containsFormData() {
+ return this.sendQuery("ContainsFormData");
+ }
+
+ restoreState({ history, switchId, formdata, scrolldata }) {
+ if (Services.appinfo.sessionHistoryInParent) {
+ const { browsingContext } = this.browser;
+ lazy.SessionHistory.restoreFromParent(
+ browsingContext.sessionHistory,
+ history
+ );
+
+ // TODO Bug 1648158 this should include scroll, form history, etc
+ return SessionStoreUtils.initializeRestore(
+ browsingContext,
+ SessionStoreUtils.constructSessionStoreRestoreData()
+ );
+ }
+
+ // Restoring is made of two parts. First we need to restore the history
+ // of the tab and navigating to the current page, after the page
+ // navigates to the current page we need to restore the state of the
+ // page (scroll position, form data, etc).
+ //
+ // We can't do everything in one step inside the child actor because
+ // the actor is recreated when navigating, so we need to keep the state
+ // on the parent side until we navigate.
+ this.sendAsyncMessage("RestoreHistoryAndNavigate", {
+ history,
+ switchId,
+ });
+
+ if (!formdata && !scrolldata) {
+ return null;
+ }
+
+ const progressFilter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+
+ const { browser } = this;
+ const progressListener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener"]),
+
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (!aWebProgress.isTopLevel) {
+ return;
+ }
+ // The actor might get recreated between navigations so we need to
+ // query it again for the up-to-date instance.
+ browser.browsingContext.currentWindowGlobal
+ .getActor("GeckoViewContent")
+ .sendAsyncMessage("RestoreSessionState", { formdata, scrolldata });
+ progressFilter.removeProgressListener(this);
+ browser.removeProgressListener(progressFilter);
+ },
+ };
+
+ const flags = Ci.nsIWebProgress.NOTIFY_LOCATION;
+ progressFilter.addProgressListener(progressListener, flags);
+ browser.addProgressListener(progressFilter, flags);
+ return null;
+ }
+}
diff --git a/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs b/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs
new file mode 100644
index 0000000000..5e83ef9063
--- /dev/null
+++ b/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs
@@ -0,0 +1,69 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class GeckoViewExperimentDelegateParent extends GeckoViewActorParent {
+ constructor() {
+ super();
+ }
+
+ /**
+ * Gets experiment information on a given feature.
+ *
+ * @param feature the experiment item to retrieve information on
+ * @returns a promise of success with a JSON message or failure
+ */
+ async getExperimentFeature(feature) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:GetExperimentFeature",
+ feature,
+ });
+ }
+
+ /**
+ * Records an exposure event, that the experiment area was encountered, on a given feature.
+ *
+ * @param feature the experiment item to record an exposure event of
+ * @returns a promise of success or failure
+ */
+ async recordExposure(feature) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:RecordExposure",
+ feature,
+ });
+ }
+
+ /**
+ * Records an exposure event on a specific experiment feature and element.
+ *
+ * Note: Use recordExposure, if the slug is not known.
+ *
+ * @param feature the experiment item to record an exposure event of
+ * @param slug a specific experiment element
+ * @returns a promise of success or failure
+ */
+ async recordExperimentExposure(feature, slug) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:RecordExperimentExposure",
+ feature,
+ slug,
+ });
+ }
+
+ /**
+ * For recording malformed configuration.
+ *
+ * @param feature the experiment item to record an exposure event of
+ * @param part malformed information to send
+ * @returns a promise of success or failure
+ */
+ async recordExperimentMalformedConfig(feature, part) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:RecordMalformedConfig",
+ feature,
+ part,
+ });
+ }
+}
diff --git a/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs b/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs
new file mode 100644
index 0000000000..869271cfe7
--- /dev/null
+++ b/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs
@@ -0,0 +1,19 @@
+/* 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";
+
+export class GeckoViewFormValidationChild extends GeckoViewActorChild {
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "MozInvalidForm": {
+ // Handle invalid form submission. If we don't hook up to this,
+ // invalid forms are allowed to be submitted!
+ aEvent.preventDefault();
+ // We should show the validation message, bug 1510450.
+ break;
+ }
+ }
+ }
+}
diff --git a/mobile/android/actors/GeckoViewPermissionChild.sys.mjs b/mobile/android/actors/GeckoViewPermissionChild.sys.mjs
new file mode 100644
index 0000000000..bbe9457cc5
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionChild.sys.mjs
@@ -0,0 +1,188 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+const PERM_ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION";
+
+const MAPPED_TO_EXTENSION_PERMISSIONS = [
+ "persistent-storage",
+ // TODO(Bug 1336194): support geolocation manifest permission
+ // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1336194#c17)l
+];
+
+export class GeckoViewPermissionChild extends GeckoViewActorChild {
+ getMediaPermission(aPermission) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:MediaPermission",
+ ...aPermission,
+ });
+ }
+
+ addCameraPermission() {
+ return this.sendQuery("AddCameraPermission");
+ }
+
+ getAppPermissions(aPermissions) {
+ return this.sendQuery("GetAppPermissions", aPermissions);
+ }
+
+ mediaRecordingStatusChanged(aDevices) {
+ return this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaRecordingStatusChanged",
+ devices: aDevices,
+ });
+ }
+
+ // Some WebAPI permissions can be requested and granted to extensions through the
+ // the extension manifest.json, which the user have been already prompted for
+ // (e.g. at install time for the one listed in the manifest.json permissions property,
+ // or at runtime through the optional_permissions property and the permissions.request
+ // WebExtensions API method).
+ //
+ // WebAPI permission that are expected to be mapped to extensions permissions are listed
+ // in the MAPPED_TO_EXTENSION_PERMISSIONS array.
+ //
+ // @param {nsIContentPermissionType} perm
+ // The WebAPI permission being requested
+ // @param {nsIContentPermissionRequest} aRequest
+ // The nsIContentPermissionRequest as received by the promptPermission method.
+ //
+ // @returns {null | { allow: boolean, permission: Object }
+ // Returns null if the request was not handled and should continue with the
+ // regular permission prompting flow, otherwise it returns an object to
+ // allow or disallow the permission request right away.
+ checkIfGrantedByExtensionPermissions(perm, aRequest) {
+ if (!aRequest.principal.addonPolicy) {
+ // Not an extension, continue with the regular permission prompting flow.
+ return null;
+ }
+
+ // Return earlier and continue with the regular permission prompting flow if the
+ // the permission is no one that can be requested from the extension manifest file.
+ if (!MAPPED_TO_EXTENSION_PERMISSIONS.includes(perm.type)) {
+ return null;
+ }
+
+ // Disallow if the extension is not active anymore.
+ if (!aRequest.principal.addonPolicy.active) {
+ return { allow: false };
+ }
+
+ // Check if the permission have been already granted to the extension, if it is allow it right away.
+ const isGranted =
+ Services.perms.testPermissionFromPrincipal(
+ aRequest.principal,
+ perm.type
+ ) === Services.perms.ALLOW_ACTION;
+ if (isGranted) {
+ return {
+ allow: true,
+ permission: { [perm.type]: Services.perms.ALLOW_ACTION },
+ };
+ }
+
+ // continue with the regular permission prompting flow otherwise.
+ return null;
+ }
+
+ async promptPermission(aRequest) {
+ // Only allow exactly one permission request here.
+ const types = aRequest.types.QueryInterface(Ci.nsIArray);
+ if (types.length !== 1) {
+ return { allow: false };
+ }
+
+ const perm = types.queryElementAt(0, Ci.nsIContentPermissionType);
+
+ // Check if the request principal is an extension principal and if the permission requested
+ // should be already granted based on the extension permissions (or disallowed right away
+ // because the extension is not enabled anymore.
+ const extensionResult = this.checkIfGrantedByExtensionPermissions(
+ perm,
+ aRequest
+ );
+ if (extensionResult) {
+ return extensionResult;
+ }
+
+ if (
+ perm.type === "desktop-notification" &&
+ !aRequest.hasValidTransientUserGestureActivation &&
+ Services.prefs.getBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ true
+ )
+ ) {
+ // We need user interaction and don't have it.
+ return { allow: false };
+ }
+
+ const principal =
+ perm.type === "storage-access"
+ ? aRequest.principal
+ : aRequest.topLevelPrincipal;
+
+ let allowOrDeny;
+ try {
+ allowOrDeny = await this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:ContentPermission",
+ uri: principal.URI.displaySpec,
+ thirdPartyOrigin: aRequest.principal.origin,
+ principal: lazy.E10SUtils.serializePrincipal(principal),
+ perm: perm.type,
+ value: perm.capability,
+ contextId: principal.originAttributes.geckoViewSessionContextId ?? null,
+ privateMode: principal.privateBrowsingId != 0,
+ });
+
+ if (allowOrDeny === Services.perms.ALLOW_ACTION) {
+ // Ask for app permission after asking for content permission.
+ if (perm.type === "geolocation") {
+ const granted = await this.getAppPermissions([
+ PERM_ACCESS_FINE_LOCATION,
+ ]);
+ allowOrDeny = granted
+ ? Services.perms.ALLOW_ACTION
+ : Services.perms.DENY_ACTION;
+ }
+ }
+ } catch (error) {
+ console.error("Permission error:", error);
+ allowOrDeny = Services.perms.DENY_ACTION;
+ }
+
+ // Manually release the target request here to facilitate garbage collection.
+ aRequest = undefined;
+
+ const allow = allowOrDeny === Services.perms.ALLOW_ACTION;
+
+ // The storage access code adds itself to the perm manager; no need for us to do it.
+ if (perm.type === "storage-access") {
+ if (allow) {
+ return { allow, permission: { "storage-access": "allow" } };
+ }
+ return { allow };
+ }
+
+ Services.perms.addFromPrincipal(
+ principal,
+ perm.type,
+ allowOrDeny,
+ Services.perms.EXPIRE_NEVER
+ );
+
+ return { allow };
+ }
+}
+
+const { debug, warn } = GeckoViewPermissionChild.initLogging(
+ "GeckoViewPermissionChild"
+);
diff --git a/mobile/android/actors/GeckoViewPermissionParent.sys.mjs b/mobile/android/actors/GeckoViewPermissionParent.sys.mjs
new file mode 100644
index 0000000000..c4a5cf56cf
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionParent.sys.mjs
@@ -0,0 +1,65 @@
+/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class GeckoViewPermissionParent extends GeckoViewActorParent {
+ _appPermissions = {};
+
+ async getAppPermissions(aPermissions) {
+ const perms = aPermissions.filter(perm => !this._appPermissions[perm]);
+ if (!perms.length) {
+ return Promise.resolve(/* granted */ true);
+ }
+
+ const granted = await this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:AndroidPermission",
+ perms,
+ });
+
+ if (granted) {
+ for (const perm of perms) {
+ this._appPermissions[perm] = true;
+ }
+ }
+
+ return granted;
+ }
+
+ addCameraPermission() {
+ const principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ this.browsingContext.top.currentWindowGlobal.documentPrincipal.origin
+ );
+
+ // Although the lifetime is "session" it will be removed upon
+ // use so it's more of a one-shot.
+ Services.perms.addFromPrincipal(
+ principal,
+ "MediaManagerVideo",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_SESSION
+ );
+
+ return null;
+ }
+
+ receiveMessage(aMessage) {
+ debug`receiveMessage ${aMessage.name}`;
+
+ switch (aMessage.name) {
+ case "GetAppPermissions": {
+ return this.getAppPermissions(aMessage.data);
+ }
+ case "AddCameraPermission": {
+ return this.addCameraPermission();
+ }
+ }
+
+ return super.receiveMessage(aMessage);
+ }
+}
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPermissionParent");
diff --git a/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs b/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs
new file mode 100644
index 0000000000..2c9d271bbd
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs
@@ -0,0 +1,191 @@
+/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "MediaManagerService",
+ "@mozilla.org/mediaManagerService;1",
+ "nsIMediaManagerService"
+);
+
+const STATUS_RECORDING = "recording";
+const STATUS_INACTIVE = "inactive";
+const TYPE_CAMERA = "camera";
+const TYPE_MICROPHONE = "microphone";
+
+export class GeckoViewPermissionProcessChild extends JSProcessActorChild {
+ getActor(window) {
+ return window.windowGlobalChild.getActor("GeckoViewPermission");
+ }
+
+ /* ---------- nsIObserver ---------- */
+ async observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "getUserMedia:ask-device-permission": {
+ await this.sendQuery("AskDevicePermission", aData);
+ Services.obs.notifyObservers(
+ aSubject,
+ "getUserMedia:got-device-permission"
+ );
+ break;
+ }
+ case "getUserMedia:request": {
+ const { callID } = aSubject;
+ const allowedDevices = await this.handleMediaRequest(aSubject);
+ Services.obs.notifyObservers(
+ allowedDevices,
+ allowedDevices
+ ? "getUserMedia:response:allow"
+ : "getUserMedia:response:deny",
+ callID
+ );
+ break;
+ }
+ case "PeerConnection:request": {
+ Services.obs.notifyObservers(
+ null,
+ "PeerConnection:response:allow",
+ aSubject.callID
+ );
+ break;
+ }
+ case "recording-device-events": {
+ this.handleRecordingDeviceEvents(aSubject);
+ break;
+ }
+ }
+ }
+
+ handleRecordingDeviceEvents(aRequest) {
+ aRequest.QueryInterface(Ci.nsIPropertyBag2);
+ const contentWindow = aRequest.getProperty("window");
+ const devices = [];
+
+ const getStatusString = function (activityStatus) {
+ switch (activityStatus) {
+ case lazy.MediaManagerService.STATE_CAPTURE_ENABLED:
+ case lazy.MediaManagerService.STATE_CAPTURE_DISABLED:
+ return STATUS_RECORDING;
+ case lazy.MediaManagerService.STATE_NOCAPTURE:
+ return STATUS_INACTIVE;
+ default:
+ throw new Error("Unexpected activityStatus value");
+ }
+ };
+
+ const hasCamera = {};
+ const hasMicrophone = {};
+ const screen = {};
+ const window = {};
+ const browser = {};
+ const mediaDevices = {};
+ lazy.MediaManagerService.mediaCaptureWindowState(
+ contentWindow,
+ hasCamera,
+ hasMicrophone,
+ screen,
+ window,
+ browser,
+ mediaDevices
+ );
+ var cameraStatus = getStatusString(hasCamera.value);
+ var microphoneStatus = getStatusString(hasMicrophone.value);
+ if (hasCamera.value != lazy.MediaManagerService.STATE_NOCAPTURE) {
+ devices.push({
+ type: TYPE_CAMERA,
+ status: cameraStatus,
+ });
+ }
+ if (hasMicrophone.value != lazy.MediaManagerService.STATE_NOCAPTURE) {
+ devices.push({
+ type: TYPE_MICROPHONE,
+ status: microphoneStatus,
+ });
+ }
+ this.getActor(contentWindow).mediaRecordingStatusChanged(devices);
+ }
+
+ async handleMediaRequest(aRequest) {
+ const constraints = aRequest.getConstraints();
+ const { devices, windowID } = aRequest;
+ const window = Services.wm.getOuterWindowWithId(windowID);
+ if (window.closed) {
+ return null;
+ }
+
+ // Release the request first
+ aRequest = undefined;
+
+ const sources = devices.map(device => {
+ device = device.QueryInterface(Ci.nsIMediaDevice);
+ return {
+ type: device.type,
+ id: device.rawId,
+ rawId: device.rawId,
+ name: device.rawName, // unfiltered device name to show to the user
+ mediaSource: device.mediaSource,
+ };
+ });
+
+ if (
+ constraints.video &&
+ !sources.some(source => source.type === "videoinput")
+ ) {
+ console.error("Media device error: no video source");
+ return null;
+ } else if (
+ constraints.audio &&
+ !sources.some(source => source.type === "audioinput")
+ ) {
+ console.error("Media device error: no audio source");
+ return null;
+ }
+
+ const response = await this.getActor(window).getMediaPermission({
+ uri: window.document.documentURI,
+ video: constraints.video
+ ? sources.filter(source => source.type === "videoinput")
+ : null,
+ audio: constraints.audio
+ ? sources.filter(source => source.type === "audioinput")
+ : null,
+ });
+
+ if (!response) {
+ // Rejected.
+ return null;
+ }
+
+ const allowedDevices = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ if (constraints.video) {
+ const video = devices.find(device => response.video === device.rawId);
+ if (!video) {
+ console.error("Media device error: invalid video id");
+ return null;
+ }
+ await this.getActor(window).addCameraPermission();
+ allowedDevices.appendElement(video);
+ }
+ if (constraints.audio) {
+ const audio = devices.find(device => response.audio === device.rawId);
+ if (!audio) {
+ console.error("Media device error: invalid audio id");
+ return null;
+ }
+ allowedDevices.appendElement(audio);
+ }
+ return allowedDevices;
+ }
+}
+
+const { debug, warn } = GeckoViewUtils.initLogging(
+ "GeckoViewPermissionProcessChild"
+);
diff --git a/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs b/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs
new file mode 100644
index 0000000000..47b98602a5
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs
@@ -0,0 +1,56 @@
+/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+
+// See: http://developer.android.com/reference/android/Manifest.permission.html
+const PERM_CAMERA = "android.permission.CAMERA";
+const PERM_RECORD_AUDIO = "android.permission.RECORD_AUDIO";
+
+export class GeckoViewPermissionProcessParent extends JSProcessActorParent {
+ async askDevicePermission(aType) {
+ const perms = [];
+ if (aType === "video" || aType === "all") {
+ perms.push(PERM_CAMERA);
+ }
+ if (aType === "audio" || aType === "all") {
+ perms.push(PERM_RECORD_AUDIO);
+ }
+
+ try {
+ // This looks sketchy but it's fine: Android needs the audio/video
+ // permission to enumerate devices, which Gecko wants to do even before
+ // we expose the list to web pages.
+ // So really it doesn't matter what the request source is, because we
+ // will, separately, issue a windowId-specific request to let the webpage
+ // actually have access to the list of devices. So even if the source of
+ // *this* request is incorrect, no actual harm will be done, as the user
+ // will always receive the request with the right origin after this.
+ const window = Services.wm.getMostRecentWindow("navigator:geckoview");
+ const windowActor = window.browsingContext.currentWindowGlobal.getActor(
+ "GeckoViewPermission"
+ );
+ await windowActor.getAppPermissions(perms);
+ } catch (error) {
+ // We can't really do anything here so we pretend we got the permission.
+ warn`Error getting app permission: ${error}`;
+ }
+ }
+
+ receiveMessage(aMessage) {
+ debug`receiveMessage ${aMessage.name}`;
+
+ switch (aMessage.name) {
+ case "AskDevicePermission": {
+ return this.askDevicePermission(aMessage.data);
+ }
+ }
+
+ return super.receiveMessage(aMessage);
+ }
+}
+
+const { debug, warn } = GeckoViewUtils.initLogging(
+ "GeckoViewPermissionProcess"
+);
diff --git a/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs b/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs
new file mode 100644
index 0000000000..4004ffa779
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs
@@ -0,0 +1,7 @@
+/* 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";
+
+export class GeckoViewPrintDelegateChild extends GeckoViewActorChild {}
diff --git a/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs b/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs
new file mode 100644
index 0000000000..db2edf652b
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs
@@ -0,0 +1,35 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class GeckoViewPrintDelegateParent extends GeckoViewActorParent {
+ constructor() {
+ super();
+ this._browserStaticClone = null;
+ }
+
+ set browserStaticClone(staticClone) {
+ this._browserStaticClone = staticClone;
+ }
+
+ get browserStaticClone() {
+ return this._browserStaticClone;
+ }
+
+ clearStaticClone() {
+ // Removes static browser element from DOM that was made for window.print
+ this.browserStaticClone?.remove();
+ this.browserStaticClone = null;
+ }
+
+ printRequest() {
+ if (this.browserStaticClone != null) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:DotPrintRequest",
+ canonicalBrowsingContextId: this.browserStaticClone.browsingContext.id,
+ });
+ }
+ }
+}
diff --git a/mobile/android/actors/GeckoViewPromptChild.sys.mjs b/mobile/android/actors/GeckoViewPromptChild.sys.mjs
new file mode 100644
index 0000000000..7e5574a7c0
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPromptChild.sys.mjs
@@ -0,0 +1,24 @@
+/* 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";
+
+export class GeckoViewPromptChild extends GeckoViewActorChild {
+ handleEvent(event) {
+ const { type } = event;
+ debug`handleEvent: ${type}`;
+
+ switch (type) {
+ case "MozOpenDateTimePicker":
+ case "mozshowdropdown":
+ case "mozshowdropdown-sourcetouch":
+ case "click":
+ case "contextmenu":
+ case "DOMPopupBlocked":
+ Services.prompt.wrappedJSObject.handleEvent(event);
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewPromptChild.initLogging("GeckoViewPrompt");
diff --git a/mobile/android/actors/GeckoViewPrompterChild.sys.mjs b/mobile/android/actors/GeckoViewPrompterChild.sys.mjs
new file mode 100644
index 0000000000..bb8c4fbcff
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrompterChild.sys.mjs
@@ -0,0 +1,94 @@
+/* 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";
+
+export class GeckoViewPrompterChild extends GeckoViewActorChild {
+ constructor() {
+ super();
+ this._prompts = new Map();
+ }
+
+ dismissPrompt(prompt) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:Prompt:Dismiss",
+ id: prompt.id,
+ });
+ this.unregisterPrompt(prompt);
+ }
+
+ updatePrompt(message) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:Prompt:Update",
+ prompt: message,
+ });
+ }
+
+ unregisterPrompt(prompt) {
+ this._prompts.delete(prompt.id);
+ this.sendAsyncMessage("UnregisterPrompt", {
+ id: prompt.id,
+ });
+ }
+
+ prompt(prompt, message) {
+ this._prompts.set(prompt.id, prompt);
+ this.sendAsyncMessage("RegisterPrompt", {
+ id: prompt.id,
+ promptType: prompt.getPromptType(),
+ });
+ // We intentionally do not await here as we want to fire NotifyPromptShow
+ // immediately rather than waiting until the user accepts/dismisses the
+ // prompt.
+ const result = this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:Prompt",
+ prompt: message,
+ });
+ this.sendAsyncMessage("NotifyPromptShow", {
+ id: prompt.id,
+ });
+ return result;
+ }
+
+ /**
+ * Handles the message coming from GeckoViewPrompterParent.
+ *
+ * @param {string} message.name The subject of the message.
+ * @param {object} message.data The data of the message.
+ */
+ async receiveMessage({ name, data }) {
+ const prompt = this._prompts.get(data.id);
+ if (!prompt) {
+ // Unknown prompt, probably for a different child actor.
+ return;
+ }
+ switch (name) {
+ case "GetPromptText": {
+ // eslint-disable-next-line consistent-return
+ return prompt.getPromptText();
+ }
+ case "GetInputText": {
+ // eslint-disable-next-line consistent-return
+ return prompt.getInputText();
+ }
+ case "SetInputText": {
+ prompt.setInputText(data.text);
+ break;
+ }
+ case "AcceptPrompt": {
+ prompt.accept();
+ break;
+ }
+ case "DismissPrompt": {
+ prompt.dismiss();
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewPrompterChild.initLogging("Prompter");
diff --git a/mobile/android/actors/GeckoViewPrompterParent.sys.mjs b/mobile/android/actors/GeckoViewPrompterParent.sys.mjs
new file mode 100644
index 0000000000..53eda226c8
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrompterParent.sys.mjs
@@ -0,0 +1,167 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+const DIALOGS = [
+ "alert",
+ "alertCheck",
+ "confirm",
+ "confirmCheck",
+ "prompt",
+ "promptCheck",
+];
+
+export class GeckoViewPrompterParent extends GeckoViewActorParent {
+ constructor() {
+ super();
+ this._prompts = new Map();
+ }
+
+ get rootActor() {
+ return this.browsingContext.top.currentWindowGlobal.getActor(
+ "GeckoViewPrompter"
+ );
+ }
+
+ registerPrompt(promptId, promptType, actor) {
+ return this._prompts.set(
+ promptId,
+ new RemotePrompt(promptId, promptType, actor)
+ );
+ }
+
+ unregisterPrompt(promptId) {
+ this._prompts.delete(promptId);
+ }
+
+ notifyPromptShow(promptId) {
+ // ToDo: Bug 1761480 - GeckoView can send additional prompts to Marionette
+ if (this._prompts.get(promptId).isDialog) {
+ Services.obs.notifyObservers({ id: promptId }, "geckoview-prompt-show");
+ }
+ }
+
+ getPrompts() {
+ const self = this;
+ const prompts = [];
+ // Marionette expects this event to be fired from the parent
+ const createDialogClosedEvent = detail =>
+ new CustomEvent("DOMModalDialogClosed", {
+ cancelable: true,
+ bubbles: true,
+ detail,
+ });
+
+ for (const [, prompt] of this._prompts) {
+ // Adding only WebDriver compliant dialogs to the window
+ if (prompt.isDialog) {
+ prompts.push({
+ args: {
+ modalType: "GeckoViewPrompter",
+ promptType: prompt.type,
+ isDialog: prompt.isDialog,
+ },
+ setInputText(text) {
+ prompt.inputText = text;
+ prompt.setInputText(text);
+ },
+ async getPromptText() {
+ return prompt.getPromptText();
+ },
+ async getInputText() {
+ return prompt.getInputText();
+ },
+ acceptPrompt() {
+ prompt.acceptPrompt();
+ self.window.dispatchEvent(
+ createDialogClosedEvent({
+ areLeaving: true,
+ value: prompt.inputText,
+ })
+ );
+ },
+ dismissPrompt() {
+ prompt.dismissPrompt();
+ self.window.dispatchEvent(
+ createDialogClosedEvent({ areLeaving: false })
+ );
+ },
+ });
+ }
+ }
+ return prompts;
+ }
+
+ /**
+ * Handles the message coming from GeckoViewPrompterChild.
+ *
+ * @param {string} message.name The subject of the message.
+ * @param {object} message.data The data of the message.
+ */
+ // eslint-disable-next-line consistent-return
+ async receiveMessage({ name, data }) {
+ switch (name) {
+ case "RegisterPrompt": {
+ this.rootActor.registerPrompt(data.id, data.promptType, this);
+ break;
+ }
+ case "UnregisterPrompt": {
+ this.rootActor.unregisterPrompt(data.id);
+ break;
+ }
+ case "NotifyPromptShow": {
+ this.rootActor.notifyPromptShow(data.id);
+ break;
+ }
+ default: {
+ return super.receiveMessage({ name, data });
+ }
+ }
+ }
+}
+
+class RemotePrompt {
+ constructor(id, type, actor) {
+ this.id = id;
+ this.type = type;
+ this.actor = actor;
+ }
+
+ // Checks if the prompt conforms to a WebDriver simple dialog.
+ get isDialog() {
+ return DIALOGS.includes(this.type);
+ }
+
+ getPromptText() {
+ return this.actor.sendQuery("GetPromptText", {
+ id: this.id,
+ });
+ }
+
+ getInputText() {
+ return this.actor.sendQuery("GetInputText", {
+ id: this.id,
+ });
+ }
+
+ setInputText(inputText) {
+ this.actor.sendAsyncMessage("SetInputText", {
+ id: this.id,
+ text: inputText,
+ });
+ }
+
+ acceptPrompt() {
+ this.actor.sendAsyncMessage("AcceptPrompt", {
+ id: this.id,
+ });
+ }
+
+ dismissPrompt() {
+ this.actor.sendAsyncMessage("DismissPrompt", {
+ id: this.id,
+ });
+ }
+}
diff --git a/mobile/android/actors/GeckoViewSettingsChild.sys.mjs b/mobile/android/actors/GeckoViewSettingsChild.sys.mjs
new file mode 100644
index 0000000000..d5e6c01e27
--- /dev/null
+++ b/mobile/android/actors/GeckoViewSettingsChild.sys.mjs
@@ -0,0 +1,26 @@
+/* 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";
+
+// Handles GeckoView content settings
+export class GeckoViewSettingsChild extends GeckoViewActorChild {
+ receiveMessage(message) {
+ const { name } = message;
+ debug`receiveMessage: ${name}`;
+
+ switch (name) {
+ case "SettingsUpdate": {
+ const settings = message.data;
+
+ if (settings.isPopup) {
+ // Allow web extensions to close their own action popups (bz1612363)
+ this.contentWindow.windowUtils.allowScriptsToClose();
+ }
+ }
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewSettingsChild.initLogging("GeckoViewSettings");
diff --git a/mobile/android/actors/LoadURIDelegateChild.sys.mjs b/mobile/android/actors/LoadURIDelegateChild.sys.mjs
new file mode 100644
index 0000000000..bb54209f0c
--- /dev/null
+++ b/mobile/android/actors/LoadURIDelegateChild.sys.mjs
@@ -0,0 +1,44 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs",
+});
+
+// Implements nsILoadURIDelegate.
+export class LoadURIDelegateChild extends GeckoViewActorChild {
+ // nsILoadURIDelegate.
+ handleLoadError(aUri, aError, aErrorModule) {
+ debug`handleLoadError: uri=${aUri && aUri.spec}
+ displaySpec=${aUri && aUri.displaySpec}
+ error=${aError}`;
+ if (aUri && lazy.LoadURIDelegate.isSafeBrowsingError(aError)) {
+ const message = {
+ type: "GeckoView:ContentBlocked",
+ uri: aUri.spec,
+ error: aError,
+ };
+
+ this.eventDispatcher.sendRequest(message);
+ }
+
+ return lazy.LoadURIDelegate.handleLoadError(
+ this.contentWindow,
+ this.eventDispatcher,
+ aUri,
+ aError,
+ aErrorModule
+ );
+ }
+}
+
+LoadURIDelegateChild.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsILoadURIDelegate",
+]);
+
+const { debug, warn } = LoadURIDelegateChild.initLogging("LoadURIDelegate");
diff --git a/mobile/android/actors/LoadURIDelegateParent.sys.mjs b/mobile/android/actors/LoadURIDelegateParent.sys.mjs
new file mode 100644
index 0000000000..0446d7d88a
--- /dev/null
+++ b/mobile/android/actors/LoadURIDelegateParent.sys.mjs
@@ -0,0 +1,8 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+// For this.eventDispatcher in the child
+export class LoadURIDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/MediaControlDelegateChild.sys.mjs b/mobile/android/actors/MediaControlDelegateChild.sys.mjs
new file mode 100644
index 0000000000..1db32b33f0
--- /dev/null
+++ b/mobile/android/actors/MediaControlDelegateChild.sys.mjs
@@ -0,0 +1,59 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MediaUtils: "resource://gre/modules/MediaUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+export class MediaControlDelegateChild extends GeckoViewActorChild {
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited":
+ this.handleFullscreenChanged(true);
+ break;
+ }
+ }
+
+ async handleFullscreenChanged(retry) {
+ debug`handleFullscreenChanged`;
+
+ const element = this.document.fullscreenElement;
+ const mediaElement = lazy.MediaUtils.findMediaElement(element);
+
+ if (element && !mediaElement) {
+ // Non-media element fullscreen.
+ debug`No fullscreen media element found.`;
+ }
+
+ const activated = await this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:MediaSession:Fullscreen",
+ metadata: lazy.MediaUtils.getMetadata(mediaElement) ?? {},
+ enabled: !!element,
+ });
+ if (activated) {
+ return;
+ }
+ if (retry && element) {
+ // When media session is going to active, we have a race condition of
+ // full screen event because media session will be activated by full
+ // screen event.
+ // So we retry to call media session delegate for this situation.
+ lazy.setTimeout(() => {
+ this.handleFullscreenChanged(false);
+ }, 100);
+ }
+ }
+}
+
+const { debug } = MediaControlDelegateChild.initLogging(
+ "MediaControlDelegateChild"
+);
diff --git a/mobile/android/actors/MediaControlDelegateParent.sys.mjs b/mobile/android/actors/MediaControlDelegateParent.sys.mjs
new file mode 100644
index 0000000000..f0c3f47984
--- /dev/null
+++ b/mobile/android/actors/MediaControlDelegateParent.sys.mjs
@@ -0,0 +1,8 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+// For this.eventDispatcher in the child
+export class MediaControlDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/ProgressDelegateChild.sys.mjs b/mobile/android/actors/ProgressDelegateChild.sys.mjs
new file mode 100644
index 0000000000..4992ee5916
--- /dev/null
+++ b/mobile/android/actors/ProgressDelegateChild.sys.mjs
@@ -0,0 +1,26 @@
+/* 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";
+
+export class ProgressDelegateChild extends GeckoViewActorChild {
+ // eslint-disable-next-line complexity
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+ switch (aEvent.type) {
+ case "DOMContentLoaded": // fall-through
+ case "MozAfterPaint": // fall-through
+ case "pageshow": {
+ // Forward to main process
+ const target = aEvent.originalTarget;
+ const uri = target?.location.href;
+ this.sendAsyncMessage(aEvent.type, {
+ uri,
+ });
+ }
+ }
+ }
+}
+
+const { debug, warn } = ProgressDelegateChild.initLogging("ProgressDelegate");
diff --git a/mobile/android/actors/ProgressDelegateParent.sys.mjs b/mobile/android/actors/ProgressDelegateParent.sys.mjs
new file mode 100644
index 0000000000..bb3f1ec416
--- /dev/null
+++ b/mobile/android/actors/ProgressDelegateParent.sys.mjs
@@ -0,0 +1,7 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class ProgressDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/ScrollDelegateChild.sys.mjs b/mobile/android/actors/ScrollDelegateChild.sys.mjs
new file mode 100644
index 0000000000..5d71482f23
--- /dev/null
+++ b/mobile/android/actors/ScrollDelegateChild.sys.mjs
@@ -0,0 +1,31 @@
+/* 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";
+
+export class ScrollDelegateChild extends GeckoViewActorChild {
+ // eslint-disable-next-line complexity
+ handleEvent(aEvent) {
+ if (aEvent.originalTarget.ownerGlobal != this.contentWindow) {
+ return;
+ }
+
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "mozvisualscroll":
+ const x = {};
+ const y = {};
+ this.contentWindow.windowUtils.getVisualViewportOffset(x, y);
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ScrollChanged",
+ scrollX: x.value,
+ scrollY: y.value,
+ });
+ break;
+ }
+ }
+}
+
+const { debug, warn } = ScrollDelegateChild.initLogging("ScrollDelegate");
diff --git a/mobile/android/actors/ScrollDelegateParent.sys.mjs b/mobile/android/actors/ScrollDelegateParent.sys.mjs
new file mode 100644
index 0000000000..39921d4411
--- /dev/null
+++ b/mobile/android/actors/ScrollDelegateParent.sys.mjs
@@ -0,0 +1,8 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+// For this.eventDispatcher in the child
+export class ScrollDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/SelectionActionDelegateChild.sys.mjs b/mobile/android/actors/SelectionActionDelegateChild.sys.mjs
new file mode 100644
index 0000000000..9f05906e09
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateChild.sys.mjs
@@ -0,0 +1,442 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
+});
+
+const MAGNIFIER_PREF = "layout.accessiblecaret.magnifier.enabled";
+const ACCESSIBLECARET_HEIGHT_PREF = "layout.accessiblecaret.height";
+const PREFS = [MAGNIFIER_PREF, ACCESSIBLECARET_HEIGHT_PREF];
+
+// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to
+// the GeckoSession on accessible caret changes.
+export class SelectionActionDelegateChild extends GeckoViewActorChild {
+ constructor(aModuleName, aMessageManager) {
+ super(aModuleName, aMessageManager);
+
+ this._actionCallback = () => {};
+ this._isActive = false;
+ this._previousMessage = "";
+
+ // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
+ // directly, so we create a new function here instead to act as our
+ // nsIObserver, which forwards the notification to the observe method.
+ this._observerFunction = (subject, topic, data) => {
+ this.observe(subject, topic, data);
+ };
+ for (const pref of PREFS) {
+ Services.prefs.addObserver(pref, this._observerFunction);
+ }
+
+ this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
+ this._accessiblecaretHeight = parseFloat(
+ Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
+ );
+ }
+
+ didDestroy() {
+ for (const pref of PREFS) {
+ Services.prefs.removeObserver(pref, this._observerFunction);
+ }
+ }
+
+ _actions = [
+ {
+ id: "org.mozilla.geckoview.HIDE",
+ predicate: _ => true,
+ perform: _ => this.handleEvent({ type: "pagehide" }),
+ },
+ {
+ id: "org.mozilla.geckoview.CUT",
+ predicate: e =>
+ !e.collapsed && e.selectionEditable && !this._isPasswordField(e),
+ perform: _ => this.docShell.doCommand("cmd_cut"),
+ },
+ {
+ id: "org.mozilla.geckoview.COPY",
+ predicate: e => !e.collapsed && !this._isPasswordField(e),
+ perform: _ => this.docShell.doCommand("cmd_copy"),
+ },
+ {
+ id: "org.mozilla.geckoview.PASTE",
+ predicate: e =>
+ (this._isContentHtmlEditable(e) &&
+ Services.clipboard.hasDataMatchingFlavors(
+ /* The following image types are considered by editor */
+ ["image/gif", "image/jpeg", "image/png"],
+ Ci.nsIClipboard.kGlobalClipboard
+ )) ||
+ (e.selectionEditable &&
+ Services.clipboard.hasDataMatchingFlavors(
+ ["text/plain"],
+ Ci.nsIClipboard.kGlobalClipboard
+ )),
+ perform: _ => this._performPaste(),
+ },
+ {
+ id: "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT",
+ predicate: e =>
+ this._isContentHtmlEditable(e) &&
+ Services.clipboard.hasDataMatchingFlavors(
+ ["text/html"],
+ Ci.nsIClipboard.kGlobalClipboard
+ ),
+ perform: _ => this._performPasteAsPlainText(),
+ },
+ {
+ id: "org.mozilla.geckoview.DELETE",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: _ => this.docShell.doCommand("cmd_delete"),
+ },
+ {
+ id: "org.mozilla.geckoview.COLLAPSE_TO_START",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_moveLeft"),
+ },
+ {
+ id: "org.mozilla.geckoview.COLLAPSE_TO_END",
+ predicate: e => !e.collapsed && e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_moveRight"),
+ },
+ {
+ id: "org.mozilla.geckoview.UNSELECT",
+ predicate: e => !e.collapsed && !e.selectionEditable,
+ perform: e => this.docShell.doCommand("cmd_selectNone"),
+ },
+ {
+ id: "org.mozilla.geckoview.SELECT_ALL",
+ predicate: e => {
+ if (e.reason === "longpressonemptycontent") {
+ return false;
+ }
+ // When on design mode, focusedElement will be null.
+ const element =
+ Services.focus.focusedElement || e.target?.activeElement;
+ if (e.selectionEditable && e.target && element) {
+ let value = "";
+ if (element.value) {
+ value = element.value;
+ } else if (
+ element.isContentEditable ||
+ e.target.designMode === "on"
+ ) {
+ value = element.innerText;
+ }
+ // Do not show SELECT_ALL if the editable is empty
+ // or all the editable text is already selected.
+ return value !== "" && value !== e.selectedTextContent;
+ }
+ return true;
+ },
+ perform: e => this.docShell.doCommand("cmd_selectAll"),
+ },
+ ];
+
+ receiveMessage({ name, data }) {
+ debug`receiveMessage ${name}`;
+
+ switch (name) {
+ case "ExecuteSelectionAction": {
+ this._actionCallback(data);
+ }
+ }
+ }
+
+ _performPaste() {
+ this.handleEvent({ type: "pagehide" });
+ this.docShell.doCommand("cmd_paste");
+ }
+
+ _performPasteAsPlainText() {
+ this.handleEvent({ type: "pagehide" });
+ this.docShell.doCommand("cmd_pasteNoFormatting");
+ }
+
+ _isPasswordField(aEvent) {
+ if (!aEvent.selectionEditable) {
+ return false;
+ }
+
+ const win = aEvent.target.defaultView;
+ const focus = aEvent.target.activeElement;
+ return (
+ win &&
+ win.HTMLInputElement &&
+ win.HTMLInputElement.isInstance(focus) &&
+ !focus.mozIsTextField(/* excludePassword */ true)
+ );
+ }
+
+ _isContentHtmlEditable(aEvent) {
+ if (!aEvent.selectionEditable) {
+ return false;
+ }
+
+ if (aEvent.target.designMode == "on") {
+ return true;
+ }
+
+ // focused element isn't <input> nor <textarea>
+ const win = aEvent.target.defaultView;
+ const focus = Services.focus.focusedElement;
+ return (
+ win &&
+ win.HTMLInputElement &&
+ win.HTMLTextAreaElement &&
+ !win.HTMLInputElement.isInstance(focus) &&
+ !win.HTMLTextAreaElement.isInstance(focus)
+ );
+ }
+
+ _getDefaultMagnifierPoint(aEvent) {
+ const rect = lazy.LayoutUtils.rectToScreenRect(aEvent.target.ownerGlobal, {
+ left: aEvent.clientX,
+ top: aEvent.clientY - this._accessiblecaretHeight,
+ width: 0,
+ height: 0,
+ });
+ return { x: rect.left, y: rect.top };
+ }
+
+ _getBetterMagnifierPoint(aEvent) {
+ const win = aEvent.target.defaultView;
+ if (!win) {
+ return this._getDefaultMagnifierPoint(aEvent);
+ }
+
+ const focus = aEvent.target.activeElement;
+ if (
+ win.HTMLInputElement?.isInstance(focus) &&
+ focus.mozIsTextField(false)
+ ) {
+ // <input> element. Use vertical center position of input element.
+ const bounds = focus.getBoundingClientRect();
+ const rect = lazy.LayoutUtils.rectToScreenRect(
+ aEvent.target.ownerGlobal,
+ {
+ left: aEvent.clientX,
+ top: bounds.top,
+ width: 0,
+ height: bounds.height,
+ }
+ );
+ return { x: rect.left, y: rect.top + rect.height / 2 };
+ }
+
+ if (win.HTMLTextAreaElement?.isInstance(focus)) {
+ // TODO:
+ // <textarea> element. How to get better selection bounds?
+ return this._getDefaultMagnifierPoint(aEvent);
+ }
+
+ const selection = win.getSelection();
+ if (selection.rangeCount != 1) {
+ // When selecting text using accessible caret, selection count will be 1.
+ // This situation means that current selection isn't into text.
+ return this._getDefaultMagnifierPoint(aEvent);
+ }
+
+ // We are looking for better selection bounds, then use it.
+ const bounds = (() => {
+ const range = selection.getRangeAt(0);
+ let distance = Number.MAX_SAFE_INTEGER;
+ let y = aEvent.clientY;
+ const rectList = range.getClientRects();
+ for (const rect of rectList) {
+ const newDistance = Math.abs(aEvent.clientY - rect.bottom);
+ if (distance > newDistance) {
+ y = rect.top + rect.height / 2;
+ distance = newDistance;
+ }
+ }
+ return { left: aEvent.clientX, top: y, width: 0, height: 0 };
+ })();
+
+ const rect = lazy.LayoutUtils.rectToScreenRect(
+ aEvent.target.ownerGlobal,
+ bounds
+ );
+ return { x: rect.left, y: rect.top };
+ }
+
+ _handleMagnifier(aEvent) {
+ if (["presscaret", "dragcaret"].includes(aEvent.reason)) {
+ debug`_handleMagnifier: ${aEvent.reason}`;
+ const screenPoint = this._getBetterMagnifierPoint(aEvent);
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ShowMagnifier",
+ screenPoint,
+ });
+ } else if (aEvent.reason == "releasecaret") {
+ debug`_handleMagnifier: ${aEvent.reason}`;
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:HideMagnifier",
+ });
+ }
+ }
+
+ /**
+ * Receive and act on AccessibleCarets caret state-change
+ * (mozcaretstatechanged and pagehide) events.
+ */
+ handleEvent(aEvent) {
+ if (aEvent.type === "pagehide" || aEvent.type === "deactivate") {
+ // Hide any selection actions on page hide or deactivate.
+ aEvent = {
+ reason: "visibilitychange",
+ caretVisibile: false,
+ selectionVisible: false,
+ collapsed: true,
+ selectionEditable: false,
+ };
+ }
+
+ let reason = aEvent.reason;
+
+ if (this._isActive && !aEvent.caretVisible) {
+ // For mozcaretstatechanged, "visibilitychange" means the caret is hidden.
+ reason = "visibilitychange";
+ } else if (!aEvent.collapsed && !aEvent.selectionVisible) {
+ reason = "invisibleselection";
+ } else if (
+ !this._isActive &&
+ aEvent.selectionEditable &&
+ aEvent.collapsed &&
+ reason !== "longpressonemptycontent" &&
+ reason !== "taponcaret" &&
+ !Services.prefs.getBoolPref(
+ "geckoview.selection_action.show_on_focus",
+ false
+ )
+ ) {
+ // Don't show selection actions when merely focusing on an editor or
+ // repositioning the cursor. Wait until long press or the caret is tapped
+ // in order to match Android behavior.
+ reason = "visibilitychange";
+ }
+
+ debug`handleEvent: ${reason}`;
+
+ if (this._magnifierEnabled) {
+ this._handleMagnifier(aEvent);
+ }
+
+ if (
+ [
+ "longpressonemptycontent",
+ "releasecaret",
+ "taponcaret",
+ "updateposition",
+ ].includes(reason)
+ ) {
+ const actions = this._actions.filter(action =>
+ action.predicate.call(this, aEvent)
+ );
+
+ const screenRect = (() => {
+ const boundingRect = aEvent.boundingClientRect;
+ if (!boundingRect) {
+ return null;
+ }
+ const rect = lazy.LayoutUtils.rectToScreenRect(
+ aEvent.target.ownerGlobal,
+ boundingRect
+ );
+ return {
+ left: rect.left,
+ top: rect.top,
+ right: rect.right,
+ bottom: rect.bottom + this._accessiblecaretHeight,
+ };
+ })();
+
+ const password = this._isPasswordField(aEvent);
+
+ const msg = {
+ collapsed: aEvent.collapsed,
+ editable: aEvent.selectionEditable,
+ password,
+ selection: password ? "" : aEvent.selectedTextContent,
+ screenRect,
+ actions: actions.map(action => action.id),
+ };
+
+ if (this._isActive && JSON.stringify(msg) === this._previousMessage) {
+ // Don't call again if we're already active and things haven't changed.
+ return;
+ }
+
+ this._isActive = true;
+ this._previousMessage = JSON.stringify(msg);
+
+ // We can't just listen to the response of the message because we accept
+ // multiple callbacks.
+ this._actionCallback = data => {
+ const action = actions.find(action => action.id === data.id);
+ if (action) {
+ debug`Performing ${data.id}`;
+ action.perform.call(this, aEvent);
+ } else {
+ warn`Invalid action ${data.id}`;
+ }
+ };
+ this.sendAsyncMessage("ShowSelectionAction", msg);
+ } else if (
+ [
+ "invisibleselection",
+ "presscaret",
+ "scroll",
+ "visibilitychange",
+ ].includes(reason)
+ ) {
+ if (!this._isActive) {
+ return;
+ }
+ this._isActive = false;
+
+ // Mark previous actions as stale. Don't do this for "invisibleselection"
+ // or "scroll" because previous actions should still be valid even after
+ // these events occur.
+ if (reason !== "invisibleselection" && reason !== "scroll") {
+ this._seqNo++;
+ }
+
+ this.sendAsyncMessage("HideSelectionAction", { reason });
+ } else if (reason == "dragcaret") {
+ // nothing for selection action
+ } else {
+ warn`Unknown reason: ${reason}`;
+ }
+ }
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "nsPref:changed") {
+ return;
+ }
+
+ switch (aData) {
+ case ACCESSIBLECARET_HEIGHT_PREF:
+ this._accessiblecaretHeight = parseFloat(
+ Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0")
+ );
+ break;
+ case MAGNIFIER_PREF:
+ this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF);
+ break;
+ }
+ // Reset magnifier
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:HideMagnifier",
+ });
+ }
+}
+
+const { debug, warn } = SelectionActionDelegateChild.initLogging(
+ "SelectionActionDelegate"
+);
diff --git a/mobile/android/actors/SelectionActionDelegateParent.sys.mjs b/mobile/android/actors/SelectionActionDelegateParent.sys.mjs
new file mode 100644
index 0000000000..3ef5830ce4
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateParent.sys.mjs
@@ -0,0 +1,72 @@
+/* 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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class SelectionActionDelegateParent extends GeckoViewActorParent {
+ respondTo = null;
+ actionId = null;
+
+ get rootActor() {
+ return this.browsingContext.top.currentWindowGlobal.getActor(
+ "SelectionActionDelegate"
+ );
+ }
+
+ receiveMessage(aMessage) {
+ const { data, name } = aMessage;
+ switch (name) {
+ case "ShowSelectionAction": {
+ this.rootActor.showSelectionAction(this, data);
+ break;
+ }
+
+ case "HideSelectionAction": {
+ this.rootActor.hideSelectionAction(this, data.reason);
+ break;
+ }
+
+ default: {
+ super.receiveMessage(aMessage);
+ }
+ }
+ }
+
+ hideSelectionAction(aRespondTo, reason) {
+ // Mark previous actions as stale. Don't do this for "invisibleselection"
+ // or "scroll" because previous actions should still be valid even after
+ // these events occur.
+ if (reason !== "invisibleselection" && reason !== "scroll") {
+ this.actionId = null;
+ }
+
+ this.eventDispatcher?.sendRequest({
+ type: "GeckoView:HideSelectionAction",
+ reason,
+ });
+ }
+
+ showSelectionAction(aRespondTo, aData) {
+ this.actionId = Services.uuid.generateUUID().toString();
+ this.respondTo = aRespondTo;
+
+ this.eventDispatcher?.sendRequest({
+ type: "GeckoView:ShowSelectionAction",
+ actionId: this.actionId,
+ ...aData,
+ });
+ }
+
+ executeSelectionAction(aData) {
+ if (this.actionId === null || aData.actionId != this.actionId) {
+ warn`Stale response ${aData.id} ${aData.actionId}`;
+ return;
+ }
+ this.respondTo.sendAsyncMessage("ExecuteSelectionAction", aData);
+ }
+}
+
+const { debug, warn } = SelectionActionDelegateParent.initLogging(
+ "SelectionActionDelegate"
+);
diff --git a/mobile/android/actors/metrics.yaml b/mobile/android/actors/metrics.yaml
new file mode 100644
index 0000000000..636292100c
--- /dev/null
+++ b/mobile/android/actors/metrics.yaml
@@ -0,0 +1,11 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'GeckoView :: General'
diff --git a/mobile/android/actors/moz.build b/mobile/android/actors/moz.build
new file mode 100644
index 0000000000..739405e56c
--- /dev/null
+++ b/mobile/android/actors/moz.build
@@ -0,0 +1,39 @@
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("GeckoView", "General")
+
+FINAL_TARGET_FILES.actors += [
+ "ContentDelegateChild.sys.mjs",
+ "ContentDelegateParent.sys.mjs",
+ "GeckoViewAutoFillChild.sys.mjs",
+ "GeckoViewAutoFillParent.sys.mjs",
+ "GeckoViewContentChild.sys.mjs",
+ "GeckoViewContentParent.sys.mjs",
+ "GeckoViewExperimentDelegateParent.sys.mjs",
+ "GeckoViewFormValidationChild.sys.mjs",
+ "GeckoViewPermissionChild.sys.mjs",
+ "GeckoViewPermissionParent.sys.mjs",
+ "GeckoViewPermissionProcessChild.sys.mjs",
+ "GeckoViewPermissionProcessParent.sys.mjs",
+ "GeckoViewPrintDelegateChild.sys.mjs",
+ "GeckoViewPrintDelegateParent.sys.mjs",
+ "GeckoViewPromptChild.sys.mjs",
+ "GeckoViewPrompterChild.sys.mjs",
+ "GeckoViewPrompterParent.sys.mjs",
+ "GeckoViewSettingsChild.sys.mjs",
+ "LoadURIDelegateChild.sys.mjs",
+ "LoadURIDelegateParent.sys.mjs",
+ "MediaControlDelegateChild.sys.mjs",
+ "MediaControlDelegateParent.sys.mjs",
+ "ProgressDelegateChild.sys.mjs",
+ "ProgressDelegateParent.sys.mjs",
+ "ScrollDelegateChild.sys.mjs",
+ "ScrollDelegateParent.sys.mjs",
+ "SelectionActionDelegateChild.sys.mjs",
+ "SelectionActionDelegateParent.sys.mjs",
+]
+
+MOCHITEST_MANIFESTS += ["tests/mochitests/mochitest.toml"]
diff --git a/mobile/android/actors/tests/mochitests/head.js b/mobile/android/actors/tests/mochitests/head.js
new file mode 100644
index 0000000000..66735cddb6
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/head.js
@@ -0,0 +1,5 @@
+/* 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/. */
+
+"use strict";
diff --git a/mobile/android/actors/tests/mochitests/mochitest.toml b/mobile/android/actors/tests/mochitests/mochitest.toml
new file mode 100644
index 0000000000..10fb8342b4
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/mochitest.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+support-files = ["head.js"]
+run-if = ["os == 'android'"]
+
+["test_geckoview_experiment_delegate.html"]
diff --git a/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html b/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html
new file mode 100644
index 0000000000..c6425e2983
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html
@@ -0,0 +1,108 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1845824
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test Experiment Delegate</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="head.js" type="application/javascript"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+<script class="testbody" type="text/javascript">
+
+ // Note: TestRunnerActivity provides a pseudo Experiment Delegate for this test.
+ async function requestExperiment(message) {
+ const chromeScript = SpecialPowers.loadChromeScript(_ => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("experiment", (msg) => {
+ var result;
+ const navigator = Services.wm.getMostRecentWindow("navigator:geckoview");
+ const experimentActor = navigator.window.moduleManager.getActor("GeckoViewExperimentDelegate");
+ switch (msg.endpoint) {
+ case 'getExperimentFeature':
+ result = experimentActor.getExperimentFeature(msg.feature);
+ break;
+ case 'recordExposure':
+ result = experimentActor.recordExposure(msg.feature);
+ break
+ case 'recordExperimentExposure':
+ result = experimentActor.recordExperimentExposure(msg.feature, msg.slug);
+ break;
+ case 'recordExperimentMalformedConfig':
+ result = experimentActor.recordExperimentMalformedConfig(msg.feature, msg.part);
+ break;
+ default:
+ result = null;
+ break;
+ }
+ return result;
+ });
+
+ });
+
+ const result = await chromeScript.sendQuery("experiment", message);
+ chromeScript.destroy();
+ return result;
+ }
+
+ add_task(async function test_getExperimentFeature() {
+ const success = await requestExperiment({endpoint: "getExperimentFeature", feature: "test"});
+ is(success["item-one"], true, "Retrieved TestRunnerActivity experiment feature 'test' for 'item-one'.");
+ is(success["item-two"], 5, "Retrieved TestRunnerActivity experiment feature 'test' for 'item-two'.");
+ var didErrorOccur = false;
+ try {
+ await requestExperiment({endpoint: "getExperimentFeature", feature: "no-feature"});
+ } catch (error) {
+ is(error, "An error occurred while retrieving feature data.", "Correctly failed when the feature did not exist.");
+ didErrorOccur = true;
+ }
+ is(didErrorOccur, true, "Error was caught when no feature existed.");
+ });
+
+ add_task(async function test_recordExposure() {
+ const success = await requestExperiment({endpoint: "recordExposure", feature: "test"});
+ is(success, true, "Recorded exposure for the feature.");
+ var didErrorOccur = false;
+ try {
+ await requestExperiment({endpoint: "recordExposure", feature: "no-feature"});
+ } catch (error) {
+ is(error, "An error occurred while recording feature.", "Correctly failed when the feature did not exist.");
+ didErrorOccur = true;
+ }
+ is(didErrorOccur, true, "Error was caught when no feature existed.");
+ });
+
+
+ add_task(async function test_recordExperimentExposure() {
+ const success = await requestExperiment({endpoint: "recordExperimentExposure", feature: "test", slug: "test"});
+ is(success, true, "Recorded experiment exposure for the feature.");
+ var didErrorOccur = false;
+ try {
+ await requestExperiment({endpoint: "recordExperimentExposure", feature: "no-feature", slug: "no-slug"});
+ } catch (error) {
+ is(error, "An error occurred while recording experiment feature.", "Correctly failed when the feature did not exist.");
+ didErrorOccur = true;
+ }
+ is(didErrorOccur, true, "Error was caught when no feature existed.");
+ });
+
+
+ add_task(async function test_recordExperimentMalformedConfig() {
+ const success = await requestExperiment({endpoint: "recordExperimentMalformedConfig", feature: "test", part: "test"});
+ is(success, true, "Recorded exposure for the feature.");
+ var didErrorOccur = false;
+ try {
+ await requestExperiment({endpoint: "recordExperimentMalformedConfig", feature: "no-feature", part:"no-part"});
+ } catch (error) {
+ is(error, "An error occurred while recording malformed feature config.", "Correctly failed when the feature did not exist.");
+ didErrorOccur = true;
+ }
+ is(didErrorOccur, true, "Error was caught when no feature existed.");
+ });
+
+</script>
+</body>
+</html>