summaryrefslogtreecommitdiffstats
path: root/mobile/android/actors
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /mobile/android/actors
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/actors')
-rw-r--r--mobile/android/actors/ContentDelegateChild.jsm170
-rw-r--r--mobile/android/actors/ContentDelegateParent.jsm36
-rw-r--r--mobile/android/actors/GeckoViewAutoFillChild.jsm410
-rw-r--r--mobile/android/actors/GeckoViewAutoFillParent.jsm101
-rw-r--r--mobile/android/actors/GeckoViewClipboardPermissionChild.jsm100
-rw-r--r--mobile/android/actors/GeckoViewClipboardPermissionParent.jsm49
-rw-r--r--mobile/android/actors/GeckoViewContentChild.jsm354
-rw-r--r--mobile/android/actors/GeckoViewContentParent.jsm92
-rw-r--r--mobile/android/actors/GeckoViewFormValidationChild.jsm25
-rw-r--r--mobile/android/actors/GeckoViewPermissionChild.jsm123
-rw-r--r--mobile/android/actors/GeckoViewPermissionParent.jsm74
-rw-r--r--mobile/android/actors/GeckoViewPermissionProcessChild.jsm201
-rw-r--r--mobile/android/actors/GeckoViewPermissionProcessParent.jsm61
-rw-r--r--mobile/android/actors/GeckoViewPromptChild.jsm32
-rw-r--r--mobile/android/actors/GeckoViewPrompterChild.jsm100
-rw-r--r--mobile/android/actors/GeckoViewPrompterParent.jsm162
-rw-r--r--mobile/android/actors/GeckoViewSettingsChild.jsm30
-rw-r--r--mobile/android/actors/LoadURIDelegateChild.jsm82
-rw-r--r--mobile/android/actors/LoadURIDelegateParent.jsm13
-rw-r--r--mobile/android/actors/MediaControlDelegateChild.jsm56
-rw-r--r--mobile/android/actors/MediaControlDelegateParent.jsm13
-rw-r--r--mobile/android/actors/ProgressDelegateChild.jsm34
-rw-r--r--mobile/android/actors/ProgressDelegateParent.jsm12
-rw-r--r--mobile/android/actors/ScrollDelegateChild.jsm35
-rw-r--r--mobile/android/actors/ScrollDelegateParent.jsm13
-rw-r--r--mobile/android/actors/SelectionActionDelegateChild.jsm506
-rw-r--r--mobile/android/actors/SelectionActionDelegateParent.jsm77
-rw-r--r--mobile/android/actors/moz.build36
28 files changed, 2997 insertions, 0 deletions
diff --git a/mobile/android/actors/ContentDelegateChild.jsm b/mobile/android/actors/ContentDelegateChild.jsm
new file mode 100644
index 0000000000..9af3e6a2a5
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateChild.jsm
@@ -0,0 +1,170 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ManifestObtainer: "resource://gre/modules/ManifestObtainer.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["ContentDelegateChild"];
+
+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.isValidURL(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,
+ };
+
+ 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.jsm b/mobile/android/actors/ContentDelegateParent.jsm
new file mode 100644
index 0000000000..d0475fb286
--- /dev/null
+++ b/mobile/android/actors/ContentDelegateParent.jsm
@@ -0,0 +1,36 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["ContentDelegateParent"];
+
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewUtils.sys.mjs"
+);
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+const { debug, warn } = GeckoViewUtils.initLogging("ContentDelegateParent");
+
+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.jsm b/mobile/android/actors/GeckoViewAutoFillChild.jsm
new file mode 100644
index 0000000000..a37640700a
--- /dev/null
+++ b/mobile/android/actors/GeckoViewAutoFillChild.jsm
@@ -0,0 +1,410 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "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",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ LoginManagerChild: "resource://gre/modules/LoginManagerChild.jsm",
+});
+
+const EXPORTED_SYMBOLS = ["GeckoViewAutoFillChild"];
+
+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.jsm b/mobile/android/actors/GeckoViewAutoFillParent.jsm
new file mode 100644
index 0000000000..209286b14e
--- /dev/null
+++ b/mobile/android/actors/GeckoViewAutoFillParent.jsm
@@ -0,0 +1,101 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewAutoFillParent"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ gAutofillManager: "resource://gre/modules/GeckoViewAutofill.jsm",
+});
+
+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.jsm b/mobile/android/actors/GeckoViewClipboardPermissionChild.jsm
new file mode 100644
index 0000000000..2a5ffd5eaa
--- /dev/null
+++ b/mobile/android/actors/GeckoViewClipboardPermissionChild.jsm
@@ -0,0 +1,100 @@
+/* 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";
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["GeckoViewClipboardPermissionChild"];
+
+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.jsm b/mobile/android/actors/GeckoViewClipboardPermissionParent.jsm
new file mode 100644
index 0000000000..2955292857
--- /dev/null
+++ b/mobile/android/actors/GeckoViewClipboardPermissionParent.jsm
@@ -0,0 +1,49 @@
+/* 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";
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["GeckoViewClipboardPermissionParent"];
+
+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.jsm b/mobile/android/actors/GeckoViewContentChild.jsm
new file mode 100644
index 0000000000..8b57261814
--- /dev/null
+++ b/mobile/android/actors/GeckoViewContentChild.jsm
@@ -0,0 +1,354 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.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",
+});
+
+var EXPORTED_SYMBOLS = ["GeckoViewContentChild"];
+
+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.jsm b/mobile/android/actors/GeckoViewContentParent.jsm
new file mode 100644
index 0000000000..37dee7340b
--- /dev/null
+++ b/mobile/android/actors/GeckoViewContentParent.jsm
@@ -0,0 +1,92 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewContentParent"];
+
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewUtils.sys.mjs"
+);
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "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");
+
+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.jsm b/mobile/android/actors/GeckoViewFormValidationChild.jsm
new file mode 100644
index 0000000000..d794b486c3
--- /dev/null
+++ b/mobile/android/actors/GeckoViewFormValidationChild.jsm
@@ -0,0 +1,25 @@
+/* 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";
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["GeckoViewFormValidationChild"];
+
+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.jsm b/mobile/android/actors/GeckoViewPermissionChild.jsm
new file mode 100644
index 0000000000..713d0036e6
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionChild.jsm
@@ -0,0 +1,123 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPermissionChild"];
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "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";
+
+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.jsm b/mobile/android/actors/GeckoViewPermissionParent.jsm
new file mode 100644
index 0000000000..4b6cf4d0df
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionParent.jsm
@@ -0,0 +1,74 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPermissionParent"];
+
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewUtils.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+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.jsm b/mobile/android/actors/GeckoViewPermissionProcessChild.jsm
new file mode 100644
index 0000000000..c5667cd2bd
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionProcessChild.jsm
@@ -0,0 +1,201 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPermissionProcessChild"];
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewUtils.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "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";
+
+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.jsm b/mobile/android/actors/GeckoViewPermissionProcessParent.jsm
new file mode 100644
index 0000000000..9e80a16bf4
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPermissionProcessParent.jsm
@@ -0,0 +1,61 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPermissionProcessParent"];
+
+const { GeckoViewUtils } = ChromeUtils.importESModule(
+ "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";
+
+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/GeckoViewPromptChild.jsm b/mobile/android/actors/GeckoViewPromptChild.jsm
new file mode 100644
index 0000000000..bc655ab7d4
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPromptChild.jsm
@@ -0,0 +1,32 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["GeckoViewPromptChild"];
+
+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.jsm b/mobile/android/actors/GeckoViewPrompterChild.jsm
new file mode 100644
index 0000000000..4c75f1ffb7
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrompterChild.jsm
@@ -0,0 +1,100 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPrompterChild"];
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+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.jsm b/mobile/android/actors/GeckoViewPrompterParent.jsm
new file mode 100644
index 0000000000..67d87922ee
--- /dev/null
+++ b/mobile/android/actors/GeckoViewPrompterParent.jsm
@@ -0,0 +1,162 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["GeckoViewPrompterParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+const DIALOGS = [
+ "alert",
+ "alertCheck",
+ "confirm",
+ "confirmCheck",
+ "prompt",
+ "promptCheck",
+];
+
+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.jsm b/mobile/android/actors/GeckoViewSettingsChild.jsm
new file mode 100644
index 0000000000..8e670335a0
--- /dev/null
+++ b/mobile/android/actors/GeckoViewSettingsChild.jsm
@@ -0,0 +1,30 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["GeckoViewSettingsChild"];
+
+// Handles GeckoView content settings
+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.jsm b/mobile/android/actors/LoadURIDelegateChild.jsm
new file mode 100644
index 0000000000..e7cb5048fa
--- /dev/null
+++ b/mobile/android/actors/LoadURIDelegateChild.jsm
@@ -0,0 +1,82 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+const { LoadURIDelegate } = ChromeUtils.import(
+ "resource://gre/modules/LoadURIDelegate.jsm"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+var EXPORTED_SYMBOLS = ["LoadURIDelegateChild"];
+
+// Implements nsILoadURIDelegate.
+class LoadURIDelegateChild extends GeckoViewActorChild {
+ // nsILoadURIDelegate.
+ loadURI(aUri, aWhere, aFlags, aTriggeringPrincipal) {
+ debug`loadURI: uri=${aUri && aUri.spec}
+ where=${aWhere} flags=0x${aFlags.toString(16)}
+ tp=${aTriggeringPrincipal && aTriggeringPrincipal.spec}`;
+
+ // Ignore any load going to the extension process
+ // TODO: Remove workaround after Bug 1619798
+ if (
+ WebExtensionPolicy.useRemoteWebExtensions &&
+ lazy.E10SUtils.getRemoteTypeForURIObject(
+ aUri,
+ /* aMultiProcess */ true,
+ /* aRemoteSubframes */ false,
+ Services.appinfo.remoteType
+ ) == lazy.E10SUtils.EXTENSION_REMOTE_TYPE
+ ) {
+ debug`Bypassing load delegate in the Extension process.`;
+ return false;
+ }
+
+ return LoadURIDelegate.load(
+ this.contentWindow,
+ this.eventDispatcher,
+ aUri,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal
+ );
+ }
+
+ // nsILoadURIDelegate.
+ handleLoadError(aUri, aError, aErrorModule) {
+ debug`handleLoadError: uri=${aUri && aUri.spec}
+ displaySpec=${aUri && aUri.displaySpec}
+ error=${aError}`;
+ if (aUri && LoadURIDelegate.isSafeBrowsingError(aError)) {
+ const message = {
+ type: "GeckoView:ContentBlocked",
+ uri: aUri.spec,
+ error: aError,
+ };
+
+ this.eventDispatcher.sendRequest(message);
+ }
+
+ return 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.jsm b/mobile/android/actors/LoadURIDelegateParent.jsm
new file mode 100644
index 0000000000..805da961c6
--- /dev/null
+++ b/mobile/android/actors/LoadURIDelegateParent.jsm
@@ -0,0 +1,13 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["LoadURIDelegateParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+// For this.eventDispatcher in the child
+class LoadURIDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/MediaControlDelegateChild.jsm b/mobile/android/actors/MediaControlDelegateChild.jsm
new file mode 100644
index 0000000000..82b78c404b
--- /dev/null
+++ b/mobile/android/actors/MediaControlDelegateChild.jsm
@@ -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/. */
+
+"use strict";
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ MediaUtils: "resource://gre/modules/MediaUtils.jsm",
+});
+
+var EXPORTED_SYMBOLS = ["MediaControlDelegateChild"];
+
+class MediaControlDelegateChild extends GeckoViewActorChild {
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "MozDOMFullscreen:Entered":
+ case "MozDOMFullscreen:Exited":
+ this.handleFullscreenChanged();
+ break;
+ }
+ }
+
+ handleFullscreenChanged() {
+ 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.`;
+ }
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:Fullscreen",
+ metadata: lazy.MediaUtils.getMetadata(mediaElement) ?? {},
+ enabled: !!element,
+ });
+ }
+}
+
+const { debug } = MediaControlDelegateChild.initLogging(
+ "MediaControlDelegateChild"
+);
diff --git a/mobile/android/actors/MediaControlDelegateParent.jsm b/mobile/android/actors/MediaControlDelegateParent.jsm
new file mode 100644
index 0000000000..c953d85586
--- /dev/null
+++ b/mobile/android/actors/MediaControlDelegateParent.jsm
@@ -0,0 +1,13 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["MediaControlDelegateParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+// For this.eventDispatcher in the child
+class MediaControlDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/ProgressDelegateChild.jsm b/mobile/android/actors/ProgressDelegateChild.jsm
new file mode 100644
index 0000000000..5a89f3851a
--- /dev/null
+++ b/mobile/android/actors/ProgressDelegateChild.jsm
@@ -0,0 +1,34 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["ProgressDelegateChild"];
+
+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.jsm b/mobile/android/actors/ProgressDelegateParent.jsm
new file mode 100644
index 0000000000..c7a7763e7c
--- /dev/null
+++ b/mobile/android/actors/ProgressDelegateParent.jsm
@@ -0,0 +1,12 @@
+/* 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";
+
+const EXPORTED_SYMBOLS = ["ProgressDelegateParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+class ProgressDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/ScrollDelegateChild.jsm b/mobile/android/actors/ScrollDelegateChild.jsm
new file mode 100644
index 0000000000..ae40605cac
--- /dev/null
+++ b/mobile/android/actors/ScrollDelegateChild.jsm
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const EXPORTED_SYMBOLS = ["ScrollDelegateChild"];
+
+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.jsm b/mobile/android/actors/ScrollDelegateParent.jsm
new file mode 100644
index 0000000000..6d208fc2e7
--- /dev/null
+++ b/mobile/android/actors/ScrollDelegateParent.jsm
@@ -0,0 +1,13 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["ScrollDelegateParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+// For this.eventDispatcher in the child
+class ScrollDelegateParent extends GeckoViewActorParent {}
diff --git a/mobile/android/actors/SelectionActionDelegateChild.jsm b/mobile/android/actors/SelectionActionDelegateChild.jsm
new file mode 100644
index 0000000000..e8f5cf8108
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateChild.jsm
@@ -0,0 +1,506 @@
+/* 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/. */
+
+const { GeckoViewActorChild } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorChild.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
+});
+
+const EXPORTED_SYMBOLS = ["SelectionActionDelegateChild"];
+
+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.
+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/unicode"],
+ 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)
+ );
+ }
+
+ _getFrameOffset(aEvent) {
+ // Get correct offset in case of nested iframe.
+ const offset = {
+ left: 0,
+ top: 0,
+ };
+
+ let currentWindow = aEvent.target.defaultView;
+ while (currentWindow.realFrameElement) {
+ const frameElement = currentWindow.realFrameElement;
+ currentWindow = frameElement.ownerGlobal;
+
+ // The offset of the iframe window relative to the parent window
+ // includes the iframe's border, and the iframe's origin in its
+ // containing document.
+ const currentRect = frameElement.getBoundingClientRect();
+ const style = currentWindow.getComputedStyle(frameElement);
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
+ const paddingTop = parseFloat(style.paddingTop) || 0;
+
+ offset.left += currentRect.left + borderLeft + paddingLeft;
+ offset.top += currentRect.top + borderTop + paddingTop;
+
+ const targetDocShell = currentWindow.docShell;
+ if (targetDocShell.isMozBrowser) {
+ break;
+ }
+ }
+
+ // Now we have coordinates relative to the root content document's
+ // layout viewport. Subtract the offset of the visual viewport
+ // relative to the layout viewport, to get coordinates relative to
+ // the visual viewport.
+ var offsetX = {};
+ var offsetY = {};
+ currentWindow.windowUtils.getVisualViewportOffsetRelativeToLayoutViewport(
+ offsetX,
+ offsetY
+ );
+ offset.left -= offsetX.value;
+ offset.top -= offsetY.value;
+
+ return offset;
+ }
+
+ _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 clientRect = (() => {
+ const boundingRect = aEvent.boundingClientRect;
+ if (!boundingRect) {
+ return null;
+ }
+ const offset = this._getFrameOffset(aEvent);
+ return {
+ left: aEvent.boundingClientRect.left + offset.left,
+ top: aEvent.boundingClientRect.top + offset.top,
+ right: aEvent.boundingClientRect.right + offset.left,
+ bottom:
+ aEvent.boundingClientRect.bottom +
+ offset.top +
+ this._accessiblecaretHeight,
+ };
+ })();
+
+ const password = this._isPasswordField(aEvent);
+
+ const msg = {
+ collapsed: aEvent.collapsed,
+ editable: aEvent.selectionEditable,
+ password,
+ selection: password ? "" : aEvent.selectedTextContent,
+ // clientRect is deprecated
+ clientRect,
+ 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.jsm b/mobile/android/actors/SelectionActionDelegateParent.jsm
new file mode 100644
index 0000000000..5791fdec41
--- /dev/null
+++ b/mobile/android/actors/SelectionActionDelegateParent.jsm
@@ -0,0 +1,77 @@
+/* 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";
+
+var EXPORTED_SYMBOLS = ["SelectionActionDelegateParent"];
+
+const { GeckoViewActorParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/GeckoViewActorParent.sys.mjs"
+);
+
+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/moz.build b/mobile/android/actors/moz.build
new file mode 100644
index 0000000000..4ed7fae41e
--- /dev/null
+++ b/mobile/android/actors/moz.build
@@ -0,0 +1,36 @@
+# 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.jsm",
+ "ContentDelegateParent.jsm",
+ "GeckoViewAutoFillChild.jsm",
+ "GeckoViewAutoFillParent.jsm",
+ "GeckoViewClipboardPermissionChild.jsm",
+ "GeckoViewClipboardPermissionParent.jsm",
+ "GeckoViewContentChild.jsm",
+ "GeckoViewContentParent.jsm",
+ "GeckoViewFormValidationChild.jsm",
+ "GeckoViewPermissionChild.jsm",
+ "GeckoViewPermissionParent.jsm",
+ "GeckoViewPermissionProcessChild.jsm",
+ "GeckoViewPermissionProcessParent.jsm",
+ "GeckoViewPromptChild.jsm",
+ "GeckoViewPrompterChild.jsm",
+ "GeckoViewPrompterParent.jsm",
+ "GeckoViewSettingsChild.jsm",
+ "LoadURIDelegateChild.jsm",
+ "LoadURIDelegateParent.jsm",
+ "MediaControlDelegateChild.jsm",
+ "MediaControlDelegateParent.jsm",
+ "ProgressDelegateChild.jsm",
+ "ProgressDelegateParent.jsm",
+ "ScrollDelegateChild.jsm",
+ "ScrollDelegateParent.jsm",
+ "SelectionActionDelegateChild.jsm",
+ "SelectionActionDelegateParent.jsm",
+]