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