summaryrefslogtreecommitdiffstats
path: root/mobile/android/actors
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/actors')
-rw-r--r--mobile/android/actors/ContentDelegateChild.sys.mjs163
-rw-r--r--mobile/android/actors/ContentDelegateParent.sys.mjs28
-rw-r--r--mobile/android/actors/GeckoViewAutoFillChild.sys.mjs395
-rw-r--r--mobile/android/actors/GeckoViewAutoFillParent.sys.mjs89
-rw-r--r--mobile/android/actors/GeckoViewClipboardPermissionChild.sys.mjs95
-rw-r--r--mobile/android/actors/GeckoViewClipboardPermissionParent.sys.mjs44
-rw-r--r--mobile/android/actors/GeckoViewContentChild.sys.mjs335
-rw-r--r--mobile/android/actors/GeckoViewContentParent.sys.mjs84
-rw-r--r--mobile/android/actors/GeckoViewFormValidationChild.sys.mjs19
-rw-r--r--mobile/android/actors/GeckoViewPermissionChild.sys.mjs118
-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.mjs78
-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.mjs156
-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.mjs436
-rw-r--r--mobile/android/actors/SelectionActionDelegateParent.sys.mjs72
-rw-r--r--mobile/android/actors/metrics.yaml55
-rw-r--r--mobile/android/actors/moz.build40
-rw-r--r--mobile/android/actors/tests/mochitests/head.js51
-rw-r--r--mobile/android/actors/tests/mochitests/mochitest.ini8
-rw-r--r--mobile/android/actors/tests/mochitests/test_geckoview_actor_telemetry.html106
34 files changed, 3026 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..9d1a41fb8b
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateChild.sys.mjs
@@ -0,0 +1,163 @@
+/* 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": {
+ 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..abf9a59bdc
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateParent.sys.mjs
@@ -0,0 +1,28 @@
+/* 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 {
+ async receiveMessage(aMsg) {
+ debug`receiveMessage: ${aMsg.name}`;
+
+ switch (aMsg.name) {
+ case "GeckoView:DOMFullscreenExit": {
+ this.window.windowUtils.remoteFrameFullscreenReverted();
+ return null;
+ }
+
+ case "GeckoView:DOMFullscreenRequest": {
+ this.window.windowUtils.remoteFrameFullscreenChanged(this.browser);
+ return null;
+ }
+ }
+
+ return super.receiveMessage(aMsg);
+ }
+}
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/GeckoViewClipboardPermissionChild.sys.mjs b/mobile/android/actors/GeckoViewClipboardPermissionChild.sys.mjs
new file mode 100644
index 0000000000..56e7a39e7b
--- /dev/null
+++ b/mobile/android/actors/GeckoViewClipboardPermissionChild.sys.mjs
@@ -0,0 +1,95 @@
+/* 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 GeckoViewClipboardPermissionChild extends GeckoViewActorChild {
+ constructor() {
+ super();
+ this._pendingPromise = null;
+ }
+
+ async promptPermissionForClipboardRead() {
+ const uri = this.contentWindow.location.href;
+
+ const { x, y } = await this.sendQuery(
+ "ClipboardReadTextPaste:GetLastPointerLocation"
+ );
+
+ const promise = this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:ClipboardPermissionRequest",
+ uri,
+ screenPoint: {
+ x,
+ y,
+ },
+ });
+
+ this._pendingPromise = promise;
+
+ try {
+ const allowOrDeny = await promise;
+ if (this._pendingPromise !== promise) {
+ // Current pending promise is newer. So it means that this promise
+ // is already resolved or rejected. Do nothing.
+ return;
+ }
+ this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup(
+ allowOrDeny
+ );
+ this._pendingPromise = null;
+ } catch (error) {
+ debug`Permission error: ${error}`;
+
+ if (this._pendingPromise !== promise) {
+ // Current pending promise is newer. So it means that this promise
+ // is already resolved or rejected. Do nothing.
+ return;
+ }
+
+ this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup(
+ false
+ );
+ this._pendingPromise = null;
+ }
+ }
+
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "MozClipboardReadPaste":
+ if (aEvent.isTrusted) {
+ this.promptPermissionForClipboardRead();
+ }
+ break;
+
+ // page hide or deactivate cancel clipboard permission.
+ case "pagehide":
+ // fallthrough for the next three events.
+ case "deactivate":
+ case "mousedown":
+ case "mozvisualscroll":
+ // Gecko desktop uses XUL popup to show clipboard permission prompt.
+ // So it will be closed automatically by scroll and other user
+ // activation. So GeckoView has to close permission prompt by some user
+ // activations, too.
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:DismissClipboardPermissionRequest",
+ });
+ if (this._pendingPromise) {
+ this.contentWindow.navigator.clipboard.onUserReactedToPasteMenuPopup(
+ false
+ );
+ this._pendingPromise = null;
+ }
+ break;
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewClipboardPermissionChild.initLogging(
+ "GeckoViewClipboardPermissionChild"
+);
diff --git a/mobile/android/actors/GeckoViewClipboardPermissionParent.sys.mjs b/mobile/android/actors/GeckoViewClipboardPermissionParent.sys.mjs
new file mode 100644
index 0000000000..cbdbcb59e3
--- /dev/null
+++ b/mobile/android/actors/GeckoViewClipboardPermissionParent.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 { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs";
+
+export class GeckoViewClipboardPermissionParent extends GeckoViewActorParent {
+ getLastOverWindowPointerLocation() {
+ const mouseXInCSSPixels = {};
+ const mouseYInCSSPixels = {};
+ const windowUtils = this.window.windowUtils;
+ windowUtils.getLastOverWindowPointerLocationInCSSPixels(
+ mouseXInCSSPixels,
+ mouseYInCSSPixels
+ );
+ const screenRect = windowUtils.toScreenRect(
+ mouseXInCSSPixels.value,
+ mouseYInCSSPixels.value,
+ 0,
+ 0
+ );
+
+ return {
+ x: screenRect.x,
+ y: screenRect.y,
+ };
+ }
+
+ receiveMessage(aMessage) {
+ debug`receiveMessage: ${aMessage.name}`;
+
+ switch (aMessage.name) {
+ case "ClipboardReadTextPaste:GetLastPointerLocation":
+ return this.getLastOverWindowPointerLocation();
+
+ default:
+ return super.receiveMessage(aMessage);
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewClipboardPermissionParent.initLogging(
+ "GeckoViewClipboardPermissionParent"
+);
diff --git a/mobile/android/actors/GeckoViewContentChild.sys.mjs b/mobile/android/actors/GeckoViewContentChild.sys.mjs
new file mode 100644
index 0000000000..c0a19e5b6b
--- /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.getContentViewerSize(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/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..f0224de2e9
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionChild.sys.mjs
@@ -0,0 +1,118 @@
+/* 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";
+
+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,
+ });
+ }
+
+ 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);
+ 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..9e8f85a4a5
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs
@@ -0,0 +1,78 @@
+/* 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;
+ }
+
+ telemetryDotPrintRequested() {
+ Glean.dotprint.requested.add(1);
+ }
+
+ telemetryDotPrintPdfCompleted(status) {
+ if (status.isPdfSuccessful) {
+ Glean.dotprint.androidDialogRequested.add(1);
+ } else {
+ var reason = "";
+ switch (status.errorReason) {
+ // ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE
+ case -1: {
+ reason = "no_settings_service";
+ break;
+ }
+ // ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS
+ case -2: {
+ reason = "no_settings";
+ break;
+ }
+ // ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT
+ case -3: {
+ reason = "no_canonical_context";
+ break;
+ }
+ // ERROR_NO_ACTIVITY_CONTEXT_DELEGATE
+ case -4: {
+ reason = "no_activity_context_delegate";
+ break;
+ }
+ // ERROR_NO_ACTIVITY_CONTEXT
+ case -5: {
+ reason = "no_activity_context";
+ break;
+ }
+ default:
+ reason = "unknown";
+ }
+ Glean.dotprint.failure[reason].add(1);
+ }
+ }
+
+ printRequest() {
+ if (this.browserStaticClone != null) {
+ this.telemetryDotPrintRequested();
+ 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..8e06b39744
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrompterParent.sys.mjs
@@ -0,0 +1,156 @@
+/* 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 dialogClosedEvent = new CustomEvent("DOMModalDialogClosed", {
+ cancelable: true,
+ bubbles: true,
+ });
+ 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.setInputText(text);
+ },
+ async getPromptText() {
+ return prompt.getPromptText();
+ },
+ async getInputText() {
+ return prompt.getInputText();
+ },
+ acceptPrompt() {
+ prompt.acceptPrompt();
+ self.window.dispatchEvent(dialogClosedEvent);
+ },
+ dismissPrompt() {
+ prompt.dismissPrompt();
+ self.window.dispatchEvent(dialogClosedEvent);
+ },
+ });
+ }
+ }
+ 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..8949bf6884
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateChild.sys.mjs
@@ -0,0 +1,436 @@
+/* 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 =>
+ 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..62297c26a6
--- /dev/null
+++ b/mobile/android/actors/metrics.yaml
@@ -0,0 +1,55 @@
+# 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'
+
+dotprint:
+ requested:
+ type: counter
+ expires: 124
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1816151
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1816151#c3
+ description: >
+ How many times window.print was requested.
+ notification_emails:
+ - android-probes@mozilla.com
+ failure:
+ type: labeled_counter
+ expires: 124
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1826188
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1826188#c5
+ description: >
+ An error occured while setting up for printing.
+ Default label is 'unknown'.
+ labels:
+ - no_settings_service
+ - no_settings
+ - no_canonical_context
+ - no_activity_context_delegate
+ - no_activity_context
+ - io_error
+ - unknown
+ notification_emails:
+ - android-probes@mozilla.com
+ android_dialog_requested:
+ type: counter
+ expires: 124
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1826188
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1826188#c5
+ description: >
+ Opening the Android print dialog was requested via window.print.
+ notification_emails:
+ - android-probes@mozilla.com
diff --git a/mobile/android/actors/moz.build b/mobile/android/actors/moz.build
new file mode 100644
index 0000000000..48dcc26b09
--- /dev/null
+++ b/mobile/android/actors/moz.build
@@ -0,0 +1,40 @@
+# 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",
+ "GeckoViewClipboardPermissionChild.sys.mjs",
+ "GeckoViewClipboardPermissionParent.sys.mjs",
+ "GeckoViewContentChild.sys.mjs",
+ "GeckoViewContentParent.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.ini"]
diff --git a/mobile/android/actors/tests/mochitests/head.js b/mobile/android/actors/tests/mochitests/head.js
new file mode 100644
index 0000000000..01288ca822
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/head.js
@@ -0,0 +1,51 @@
+/* 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";
+
+// Bug 1799977: Using workaround to test telemtry in plain mochitests
+const GleanTest = new Proxy(
+ {},
+ {
+ get(target, categoryName, receiver) {
+ return new Proxy(
+ {},
+ {
+ // eslint-disable-next-line no-shadow
+ get(target, metricName, receiver) {
+ return {
+ // The only API we actually implement right now.
+ async testGetValue() {
+ return SpecialPowers.spawnChrome(
+ [categoryName, metricName],
+ // eslint-disable-next-line no-shadow
+ async (categoryName, metricName) => {
+ await Services.fog.testFlushAllChildren();
+ const window = this.browsingContext.topChromeWindow;
+ return window.Glean[categoryName][
+ metricName
+ ].testGetValue();
+ }
+ );
+ },
+ async testGetValueLabel(label) {
+ return SpecialPowers.spawnChrome(
+ [categoryName, metricName, label],
+ // eslint-disable-next-line no-shadow
+ async (categoryName, metricName, label) => {
+ await Services.fog.testFlushAllChildren();
+ const window = this.browsingContext.topChromeWindow;
+ return window.Glean[categoryName][metricName][
+ label
+ ].testGetValue();
+ }
+ );
+ },
+ };
+ },
+ }
+ );
+ },
+ }
+);
diff --git a/mobile/android/actors/tests/mochitests/mochitest.ini b/mobile/android/actors/tests/mochitests/mochitest.ini
new file mode 100644
index 0000000000..31fe69cb03
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/mochitest.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+support-files =
+ head.js
+prefs =
+ dom.enable_window_print=true
+skip-if =
+ os != 'android'
+[test_geckoview_actor_telemetry.html] \ No newline at end of file
diff --git a/mobile/android/actors/tests/mochitests/test_geckoview_actor_telemetry.html b/mobile/android/actors/tests/mochitests/test_geckoview_actor_telemetry.html
new file mode 100644
index 0000000000..fa4e27b37a
--- /dev/null
+++ b/mobile/android/actors/tests/mochitests/test_geckoview_actor_telemetry.html
@@ -0,0 +1,106 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1816151
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Tests for Telemetry in GeckoView Actors</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>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1816151">Mozilla Bug 1816151 for Window.Print() Telemetry</a>
+<script class="testbody" type="text/javascript">
+
+ const printScript = SpecialPowers.loadChromeScript(_ => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("print",() => {
+ const navigator = Services.wm.getMostRecentWindow("navigator:geckoview");
+ const printActor = navigator.window.moduleManager.getActor("GeckoViewPrintDelegate");
+ printActor.telemetryDotPrintRequested();
+ });
+ addMessageListener("completed",(message) => {
+ const navigator = Services.wm.getMostRecentWindow("navigator:geckoview");
+ const printActor = navigator.window.moduleManager.getActor("GeckoViewPrintDelegate");
+ printActor.telemetryDotPrintPdfCompleted(message);
+ });
+ });
+
+ add_task(async function test_windowDotPrintTelemetry() {
+ const telemetryStart = await GleanTest.dotprint.requested.testGetValue() ?? 0;
+
+ // Using the print actor directly because
+ // if window.print() is requested Android exits the TestRunnerActivity and starts a PrintActivity,
+ // which causes the test harness to stop unexpectedly
+ await printScript.sendAsyncMessage("print");
+
+ const requestPrintOnce = await GleanTest.dotprint.requested.testGetValue() ?? 0;
+ is(requestPrintOnce - telemetryStart, 1, "GeckoView Dot Print Telemetry Incremented Once");
+
+ await printScript.sendAsyncMessage("print");
+ const requestPrintTwice = await GleanTest.dotprint.requested.testGetValue() ?? 0;
+ is(requestPrintTwice - telemetryStart, 2, "GeckoView Dot Print Telemetry Incremented Twice");
+ });
+
+ add_task(async function test_windowDotPrintDialogOpenedTelemetry() {
+ const success = {isPdfSuccessful: true}
+ const telemetryStart = await GleanTest.dotprint.androidDialogRequested.testGetValue() ?? 0;
+ await printScript.sendAsyncMessage("completed", success);
+ const dialogSuccessOnce = await GleanTest.dotprint.androidDialogRequested.testGetValue() ?? 0;
+ is(dialogSuccessOnce - telemetryStart, 1, "GeckoView Dot Print Telemetry for Android Dialog Incremented Once");
+ await printScript.sendAsyncMessage("completed", success);
+ const dialogSuccessTwice = await GleanTest.dotprint.androidDialogRequested.testGetValue() ?? 0;
+ is(dialogSuccessTwice - telemetryStart, 2, "GeckoView Dot Print Telemetry for Android Dialog Incremented Twice");
+ });
+
+ add_task(async function test_windowDotPrintFailureTelemetry() {
+ // UNKNOWN Failure
+ const failureStart = await GleanTest.dotprint.failure.testGetValueLabel("unknown") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false});
+ const printFailure = await GleanTest.dotprint.failure.testGetValueLabel("unknown") ?? 0;
+ is(printFailure - failureStart, 1, "GeckoView Dot Print Telemetry Fail Without a Specified Reason");
+
+ await printScript.sendAsyncMessage("completed", {});
+ const printFailureEmpty = await GleanTest.dotprint.failure.testGetValueLabel("unknown") ?? 0;
+ is(printFailureEmpty - failureStart, 2, "GeckoView Dot Print Telemetry Fail When Empty");
+
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -10});
+ const printFailureUnk = await GleanTest.dotprint.failure.testGetValueLabel("unknown") ?? 0;
+ is(printFailureUnk - failureStart, 3, "GeckoView Dot Print Telemetry Fail With An Unknown Code");
+
+ // ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE -1 Failure
+ const failure1Start = await GleanTest.dotprint.failure.testGetValueLabel("no_settings_service") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -1});
+ const failure1End = await GleanTest.dotprint.failure.testGetValueLabel("no_settings_service") ?? 0;
+ is(failure1End - failure1Start, 1, "GeckoView Dot Print Telemetry Fail With No Settings Service");
+
+ // ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS -2 Failure
+ const failure2Start = await GleanTest.dotprint.failure.testGetValueLabel("no_settings") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -2});
+ const failure2End = await GleanTest.dotprint.failure.testGetValueLabel("no_settings") ?? 0;
+ is(failure2End - failure2Start, 1, "GeckoView Dot Print Telemetry Fail With No Settings");
+
+ // ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT -3 Failure
+ const failure3Start = await GleanTest.dotprint.failure.testGetValueLabel("no_canonical_context") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -3});
+ const failure3End = await GleanTest.dotprint.failure.testGetValueLabel("no_canonical_context") ?? 0;
+ is(failure3End - failure3Start, 1, "GeckoView Dot Print Telemetry Fail With No Canonical Context");
+
+ // ERROR_NO_ACTIVITY_CONTEXT_DELEGATE -4 Failure
+ const failure4Start = await GleanTest.dotprint.failure.testGetValueLabel("no_activity_context_delegate") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -4});
+ const failure4End = await GleanTest.dotprint.failure.testGetValueLabel("no_activity_context_delegate") ?? 0;
+ is(failure4End - failure4Start, 1, "GeckoView Dot Print Telemetry Fail With No Activity Context Delegate");
+
+ // ERROR_NO_ACTIVITY_CONTEXT -5 Failure
+ const failure5Start = await GleanTest.dotprint.failure.testGetValueLabel("no_activity_context") ?? 0;
+ await printScript.sendAsyncMessage("completed", {isPdfSuccessful: false, errorReason: -5});
+ const failure5End = await GleanTest.dotprint.failure.testGetValueLabel("no_activity_context") ?? 0;
+ is(failure5End - failure5Start, 1, "GeckoView Dot Print Telemetry Fail With No Activity Context");
+ });
+
+</script>
+</body>
+</html>