diff options
Diffstat (limited to 'mobile/android/actors')
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", +] |