summaryrefslogtreecommitdiffstats
path: root/mobile/android/modules/geckoview
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/modules/geckoview')
-rw-r--r--mobile/android/modules/geckoview/AndroidLog.sys.mjs82
-rw-r--r--mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs21
-rw-r--r--mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs107
-rw-r--r--mobile/android/modules/geckoview/DelayedInit.sys.mjs174
-rw-r--r--mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs19
-rw-r--r--mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs27
-rw-r--r--mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs51
-rw-r--r--mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs730
-rw-r--r--mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs96
-rw-r--r--mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs81
-rw-r--r--mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs99
-rw-r--r--mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs174
-rw-r--r--mobile/android/modules/geckoview/GeckoViewContent.sys.mjs762
-rw-r--r--mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs113
-rw-r--r--mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs89
-rw-r--r--mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs234
-rw-r--r--mobile/android/modules/geckoview/GeckoViewModule.sys.mjs165
-rw-r--r--mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs659
-rw-r--r--mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs210
-rw-r--r--mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs636
-rw-r--r--mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs70
-rw-r--r--mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs141
-rw-r--r--mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs36
-rw-r--r--mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs187
-rw-r--r--mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs182
-rw-r--r--mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs351
-rw-r--r--mobile/android/modules/geckoview/GeckoViewTab.sys.mjs219
-rw-r--r--mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs44
-rw-r--r--mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs62
-rw-r--r--mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs572
-rw-r--r--mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs510
-rw-r--r--mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs1367
-rw-r--r--mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs99
-rw-r--r--mobile/android/modules/geckoview/MediaUtils.sys.mjs79
-rw-r--r--mobile/android/modules/geckoview/Messaging.sys.mjs319
-rw-r--r--mobile/android/modules/geckoview/metrics.yaml153
-rw-r--r--mobile/android/modules/geckoview/moz.build45
-rw-r--r--mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js102
-rw-r--r--mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml5
39 files changed, 9072 insertions, 0 deletions
diff --git a/mobile/android/modules/geckoview/AndroidLog.sys.mjs b/mobile/android/modules/geckoview/AndroidLog.sys.mjs
new file mode 100644
index 0000000000..f24e2bf899
--- /dev/null
+++ b/mobile/android/modules/geckoview/AndroidLog.sys.mjs
@@ -0,0 +1,82 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+/**
+ * Native Android logging for JavaScript. Lets you specify a priority and tag
+ * in addition to the message being logged. Resembles the android.util.Log API
+ * <http://developer.android.com/reference/android/util/Log.html>.
+ *
+ * // Import it as a ESM:
+ * let Log =
+ * ChromeUtils.importESModule("resource://gre/modules/AndroidLog.sys.mjs")
+ * .AndroidLog;
+ *
+ * // Use Log.i, Log.v, Log.d, Log.w, and Log.e to log verbose, debug, info,
+ * // warning, and error messages, respectively.
+ * Log.v("MyModule", "This is a verbose message.");
+ * Log.d("MyModule", "This is a debug message.");
+ * Log.i("MyModule", "This is an info message.");
+ * Log.w("MyModule", "This is a warning message.");
+ * Log.e("MyModule", "This is an error message.");
+ *
+ * // Bind a function with a tag to replace a bespoke dump/log/debug function:
+ * let debug = Log.d.bind(null, "MyModule");
+ * debug("This is a debug message.");
+ * // Outputs "D/GeckoMyModule(#####): This is a debug message."
+ *
+ * // Or "bind" the module object to a tag to automatically tag messages:
+ * Log = Log.bind("MyModule");
+ * Log.d("This is a debug message.");
+ * // Outputs "D/GeckoMyModule(#####): This is a debug message."
+ *
+ * Note: the module automatically prepends "Gecko" to the tag you specify,
+ * since all tags used by Fennec code should start with that string; and it
+ * truncates tags longer than MAX_TAG_LENGTH characters (not including "Gecko").
+ */
+
+import { ctypes } from "resource://gre/modules/ctypes.sys.mjs";
+
+// From <https://android.googlesource.com/platform/system/core/+/master/include/android/log.h>.
+const ANDROID_LOG_VERBOSE = 2;
+const ANDROID_LOG_DEBUG = 3;
+const ANDROID_LOG_INFO = 4;
+const ANDROID_LOG_WARN = 5;
+const ANDROID_LOG_ERROR = 6;
+
+// android.util.Log.isLoggable throws IllegalArgumentException if a tag length
+// exceeds 23 characters, and we prepend five characters ("Gecko") to every tag.
+// However, __android_log_write itself and other android.util.Log methods don't
+// seem to mind longer tags.
+const MAX_TAG_LENGTH = 18;
+
+var liblog = ctypes.open("liblog.so"); // /system/lib/liblog.so
+var __android_log_write = liblog.declare(
+ "__android_log_write",
+ ctypes.default_abi,
+ ctypes.int, // return value: num bytes logged
+ ctypes.int, // priority (ANDROID_LOG_* constant)
+ ctypes.char.ptr, // tag
+ ctypes.char.ptr
+); // message
+
+export var AndroidLog = {
+ MAX_TAG_LENGTH,
+ v: (tag, msg) => __android_log_write(ANDROID_LOG_VERBOSE, "Gecko" + tag, msg),
+ d: (tag, msg) => __android_log_write(ANDROID_LOG_DEBUG, "Gecko" + tag, msg),
+ i: (tag, msg) => __android_log_write(ANDROID_LOG_INFO, "Gecko" + tag, msg),
+ w: (tag, msg) => __android_log_write(ANDROID_LOG_WARN, "Gecko" + tag, msg),
+ e: (tag, msg) => __android_log_write(ANDROID_LOG_ERROR, "Gecko" + tag, msg),
+
+ bind(tag) {
+ return {
+ MAX_TAG_LENGTH,
+ v: AndroidLog.v.bind(null, tag),
+ d: AndroidLog.d.bind(null, tag),
+ i: AndroidLog.i.bind(null, tag),
+ w: AndroidLog.w.bind(null, tag),
+ e: AndroidLog.e.bind(null, tag),
+ };
+ },
+};
diff --git a/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs
new file mode 100644
index 0000000000..480a54e32d
--- /dev/null
+++ b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs
@@ -0,0 +1,21 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* 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/. */
+
+// Used by nsIBrowserUsage
+export function getUniqueDomainsVisitedInPast24Hours() {
+ // The prompting heuristic for the storage access API looks at 1% of the
+ // number of the domains visited in the past 24 hours, with a minimum cap of
+ // 5 domains, in order to prevent prompts from showing up before a tracker is
+ // about to obtain tracking power over a significant portion of the user's
+ // cross-site browsing activity (that is, we do not want to allow automatic
+ // access grants over 1% of the domains). We have the
+ // dom.storage_access.max_concurrent_auto_grants which establishes the
+ // minimum cap here (set to 5 by default) so if we return 0 here the minimum
+ // cap would always take effect. That would only become inaccurate if the
+ // user has browsed more than 500 top-level eTLD's in the past 24 hours,
+ // which should be a very unlikely scenario on mobile anyway.
+
+ return 0;
+}
diff --git a/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs
new file mode 100644
index 0000000000..52a929511a
--- /dev/null
+++ b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs
@@ -0,0 +1,107 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("ChildCrashHandler");
+
+function getDir(name) {
+ const uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path;
+ return PathUtils.join(uAppDataPath, "Crash Reports", name);
+}
+
+function getPendingMinidump(id) {
+ const pendingDir = getDir("pending");
+
+ return [".dmp", ".extra"].map(suffix => {
+ return PathUtils.join(pendingDir, `${id}${suffix}`);
+ });
+}
+
+export var ChildCrashHandler = {
+ // Map a child ID to a remote type.
+ childMap: new Map(),
+
+ // The event listener for this is hooked up in GeckoViewStartup.jsm
+ observe(aSubject, aTopic, aData) {
+ const childID = aData;
+
+ switch (aTopic) {
+ case "process-type-set":
+ // Intentional fall-through
+ case "ipc:content-created": {
+ const pp = aSubject.QueryInterface(Ci.nsIDOMProcessParent);
+ this.childMap.set(childID, pp.remoteType);
+ break;
+ }
+
+ case "ipc:content-shutdown":
+ // Intentional fall-through
+ case "compositor:process-aborted": {
+ aSubject.QueryInterface(Ci.nsIPropertyBag2);
+
+ const disableReporting = Services.env.get(
+ "MOZ_CRASHREPORTER_NO_REPORT"
+ );
+
+ if (
+ !aSubject.get("abnormal") ||
+ !AppConstants.MOZ_CRASHREPORTER ||
+ disableReporting
+ ) {
+ return;
+ }
+
+ // If dumpID is empty the process was likely killed by the system and
+ // we therefore do not want to report the crash. This includes most
+ // "expected" extensions process crashes on Android.
+ const dumpID = aSubject.get("dumpID");
+ if (!dumpID) {
+ Services.telemetry
+ .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE")
+ .add(1);
+ return;
+ }
+
+ debug`Notifying child process crash, dump ID ${dumpID}`;
+ const [minidumpPath, extrasPath] = getPendingMinidump(dumpID);
+
+ let remoteType = this.childMap.get(childID);
+ this.childMap.delete(childID);
+
+ if (remoteType?.length) {
+ // Only send the remote type prefix since everything after a "=" is
+ // dynamic, and used to control the process pool to use.
+ remoteType = remoteType.split("=")[0];
+ }
+
+ // Report GPU and extension process crashes as occuring in a background
+ // process, and others as foreground.
+ const processType =
+ aTopic === "compositor:process-aborted" || remoteType === "extension"
+ ? "BACKGROUND_CHILD"
+ : "FOREGROUND_CHILD";
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:ChildCrashReport",
+ minidumpPath,
+ extrasPath,
+ success: true,
+ fatal: false,
+ processType,
+ remoteType,
+ });
+
+ break;
+ }
+ }
+ },
+};
diff --git a/mobile/android/modules/geckoview/DelayedInit.sys.mjs b/mobile/android/modules/geckoview/DelayedInit.sys.mjs
new file mode 100644
index 0000000000..db5984c8ec
--- /dev/null
+++ b/mobile/android/modules/geckoview/DelayedInit.sys.mjs
@@ -0,0 +1,174 @@
+/* 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 DelayedInit to schedule initializers to run some time after startup.
+ * Initializers are added to a list of pending inits. Whenever the main thread
+ * message loop is idle, DelayedInit will start running initializers from the
+ * pending list. To prevent monopolizing the message loop, every idling period
+ * has a maximum duration. When that's reached, we give up the message loop and
+ * wait for the next idle.
+ *
+ * DelayedInit is compatible with lazy getters like those from XPCOMUtils. When
+ * the lazy getter is first accessed, its corresponding initializer is run
+ * automatically if it hasn't been run already. Each initializer also has a
+ * maximum wait parameter that specifies a mandatory timeout; when the timeout
+ * is reached, the initializer is forced to run.
+ *
+ * DelayedInit.schedule(() => Foo.init(), null, null, 5000);
+ *
+ * In the example above, Foo.init will run automatically when the message loop
+ * becomes idle, or when 5000ms has elapsed, whichever comes first.
+ *
+ * DelayedInit.schedule(() => Foo.init(), this, "Foo", 5000);
+ *
+ * In the example above, Foo.init will run automatically when the message loop
+ * becomes idle, when |this.Foo| is accessed, or when 5000ms has elapsed,
+ * whichever comes first.
+ *
+ * It may be simpler to have a wrapper for DelayedInit.schedule. For example,
+ *
+ * function InitLater(fn, obj, name) {
+ * return DelayedInit.schedule(fn, obj, name, 5000); // constant max wait
+ * }
+ * InitLater(() => Foo.init());
+ * InitLater(() => Bar.init(), this, "Bar");
+ */
+export var DelayedInit = {
+ schedule(fn, object, name, maxWait) {
+ return Impl.scheduleInit(fn, object, name, maxWait);
+ },
+
+ scheduleList(fns, maxWait) {
+ for (const fn of fns) {
+ Impl.scheduleInit(fn, null, null, maxWait);
+ }
+ },
+};
+
+// Maximum duration for each idling period. Pending inits are run until this
+// duration is exceeded; then we wait for next idling period.
+const MAX_IDLE_RUN_MS = 50;
+
+var Impl = {
+ pendingInits: [],
+
+ onIdle() {
+ const startTime = Cu.now();
+ let time = startTime;
+ let nextDue;
+
+ // Go through all the pending inits. Even if we don't run them,
+ // we still need to find out when the next timeout should be.
+ for (const init of this.pendingInits) {
+ if (init.complete) {
+ continue;
+ }
+
+ if (time - startTime < MAX_IDLE_RUN_MS) {
+ init.maybeInit();
+ time = Cu.now();
+ } else {
+ // We ran out of time; find when the next closest due time is.
+ nextDue = nextDue ? Math.min(nextDue, init.due) : init.due;
+ }
+ }
+
+ // Get rid of completed ones.
+ this.pendingInits = this.pendingInits.filter(init => !init.complete);
+
+ if (nextDue !== undefined) {
+ // Schedule the next idle, if we still have pending inits.
+ ChromeUtils.idleDispatch(() => this.onIdle(), {
+ timeout: Math.max(0, nextDue - time),
+ });
+ }
+ },
+
+ addPendingInit(fn, wait) {
+ const init = {
+ fn,
+ due: Cu.now() + wait,
+ complete: false,
+ maybeInit() {
+ if (this.complete) {
+ return false;
+ }
+ this.complete = true;
+ this.fn.call();
+ this.fn = null;
+ return true;
+ },
+ };
+
+ if (!this.pendingInits.length) {
+ // Schedule for the first idle.
+ ChromeUtils.idleDispatch(() => this.onIdle(), { timeout: wait });
+ }
+ this.pendingInits.push(init);
+ return init;
+ },
+
+ scheduleInit(fn, object, name, wait) {
+ const init = this.addPendingInit(fn, wait);
+
+ if (!object || !name) {
+ // No lazy getter needed.
+ return;
+ }
+
+ // Get any existing information about the property.
+ let prop = Object.getOwnPropertyDescriptor(object, name) || {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ };
+
+ if (!prop.configurable) {
+ // Object.defineProperty won't work, so just perform init here.
+ init.maybeInit();
+ return;
+ }
+
+ // Define proxy getter/setter that will call first initializer first,
+ // before delegating the get/set to the original target.
+ Object.defineProperty(object, name, {
+ get: function proxy_getter() {
+ init.maybeInit();
+
+ // If the initializer actually ran, it may have replaced our proxy
+ // property with a real one, so we need to reload he property.
+ const newProp = Object.getOwnPropertyDescriptor(object, name);
+ if (newProp.get !== proxy_getter) {
+ // Set prop if newProp doesn't refer to our proxy property.
+ prop = newProp;
+ } else {
+ // Otherwise, reset to the original property.
+ Object.defineProperty(object, name, prop);
+ }
+
+ if (prop.get) {
+ return prop.get.call(object);
+ }
+ return prop.value;
+ },
+ set(newVal) {
+ init.maybeInit();
+
+ // Since our initializer already ran,
+ // we can get rid of our proxy property.
+ if (prop.get || prop.set) {
+ Object.defineProperty(object, name, prop);
+ prop.set.call(object);
+ return;
+ }
+
+ prop.value = newVal;
+ Object.defineProperty(object, name, prop);
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs
new file mode 100644
index 0000000000..8b728e7544
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewActorChild.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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+import { EventDispatcher } from "resource://gre/modules/Messaging.sys.mjs";
+
+export class GeckoViewActorChild extends JSWindowActorChild {
+ static initLogging(aModuleName) {
+ const tag = aModuleName.replace("GeckoView", "") + "[C]";
+ return GeckoViewUtils.initLogging(tag);
+ }
+
+ actorCreated() {
+ this.eventDispatcher = EventDispatcher.forActor(this);
+ }
+}
+
+const { debug, warn } = GeckoViewUtils.initLogging("Actor[C]");
diff --git a/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs
new file mode 100644
index 0000000000..991dfe7581
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs
@@ -0,0 +1,27 @@
+/* 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";
+
+const actors = new Set();
+
+export var GeckoViewActorManager = {
+ addJSWindowActors(actors) {
+ for (const [actorName, actor] of Object.entries(actors)) {
+ this._register(actorName, actor);
+ }
+ },
+
+ _register(actorName, actor) {
+ if (actors.has(actorName)) {
+ // Actor already registered, nothing to do
+ return;
+ }
+
+ ChromeUtils.registerWindowActor(actorName, actor);
+ actors.add(actorName);
+ },
+};
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorManager");
diff --git a/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs
new file mode 100644
index 0000000000..c4569fb052
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+
+export class GeckoViewActorParent extends JSWindowActorParent {
+ static initLogging(aModuleName) {
+ const tag = aModuleName.replace("GeckoView", "");
+ return GeckoViewUtils.initLogging(tag);
+ }
+
+ get browser() {
+ return this.browsingContext.top.embedderElement;
+ }
+
+ get window() {
+ const { browsingContext } = this;
+ // If this is a chrome actor, the chrome window will be at
+ // browsingContext.window.
+ if (!browsingContext.isContent && browsingContext.window) {
+ return browsingContext.window;
+ }
+ return this.browser?.ownerGlobal;
+ }
+
+ get eventDispatcher() {
+ return this.window?.moduleManager.eventDispatcher;
+ }
+
+ receiveMessage(aMessage) {
+ if (!this.window) {
+ // If we have no window, it means that this browsingContext has been
+ // destroyed already and there's nothing to do here for us.
+ debug`receiveMessage window destroyed ${aMessage.name} ${aMessage.data?.type}`;
+ return null;
+ }
+
+ switch (aMessage.name) {
+ case "DispatcherMessage":
+ return this.eventDispatcher.sendRequest(aMessage.data);
+ case "DispatcherQuery":
+ return this.eventDispatcher.sendRequestForResult(aMessage.data);
+ }
+
+ // By default messages are forwarded to the module.
+ return this.window.moduleManager.onMessageFromActor(this.name, aMessage);
+ }
+}
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorParent");
diff --git a/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs
new file mode 100644
index 0000000000..2bac20281e
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs
@@ -0,0 +1,730 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "LoginInfo", () =>
+ Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ "nsILoginInfo",
+ "init"
+ )
+);
+
+export class LoginEntry {
+ constructor({
+ origin,
+ formActionOrigin,
+ httpRealm,
+ username,
+ password,
+ guid,
+ timeCreated,
+ timeLastUsed,
+ timePasswordChanged,
+ timesUsed,
+ }) {
+ this.origin = origin ?? "";
+ this.formActionOrigin = formActionOrigin ?? null;
+ this.httpRealm = httpRealm ?? null;
+ this.username = username ?? "";
+ this.password = password ?? "";
+
+ // Metadata.
+ this.guid = guid ?? null;
+ // TODO: Not supported by GV.
+ this.timeCreated = timeCreated ?? null;
+ this.timeLastUsed = timeLastUsed ?? null;
+ this.timePasswordChanged = timePasswordChanged ?? null;
+ this.timesUsed = timesUsed ?? null;
+ }
+
+ toLoginInfo() {
+ const info = new lazy.LoginInfo(
+ this.origin,
+ this.formActionOrigin,
+ this.httpRealm,
+ this.username,
+ this.password
+ );
+
+ // Metadata.
+ info.QueryInterface(Ci.nsILoginMetaInfo);
+ info.guid = this.guid;
+ info.timeCreated = this.timeCreated;
+ info.timeLastUsed = this.timeLastUsed;
+ info.timePasswordChanged = this.timePasswordChanged;
+ info.timesUsed = this.timesUsed;
+
+ return info;
+ }
+
+ static parse(aObj) {
+ const entry = new LoginEntry({});
+ Object.assign(entry, aObj);
+
+ return entry;
+ }
+
+ static fromLoginInfo(aInfo) {
+ const entry = new LoginEntry({});
+ entry.origin = aInfo.origin;
+ entry.formActionOrigin = aInfo.formActionOrigin;
+ entry.httpRealm = aInfo.httpRealm;
+ entry.username = aInfo.username;
+ entry.password = aInfo.password;
+
+ // Metadata.
+ aInfo.QueryInterface(Ci.nsILoginMetaInfo);
+ entry.guid = aInfo.guid;
+ entry.timeCreated = aInfo.timeCreated;
+ entry.timeLastUsed = aInfo.timeLastUsed;
+ entry.timePasswordChanged = aInfo.timePasswordChanged;
+ entry.timesUsed = aInfo.timesUsed;
+
+ return entry;
+ }
+}
+
+export class Address {
+ constructor({
+ name,
+ givenName,
+ additionalName,
+ familyName,
+ organization,
+ streetAddress,
+ addressLevel1,
+ addressLevel2,
+ addressLevel3,
+ postalCode,
+ country,
+ tel,
+ email,
+ guid,
+ timeCreated,
+ timeLastUsed,
+ timeLastModified,
+ timesUsed,
+ version,
+ }) {
+ this.name = name ?? "";
+ this.givenName = givenName ?? "";
+ this.additionalName = additionalName ?? "";
+ this.familyName = familyName ?? "";
+ this.organization = organization ?? "";
+ this.streetAddress = streetAddress ?? "";
+ this.addressLevel1 = addressLevel1 ?? "";
+ this.addressLevel2 = addressLevel2 ?? "";
+ this.addressLevel3 = addressLevel3 ?? "";
+ this.postalCode = postalCode ?? "";
+ this.country = country ?? "";
+ this.tel = tel ?? "";
+ this.email = email ?? "";
+
+ // Metadata.
+ this.guid = guid ?? null;
+ // TODO: Not supported by GV.
+ this.timeCreated = timeCreated ?? null;
+ this.timeLastUsed = timeLastUsed ?? null;
+ this.timeLastModified = timeLastModified ?? null;
+ this.timesUsed = timesUsed ?? null;
+ this.version = version ?? null;
+ }
+
+ isValid() {
+ return (
+ (this.name ?? this.givenName ?? this.familyName) !== "" &&
+ this.streetAddress !== "" &&
+ this.postalCode !== ""
+ );
+ }
+
+ static fromGecko(aObj) {
+ return new Address({
+ version: aObj.version,
+ name: aObj.name,
+ givenName: aObj["given-name"],
+ additionalName: aObj["additional-name"],
+ familyName: aObj["family-name"],
+ organization: aObj.organization,
+ streetAddress: aObj["street-address"],
+ addressLevel1: aObj["address-level1"],
+ addressLevel2: aObj["address-level2"],
+ addressLevel3: aObj["address-level3"],
+ postalCode: aObj["postal-code"],
+ country: aObj.country,
+ tel: aObj.tel,
+ email: aObj.email,
+ guid: aObj.guid,
+ timeCreated: aObj.timeCreated,
+ timeLastUsed: aObj.timeLastUsed,
+ timeLastModified: aObj.timeLastModified,
+ timesUsed: aObj.timesUsed,
+ });
+ }
+
+ static parse(aObj) {
+ const entry = new Address({});
+ Object.assign(entry, aObj);
+
+ return entry;
+ }
+
+ toGecko() {
+ return {
+ version: this.version,
+ name: this.name,
+ "given-name": this.givenName,
+ "additional-name": this.additionalName,
+ "family-name": this.familyName,
+ organization: this.organization,
+ "street-address": this.streetAddress,
+ "address-level1": this.addressLevel1,
+ "address-level2": this.addressLevel2,
+ "address-level3": this.addressLevel3,
+ "postal-code": this.postalCode,
+ country: this.country,
+ tel: this.tel,
+ email: this.email,
+ guid: this.guid,
+ };
+ }
+}
+
+export class CreditCard {
+ constructor({
+ name,
+ number,
+ expMonth,
+ expYear,
+ type,
+ guid,
+ timeCreated,
+ timeLastUsed,
+ timeLastModified,
+ timesUsed,
+ version,
+ }) {
+ this.name = name ?? "";
+ this.number = number ?? "";
+ this.expMonth = expMonth ?? "";
+ this.expYear = expYear ?? "";
+ this.type = type ?? "";
+
+ // Metadata.
+ this.guid = guid ?? null;
+ // TODO: Not supported by GV.
+ this.timeCreated = timeCreated ?? null;
+ this.timeLastUsed = timeLastUsed ?? null;
+ this.timeLastModified = timeLastModified ?? null;
+ this.timesUsed = timesUsed ?? null;
+ this.version = version ?? null;
+ }
+
+ isValid() {
+ return (
+ this.name !== "" &&
+ this.number !== "" &&
+ this.expMonth !== "" &&
+ this.expYear !== ""
+ );
+ }
+
+ static fromGecko(aObj) {
+ return new CreditCard({
+ version: aObj.version,
+ name: aObj["cc-name"],
+ number: aObj["cc-number"],
+ expMonth: aObj["cc-exp-month"]?.toString(),
+ expYear: aObj["cc-exp-year"]?.toString(),
+ type: aObj["cc-type"],
+ guid: aObj.guid,
+ timeCreated: aObj.timeCreated,
+ timeLastUsed: aObj.timeLastUsed,
+ timeLastModified: aObj.timeLastModified,
+ timesUsed: aObj.timesUsed,
+ });
+ }
+
+ static parse(aObj) {
+ const entry = new CreditCard({});
+ Object.assign(entry, aObj);
+
+ return entry;
+ }
+
+ toGecko() {
+ return {
+ version: this.version,
+ "cc-name": this.name,
+ "cc-number": this.number,
+ "cc-exp-month": this.expMonth,
+ "cc-exp-year": this.expYear,
+ "cc-type": this.type,
+ guid: this.guid,
+ };
+ }
+}
+
+export class SelectOption {
+ // Sync with Autocomplete.SelectOption.Hint in Autocomplete.java.
+ static Hint = {
+ NONE: 0,
+ GENERATED: 1 << 0,
+ INSECURE_FORM: 1 << 1,
+ DUPLICATE_USERNAME: 1 << 2,
+ MATCHING_ORIGIN: 1 << 3,
+ };
+
+ constructor({ value, hint }) {
+ this.value = value ?? null;
+ this.hint = hint ?? SelectOption.Hint.NONE;
+ }
+}
+
+// Sync with Autocomplete.UsedField in Autocomplete.java.
+const UsedField = { PASSWORD: 1 };
+
+export const GeckoViewAutocomplete = {
+ /** current opened prompt */
+ _prompt: null,
+
+ /**
+ * Delegates login entry fetching for the given domain to the attached
+ * LoginStorage GeckoView delegate.
+ *
+ * @param aDomain
+ * The domain string to fetch login entries for. If null, all logins
+ * will be fetched.
+ * @return {Promise}
+ * Resolves with an array of login objects or null.
+ * Rejected if no delegate is attached.
+ * Login object string properties:
+ * { guid, origin, formActionOrigin, httpRealm, username, password }
+ */
+ fetchLogins(aDomain = null) {
+ debug`fetchLogins for ${aDomain ?? "All domains"}`;
+
+ return lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:Autocomplete:Fetch:Login",
+ domain: aDomain,
+ });
+ },
+
+ /**
+ * Delegates credit card entry fetching to the attached LoginStorage
+ * GeckoView delegate.
+ *
+ * @return {Promise}
+ * Resolves with an array of credit card objects or null.
+ * Rejected if no delegate is attached.
+ * Login object string properties:
+ * { guid, name, number, expMonth, expYear, type }
+ */
+ fetchCreditCards() {
+ debug`fetchCreditCards`;
+
+ return lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:Autocomplete:Fetch:CreditCard",
+ });
+ },
+
+ /**
+ * Delegates address entry fetching to the attached LoginStorage
+ * GeckoView delegate.
+ *
+ * @return {Promise}
+ * Resolves with an array of address objects or null.
+ * Rejected if no delegate is attached.
+ * Login object string properties:
+ * { guid, name, givenName, additionalName, familyName,
+ * organization, streetAddress, addressLevel1, addressLevel2,
+ * addressLevel3, postalCode, country, tel, email }
+ */
+ fetchAddresses() {
+ debug`fetchAddresses`;
+
+ return lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:Autocomplete:Fetch:Address",
+ });
+ },
+
+ /**
+ * Delegates credit card entry saving to the attached LoginStorage GeckoView delegate.
+ * Call this when a new or modified credit card entry has been submitted.
+ *
+ * @param aCreditCard The {CreditCard} to be saved.
+ */
+ onCreditCardSave(aCreditCard) {
+ debug`onCreditCardSave ${aCreditCard}`;
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:Autocomplete:Save:CreditCard",
+ creditCard: aCreditCard,
+ });
+ },
+
+ /**
+ * Delegates address entry saving to the attached LoginStorage GeckoView delegate.
+ * Call this when a new or modified address entry has been submitted.
+ *
+ * @param aAddress The {Address} to be saved.
+ */
+ onAddressSave(aAddress) {
+ debug`onAddressSave ${aAddress}`;
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:Autocomplete:Save:Address",
+ address: aAddress,
+ });
+ },
+
+ /**
+ * Delegates login entry saving to the attached LoginStorage GeckoView delegate.
+ * Call this when a new login entry or a new password for an existing login
+ * entry has been submitted.
+ *
+ * @param aLogin The {LoginEntry} to be saved.
+ */
+ onLoginSave(aLogin) {
+ debug`onLoginSave ${aLogin}`;
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:Autocomplete:Save:Login",
+ login: aLogin,
+ });
+ },
+
+ /**
+ * Delegates login entry password usage to the attached LoginStorage GeckoView
+ * delegate.
+ * Call this when the password of an existing login entry, as returned by
+ * fetchLogins, has been used for autofill.
+ *
+ * @param aLogin The {LoginEntry} whose password was used.
+ */
+ onLoginPasswordUsed(aLogin) {
+ debug`onLoginUsed ${aLogin}`;
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:Autocomplete:Used:Login",
+ usedFields: UsedField.PASSWORD,
+ login: aLogin,
+ });
+ },
+
+ _numActiveSelections: 0,
+
+ /**
+ * Delegates login entry selection.
+ * Call this when there are multiple login entry option for a form to delegate
+ * the selection.
+ *
+ * @param aBrowser The browser instance the triggered the selection.
+ * @param aOptions The list of {SelectOption} depicting viable options.
+ */
+ onLoginSelect(aBrowser, aOptions) {
+ debug`onLoginSelect ${aOptions}`;
+
+ return new Promise((resolve, reject) => {
+ if (!aBrowser || !aOptions) {
+ debug`onLoginSelect Rejecting - no browser or options provided`;
+ reject();
+ return;
+ }
+
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ prompt.asyncShowPrompt(
+ {
+ type: "Autocomplete:Select:Login",
+ options: aOptions,
+ },
+ result => {
+ if (!result || !result.selection) {
+ reject();
+ return;
+ }
+
+ const option = new SelectOption({
+ value: LoginEntry.parse(result.selection.value),
+ hint: result.selection.hint,
+ });
+ resolve(option);
+ }
+ );
+ this._prompt = prompt;
+ });
+ },
+
+ /**
+ * Delegates credit card entry selection.
+ * Call this when there are multiple credit card entry option for a form to delegate
+ * the selection.
+ *
+ * @param aBrowser The browser instance the triggered the selection.
+ * @param aOptions The list of {SelectOption} depicting viable options.
+ */
+ onCreditCardSelect(aBrowser, aOptions) {
+ debug`onCreditCardSelect ${aOptions}`;
+
+ return new Promise((resolve, reject) => {
+ if (!aBrowser || !aOptions) {
+ debug`onCreditCardSelect Rejecting - no browser or options provided`;
+ reject();
+ return;
+ }
+
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ prompt.asyncShowPrompt(
+ {
+ type: "Autocomplete:Select:CreditCard",
+ options: aOptions,
+ },
+ result => {
+ if (!result || !result.selection) {
+ reject();
+ return;
+ }
+
+ const option = new SelectOption({
+ value: CreditCard.parse(result.selection.value),
+ hint: result.selection.hint,
+ });
+ resolve(option);
+ }
+ );
+ this._prompt = prompt;
+ });
+ },
+
+ /**
+ * Delegates address entry selection.
+ * Call this when there are multiple address entry option for a form to delegate
+ * the selection.
+ *
+ * @param aBrowser The browser instance the triggered the selection.
+ * @param aOptions The list of {SelectOption} depicting viable options.
+ */
+ onAddressSelect(aBrowser, aOptions) {
+ debug`onAddressSelect ${aOptions}`;
+
+ return new Promise((resolve, reject) => {
+ if (!aBrowser || !aOptions) {
+ debug`onAddressSelect Rejecting - no browser or options provided`;
+ reject();
+ return;
+ }
+
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ prompt.asyncShowPrompt(
+ {
+ type: "Autocomplete:Select:Address",
+ options: aOptions,
+ },
+ result => {
+ if (!result || !result.selection) {
+ reject();
+ return;
+ }
+
+ const option = new SelectOption({
+ value: Address.parse(result.selection.value),
+ hint: result.selection.hint,
+ });
+ resolve(option);
+ }
+ );
+ this._prompt = prompt;
+ });
+ },
+
+ async delegateSelection({
+ browsingContext,
+ options,
+ inputElementIdentifier,
+ formOrigin,
+ }) {
+ debug`delegateSelection ${options}`;
+
+ if (!options.length) {
+ return;
+ }
+
+ let insecureHint = SelectOption.Hint.NONE;
+ let loginStyle = null;
+
+ // TODO: Replace this string with more robust mechanics.
+ let selectionType = null;
+ const selectOptions = [];
+
+ for (const option of options) {
+ switch (option.style) {
+ case "insecureWarning": {
+ // We depend on the insecure warning to be the first option.
+ insecureHint = SelectOption.Hint.INSECURE_FORM;
+ break;
+ }
+ case "generatedPassword": {
+ selectionType = "login";
+ const comment = JSON.parse(option.comment);
+ selectOptions.push(
+ new SelectOption({
+ value: new LoginEntry({
+ password: comment.generatedPassword,
+ }),
+ hint: SelectOption.Hint.GENERATED | insecureHint,
+ })
+ );
+ break;
+ }
+ case "login":
+ // Fallthrough.
+ case "loginWithOrigin": {
+ selectionType = "login";
+ loginStyle = option.style;
+ const comment = JSON.parse(option.comment);
+
+ let hint = SelectOption.Hint.NONE | insecureHint;
+ if (comment.isDuplicateUsername) {
+ hint |= SelectOption.Hint.DUPLICATE_USERNAME;
+ }
+ if (comment.isOriginMatched) {
+ hint |= SelectOption.Hint.MATCHING_ORIGIN;
+ }
+
+ selectOptions.push(
+ new SelectOption({
+ value: LoginEntry.parse(comment.login),
+ hint,
+ })
+ );
+ break;
+ }
+ case "autofill-profile": {
+ const comment = JSON.parse(option.comment);
+ debug`delegateSelection ${comment}`;
+ const creditCard = CreditCard.fromGecko(comment);
+ const address = Address.fromGecko(comment);
+ if (creditCard.isValid()) {
+ selectionType = "creditCard";
+ selectOptions.push(
+ new SelectOption({
+ value: creditCard,
+ hint: insecureHint,
+ })
+ );
+ } else if (address.isValid()) {
+ selectionType = "address";
+ selectOptions.push(
+ new SelectOption({
+ value: address,
+ hint: insecureHint,
+ })
+ );
+ }
+ break;
+ }
+ default:
+ debug`delegateSelection - ignoring unknown option style ${option.style}`;
+ }
+ }
+
+ if (selectOptions.length < 1) {
+ debug`Abort delegateSelection - no valid options provided`;
+ return;
+ }
+
+ if (this._numActiveSelections > 0) {
+ debug`Abort delegateSelection - there is already one delegation active`;
+ return;
+ }
+
+ ++this._numActiveSelections;
+
+ let selectedOption = null;
+ const browser = browsingContext.top.embedderElement;
+ if (selectionType === "login") {
+ selectedOption = await this.onLoginSelect(browser, selectOptions).catch(
+ _ => {
+ debug`No GV delegate attached`;
+ }
+ );
+ } else if (selectionType === "creditCard") {
+ selectedOption = await this.onCreditCardSelect(
+ browser,
+ selectOptions
+ ).catch(_ => {
+ debug`No GV delegate attached`;
+ });
+ } else if (selectionType === "address") {
+ selectedOption = await this.onAddressSelect(browser, selectOptions).catch(
+ _ => {
+ debug`No GV delegate attached`;
+ }
+ );
+ }
+
+ // prompt is closed now.
+ this._prompt = null;
+
+ --this._numActiveSelections;
+
+ debug`delegateSelection selected option: ${selectedOption}`;
+
+ if (selectionType === "login") {
+ const selectedLogin = selectedOption?.value?.toLoginInfo();
+
+ if (!selectedLogin) {
+ debug`Abort delegateSelection - no login entry selected`;
+ return;
+ }
+
+ debug`delegateSelection - filling form`;
+
+ const actor =
+ browsingContext.currentWindowGlobal.getActor("LoginManager");
+
+ await actor.fillForm({
+ browser,
+ inputElementIdentifier,
+ loginFormOrigin: formOrigin,
+ login: selectedLogin,
+ style:
+ selectedOption.hint & SelectOption.Hint.GENERATED
+ ? "generatedPassword"
+ : loginStyle,
+ });
+ } else if (selectionType === "creditCard") {
+ const selectedCreditCard = selectedOption?.value?.toGecko();
+ const actor =
+ browsingContext.currentWindowGlobal.getActor("FormAutofill");
+
+ actor.sendAsyncMessage("FormAutofill:FillForm", selectedCreditCard);
+ } else if (selectionType === "address") {
+ const selectedAddress = selectedOption?.value?.toGecko();
+ const actor =
+ browsingContext.currentWindowGlobal.getActor("FormAutofill");
+
+ actor.sendAsyncMessage("FormAutofill:FillForm", selectedAddress);
+ }
+
+ debug`delegateSelection - form filled`;
+ },
+
+ delegateDismiss() {
+ debug`delegateDismiss`;
+
+ this._prompt?.dismiss();
+ },
+};
+
+const { debug } = GeckoViewUtils.initLogging("GeckoViewAutocomplete");
diff --git a/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs
new file mode 100644
index 0000000000..1249685aaa
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs
@@ -0,0 +1,96 @@
+/* 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";
+
+class Autofill {
+ constructor(sessionId, eventDispatcher) {
+ this.eventDispatcher = eventDispatcher;
+ this.sessionId = sessionId;
+ }
+
+ start() {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:StartAutofill",
+ sessionId: this.sessionId,
+ });
+ }
+
+ add(node) {
+ return this.eventDispatcher.sendRequestForResult({
+ type: "GeckoView:AddAutofill",
+ node,
+ });
+ }
+
+ focus(node) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:OnAutofillFocus",
+ node,
+ });
+ }
+
+ update(node) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:UpdateAutofill",
+ node,
+ });
+ }
+
+ commit(node) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:CommitAutofill",
+ node,
+ });
+ }
+
+ clear() {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ClearAutofill",
+ });
+ }
+}
+
+class AutofillManager {
+ sessions = new Set();
+ autofill = null;
+
+ ensure(sessionId, eventDispatcher) {
+ if (!this.sessions.has(sessionId)) {
+ this.autofill = new Autofill(sessionId, eventDispatcher);
+ this.sessions.add(sessionId);
+ this.autofill.start();
+ }
+ // This could be called for an outdated session, in which case we will just
+ // ignore the autofill call.
+ if (sessionId !== this.autofill.sessionId) {
+ return null;
+ }
+ return this.autofill;
+ }
+
+ get(sessionId) {
+ if (!this.autofill || sessionId !== this.autofill.sessionId) {
+ warn`Disregarding old session ${sessionId}`;
+ // We disregard old sessions
+ return null;
+ }
+ return this.autofill;
+ }
+
+ delete(sessionId) {
+ this.sessions.delete(sessionId);
+ if (!this.autofill || sessionId !== this.autofill.sessionId) {
+ // this delete call might happen *after* the next session already
+ // started, in that case, we can safely ignore this call.
+ return;
+ }
+ this.autofill.clear();
+ this.autofill = null;
+ }
+}
+
+export var gAutofillManager = new AutofillManager();
+
+const { debug, warn } = GeckoViewUtils.initLogging("Autofill");
diff --git a/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs
new file mode 100644
index 0000000000..68cff76ba7
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs
@@ -0,0 +1,81 @@
+/* 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";
+
+const { debug, warn } = GeckoViewUtils.initLogging("Module[C]");
+
+export class GeckoViewChildModule {
+ static initLogging(aModuleName) {
+ this._moduleName = aModuleName;
+ const tag = aModuleName.replace("GeckoView", "") + "[C]";
+ return GeckoViewUtils.initLogging(tag);
+ }
+
+ static create(aGlobal, aModuleName) {
+ return new this(aModuleName || this._moduleName, aGlobal);
+ }
+
+ constructor(aModuleName, aGlobal) {
+ this.moduleName = aModuleName;
+ this.messageManager = aGlobal;
+ this.enabled = false;
+
+ if (!aGlobal._gvEventDispatcher) {
+ aGlobal._gvEventDispatcher = GeckoViewUtils.getDispatcherForWindow(
+ aGlobal.content
+ );
+ aGlobal.addEventListener(
+ "unload",
+ event => {
+ if (event.target === this.messageManager) {
+ aGlobal._gvEventDispatcher.finalize();
+ }
+ },
+ {
+ mozSystemGroup: true,
+ }
+ );
+ }
+ this.eventDispatcher = aGlobal._gvEventDispatcher;
+
+ this.messageManager.addMessageListener(
+ "GeckoView:UpdateModuleState",
+ aMsg => {
+ if (aMsg.data.module !== this.moduleName) {
+ return;
+ }
+
+ const { enabled } = aMsg.data;
+
+ if (enabled !== this.enabled) {
+ if (!enabled) {
+ this.onDisable();
+ }
+
+ this.enabled = enabled;
+
+ if (enabled) {
+ this.onEnable();
+ }
+ }
+ }
+ );
+
+ this.onInit();
+
+ this.messageManager.sendAsyncMessage("GeckoView:ContentModuleLoaded", {
+ module: this.moduleName,
+ });
+ }
+
+ // Override to initialize module.
+ onInit() {}
+
+ // Override to enable module after setting a Java delegate.
+ onEnable() {}
+
+ // Override to disable module after clearing the Java delegate.
+ onDisable() {}
+}
diff --git a/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs
new file mode 100644
index 0000000000..2c8e55c380
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs",
+});
+
+import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+
+const { debug } = GeckoViewUtils.initLogging("GeckoViewClipboardPermission");
+
+export var GeckoViewClipboardPermission = {
+ confirmUserPaste(aWindowContext) {
+ return new Promise((resolve, reject) => {
+ if (!aWindowContext) {
+ reject(
+ Components.Exception("Null window context.", Cr.NS_ERROR_INVALID_ARG)
+ );
+ return;
+ }
+
+ const { document } = aWindowContext.browsingContext.topChromeWindow;
+ if (!document) {
+ reject(
+ Components.Exception(
+ "Unable to get chrome document.",
+ Cr.NS_ERROR_FAILURE
+ )
+ );
+ return;
+ }
+
+ if (this._pendingRequest) {
+ reject(
+ Components.Exception(
+ "There is an ongoing request.",
+ Cr.NS_ERROR_FAILURE
+ )
+ );
+ return;
+ }
+
+ this._pendingRequest = { resolve, reject };
+
+ const mouseXInCSSPixels = {};
+ const mouseYInCSSPixels = {};
+ const windowUtils = document.ownerGlobal.windowUtils;
+ windowUtils.getLastOverWindowPointerLocationInCSSPixels(
+ mouseXInCSSPixels,
+ mouseYInCSSPixels
+ );
+ const screenRect = windowUtils.toScreenRect(
+ mouseXInCSSPixels.value,
+ mouseYInCSSPixels.value,
+ 0,
+ 0
+ );
+
+ debug`confirmUserPaste (${screenRect.x}, ${screenRect.y})`;
+
+ document.addEventListener("pointerdown", this);
+ document.ownerGlobal.WindowEventDispatcher.sendRequestForResult({
+ type: "GeckoView:ClipboardPermissionRequest",
+ screenPoint: {
+ x: screenRect.x,
+ y: screenRect.y,
+ },
+ }).then(
+ allowOrDeny => {
+ const propBag = lazy.PromptUtils.objectToPropBag({ ok: allowOrDeny });
+ this._pendingRequest.resolve(propBag);
+ this._pendingRequest = null;
+ document.removeEventListener("pointerdown", this);
+ },
+ error => {
+ debug`Permission error: ${error}`;
+ this._pendingRequest.reject();
+ this._pendingRequest = null;
+ document.removeEventListener("pointerdown", this);
+ }
+ );
+ });
+ },
+
+ // EventListener interface.
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+ switch (aEvent.type) {
+ case "pointerdown": {
+ aEvent.target.ownerGlobal.WindowEventDispatcher.sendRequestForResult({
+ type: "GeckoView:DismissClipboardPermissionRequest",
+ });
+ break;
+ }
+ }
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs
new file mode 100644
index 0000000000..acf7a12aa2
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs
@@ -0,0 +1,174 @@
+/* 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";
+
+const { debug, warn } = GeckoViewUtils.initLogging("Console");
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () => new Localization(["mobile/android/geckoViewConsole.ftl"], true)
+);
+
+export var GeckoViewConsole = {
+ _isEnabled: false,
+
+ get enabled() {
+ return this._isEnabled;
+ },
+
+ set enabled(aVal) {
+ debug`enabled = ${aVal}`;
+ if (!!aVal === this._isEnabled) {
+ return;
+ }
+
+ this._isEnabled = !!aVal;
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+ if (this._isEnabled) {
+ this._consoleMessageListener = this._handleConsoleMessage.bind(this);
+ ConsoleAPIStorage.addLogEventListener(
+ this._consoleMessageListener,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+ } else if (this._consoleMessageListener) {
+ ConsoleAPIStorage.removeLogEventListener(this._consoleMessageListener);
+ delete this._consoleMessageListener;
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ this.enabled = Services.prefs.getBoolPref(aData, false);
+ }
+ },
+
+ _handleConsoleMessage(aMessage) {
+ aMessage = aMessage.wrappedJSObject;
+
+ const mappedArguments = Array.from(
+ aMessage.arguments,
+ this.formatResult,
+ this
+ );
+ const joinedArguments = mappedArguments.join(" ");
+
+ if (aMessage.level == "error" || aMessage.level == "warn") {
+ const flag =
+ aMessage.level == "error"
+ ? Ci.nsIScriptError.errorFlag
+ : Ci.nsIScriptError.warningFlag;
+ const consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ consoleMsg.init(
+ joinedArguments,
+ null,
+ null,
+ 0,
+ 0,
+ flag,
+ "content javascript"
+ );
+ Services.console.logMessage(consoleMsg);
+ } else if (aMessage.level == "trace") {
+ const args = aMessage.arguments;
+ const msgDetails = args[0] ?? aMessage;
+ const filename = this.abbreviateSourceURL(msgDetails.filename);
+ const functionName =
+ msgDetails.functionName ||
+ lazy.l10n.formatValueSync("console-stacktrace-anonymous-function");
+
+ let body = lazy.l10n.formatValueSync("console-stacktrace", {
+ filename,
+ functionName,
+ lineNumber: msgDetails.lineNumber ?? "",
+ });
+ body += "\n";
+ for (const aFrame of args) {
+ const functionName =
+ aFrame.functionName ||
+ lazy.l10n.formatValueSync("console-stacktrace-anonymous-function");
+ body += ` ${aFrame.filename} :: ${functionName} :: ${aFrame.lineNumber}\n`;
+ }
+
+ Services.console.logStringMessage(body);
+ } else if (aMessage.level == "time" && aMessage.arguments) {
+ const body = lazy.l10n.formatValueSync("console-timer-start", {
+ name: aMessage.arguments.name ?? "",
+ });
+ Services.console.logStringMessage(body);
+ } else if (aMessage.level == "timeEnd" && aMessage.arguments) {
+ const body = lazy.l10n.formatValueSync("console-timer-end", {
+ name: aMessage.arguments.name ?? "",
+ duration: aMessage.arguments.duration ?? "",
+ });
+ Services.console.logStringMessage(body);
+ } else if (
+ ["group", "groupCollapsed", "groupEnd"].includes(aMessage.level)
+ ) {
+ // Do nothing yet
+ } else {
+ Services.console.logStringMessage(joinedArguments);
+ }
+ },
+
+ getResultType(aResult) {
+ let type = aResult === null ? "null" : typeof aResult;
+ if (type == "object" && aResult.constructor && aResult.constructor.name) {
+ type = aResult.constructor.name;
+ }
+ return type.toLowerCase();
+ },
+
+ formatResult(aResult) {
+ let output = "";
+ const type = this.getResultType(aResult);
+ switch (type) {
+ case "string":
+ case "boolean":
+ case "date":
+ case "error":
+ case "number":
+ case "regexp":
+ output = aResult.toString();
+ break;
+ case "null":
+ case "undefined":
+ output = type;
+ break;
+ default:
+ output = aResult.toString();
+ break;
+ }
+
+ return output;
+ },
+
+ abbreviateSourceURL(aSourceURL) {
+ // Remove any query parameters.
+ const hookIndex = aSourceURL.indexOf("?");
+ if (hookIndex > -1) {
+ aSourceURL = aSourceURL.substring(0, hookIndex);
+ }
+
+ // Remove a trailing "/".
+ if (aSourceURL[aSourceURL.length - 1] == "/") {
+ aSourceURL = aSourceURL.substring(0, aSourceURL.length - 1);
+ }
+
+ // Remove all but the last path component.
+ const slashIndex = aSourceURL.lastIndexOf("/");
+ if (slashIndex > -1) {
+ aSourceURL = aSourceURL.substring(slashIndex + 1);
+ }
+
+ return aSourceURL;
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs
new file mode 100644
index 0000000000..b593a6f8e4
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs
@@ -0,0 +1,762 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs",
+ ShoppingProduct: "chrome://global/content/shopping/ShoppingProduct.mjs",
+});
+
+export class GeckoViewContent extends GeckoViewModule {
+ onInit() {
+ this.registerListener([
+ "GeckoViewContent:ExitFullScreen",
+ "GeckoView:ClearMatches",
+ "GeckoView:DisplayMatches",
+ "GeckoView:FindInPage",
+ "GeckoView:HasCookieBannerRuleForBrowsingContextTree",
+ "GeckoView:RestoreState",
+ "GeckoView:ContainsFormData",
+ "GeckoView:RequestCreateAnalysis",
+ "GeckoView:RequestAnalysisStatus",
+ "GeckoView:RequestAnalysisCreationStatus",
+ "GeckoView:PollForAnalysisCompleted",
+ "GeckoView:SendClickAttributionEvent",
+ "GeckoView:SendImpressionAttributionEvent",
+ "GeckoView:SendPlacementAttributionEvent",
+ "GeckoView:RequestAnalysis",
+ "GeckoView:RequestRecommendations",
+ "GeckoView:ReportBackInStock",
+ "GeckoView:ScrollBy",
+ "GeckoView:ScrollTo",
+ "GeckoView:SetActive",
+ "GeckoView:SetFocused",
+ "GeckoView:SetPriorityHint",
+ "GeckoView:UpdateInitData",
+ "GeckoView:ZoomToInput",
+ "GeckoView:IsPdfJs",
+ ]);
+ }
+
+ onEnable() {
+ this.window.addEventListener(
+ "MozDOMFullscreen:Entered",
+ this,
+ /* capture */ true,
+ /* untrusted */ false
+ );
+ this.window.addEventListener(
+ "MozDOMFullscreen:Exited",
+ this,
+ /* capture */ true,
+ /* untrusted */ false
+ );
+ this.window.addEventListener(
+ "framefocusrequested",
+ this,
+ /* capture */ true,
+ /* untrusted */ false
+ );
+
+ this.window.addEventListener("DOMWindowClose", this);
+ this.window.addEventListener("pagetitlechanged", this);
+ this.window.addEventListener("pageinfo", this);
+
+ this.window.addEventListener("cookiebannerdetected", this);
+ this.window.addEventListener("cookiebannerhandled", this);
+
+ Services.obs.addObserver(this, "oop-frameloader-crashed");
+ Services.obs.addObserver(this, "ipc:content-shutdown");
+ }
+
+ onDisable() {
+ this.window.removeEventListener(
+ "MozDOMFullscreen:Entered",
+ this,
+ /* capture */ true
+ );
+ this.window.removeEventListener(
+ "MozDOMFullscreen:Exited",
+ this,
+ /* capture */ true
+ );
+ this.window.removeEventListener(
+ "framefocusrequested",
+ this,
+ /* capture */ true
+ );
+
+ this.window.removeEventListener("DOMWindowClose", this);
+ this.window.removeEventListener("pagetitlechanged", this);
+ this.window.removeEventListener("pageinfo", this);
+
+ this.window.removeEventListener("cookiebannerdetected", this);
+ this.window.removeEventListener("cookiebannerhandled", this);
+
+ Services.obs.removeObserver(this, "oop-frameloader-crashed");
+ Services.obs.removeObserver(this, "ipc:content-shutdown");
+ }
+
+ get actor() {
+ return this.getActor("GeckoViewContent");
+ }
+
+ get isPdfJs() {
+ return (
+ this.browser.contentPrincipal.spec === "resource://pdf.js/web/viewer.html"
+ );
+ }
+
+ // Goes up the browsingContext chain and sends the message every time
+ // we cross the process boundary so that every process in the chain is
+ // notified.
+ sendToAllChildren(aEvent, aData) {
+ let { browsingContext } = this.actor;
+
+ while (browsingContext) {
+ if (!browsingContext.currentWindowGlobal) {
+ break;
+ }
+
+ const currentPid = browsingContext.currentWindowGlobal.osPid;
+ const parentPid = browsingContext.parent?.currentWindowGlobal.osPid;
+
+ if (currentPid != parentPid) {
+ const actor =
+ browsingContext.currentWindowGlobal.getActor("GeckoViewContent");
+ actor.sendAsyncMessage(aEvent, aData);
+ }
+
+ browsingContext = browsingContext.parent;
+ }
+ }
+
+ #sendDOMFullScreenEventToAllChildren(aEvent) {
+ let { browsingContext } = this.actor;
+
+ while (browsingContext) {
+ if (!browsingContext.currentWindowGlobal) {
+ break;
+ }
+
+ const currentPid = browsingContext.currentWindowGlobal.osPid;
+ const parentPid = browsingContext.parent?.currentWindowGlobal.osPid;
+
+ if (currentPid != parentPid) {
+ if (!browsingContext.parent) {
+ // Top level browsing context. Use origin actor (Bug 1505916).
+ const chromeBC = browsingContext.topChromeWindow?.browsingContext;
+ const requestOrigin = chromeBC?.fullscreenRequestOrigin?.get();
+ if (requestOrigin) {
+ requestOrigin.browsingContext.currentWindowGlobal
+ .getActor("GeckoViewContent")
+ .sendAsyncMessage(aEvent, {});
+ delete chromeBC.fullscreenRequestOrigin;
+ return;
+ }
+ }
+ const actor =
+ browsingContext.currentWindowGlobal.getActor("GeckoViewContent");
+ actor.sendAsyncMessage(aEvent, {});
+ }
+
+ browsingContext = browsingContext.parent;
+ }
+ }
+
+ // Bundle event handler.
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "GeckoViewContent:ExitFullScreen":
+ this.browser.ownerDocument.exitFullscreen();
+ break;
+ case "GeckoView:ClearMatches": {
+ if (!this.isPdfJs) {
+ this._clearMatches();
+ }
+ break;
+ }
+ case "GeckoView:DisplayMatches": {
+ if (!this.isPdfJs) {
+ this._displayMatches(aData);
+ }
+ break;
+ }
+ case "GeckoView:FindInPage": {
+ if (!this.isPdfJs) {
+ this._findInPage(aData, aCallback);
+ }
+ break;
+ }
+ case "GeckoView:ZoomToInput":
+ // For ZoomToInput we just need to send the message to the current focused one.
+ const actor =
+ Services.focus.focusedContentBrowsingContext.currentWindowGlobal.getActor(
+ "GeckoViewContent"
+ );
+ actor.sendAsyncMessage(aEvent, aData);
+ break;
+ case "GeckoView:ScrollBy":
+ // Unclear if that actually works with oop iframes?
+ this.sendToAllChildren(aEvent, aData);
+ break;
+ case "GeckoView:ScrollTo":
+ // Unclear if that actually works with oop iframes?
+ this.sendToAllChildren(aEvent, aData);
+ break;
+ case "GeckoView:UpdateInitData":
+ this.sendToAllChildren(aEvent, aData);
+ break;
+ case "GeckoView:SetActive":
+ this.browser.docShellIsActive = !!aData.active;
+ break;
+ case "GeckoView:SetFocused":
+ if (aData.focused) {
+ this.browser.focus();
+ this.browser.setAttribute("primary", "true");
+ } else {
+ this.browser.removeAttribute("primary");
+ this.browser.blur();
+ }
+ break;
+ case "GeckoView:SetPriorityHint":
+ if (this.browser.isRemoteBrowser) {
+ const remoteTab = this.browser.frameLoader?.remoteTab;
+ if (remoteTab) {
+ remoteTab.priorityHint = aData.priorityHint;
+ }
+ }
+ break;
+ case "GeckoView:RestoreState":
+ this.actor.restoreState(aData);
+ break;
+ case "GeckoView:ContainsFormData":
+ this._containsFormData(aCallback);
+ break;
+ case "GeckoView:RequestAnalysis":
+ this._requestAnalysis(aData, aCallback);
+ break;
+ case "GeckoView:RequestCreateAnalysis":
+ this._requestCreateAnalysis(aData, aCallback);
+ break;
+ case "GeckoView:RequestAnalysisStatus":
+ this._requestAnalysisStatus(aData, aCallback);
+ break;
+ case "GeckoView:RequestAnalysisCreationStatus":
+ this._requestAnalysisCreationStatus(aData, aCallback);
+ break;
+ case "GeckoView:PollForAnalysisCompleted":
+ this._pollForAnalysisCompleted(aData, aCallback);
+ break;
+ case "GeckoView:SendClickAttributionEvent":
+ this._sendAttributionEvent("click", aData, aCallback);
+ break;
+ case "GeckoView:SendImpressionAttributionEvent":
+ this._sendAttributionEvent("impression", aData, aCallback);
+ break;
+ case "GeckoView:SendPlacementAttributionEvent":
+ this._sendAttributionEvent("placement", aData, aCallback);
+ break;
+ case "GeckoView:RequestRecommendations":
+ this._requestRecommendations(aData, aCallback);
+ break;
+ case "GeckoView:ReportBackInStock":
+ this._reportBackInStock(aData, aCallback);
+ break;
+ case "GeckoView:IsPdfJs":
+ aCallback.onSuccess(this.isPdfJs);
+ break;
+ case "GeckoView:HasCookieBannerRuleForBrowsingContextTree":
+ this._hasCookieBannerRuleForBrowsingContextTree(aCallback);
+ break;
+ }
+ }
+
+ // DOM event handler
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "framefocusrequested":
+ if (this.browser != aEvent.target) {
+ return;
+ }
+ if (this.browser.hasAttribute("primary")) {
+ return;
+ }
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:FocusRequest",
+ });
+ aEvent.preventDefault();
+ break;
+ case "MozDOMFullscreen:Entered":
+ if (this.browser == aEvent.target) {
+ // Remote browser; dispatch to content process.
+ this.#sendDOMFullScreenEventToAllChildren(
+ "GeckoView:DOMFullscreenEntered"
+ );
+ }
+ break;
+ case "MozDOMFullscreen:Exited":
+ this.#sendDOMFullScreenEventToAllChildren(
+ "GeckoView:DOMFullscreenExited"
+ );
+ break;
+ case "pagetitlechanged":
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageTitleChanged",
+ title: this.browser.contentTitle,
+ });
+ break;
+ case "DOMWindowClose":
+ // We need this because we want to allow the app
+ // to close the window itself. If we don't preventDefault()
+ // here Gecko will close it immediately.
+ aEvent.preventDefault();
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:DOMWindowClose",
+ });
+ break;
+ case "pageinfo":
+ if (aEvent.detail.previewImageURL) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PreviewImage",
+ previewImageUrl: aEvent.detail.previewImageURL,
+ });
+ }
+ break;
+ case "cookiebannerdetected":
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:CookieBannerEvent:Detected",
+ });
+ break;
+ case "cookiebannerhandled":
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:CookieBannerEvent:Handled",
+ });
+ break;
+ }
+ }
+
+ // nsIObserver event handler
+ observe(aSubject, aTopic, aData) {
+ debug`observe: ${aTopic}`;
+ this._contentCrashed = false;
+ const browser = aSubject.ownerElement;
+
+ switch (aTopic) {
+ case "oop-frameloader-crashed": {
+ if (!browser || browser != this.browser) {
+ return;
+ }
+ this.window.setTimeout(() => {
+ if (this._contentCrashed) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ContentCrash",
+ });
+ } else {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ContentKill",
+ });
+ }
+ }, 250);
+ break;
+ }
+ case "ipc:content-shutdown": {
+ aSubject.QueryInterface(Ci.nsIPropertyBag2);
+ if (aSubject.get("dumpID")) {
+ if (
+ browser &&
+ aSubject.get("childID") != browser.frameLoader.childID
+ ) {
+ return;
+ }
+ this._contentCrashed = true;
+ }
+ break;
+ }
+ }
+ }
+
+ async _containsFormData(aCallback) {
+ aCallback.onSuccess(await this.actor.containsFormData());
+ }
+
+ async _requestAnalysis(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const analysis = {
+ analysis_url: "https://www.example.com/mock_analysis_url",
+ product_id: "ABCDEFG123",
+ grade: "B",
+ adjusted_rating: 4.5,
+ needs_analysis: true,
+ page_not_supported: true,
+ not_enough_reviews: true,
+ highlights: null,
+ last_analysis_time: 12345,
+ deleted_product_reported: true,
+ deleted_product: true,
+ };
+ aCallback.onSuccess({ analysis });
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(`Cannot requestAnalysis on a non-product url.`);
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const analysis = await product.requestAnalysis();
+ if (!analysis) {
+ aCallback.onError(`Product analysis returned null.`);
+ return;
+ }
+ aCallback.onSuccess({ analysis });
+ }
+ }
+
+ async _requestCreateAnalysis(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const status = "pending";
+ aCallback.onSuccess(status);
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(`Cannot requestCreateAnalysis on a non-product url.`);
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const status = await product.requestCreateAnalysis();
+ if (!status) {
+ aCallback.onError(`Creation of product analysis returned null.`);
+ return;
+ }
+ aCallback.onSuccess(status.status);
+ }
+ }
+
+ async _requestAnalysisCreationStatus(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const status = "in_progress";
+ aCallback.onSuccess(status);
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(
+ `Cannot requestAnalysisCreationStatus on a non-product url.`
+ );
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const status = await product.requestAnalysisCreationStatus();
+ if (!status) {
+ aCallback.onError(
+ `Status of creation of product analysis returned null.`
+ );
+ return;
+ }
+ aCallback.onSuccess(status.status);
+ }
+ }
+
+ async _requestAnalysisStatus(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const status = { status: "in_progress", progress: 90.9 };
+ aCallback.onSuccess({ status });
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(`Cannot requestAnalysisStatus on a non-product url.`);
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const status = await product.requestAnalysisCreationStatus();
+ if (!status) {
+ aCallback.onError(`Status of product analysis returned null.`);
+ return;
+ }
+ aCallback.onSuccess({ status });
+ }
+ }
+
+ async _pollForAnalysisCompleted(aData, aCallback) {
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(
+ `Cannot pollForAnalysisCompleted on a non-product url.`
+ );
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const status = await product.pollForAnalysisCompleted();
+ if (!status) {
+ aCallback.onError(
+ `Polling the status of creation of product analysis returned null.`
+ );
+ return;
+ }
+ aCallback.onSuccess(status.status);
+ }
+ }
+
+ async _sendAttributionEvent(aEvent, aData, aCallback) {
+ let result;
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ result = { TEST_AID: "TEST_AID_RESPONSE" };
+ } else {
+ result = await lazy.ShoppingProduct.sendAttributionEvent(
+ aEvent,
+ aData.aid,
+ "geckoview_android"
+ );
+ }
+ if (!result || !(aData.aid in result) || !result[aData.aid]) {
+ aCallback.onSuccess(false);
+ return;
+ }
+ aCallback.onSuccess(true);
+ }
+
+ async _requestRecommendations(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const recommendations = [
+ {
+ name: "Mock Product",
+ url: "https://example.com/mock_url",
+ image_url: "https://example.com/mock_image_url",
+ price: "450",
+ currency: "USD",
+ grade: "C",
+ adjusted_rating: 3.5,
+ analysis_url: "https://example.com/mock_analysis_url",
+ sponsored: true,
+ aid: "mock_aid",
+ },
+ ];
+ aCallback.onSuccess({ recommendations });
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(`Cannot requestRecommendations on a non-product url.`);
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const recommendations = await product.requestRecommendations();
+ if (!recommendations) {
+ aCallback.onError(`Product recommendations returned null.`);
+ return;
+ }
+ aCallback.onSuccess({ recommendations });
+ }
+ }
+
+ async _reportBackInStock(aData, aCallback) {
+ if (
+ Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false)
+ ) {
+ const message = "report created";
+ aCallback.onSuccess(message);
+ return;
+ }
+ const url = Services.io.newURI(aData.url);
+ if (!lazy.isProductURL(url)) {
+ aCallback.onError(`Cannot reportBackInStock on a non-product url.`);
+ } else {
+ const product = new lazy.ShoppingProduct(url);
+ const message = await product.sendReport();
+ if (!message) {
+ aCallback.onError(`Reporting back in stock returned null.`);
+ return;
+ }
+ aCallback.onSuccess(message.message);
+ }
+ }
+
+ async _hasCookieBannerRuleForBrowsingContextTree(aCallback) {
+ const { browsingContext } = this.actor;
+ aCallback.onSuccess(
+ Services.cookieBanners.hasRuleForBrowsingContextTree(browsingContext)
+ );
+ }
+
+ _findInPage(aData, aCallback) {
+ debug`findInPage: data=${aData} callback=${aCallback && "non-null"}`;
+
+ let finder;
+ try {
+ finder = this.browser.finder;
+ } catch (e) {
+ if (aCallback) {
+ aCallback.onError(`No finder: ${e}`);
+ }
+ return;
+ }
+
+ if (this._finderListener) {
+ finder.removeResultListener(this._finderListener);
+ }
+
+ this._finderListener = {
+ response: {
+ found: false,
+ wrapped: false,
+ current: 0,
+ total: -1,
+ searchString: aData.searchString || finder.searchString,
+ linkURL: null,
+ clientRect: null,
+ flags: {
+ backwards: !!aData.backwards,
+ linksOnly: !!aData.linksOnly,
+ matchCase: !!aData.matchCase,
+ wholeWord: !!aData.wholeWord,
+ },
+ },
+
+ onFindResult(aOptions) {
+ if (!aCallback || aOptions.searchString !== aData.searchString) {
+ // Result from a previous search.
+ return;
+ }
+
+ Object.assign(this.response, {
+ found: aOptions.result !== Ci.nsITypeAheadFind.FIND_NOTFOUND,
+ wrapped: aOptions.result !== Ci.nsITypeAheadFind.FIND_FOUND,
+ linkURL: aOptions.linkURL,
+ clientRect: aOptions.rect && {
+ left: aOptions.rect.left,
+ top: aOptions.rect.top,
+ right: aOptions.rect.right,
+ bottom: aOptions.rect.bottom,
+ },
+ flags: {
+ backwards: aOptions.findBackwards,
+ linksOnly: aOptions.linksOnly,
+ matchCase: this.response.flags.matchCase,
+ wholeWord: this.response.flags.wholeWord,
+ },
+ });
+
+ if (!this.response.found) {
+ this.response.current = 0;
+ this.response.total = 0;
+ }
+
+ // Only send response if we have a count.
+ if (!this.response.found || this.response.current !== 0) {
+ debug`onFindResult: ${this.response}`;
+ aCallback.onSuccess(this.response);
+ aCallback = undefined;
+ }
+ },
+
+ onMatchesCountResult(aResult) {
+ if (!aCallback || finder.searchString !== aData.searchString) {
+ // Result from a previous search.
+ return;
+ }
+
+ Object.assign(this.response, {
+ current: aResult.current,
+ total: aResult.total,
+ });
+
+ // Only send response if we have a result. `found` and `wrapped` are
+ // both false only when we haven't received a result yet.
+ if (this.response.found || this.response.wrapped) {
+ debug`onMatchesCountResult: ${this.response}`;
+ aCallback.onSuccess(this.response);
+ aCallback = undefined;
+ }
+ },
+
+ onCurrentSelection() {},
+
+ onHighlightFinished() {},
+ };
+
+ finder.caseSensitive = !!aData.matchCase;
+ finder.entireWord = !!aData.wholeWord;
+ finder.matchDiacritics = !!aData.matchDiacritics;
+ finder.addResultListener(this._finderListener);
+
+ const drawOutline =
+ this._matchDisplayOptions && !!this._matchDisplayOptions.drawOutline;
+
+ if (!aData.searchString || aData.searchString === finder.searchString) {
+ // Search again.
+ aData.searchString = finder.searchString;
+ finder.findAgain(
+ aData.searchString,
+ !!aData.backwards,
+ !!aData.linksOnly,
+ drawOutline
+ );
+ } else {
+ finder.fastFind(aData.searchString, !!aData.linksOnly, drawOutline);
+ }
+ }
+
+ _clearMatches() {
+ debug`clearMatches`;
+
+ let finder;
+ try {
+ finder = this.browser.finder;
+ } catch (e) {
+ return;
+ }
+
+ finder.removeSelection();
+ finder.highlight(false);
+
+ if (this._finderListener) {
+ finder.removeResultListener(this._finderListener);
+ this._finderListener = null;
+ }
+ }
+
+ _displayMatches(aData) {
+ debug`displayMatches: data=${aData}`;
+
+ let finder;
+ try {
+ finder = this.browser.finder;
+ } catch (e) {
+ return;
+ }
+
+ this._matchDisplayOptions = aData;
+ finder.onModalHighlightChange(!!aData.dimPage);
+ finder.onHighlightAllChange(!!aData.highlightAll);
+
+ if (!aData.highlightAll && !aData.dimPage) {
+ finder.highlight(false);
+ return;
+ }
+
+ if (!this._finderListener || !finder.searchString) {
+ return;
+ }
+ const linksOnly = this._finderListener.response.linksOnly;
+ finder.highlight(true, finder.searchString, linksOnly, !!aData.drawOutline);
+ }
+}
+
+const { debug, warn } = GeckoViewContent.initLogging("GeckoViewContent");
diff --git a/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs
new file mode 100644
index 0000000000..d5d125444f
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs
@@ -0,0 +1,113 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+export class GeckoViewContentBlocking extends GeckoViewModule {
+ onEnable() {
+ const flags = Ci.nsIWebProgress.NOTIFY_CONTENT_BLOCKING;
+ this.progressFilter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ this.progressFilter.addProgressListener(this, flags);
+ this.browser.addProgressListener(this.progressFilter, flags);
+
+ this.registerListener(["ContentBlocking:RequestLog"]);
+ }
+
+ onDisable() {
+ if (this.progressFilter) {
+ this.progressFilter.removeProgressListener(this);
+ this.browser.removeProgressListener(this.progressFilter);
+ delete this.progressFilter;
+ }
+
+ this.unregisterListener(["ContentBlocking:RequestLog"]);
+ }
+
+ // Bundle event handler.
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "ContentBlocking:RequestLog": {
+ let bc = this.browser.browsingContext;
+
+ if (!bc) {
+ warn`Failed to export content blocking log.`;
+ break;
+ }
+
+ // Get the top-level browsingContext. The ContentBlockingLog is located
+ // in its current window global.
+ bc = bc.top;
+
+ const topWindowGlobal = bc.currentWindowGlobal;
+
+ if (!topWindowGlobal) {
+ warn`Failed to export content blocking log.`;
+ break;
+ }
+
+ const log = JSON.parse(topWindowGlobal.contentBlockingLog);
+ const res = Object.keys(log).map(key => {
+ const blockData = log[key].map(data => {
+ return {
+ category: data[0],
+ blocked: data[1],
+ count: data[2],
+ };
+ });
+ return {
+ origin: key,
+ blockData,
+ };
+ });
+
+ aCallback.onSuccess({ log: res });
+ break;
+ }
+ }
+ }
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ debug`onContentBlockingEvent ${aEvent.toString(16)}`;
+
+ if (!(aRequest instanceof Ci.nsIClassifiedChannel)) {
+ return;
+ }
+
+ const channel = aRequest.QueryInterface(Ci.nsIChannel);
+ const uri = channel.URI && channel.URI.spec;
+
+ if (!uri) {
+ return;
+ }
+
+ const classChannel = aRequest.QueryInterface(Ci.nsIClassifiedChannel);
+ const blockedList = classChannel.matchedList || null;
+ let loadedLists = [];
+
+ if (aRequest instanceof Ci.nsIHttpChannel) {
+ loadedLists = classChannel.matchedTrackingLists || [];
+ }
+
+ debug`onContentBlockingEvent matchedList: ${blockedList}`;
+ debug`onContentBlockingEvent matchedTrackingLists: ${loadedLists}`;
+
+ const message = {
+ type: "GeckoView:ContentBlockingEvent",
+ uri,
+ category: aEvent,
+ blockedList,
+ loadedLists,
+ };
+
+ this.eventDispatcher.sendRequest(message);
+ }
+}
+
+const { debug, warn } = GeckoViewContentBlocking.initLogging(
+ "GeckoViewContentBlocking"
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs
new file mode 100644
index 0000000000..043415122e
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs",
+});
+
+export const GeckoViewIdentityCredential = {
+ async onShowProviderPrompt(aBrowser, providers, resolve, reject) {
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ debug`onShowProviderPrompt`;
+
+ prompt.asyncShowPrompt(
+ {
+ type: "IdentityCredential:Select:Provider",
+ providers,
+ },
+ result => {
+ if (result && result.providerIndex != null) {
+ debug`onShowProviderPrompt resolve with ${result.providerIndex}`;
+ resolve(result.providerIndex);
+ } else {
+ debug`onShowProviderPrompt rejected`;
+ reject();
+ }
+ }
+ );
+ },
+ async onShowAccountsPrompt(aBrowser, accounts, resolve, reject) {
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ debug`onShowAccountsPrompt`;
+
+ prompt.asyncShowPrompt(
+ {
+ type: "IdentityCredential:Select:Account",
+ accounts,
+ },
+ result => {
+ if (result && result.accountIndex != null) {
+ debug`onShowAccountsPrompt resolve with ${result.accountIndex}`;
+ resolve(result.accountIndex);
+ } else {
+ debug`onShowAccountsPrompt rejected`;
+ reject();
+ }
+ }
+ );
+ },
+ async onShowPolicyPrompt(
+ aBrowser,
+ privacyPolicyUrl,
+ termsOfServiceUrl,
+ providerDomain,
+ host,
+ icon,
+ resolve,
+ reject
+ ) {
+ const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal);
+ debug`onShowPolicyPrompt`;
+
+ prompt.asyncShowPrompt(
+ {
+ type: "IdentityCredential:Show:Policy",
+ privacyPolicyUrl,
+ termsOfServiceUrl,
+ providerDomain,
+ host,
+ icon,
+ },
+ result => {
+ if (result && result.accept != null) {
+ debug`onShowPolicyPrompt resolve with ${result.accept}`;
+ resolve(result.accept);
+ } else {
+ debug`onShowPolicyPrompt rejected`;
+ reject();
+ }
+ }
+ );
+ },
+};
+
+const { debug } = GeckoViewUtils.initLogging("GeckoViewIdentityCredential");
diff --git a/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs
new file mode 100644
index 0000000000..1b39125fce
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs
@@ -0,0 +1,234 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+export class GeckoViewMediaControl extends GeckoViewModule {
+ onInit() {
+ debug`onInit`;
+ }
+
+ onInitBrowser() {
+ debug`onInitBrowser`;
+
+ const options = {
+ mozSystemGroup: true,
+ capture: false,
+ };
+
+ this.controller.addEventListener("activated", this, options);
+ this.controller.addEventListener("deactivated", this, options);
+ this.controller.addEventListener("supportedkeyschange", this, options);
+ this.controller.addEventListener("positionstatechange", this, options);
+ this.controller.addEventListener("metadatachange", this, options);
+ this.controller.addEventListener("playbackstatechange", this, options);
+ }
+
+ onDestroyBrowser() {
+ debug`onDestroyBrowser`;
+
+ this.controller.removeEventListener("activated", this);
+ this.controller.removeEventListener("deactivated", this);
+ this.controller.removeEventListener("supportedkeyschange", this);
+ this.controller.removeEventListener("positionstatechange", this);
+ this.controller.removeEventListener("metadatachange", this);
+ this.controller.removeEventListener("playbackstatechange", this);
+ }
+
+ onEnable() {
+ debug`onEnable`;
+
+ if (this.controller.isActive) {
+ this.handleActivated();
+ }
+
+ this.registerListener([
+ "GeckoView:MediaSession:Play",
+ "GeckoView:MediaSession:Pause",
+ "GeckoView:MediaSession:Stop",
+ "GeckoView:MediaSession:NextTrack",
+ "GeckoView:MediaSession:PrevTrack",
+ "GeckoView:MediaSession:SeekForward",
+ "GeckoView:MediaSession:SeekBackward",
+ "GeckoView:MediaSession:SkipAd",
+ "GeckoView:MediaSession:SeekTo",
+ "GeckoView:MediaSession:MuteAudio",
+ ]);
+ }
+
+ onDisable() {
+ debug`onDisable`;
+
+ this.unregisterListener();
+ }
+
+ get controller() {
+ return this.browser.browsingContext.mediaController;
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:MediaSession:Play":
+ this.controller.play();
+ break;
+ case "GeckoView:MediaSession:Pause":
+ this.controller.pause();
+ break;
+ case "GeckoView:MediaSession:Stop":
+ this.controller.stop();
+ break;
+ case "GeckoView:MediaSession:NextTrack":
+ this.controller.nextTrack();
+ break;
+ case "GeckoView:MediaSession:PrevTrack":
+ this.controller.prevTrack();
+ break;
+ case "GeckoView:MediaSession:SeekForward":
+ this.controller.seekForward();
+ break;
+ case "GeckoView:MediaSession:SeekBackward":
+ this.controller.seekBackward();
+ break;
+ case "GeckoView:MediaSession:SkipAd":
+ this.controller.skipAd();
+ break;
+ case "GeckoView:MediaSession:SeekTo":
+ this.controller.seekTo(aData.time, aData.fast);
+ break;
+ case "GeckoView:MediaSession:MuteAudio":
+ if (aData.mute) {
+ this.browser.mute();
+ } else {
+ this.browser.unmute();
+ }
+ break;
+ }
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+
+ switch (aEvent.type) {
+ case "activated":
+ this.handleActivated();
+ break;
+ case "deactivated":
+ this.handleDeactivated();
+ break;
+ case "supportedkeyschange":
+ this.handleSupportedKeysChanged();
+ break;
+ case "positionstatechange":
+ this.handlePositionStateChanged(aEvent);
+ break;
+ case "metadatachange":
+ this.handleMetadataChanged();
+ break;
+ case "playbackstatechange":
+ this.handlePlaybackStateChanged();
+ break;
+ default:
+ warn`Unknown event type ${aEvent.type}`;
+ break;
+ }
+ }
+
+ handleActivated() {
+ debug`handleActivated`;
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:Activated",
+ });
+ }
+
+ handleDeactivated() {
+ debug`handleDeactivated`;
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:Deactivated",
+ });
+ }
+
+ handlePositionStateChanged(aEvent) {
+ debug`handlePositionStateChanged`;
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:PositionState",
+ state: {
+ duration: aEvent.duration,
+ playbackRate: aEvent.playbackRate,
+ position: aEvent.position,
+ },
+ });
+ }
+
+ handleSupportedKeysChanged() {
+ const supported = this.controller.supportedKeys;
+
+ debug`handleSupportedKeysChanged ${supported}`;
+
+ // Mapping it to a key-value store for compatibility with the JNI
+ // implementation for now.
+ const features = new Map();
+ supported.forEach(key => {
+ features[key] = true;
+ });
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:Features",
+ features,
+ });
+ }
+
+ handleMetadataChanged() {
+ let metadata = null;
+ try {
+ metadata = this.controller.getMetadata();
+ } catch (e) {
+ warn`Metadata not available`;
+ }
+ debug`handleMetadataChanged ${metadata}`;
+
+ if (metadata) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:MediaSession:Metadata",
+ metadata,
+ });
+ }
+ }
+
+ handlePlaybackStateChanged() {
+ const state = this.controller.playbackState;
+ let type = null;
+
+ debug`handlePlaybackStateChanged ${state}`;
+
+ switch (state) {
+ case "none":
+ type = "GeckoView:MediaSession:Playback:None";
+ break;
+ case "paused":
+ type = "GeckoView:MediaSession:Playback:Paused";
+ break;
+ case "playing":
+ type = "GeckoView:MediaSession:Playback:Playing";
+ break;
+ }
+
+ if (!type) {
+ return;
+ }
+
+ this.eventDispatcher.sendRequest({
+ type,
+ });
+ }
+}
+
+const { debug, warn } = GeckoViewMediaControl.initLogging(
+ "GeckoViewMediaControl"
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs
new file mode 100644
index 0000000000..87ad35d817
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs
@@ -0,0 +1,165 @@
+/* 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";
+
+const { debug, warn } = GeckoViewUtils.initLogging("Module");
+
+export class GeckoViewModule {
+ static initLogging(aModuleName) {
+ const tag = aModuleName.replace("GeckoView", "");
+ return GeckoViewUtils.initLogging(tag);
+ }
+
+ constructor(aModuleInfo) {
+ this._info = aModuleInfo;
+
+ this._isContentLoaded = false;
+ this._eventProxy = new EventProxy(this, this.eventDispatcher);
+
+ this.onInitBrowser();
+ }
+
+ get name() {
+ return this._info.name;
+ }
+
+ get enabled() {
+ return this._info.enabled;
+ }
+
+ get window() {
+ return this.moduleManager.window;
+ }
+
+ getActor(aActorName) {
+ return this.moduleManager.getActor(aActorName);
+ }
+
+ get browser() {
+ return this.moduleManager.browser;
+ }
+
+ get messageManager() {
+ return this.moduleManager.messageManager;
+ }
+
+ get eventDispatcher() {
+ return this.moduleManager.eventDispatcher;
+ }
+
+ get settings() {
+ return this.moduleManager.settings;
+ }
+
+ get moduleManager() {
+ return this._info.manager;
+ }
+
+ // Override to initialize the browser before it is bound to the window.
+ onInitBrowser() {}
+
+ // Override to cleanup when the browser is destroyed.
+ onDestroyBrowser() {}
+
+ // Override to initialize module.
+ onInit() {}
+
+ // Override to cleanup when the window is closed
+ onDestroy() {}
+
+ // Override to detect settings change. Access settings via this.settings.
+ onSettingsUpdate() {}
+
+ // Override to enable module after setting a Java delegate.
+ onEnable() {}
+
+ // Override to disable module after clearing the Java delegate.
+ onDisable() {}
+
+ // Override to perform actions when content module has started loading;
+ // by default, pause events so events that depend on content modules can work.
+ onLoadContentModule() {
+ this._eventProxy.enableQueuing(true);
+ }
+
+ // Override to perform actions when content module has finished loading;
+ // by default, un-pause events and flush queued events.
+ onContentModuleLoaded() {
+ this._eventProxy.enableQueuing(false);
+ this._eventProxy.dispatchQueuedEvents();
+ }
+
+ registerListener(aEventList) {
+ this._eventProxy.registerListener(aEventList);
+ }
+
+ unregisterListener(aEventList) {
+ this._eventProxy.unregisterListener(aEventList);
+ }
+}
+
+class EventProxy {
+ constructor(aListener, aEventDispatcher) {
+ this.listener = aListener;
+ this.eventDispatcher = aEventDispatcher;
+ this._eventQueue = [];
+ this._registeredEvents = [];
+ this._enableQueuing = false;
+ }
+
+ registerListener(aEventList) {
+ debug`registerListener ${aEventList}`;
+ this.eventDispatcher.registerListener(this, aEventList);
+ this._registeredEvents = this._registeredEvents.concat(aEventList);
+ }
+
+ unregisterListener(aEventList) {
+ debug`unregisterListener`;
+ if (this._registeredEvents.length === 0) {
+ return;
+ }
+
+ if (!aEventList) {
+ this.eventDispatcher.unregisterListener(this, this._registeredEvents);
+ this._registeredEvents = [];
+ } else {
+ this.eventDispatcher.unregisterListener(this, aEventList);
+ this._registeredEvents = this._registeredEvents.filter(
+ e => !aEventList.includes(e)
+ );
+ }
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ if (this._enableQueuing) {
+ debug`queue ${aEvent}, data=${aData}`;
+ this._eventQueue.unshift(arguments);
+ } else {
+ this._dispatch(...arguments);
+ }
+ }
+
+ enableQueuing(aEnable) {
+ debug`enableQueuing ${aEnable}`;
+ this._enableQueuing = aEnable;
+ }
+
+ _dispatch(aEvent, aData, aCallback) {
+ debug`dispatch ${aEvent}, data=${aData}`;
+ if (this.listener.onEvent) {
+ this.listener.onEvent(...arguments);
+ } else {
+ this.listener(...arguments);
+ }
+ }
+
+ dispatchQueuedEvents() {
+ debug`dispatchQueued`;
+ while (this._eventQueue.length) {
+ const args = this._eventQueue.pop();
+ this._dispatch(...args);
+ }
+ }
+}
diff --git a/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs
new file mode 100644
index 0000000000..287a605dff
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs
@@ -0,0 +1,659 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs",
+ isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs",
+ TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "ReferrerInfo", () =>
+ Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ )
+);
+
+// Filter out request headers as per discussion in Bug #1567549
+// CONNECTION: Used by Gecko to manage connections
+// HOST: Relates to how gecko will ultimately interpret the resulting resource as that
+// determines the effective request URI
+const BAD_HEADERS = ["connection", "host"];
+
+// Headers use |\r\n| as separator so these characters cannot appear
+// in the header name or value
+const FORBIDDEN_HEADER_CHARACTERS = ["\n", "\r"];
+
+// Keep in sync with GeckoSession.java
+const HEADER_FILTER_CORS_SAFELISTED = 1;
+// eslint-disable-next-line no-unused-vars
+const HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;
+
+// Create default ReferrerInfo instance for the given referrer URI string.
+const createReferrerInfo = aReferrer => {
+ let referrerUri;
+ try {
+ referrerUri = Services.io.newURI(aReferrer);
+ } catch (ignored) {}
+
+ return new lazy.ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, referrerUri);
+};
+
+function convertFlags(aFlags) {
+ let navFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (!aFlags) {
+ return navFlags;
+ }
+ // These need to match the values in GeckoSession.LOAD_FLAGS_*
+ if (aFlags & (1 << 0)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ if (aFlags & (1 << 1)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY;
+ }
+ if (aFlags & (1 << 2)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
+ }
+ if (aFlags & (1 << 3)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_POPUPS;
+ }
+ if (aFlags & (1 << 4)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER;
+ }
+ if (aFlags & (1 << 5)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FORCE_ALLOW_DATA_URI;
+ }
+ if (aFlags & (1 << 6)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY;
+ }
+ if (aFlags & (1 << 7)) {
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE;
+ }
+ return navFlags;
+}
+
+// Handles navigation requests between Gecko and a GeckoView.
+// Handles GeckoView:GoBack and :GoForward requests dispatched by
+// GeckoView.goBack and .goForward.
+// Dispatches GeckoView:LocationChange to the GeckoView on location change when
+// active.
+// Implements nsIBrowserDOMWindow.
+export class GeckoViewNavigation extends GeckoViewModule {
+ onInitBrowser() {
+ this.window.browserDOMWindow = this;
+
+ debug`sessionContextId=${this.settings.sessionContextId}`;
+
+ if (this.settings.sessionContextId !== null) {
+ // Gecko may have issues with strings containing special characters,
+ // so we restrict the string format to a specific pattern.
+ if (!/^gvctx(-)?([a-f0-9]+)$/.test(this.settings.sessionContextId)) {
+ throw new Error("sessionContextId has illegal format");
+ }
+
+ this.browser.setAttribute(
+ "geckoViewSessionContextId",
+ this.settings.sessionContextId
+ );
+ }
+
+ // There may be a GeckoViewNavigation module in another window waiting for
+ // us to create a browser so it can set openWindowInfo, so allow them to do
+ // that now.
+ Services.obs.notifyObservers(this.window, "geckoview-window-created");
+ }
+
+ onInit() {
+ debug`onInit`;
+
+ this.registerListener([
+ "GeckoView:GoBack",
+ "GeckoView:GoForward",
+ "GeckoView:GotoHistoryIndex",
+ "GeckoView:LoadUri",
+ "GeckoView:Reload",
+ "GeckoView:Stop",
+ "GeckoView:PurgeHistory",
+ "GeckoView:DotPrintFinish",
+ ]);
+
+ this._initialAboutBlank = true;
+ }
+
+ validateHeader(key, value, filter) {
+ if (!key) {
+ // Key cannot be empty
+ return false;
+ }
+
+ for (const c of FORBIDDEN_HEADER_CHARACTERS) {
+ if (key.includes(c) || value?.includes(c)) {
+ return false;
+ }
+ }
+
+ if (BAD_HEADERS.includes(key.toLowerCase().trim())) {
+ return false;
+ }
+
+ if (
+ filter == HEADER_FILTER_CORS_SAFELISTED &&
+ !this.window.windowUtils.isCORSSafelistedRequestHeader(key, value)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Bundle event handler.
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:GoBack":
+ this.browser.goBack(aData.userInteraction);
+ break;
+ case "GeckoView:GoForward":
+ this.browser.goForward(aData.userInteraction);
+ break;
+ case "GeckoView:GotoHistoryIndex":
+ this.browser.gotoIndex(aData.index);
+ break;
+ case "GeckoView:LoadUri":
+ const {
+ uri,
+ referrerUri,
+ referrerSessionId,
+ flags,
+ headers,
+ headerFilter,
+ } = aData;
+
+ let navFlags = convertFlags(flags);
+ // For performance reasons we don't call the LoadUriDelegate.loadUri
+ // from Gecko, and instead we call it directly in the loadUri Java API.
+ navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE;
+
+ let triggeringPrincipal, referrerInfo, csp;
+ if (referrerSessionId) {
+ const referrerWindow = Services.ww.getWindowByName(referrerSessionId);
+ triggeringPrincipal = referrerWindow.browser.contentPrincipal;
+ csp = referrerWindow.browser.csp;
+
+ const referrerPolicy = referrerWindow.browser.referrerInfo
+ ? referrerWindow.browser.referrerInfo.referrerPolicy
+ : Ci.nsIReferrerInfo.EMPTY;
+
+ referrerInfo = new lazy.ReferrerInfo(
+ referrerPolicy,
+ true,
+ referrerWindow.browser.documentURI
+ );
+ } else if (referrerUri) {
+ referrerInfo = createReferrerInfo(referrerUri);
+ } else {
+ // External apps are treated like web pages, so they should not get
+ // a privileged principal.
+ const isExternal =
+ navFlags & Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
+ if (!isExternal || Services.io.newURI(uri).schemeIs("content")) {
+ // Always use the system principal as the triggering principal
+ // for user-initiated (ie. no referrer session and not external)
+ // loads. See discussion in bug 1573860.
+ triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ }
+ }
+
+ if (!triggeringPrincipal) {
+ triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({});
+ }
+
+ let additionalHeaders = null;
+ if (headers) {
+ additionalHeaders = "";
+ for (const [key, value] of Object.entries(headers)) {
+ if (!this.validateHeader(key, value, headerFilter)) {
+ console.error(`Ignoring invalid header '${key}'='${value}'.`);
+ continue;
+ }
+
+ additionalHeaders += `${key}:${value ?? ""}\r\n`;
+ }
+
+ if (additionalHeaders != "") {
+ additionalHeaders =
+ lazy.E10SUtils.makeInputStream(additionalHeaders);
+ } else {
+ additionalHeaders = null;
+ }
+ }
+
+ // For any navigation here, we should have an appropriate triggeringPrincipal:
+ //
+ // 1) If we have a referring session, triggeringPrincipal is the contentPrincipal from the
+ // referring document.
+ // 2) For certain URI schemes listed above, we will have a codebase principal.
+ // 3) In all other cases, we create a NullPrincipal.
+ //
+ // The navigation flags are driven by the app. We purposely do not propagate these from
+ // the referring document, but expect that the app will in most cases.
+ //
+ // The referrerInfo is derived from the referring document, if present, by propagating any
+ // referrer policy. If we only have the referrerUri from the app, we create a referrerInfo
+ // with the specified URI and no policy set. If no referrerUri is present and we have no
+ // referring session, the referrerInfo is null.
+ //
+ // csp is only present if we have a referring document, null otherwise.
+ this.browser.fixupAndLoadURIString(uri, {
+ flags: navFlags,
+ referrerInfo,
+ triggeringPrincipal,
+ headers: additionalHeaders,
+ csp,
+ });
+ break;
+ case "GeckoView:Reload":
+ // At the moment, GeckoView only supports one reload, which uses
+ // nsIWebNavigation.LOAD_FLAGS_NONE flag, and the telemetry doesn't
+ // do anything to differentiate reloads (i.e normal vs skip caches)
+ // So whenever we add more reload methods, please make sure the
+ // telemetry probe is adjusted
+ this.browser.reloadWithFlags(convertFlags(aData.flags));
+ break;
+ case "GeckoView:Stop":
+ this.browser.stop();
+ break;
+ case "GeckoView:PurgeHistory":
+ this.browser.purgeSessionHistory();
+ break;
+ case "GeckoView:DotPrintFinish":
+ var printActor = this.moduleManager.getActor("GeckoViewPrintDelegate");
+ printActor.clearStaticClone();
+ break;
+ }
+ }
+
+ waitAndSetupWindow(aSessionId, aOpenWindowInfo, aName) {
+ if (!aSessionId) {
+ return Promise.resolve(null);
+ }
+
+ return new Promise(resolve => {
+ const handler = {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic === "geckoview-window-created" &&
+ aSubject.name === aSessionId
+ ) {
+ // This value will be read by nsFrameLoader while it is being initialized.
+ aSubject.browser.openWindowInfo = aOpenWindowInfo;
+
+ // Gecko will use this attribute to set the name of the opened window.
+ if (aName) {
+ aSubject.browser.setAttribute("name", aName);
+ }
+
+ if (
+ !aOpenWindowInfo.isRemote &&
+ aSubject.browser.hasAttribute("remote")
+ ) {
+ // We cannot start in remote mode when we have an opener.
+ aSubject.browser.setAttribute("remote", "false");
+ aSubject.browser.removeAttribute("remoteType");
+ }
+ Services.obs.removeObserver(handler, "geckoview-window-created");
+ resolve(aSubject);
+ }
+ },
+ };
+
+ // This event is emitted from createBrowser() in geckoview.js
+ Services.obs.addObserver(handler, "geckoview-window-created");
+ });
+ }
+
+ handleNewSession(aUri, aOpenWindowInfo, aWhere, aFlags, aName) {
+ debug`handleNewSession: uri=${aUri && aUri.spec}
+ where=${aWhere} flags=${aFlags}`;
+
+ if (!this.enabled) {
+ return null;
+ }
+
+ const newSessionId = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1)
+ .replace(/-/g, "");
+
+ const message = {
+ type: "GeckoView:OnNewSession",
+ uri: aUri ? aUri.displaySpec : "",
+ newSessionId,
+ };
+
+ // The window might be already open by the time we get the response from
+ // the Java layer, so we need to start waiting before sending the message.
+ const setupPromise = this.waitAndSetupWindow(
+ newSessionId,
+ aOpenWindowInfo,
+ aName
+ );
+
+ let browser = undefined;
+ this.eventDispatcher
+ .sendRequestForResult(message)
+ .then(didOpenSession => {
+ if (!didOpenSession) {
+ return Promise.reject();
+ }
+ return setupPromise;
+ })
+ .then(
+ window => {
+ browser = window.browser;
+ },
+ () => {
+ browser = null;
+ }
+ );
+
+ // Wait indefinitely for app to respond with a browser or null
+ Services.tm.spinEventLoopUntil(
+ "GeckoViewNavigation.jsm:handleNewSession",
+ () => this.window.closed || browser !== undefined
+ );
+ return browser || null;
+ }
+
+ // nsIBrowserDOMWindow.
+ createContentWindow(
+ aUri,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ debug`createContentWindow: uri=${aUri && aUri.spec}
+ where=${aWhere} flags=${aFlags}`;
+
+ if (
+ lazy.LoadURIDelegate.load(
+ this.window,
+ this.eventDispatcher,
+ aUri,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal
+ )
+ ) {
+ // The app has handled the load, abort open-window handling.
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ return null;
+ }
+
+ const browser = this.handleNewSession(
+ aUri,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ null
+ );
+ if (!browser) {
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ return null;
+ }
+
+ return browser.browsingContext;
+ }
+
+ // nsIBrowserDOMWindow.
+ createContentWindowInFrame(aUri, aParams, aWhere, aFlags, aName) {
+ debug`createContentWindowInFrame: uri=${aUri && aUri.spec}
+ where=${aWhere} flags=${aFlags}
+ name=${aName}`;
+
+ if (aWhere === Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return this.window.moduleManager.onPrintWindow(aParams);
+ }
+
+ if (
+ lazy.LoadURIDelegate.load(
+ this.window,
+ this.eventDispatcher,
+ aUri,
+ aWhere,
+ aFlags,
+ aParams.triggeringPrincipal
+ )
+ ) {
+ // The app has handled the load, abort open-window handling.
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ return null;
+ }
+
+ const browser = this.handleNewSession(
+ aUri,
+ aParams.openWindowInfo,
+ aWhere,
+ aFlags,
+ aName
+ );
+ if (!browser) {
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ return null;
+ }
+
+ return browser;
+ }
+
+ handleOpenUri({
+ uri,
+ openWindowInfo,
+ where,
+ flags,
+ triggeringPrincipal,
+ csp,
+ referrerInfo = null,
+ name = null,
+ }) {
+ debug`handleOpenUri: uri=${uri && uri.spec}
+ where=${where} flags=${flags}`;
+
+ if (
+ lazy.LoadURIDelegate.load(
+ this.window,
+ this.eventDispatcher,
+ uri,
+ where,
+ flags,
+ triggeringPrincipal
+ )
+ ) {
+ return null;
+ }
+
+ let browser = this.browser;
+
+ if (
+ where === Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW ||
+ where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB ||
+ where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB_BACKGROUND
+ ) {
+ browser = this.handleNewSession(uri, openWindowInfo, where, flags, name);
+ }
+
+ if (!browser) {
+ // Should we throw?
+ return null;
+ }
+
+ // 3) We have a new session and a browser element, load the requested URI.
+ browser.loadURI(uri, {
+ triggeringPrincipal,
+ csp,
+ referrerInfo,
+ });
+ return browser;
+ }
+
+ // nsIBrowserDOMWindow.
+ openURI(aUri, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ const browser = this.handleOpenUri({
+ uri: aUri,
+ openWindowInfo: aOpenWindowInfo,
+ where: aWhere,
+ flags: aFlags,
+ triggeringPrincipal: aTriggeringPrincipal,
+ csp: aCsp,
+ });
+ return browser && browser.browsingContext;
+ }
+
+ // nsIBrowserDOMWindow.
+ openURIInFrame(aUri, aParams, aWhere, aFlags, aName) {
+ const browser = this.handleOpenUri({
+ uri: aUri,
+ openWindowInfo: aParams.openWindowInfo,
+ where: aWhere,
+ flags: aFlags,
+ triggeringPrincipal: aParams.triggeringPrincipal,
+ csp: aParams.csp,
+ referrerInfo: aParams.referrerInfo,
+ name: aName,
+ });
+ return browser;
+ }
+
+ // nsIBrowserDOMWindow.
+ canClose() {
+ debug`canClose`;
+ return true;
+ }
+
+ onEnable() {
+ debug`onEnable`;
+
+ const flags = Ci.nsIWebProgress.NOTIFY_LOCATION;
+ this.progressFilter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ this.progressFilter.addProgressListener(this, flags);
+ this.browser.addProgressListener(this.progressFilter, flags);
+ }
+
+ onDisable() {
+ debug`onDisable`;
+
+ if (!this.progressFilter) {
+ return;
+ }
+ this.progressFilter.removeProgressListener(this);
+ this.browser.removeProgressListener(this.progressFilter);
+ }
+
+ serializePermission({ type, capability, principal }) {
+ const { URI, originAttributes, privateBrowsingId } = principal;
+ return {
+ uri: Services.io.createExposableURI(URI).displaySpec,
+ principal: lazy.E10SUtils.serializePrincipal(principal),
+ perm: type,
+ value: capability,
+ contextId: originAttributes.geckoViewSessionContextId,
+ privateMode: privateBrowsingId != 0,
+ };
+ }
+
+ async isProductURL(aLocationURI) {
+ if (lazy.isProductURL(aLocationURI)) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:OnProductUrl",
+ });
+ }
+ }
+
+ // WebProgress event handler.
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ debug`onLocationChange`;
+
+ let fixedURI = aLocationURI;
+
+ try {
+ fixedURI = Services.io.createExposableURI(aLocationURI);
+ } catch (ex) {}
+
+ // We manually fire the initial about:blank messages to make sure that we
+ // consistently send them so there's nothing to do here.
+ const ignore = this._initialAboutBlank && fixedURI.spec === "about:blank";
+ this._initialAboutBlank = false;
+
+ if (ignore) {
+ return;
+ }
+
+ const { contentPrincipal } = this.browser;
+ let permissions;
+ if (
+ contentPrincipal &&
+ lazy.GeckoViewUtils.isSupportedPermissionsPrincipal(contentPrincipal)
+ ) {
+ let rawPerms = [];
+ try {
+ rawPerms = Services.perms.getAllForPrincipal(contentPrincipal);
+ } catch (ex) {
+ warn`Could not get permissions for principal. ${ex}`;
+ }
+ permissions = rawPerms.map(this.serializePermission);
+
+ // The only way for apps to set permissions is to get hold of an existing
+ // permission and change its value.
+ // Tracking protection exception permissions are only present when
+ // explicitly added by the app, so if one is not present, we need to send
+ // a DENY_ACTION tracking protection permission so that apps can use it
+ // to add tracking protection exceptions.
+ const trackingProtectionPermission =
+ contentPrincipal.privateBrowsingId == 0
+ ? "trackingprotection"
+ : "trackingprotection-pb";
+ if (
+ contentPrincipal.isContentPrincipal &&
+ rawPerms.findIndex(p => p.type == trackingProtectionPermission) == -1
+ ) {
+ permissions.push(
+ this.serializePermission({
+ type: trackingProtectionPermission,
+ capability: Ci.nsIPermissionManager.DENY_ACTION,
+ principal: contentPrincipal,
+ })
+ );
+ }
+ }
+
+ const message = {
+ type: "GeckoView:LocationChange",
+ uri: fixedURI.displaySpec,
+ canGoBack: this.browser.canGoBack,
+ canGoForward: this.browser.canGoForward,
+ isTopLevel: aWebProgress.isTopLevel,
+ permissions,
+ };
+ lazy.TranslationsParent.onLocationChange(this.browser);
+ this.eventDispatcher.sendRequest(message);
+
+ this.isProductURL(aLocationURI);
+ }
+}
+
+const { debug, warn } = GeckoViewNavigation.initLogging("GeckoViewNavigation");
diff --git a/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs
new file mode 100644
index 0000000000..7f6f14a29e
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs
@@ -0,0 +1,210 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+export class GeckoViewProcessHangMonitor extends GeckoViewModule {
+ constructor(aModuleInfo) {
+ super(aModuleInfo);
+
+ /**
+ * Collection of hang reports that haven't expired or been dismissed
+ * by the user. These are nsIHangReports.
+ */
+ this._activeReports = new Set();
+
+ /**
+ * Collection of hang reports that have been suppressed for a short
+ * period of time. Keys are nsIHangReports. Values are timeouts for
+ * when the wait time expires.
+ */
+ this._pausedReports = new Map();
+
+ /**
+ * Simple index used for report identification
+ */
+ this._nextIndex = 0;
+
+ /**
+ * Map of report IDs to report objects.
+ * Keys are numbers. Values are nsIHangReports.
+ */
+ this._reportIndex = new Map();
+
+ /**
+ * Map of report objects to report IDs.
+ * Keys are nsIHangReports. Values are numbers.
+ */
+ this._reportLookupIndex = new Map();
+ }
+
+ onInit() {
+ debug`onInit`;
+ Services.obs.addObserver(this, "process-hang-report");
+ Services.obs.addObserver(this, "clear-hang-report");
+ }
+
+ onDestroy() {
+ debug`onDestroy`;
+ Services.obs.removeObserver(this, "process-hang-report");
+ Services.obs.removeObserver(this, "clear-hang-report");
+ }
+
+ onEnable() {
+ debug`onEnable`;
+ this.registerListener([
+ "GeckoView:HangReportStop",
+ "GeckoView:HangReportWait",
+ ]);
+ }
+
+ onDisable() {
+ debug`onDisable`;
+ this.unregisterListener();
+ }
+
+ // Bundle event handler.
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ if (this._reportIndex.has(aData.hangId)) {
+ const report = this._reportIndex.get(aData.hangId);
+ switch (aEvent) {
+ case "GeckoView:HangReportStop":
+ this.stopHang(report);
+ break;
+ case "GeckoView:HangReportWait":
+ this.pauseHang(report);
+ break;
+ }
+ } else {
+ debug`Report not found: reportIndex=${this._reportIndex}`;
+ }
+ }
+
+ // nsIObserver event handler
+ observe(aSubject, aTopic, aData) {
+ debug`observe(aTopic=${aTopic})`;
+ aSubject.QueryInterface(Ci.nsIHangReport);
+ if (!aSubject.isReportForBrowserOrChildren(this.browser.frameLoader)) {
+ return;
+ }
+
+ switch (aTopic) {
+ case "process-hang-report": {
+ this.reportHang(aSubject);
+ break;
+ }
+ case "clear-hang-report": {
+ this.clearHang(aSubject);
+ break;
+ }
+ }
+ }
+
+ /**
+ * This timeout is the wait period applied after a user selects "Wait" in
+ * an existing notification.
+ */
+ get WAIT_EXPIRATION_TIME() {
+ try {
+ return Services.prefs.getIntPref("browser.hangNotification.waitPeriod");
+ } catch (ex) {
+ return 10000;
+ }
+ }
+
+ /**
+ * Terminate whatever is causing this report, be it an add-on or page script.
+ * This is done without updating any report notifications.
+ */
+ stopHang(report) {
+ report.terminateScript();
+ }
+
+ /**
+ *
+ */
+ pauseHang(report) {
+ this._activeReports.delete(report);
+
+ // Create a new timeout with notify callback
+ const timer = this.window.setTimeout(() => {
+ for (const [stashedReport, otherTimer] of this._pausedReports) {
+ if (otherTimer === timer) {
+ this._pausedReports.delete(stashedReport);
+
+ // We're still hung, so move the report back to the active
+ // list.
+ this._activeReports.add(report);
+ break;
+ }
+ }
+ }, this.WAIT_EXPIRATION_TIME);
+
+ this._pausedReports.set(report, timer);
+ }
+
+ /**
+ * construct an information bundle
+ */
+ notifyReport(report) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:HangReport",
+ hangId: this._reportLookupIndex.get(report),
+ scriptFileName: report.scriptFileName,
+ });
+ }
+
+ /**
+ * Handle a potentially new hang report.
+ */
+ reportHang(report) {
+ // if we aren't enabled then default to stopping the script
+ if (!this.enabled) {
+ this.stopHang(report);
+ return;
+ }
+
+ // if we have already notified, remind
+ if (this._activeReports.has(report)) {
+ this.notifyReport(report);
+ return;
+ }
+
+ // If this hang was already reported and paused by the user then ignore it.
+ if (this._pausedReports.has(report)) {
+ return;
+ }
+
+ const index = this._nextIndex++;
+ this._reportLookupIndex.set(report, index);
+ this._reportIndex.set(index, report);
+ this._activeReports.add(report);
+
+ // Actually notify the new report
+ this.notifyReport(report);
+ }
+
+ clearHang(report) {
+ this._activeReports.delete(report);
+
+ const timer = this._pausedReports.get(report);
+ if (timer) {
+ this.window.clearTimeout(timer);
+ }
+ this._pausedReports.delete(report);
+
+ if (this._reportLookupIndex.has(report)) {
+ const index = this._reportLookupIndex.get(report);
+ this._reportIndex.delete(index);
+ }
+ this._reportLookupIndex.delete(report);
+ report.userCanceled();
+ }
+}
+
+const { debug, warn } = GeckoViewProcessHangMonitor.initLogging(
+ "GeckoViewProcessHangMonitor"
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs
new file mode 100644
index 0000000000..66aceb974c
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs
@@ -0,0 +1,636 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "OverrideService",
+ "@mozilla.org/security/certoverride;1",
+ "nsICertOverrideService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "IDNService",
+ "@mozilla.org/network/idn-service;1",
+ "nsIIDNService"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs",
+ GleanStopwatch: "resource://gre/modules/GeckoViewTelemetry.sys.mjs",
+});
+
+var IdentityHandler = {
+ // The definitions below should be kept in sync with those in GeckoView.ProgressListener.SecurityInformation
+ // No trusted identity information. No site identity icon is shown.
+ IDENTITY_MODE_UNKNOWN: 0,
+
+ // Domain-Validation SSL CA-signed domain verification (DV).
+ IDENTITY_MODE_IDENTIFIED: 1,
+
+ // Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process.
+ IDENTITY_MODE_VERIFIED: 2,
+
+ // The following mixed content modes are only used if "security.mixed_content.block_active_content"
+ // is enabled. Our Java frontend coalesces them into one indicator.
+
+ // No mixed content information. No mixed content icon is shown.
+ MIXED_MODE_UNKNOWN: 0,
+
+ // Blocked active mixed content.
+ MIXED_MODE_CONTENT_BLOCKED: 1,
+
+ // Loaded active mixed content.
+ MIXED_MODE_CONTENT_LOADED: 2,
+
+ /**
+ * Determines the identity mode corresponding to the icon we show in the urlbar.
+ */
+ getIdentityMode: function getIdentityMode(aState) {
+ if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) {
+ return this.IDENTITY_MODE_VERIFIED;
+ }
+
+ if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) {
+ return this.IDENTITY_MODE_IDENTIFIED;
+ }
+
+ return this.IDENTITY_MODE_UNKNOWN;
+ },
+
+ getMixedDisplayMode: function getMixedDisplayMode(aState) {
+ if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) {
+ return this.MIXED_MODE_CONTENT_LOADED;
+ }
+
+ if (
+ aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT
+ ) {
+ return this.MIXED_MODE_CONTENT_BLOCKED;
+ }
+
+ return this.MIXED_MODE_UNKNOWN;
+ },
+
+ getMixedActiveMode: function getActiveDisplayMode(aState) {
+ // Only show an indicator for loaded mixed content if the pref to block it is enabled
+ if (
+ aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT &&
+ !Services.prefs.getBoolPref("security.mixed_content.block_active_content")
+ ) {
+ return this.MIXED_MODE_CONTENT_LOADED;
+ }
+
+ if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) {
+ return this.MIXED_MODE_CONTENT_BLOCKED;
+ }
+
+ return this.MIXED_MODE_UNKNOWN;
+ },
+
+ /**
+ * Determine the identity of the page being displayed by examining its SSL cert
+ * (if available). Return the data needed to update the UI.
+ */
+ checkIdentity: function checkIdentity(aState, aBrowser) {
+ const identityMode = this.getIdentityMode(aState);
+ const mixedDisplay = this.getMixedDisplayMode(aState);
+ const mixedActive = this.getMixedActiveMode(aState);
+ const result = {
+ mode: {
+ identity: identityMode,
+ mixed_display: mixedDisplay,
+ mixed_active: mixedActive,
+ },
+ };
+
+ if (aBrowser.contentPrincipal) {
+ result.origin = aBrowser.contentPrincipal.originNoSuffix;
+ }
+
+ // Don't show identity data for pages with an unknown identity or if any
+ // mixed content is loaded (mixed display content is loaded by default).
+ if (
+ identityMode === this.IDENTITY_MODE_UNKNOWN ||
+ aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN ||
+ aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE
+ ) {
+ result.secure = false;
+ return result;
+ }
+
+ result.secure = true;
+
+ let uri = aBrowser.currentURI || {};
+ try {
+ uri = Services.io.createExposableURI(uri);
+ } catch (e) {}
+
+ try {
+ result.host = lazy.IDNService.convertToDisplayIDN(uri.host, {});
+ } catch (e) {
+ result.host = uri.host;
+ }
+
+ const cert = aBrowser.securityUI.secInfo.serverCert;
+
+ result.certificate =
+ aBrowser.securityUI.secInfo.serverCert.getBase64DERString();
+
+ try {
+ result.securityException = lazy.OverrideService.hasMatchingOverride(
+ uri.host,
+ uri.port,
+ {},
+ cert,
+ {}
+ );
+
+ // If an override exists, the connection is being allowed but should not
+ // be considered secure.
+ result.secure = !result.securityException;
+ } catch (e) {}
+
+ return result;
+ },
+};
+
+class Tracker {
+ constructor(aModule) {
+ this._module = aModule;
+ }
+
+ get eventDispatcher() {
+ return this._module.eventDispatcher;
+ }
+
+ get browser() {
+ return this._module.browser;
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["nsIWebProgressListener"]);
+}
+
+class ProgressTracker extends Tracker {
+ constructor(aModule) {
+ super(aModule);
+
+ this.pageLoadStopwatch = new lazy.GleanStopwatch(
+ Glean.geckoview.pageLoadTime
+ );
+ this.pageReloadStopwatch = new lazy.GleanStopwatch(
+ Glean.geckoview.pageReloadTime
+ );
+ this.pageLoadProgressStopwatch = new lazy.GleanStopwatch(
+ Glean.geckoview.pageLoadProgressTime
+ );
+
+ this.clear();
+ this._eventReceived = null;
+ }
+
+ start(aUri) {
+ debug`ProgressTracker start ${aUri}`;
+
+ if (this._eventReceived) {
+ // A request was already in process, let's cancel it
+ this.stop(/* isSuccess */ false);
+ }
+
+ this._eventReceived = new Set();
+ this.clear();
+ const data = this._data;
+
+ if (aUri === "about:blank") {
+ data.uri = null;
+ return;
+ }
+
+ this.pageLoadProgressStopwatch.start();
+
+ data.uri = aUri;
+ data.pageStart = true;
+ this.updateProgress();
+ }
+
+ changeLocation(aUri) {
+ debug`ProgressTracker changeLocation ${aUri}`;
+
+ const data = this._data;
+ data.locationChange = true;
+ data.uri = aUri;
+ }
+
+ stop(aIsSuccess) {
+ debug`ProgressTracker stop`;
+
+ if (!this._eventReceived) {
+ // No request in progress
+ return;
+ }
+
+ if (aIsSuccess) {
+ this.pageLoadProgressStopwatch.finish();
+ } else {
+ this.pageLoadProgressStopwatch.cancel();
+ }
+
+ const data = this._data;
+ data.pageStop = true;
+ this.updateProgress();
+ this._eventReceived = null;
+ }
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ debug`ProgressTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel},
+ flags=${aStateFlags}, status=${aStatus}`;
+
+ if (!aWebProgress || !aWebProgress.isTopLevel) {
+ return;
+ }
+
+ const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI;
+
+ if (aRequest.URI.schemeIs("about")) {
+ return;
+ }
+
+ debug`ProgressTracker onStateChange: uri=${displaySpec}`;
+
+ const isPageReload =
+ (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) != 0;
+ const stopwatch = isPageReload
+ ? this.pageReloadStopwatch
+ : this.pageLoadStopwatch;
+
+ const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0;
+ const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0;
+ const isRedirecting =
+ (aStateFlags & Ci.nsIWebProgressListener.STATE_REDIRECTING) != 0;
+
+ if (isStart) {
+ stopwatch.start();
+ this.start(displaySpec);
+ } else if (isStop && !aWebProgress.isLoadingDocument) {
+ stopwatch.finish();
+ this.stop(aStatus == Cr.NS_OK);
+ } else if (isRedirecting) {
+ stopwatch.start();
+ this.start(displaySpec);
+ }
+
+ // During history naviation, global window is recycled, so pagetitlechanged isn't fired
+ // Although Firefox Desktop always set title by onLocationChange, to reduce title change call,
+ // we only send title during history navigation.
+ if ((aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) != 0) {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageTitleChanged",
+ title: this.browser.contentTitle,
+ });
+ }
+ }
+
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ if (
+ !aWebProgress ||
+ !aWebProgress.isTopLevel ||
+ !aLocationURI ||
+ aLocationURI.schemeIs("about")
+ ) {
+ return;
+ }
+
+ debug`ProgressTracker onLocationChange: location=${aLocationURI.displaySpec},
+ flags=${aFlags}`;
+
+ if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ this.stop(/* isSuccess */ false);
+ } else {
+ this.changeLocation(aLocationURI.displaySpec);
+ }
+ }
+
+ handleEvent(aEvent) {
+ if (!this._eventReceived || this._eventReceived.has(aEvent.name)) {
+ // Either we're not tracking or we have received this event already
+ return;
+ }
+
+ const data = this._data;
+
+ if (!data.uri || data.uri !== aEvent.data?.uri) {
+ return;
+ }
+
+ debug`ProgressTracker handleEvent: ${aEvent.name}`;
+
+ let needsUpdate = false;
+
+ switch (aEvent.name) {
+ case "DOMContentLoaded":
+ needsUpdate = needsUpdate || !data.parsed;
+ data.parsed = true;
+ break;
+ case "MozAfterPaint":
+ needsUpdate = needsUpdate || !data.firstPaint;
+ data.firstPaint = true;
+ break;
+ case "pageshow":
+ needsUpdate = needsUpdate || !data.pageShow;
+ data.pageShow = true;
+ break;
+ }
+
+ this._eventReceived.add(aEvent.name);
+
+ if (needsUpdate) {
+ this.updateProgress();
+ }
+ }
+
+ clear() {
+ this._data = {
+ prev: 0,
+ uri: null,
+ locationChange: false,
+ pageStart: false,
+ pageStop: false,
+ firstPaint: false,
+ pageShow: false,
+ parsed: false,
+ };
+ }
+
+ _debugData() {
+ return {
+ prev: this._data.prev,
+ uri: this._data.uri,
+ locationChange: this._data.locationChange,
+ pageStart: this._data.pageStart,
+ pageStop: this._data.pageStop,
+ firstPaint: this._data.firstPaint,
+ pageShow: this._data.pageShow,
+ parsed: this._data.parsed,
+ };
+ }
+
+ updateProgress() {
+ debug`ProgressTracker updateProgress`;
+
+ const data = this._data;
+
+ if (!this._eventReceived || !data.uri) {
+ return;
+ }
+
+ let progress = 0;
+ if (data.pageStop || data.pageShow) {
+ progress = 100;
+ } else if (data.firstPaint) {
+ progress = 80;
+ } else if (data.parsed) {
+ progress = 55;
+ } else if (data.locationChange) {
+ progress = 30;
+ } else if (data.pageStart) {
+ progress = 15;
+ }
+
+ if (data.prev >= progress) {
+ return;
+ }
+
+ debug`ProgressTracker updateProgress data=${this._debugData()}
+ progress=${progress}`;
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:ProgressChanged",
+ progress,
+ });
+
+ data.prev = progress;
+ }
+}
+
+class StateTracker extends Tracker {
+ constructor(aModule) {
+ super(aModule);
+ this._inProgress = false;
+ this._uri = null;
+ }
+
+ start(aUri) {
+ this._inProgress = true;
+ this._uri = aUri;
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageStart",
+ uri: aUri,
+ });
+ }
+
+ stop(aIsSuccess) {
+ if (!this._inProgress) {
+ // No request in progress
+ return;
+ }
+
+ this._inProgress = false;
+ this._uri = null;
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageStop",
+ success: aIsSuccess,
+ });
+
+ lazy.BrowserTelemetryUtils.recordSiteOriginTelemetry(
+ Services.wm.getEnumerator("navigator:geckoview"),
+ true
+ );
+ }
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ debug`StateTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel},
+ flags=${aStateFlags}, status=${aStatus}
+ loadType=${aWebProgress.loadType}`;
+
+ if (!aWebProgress.isTopLevel) {
+ return;
+ }
+
+ const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI;
+ const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0;
+ const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0;
+
+ if (isStart) {
+ this.start(displaySpec);
+ } else if (isStop && !aWebProgress.isLoadingDocument) {
+ this.stop(aStatus == Cr.NS_OK);
+ }
+ }
+}
+
+class SecurityTracker extends Tracker {
+ constructor(aModule) {
+ super(aModule);
+ this._hostChanged = false;
+ }
+
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ debug`SecurityTracker onLocationChange: location=${aLocationURI.displaySpec},
+ flags=${aFlags}`;
+
+ this._hostChanged = true;
+ }
+
+ onSecurityChange(aWebProgress, aRequest, aState) {
+ debug`onSecurityChange`;
+
+ // Don't need to do anything if the data we use to update the UI hasn't changed
+ if (this._state === aState && !this._hostChanged) {
+ return;
+ }
+
+ this._state = aState;
+ this._hostChanged = false;
+
+ const identity = IdentityHandler.checkIdentity(aState, this.browser);
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:SecurityChanged",
+ identity,
+ });
+ }
+}
+
+export class GeckoViewProgress extends GeckoViewModule {
+ onEnable() {
+ debug`onEnable`;
+
+ this._fireInitialLoad();
+ this._initialAboutBlank = true;
+
+ this._progressTracker = new ProgressTracker(this);
+ this._securityTracker = new SecurityTracker(this);
+ this._stateTracker = new StateTracker(this);
+
+ const flags =
+ Ci.nsIWebProgress.NOTIFY_STATE_NETWORK |
+ Ci.nsIWebProgress.NOTIFY_SECURITY |
+ Ci.nsIWebProgress.NOTIFY_LOCATION;
+ this.progressFilter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ this.progressFilter.addProgressListener(this, flags);
+ this.browser.addProgressListener(this.progressFilter, flags);
+ Services.obs.addObserver(this, "oop-frameloader-crashed");
+ this.registerListener("GeckoView:FlushSessionState");
+ }
+
+ onDisable() {
+ debug`onDisable`;
+
+ if (this.progressFilter) {
+ this.progressFilter.removeProgressListener(this);
+ this.browser.removeProgressListener(this.progressFilter);
+ }
+
+ Services.obs.removeObserver(this, "oop-frameloader-crashed");
+ this.unregisterListener("GeckoView:FlushSessionState");
+ }
+
+ receiveMessage(aMsg) {
+ debug`receiveMessage: ${aMsg.name}`;
+
+ switch (aMsg.name) {
+ case "DOMContentLoaded": // fall-through
+ case "MozAfterPaint": // fall-through
+ case "pageshow": {
+ this._progressTracker?.handleEvent(aMsg);
+ break;
+ }
+ }
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:FlushSessionState":
+ this.messageManager.sendAsyncMessage("GeckoView:FlushSessionState");
+ break;
+ }
+ }
+
+ onStateChange(...args) {
+ // GeckoView never gets PageStart or PageStop for about:blank because we
+ // set nodefaultsrc to true unconditionally so we can assume here that
+ // we're starting a page load for a non-blank page (or a consumer-initiated
+ // about:blank load).
+ this._initialAboutBlank = false;
+
+ this._progressTracker.onStateChange(...args);
+ this._stateTracker.onStateChange(...args);
+ }
+
+ onSecurityChange(...args) {
+ // We don't report messages about the initial about:blank
+ if (this._initialAboutBlank) {
+ return;
+ }
+
+ this._securityTracker.onSecurityChange(...args);
+ }
+
+ onLocationChange(...args) {
+ this._securityTracker.onLocationChange(...args);
+ this._progressTracker.onLocationChange(...args);
+ }
+
+ // The initial about:blank load events are unreliable because docShell starts
+ // up concurrently with loading geckoview.js so we're never guaranteed to get
+ // the events.
+ // What we do instead is ignore all initial about:blank events and fire them
+ // manually once the child process has booted up.
+ _fireInitialLoad() {
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageStart",
+ uri: "about:blank",
+ });
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:LocationChange",
+ uri: "about:blank",
+ canGoBack: false,
+ canGoForward: false,
+ isTopLevel: true,
+ });
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:PageStop",
+ success: true,
+ });
+ }
+
+ // nsIObserver event handler
+ observe(aSubject, aTopic, aData) {
+ debug`observe: topic=${aTopic}`;
+
+ switch (aTopic) {
+ case "oop-frameloader-crashed": {
+ const browser = aSubject.ownerElement;
+ if (!browser || browser != this.browser) {
+ return;
+ }
+
+ this._progressTracker?.stop(/* isSuccess */ false);
+ this._stateTracker?.stop(/* isSuccess */ false);
+ }
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewProgress.initLogging("GeckoViewProgress");
diff --git a/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs
new file mode 100644
index 0000000000..da2d7d04e9
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs
@@ -0,0 +1,70 @@
+/* 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,
+ "PushNotifier",
+ "@mozilla.org/push/Notifier;1",
+ "nsIPushNotifier"
+);
+
+const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPushController");
+
+function createScopeAndPrincipal(scopeAndAttrs) {
+ const principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ scopeAndAttrs
+ );
+ const scope = principal.URI.spec;
+
+ return [scope, principal];
+}
+
+export const GeckoViewPushController = {
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:PushEvent": {
+ const { scope, data } = aData;
+
+ const [url, principal] = createScopeAndPrincipal(scope);
+
+ if (
+ Services.perms.testPermissionFromPrincipal(
+ principal,
+ "desktop-notification"
+ ) != Services.perms.ALLOW_ACTION
+ ) {
+ return;
+ }
+
+ if (!data) {
+ lazy.PushNotifier.notifyPush(url, principal, "");
+ return;
+ }
+
+ const payload = new Uint8Array(
+ ChromeUtils.base64URLDecode(data, { padding: "ignore" })
+ );
+
+ lazy.PushNotifier.notifyPushWithData(url, principal, "", payload);
+ break;
+ }
+ case "GeckoView:PushSubscriptionChanged": {
+ const { scope } = aData;
+
+ const [url, principal] = createScopeAndPrincipal(scope);
+
+ lazy.PushNotifier.notifySubscriptionChange(url, principal);
+ break;
+ }
+ }
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs
new file mode 100644
index 0000000000..c4d6bf7fb5
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs
@@ -0,0 +1,141 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "require", () => {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return require;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "DevToolsServer", () => {
+ const { DevToolsServer } = lazy.require("devtools/server/devtools-server");
+ return DevToolsServer;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "SocketListener", () => {
+ const { SocketListener } = lazy.require("devtools/shared/security/socket");
+ return SocketListener;
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("RemoteDebugger");
+
+export var GeckoViewRemoteDebugger = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic !== "nsPref:changed") {
+ return;
+ }
+
+ if (Services.prefs.getBoolPref(aData, false)) {
+ this.onEnable();
+ } else {
+ this.onDisable();
+ }
+ },
+
+ onInit() {
+ debug`onInit`;
+ this._isEnabled = false;
+ this._usbDebugger = new USBRemoteDebugger();
+ },
+
+ onEnable() {
+ if (this._isEnabled) {
+ return;
+ }
+
+ debug`onEnable`;
+ lazy.DevToolsServer.init();
+ lazy.DevToolsServer.registerAllActors();
+ const { createRootActor } = lazy.require(
+ "resource://gre/modules/dbg-browser-actors.js"
+ );
+ lazy.DevToolsServer.setRootActor(createRootActor);
+ lazy.DevToolsServer.allowChromeProcess = true;
+ lazy.DevToolsServer.chromeWindowType = "navigator:geckoview";
+ // Force the Server to stay alive even if there are no connections at the moment.
+ lazy.DevToolsServer.keepAlive = true;
+
+ // Socket address for USB remote debugger expects
+ // @ANDROID_PACKAGE_NAME/firefox-debugger-socket.
+ // In /proc/net/unix, it will be outputed as
+ // @org.mozilla.geckoview_example/firefox-debugger-socket
+ //
+ // If package name isn't available, it will be "@firefox-debugger-socket".
+
+ let packageName = Services.env.get("MOZ_ANDROID_PACKAGE_NAME");
+ if (packageName) {
+ packageName = packageName + "/";
+ } else {
+ warn`Missing env MOZ_ANDROID_PACKAGE_NAME. Unable to get package name`;
+ }
+
+ this._isEnabled = true;
+ this._usbDebugger.stop();
+
+ const portOrPath = packageName + "firefox-debugger-socket";
+ this._usbDebugger.start(portOrPath);
+ },
+
+ onDisable() {
+ if (!this._isEnabled) {
+ return;
+ }
+
+ debug`onDisable`;
+ this._isEnabled = false;
+ this._usbDebugger.stop();
+ },
+};
+
+class USBRemoteDebugger {
+ start(aPortOrPath) {
+ try {
+ const AuthenticatorType =
+ lazy.DevToolsServer.Authenticators.get("PROMPT");
+ const authenticator = new AuthenticatorType.Server();
+ authenticator.allowConnection = this.allowConnection.bind(this);
+ const socketOptions = {
+ authenticator,
+ portOrPath: aPortOrPath,
+ };
+ this._listener = new lazy.SocketListener(
+ lazy.DevToolsServer,
+ socketOptions
+ );
+ this._listener.open();
+ debug`USB remote debugger - listening on ${aPortOrPath}`;
+ } catch (e) {
+ warn`Unable to start USB debugger server: ${e}`;
+ }
+ }
+
+ stop() {
+ if (!this._listener) {
+ return;
+ }
+
+ try {
+ this._listener.close();
+ this._listener = null;
+ } catch (e) {
+ warn`Unable to stop USB debugger server: ${e}`;
+ }
+ }
+
+ allowConnection(aSession) {
+ if (!this._listener) {
+ return lazy.DevToolsServer.AuthenticationResult.DENY;
+ }
+
+ if (aSession.server.port) {
+ return lazy.DevToolsServer.AuthenticationResult.DENY;
+ }
+ return lazy.DevToolsServer.AuthenticationResult.ALLOW;
+ }
+}
diff --git a/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs
new file mode 100644
index 0000000000..07498e4b00
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+export class GeckoViewSelectionAction extends GeckoViewModule {
+ onEnable() {
+ debug`onEnable`;
+ this.registerListener(["GeckoView:ExecuteSelectionAction"]);
+ }
+
+ onDisable() {
+ debug`onDisable`;
+ this.unregisterListener();
+ }
+
+ get actor() {
+ return this.getActor("SelectionActionDelegate");
+ }
+
+ // Bundle event handler.
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: ${aEvent}`;
+
+ switch (aEvent) {
+ case "GeckoView:ExecuteSelectionAction": {
+ this.actor.executeSelectionAction(aData);
+ }
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewSelectionAction.initLogging(
+ "GeckoViewSelectionAction"
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs
new file mode 100644
index 0000000000..584429295e
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs
@@ -0,0 +1,187 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("SessionStore");
+const kNoIndex = Number.MAX_SAFE_INTEGER;
+const kLastIndex = Number.MAX_SAFE_INTEGER - 1;
+
+class SHistoryListener {
+ constructor(browsingContext) {
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsISHistoryListener",
+ "nsISupportsWeakReference",
+ ]);
+
+ this._browserId = browsingContext.browserId;
+ this._fromIndex = kNoIndex;
+ }
+
+ unregister(permanentKey) {
+ const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId);
+ bc?.sessionHistory?.removeSHistoryListener(this);
+ GeckoViewSessionStore._browserSHistoryListener?.delete(permanentKey);
+ }
+
+ collect(
+ permanentKey, // eslint-disable-line no-shadow
+ browsingContext, // eslint-disable-line no-shadow
+ { collectFull = true, writeToCache = false }
+ ) {
+ // Don't bother doing anything if we haven't seen any navigations.
+ if (!collectFull && this._fromIndex === kNoIndex) {
+ return null;
+ }
+
+ const fromIndex = collectFull ? -1 : this._fromIndex;
+ this._fromIndex = kNoIndex;
+
+ const historychange = lazy.SessionHistory.collectFromParent(
+ browsingContext.currentURI?.spec,
+ true, // Bug 1704574
+ browsingContext.sessionHistory,
+ fromIndex
+ );
+
+ if (writeToCache) {
+ const win =
+ browsingContext.embedderElement?.ownerGlobal ||
+ browsingContext.currentWindowGlobal?.browsingContext?.window;
+
+ GeckoViewSessionStore.onTabStateUpdate(permanentKey, win, {
+ data: { historychange },
+ });
+ }
+
+ return historychange;
+ }
+
+ collectFrom(index) {
+ if (this._fromIndex <= index) {
+ // If we already know that we need to update history from index N we
+ // can ignore any changes that happened with an element with index
+ // larger than N.
+ //
+ // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which
+ // means we don't ignore anything here, and in case of navigation in
+ // the history back and forth cases we use kLastIndex which ignores
+ // only the subsequent navigations, but not any new elements added.
+ return;
+ }
+
+ const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId);
+ if (bc?.embedderElement?.frameLoader) {
+ this._fromIndex = index;
+
+ // Queue a tab state update on the |browser.sessionstore.interval|
+ // timer. We'll call this.collect() when we receive the update.
+ bc.embedderElement.frameLoader.requestSHistoryUpdate();
+ }
+ }
+
+ OnHistoryNewEntry(newURI, oldIndex) {
+ // We use oldIndex - 1 to collect the current entry as well. This makes
+ // sure to collect any changes that were made to the entry while the
+ // document was active.
+ this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1);
+ }
+ OnHistoryGotoIndex() {
+ this.collectFrom(kLastIndex);
+ }
+ OnHistoryPurge() {
+ this.collectFrom(-1);
+ }
+ OnHistoryReload() {
+ this.collectFrom(-1);
+ return true;
+ }
+ OnHistoryReplaceEntry() {
+ this.collectFrom(-1);
+ }
+}
+
+export var GeckoViewSessionStore = {
+ // For each <browser> element, records the SHistoryListener.
+ _browserSHistoryListener: new WeakMap(),
+
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+
+ switch (aTopic) {
+ case "browsing-context-did-set-embedder": {
+ if (
+ aSubject &&
+ aSubject === aSubject.top &&
+ aSubject.isContent &&
+ aSubject.embedderElement &&
+ aSubject.embedderElement.permanentKey
+ ) {
+ const permanentKey = aSubject.embedderElement.permanentKey;
+ this._browserSHistoryListener
+ .get(permanentKey)
+ ?.unregister(permanentKey);
+
+ this.getOrCreateSHistoryListener(permanentKey, aSubject, true);
+ }
+ break;
+ }
+ case "browsing-context-discarded":
+ const permanentKey = aSubject?.embedderElement?.permanentKey;
+ if (permanentKey) {
+ this._browserSHistoryListener
+ .get(permanentKey)
+ ?.unregister(permanentKey);
+ }
+ break;
+ }
+ },
+
+ onTabStateUpdate(permanentKey, win, data) {
+ win.WindowEventDispatcher.sendRequest({
+ type: "GeckoView:StateUpdated",
+ data: data.data,
+ });
+ },
+
+ getOrCreateSHistoryListener(
+ permanentKey,
+ browsingContext,
+ collectImmediately = false
+ ) {
+ if (!permanentKey || browsingContext !== browsingContext.top) {
+ return null;
+ }
+
+ const sessionHistory = browsingContext.sessionHistory;
+ if (!sessionHistory) {
+ return null;
+ }
+
+ let listener = this._browserSHistoryListener.get(permanentKey);
+ if (listener) {
+ return listener;
+ }
+
+ listener = new SHistoryListener(browsingContext);
+ sessionHistory.addSHistoryListener(listener);
+ this._browserSHistoryListener.set(permanentKey, listener);
+
+ if (
+ collectImmediately &&
+ (!(browsingContext.currentURI?.spec === "about:blank") ||
+ sessionHistory.count !== 0)
+ ) {
+ listener.collect(permanentKey, browsingContext, { writeToCache: true });
+ }
+
+ return listener;
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs
new file mode 100644
index 0000000000..ec927b0af6
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs
@@ -0,0 +1,182 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "MOBILE_USER_AGENT", function () {
+ return Cc["@mozilla.org/network/protocol;1?name=http"].getService(
+ Ci.nsIHttpProtocolHandler
+ ).userAgent;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "DESKTOP_USER_AGENT", function () {
+ return lazy.MOBILE_USER_AGENT.replace(
+ /Android \d.+?; [a-zA-Z]+/,
+ "X11; Linux x86_64"
+ ).replace(/Gecko\/[0-9\.]+/, "Gecko/20100101");
+});
+
+ChromeUtils.defineLazyGetter(lazy, "VR_USER_AGENT", function () {
+ return lazy.MOBILE_USER_AGENT.replace(/Mobile/, "Mobile VR");
+});
+
+// This needs to match GeckoSessionSettings.java
+const USER_AGENT_MODE_MOBILE = 0;
+const USER_AGENT_MODE_DESKTOP = 1;
+const USER_AGENT_MODE_VR = 2;
+
+// This needs to match GeckoSessionSettings.java
+const DISPLAY_MODE_BROWSER = 0;
+const DISPLAY_MODE_MINIMAL_UI = 1;
+const DISPLAY_MODE_STANDALONE = 2;
+const DISPLAY_MODE_FULLSCREEN = 3;
+
+// This needs to match GeckoSessionSettings.java
+// eslint-disable-next-line no-unused-vars
+const VIEWPORT_MODE_MOBILE = 0;
+const VIEWPORT_MODE_DESKTOP = 1;
+
+// Handles GeckoSession settings.
+export class GeckoViewSettings extends GeckoViewModule {
+ onInit() {
+ debug`onInit`;
+ this._userAgentMode = USER_AGENT_MODE_MOBILE;
+ this._userAgentOverride = null;
+ this._sessionContextId = null;
+
+ this.registerListener(["GeckoView:GetUserAgent"]);
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:GetUserAgent": {
+ aCallback.onSuccess(this.customUserAgent ?? lazy.MOBILE_USER_AGENT);
+ }
+ }
+ }
+
+ onSettingsUpdate() {
+ const { settings } = this;
+ debug`onSettingsUpdate: ${settings}`;
+
+ this.displayMode = settings.displayMode;
+ this.unsafeSessionContextId = settings.unsafeSessionContextId;
+ this.userAgentMode = settings.userAgentMode;
+ this.userAgentOverride = settings.userAgentOverride;
+ this.sessionContextId = settings.sessionContextId;
+ this.suspendMediaWhenInactive = settings.suspendMediaWhenInactive;
+ this.allowJavascript = settings.allowJavascript;
+ this.viewportMode = settings.viewportMode;
+ this.useTrackingProtection = !!settings.useTrackingProtection;
+
+ // When the page is loading from the main process (e.g. from an extension
+ // page) we won't be able to query the actor here.
+ this.getActor("GeckoViewSettings")?.sendAsyncMessage(
+ "SettingsUpdate",
+ settings
+ );
+ }
+
+ get allowJavascript() {
+ return this.browsingContext.allowJavascript;
+ }
+
+ set allowJavascript(aAllowJavascript) {
+ this.browsingContext.allowJavascript = aAllowJavascript;
+ }
+
+ get customUserAgent() {
+ if (this.userAgentOverride !== null) {
+ return this.userAgentOverride;
+ }
+ if (this.userAgentMode === USER_AGENT_MODE_DESKTOP) {
+ return lazy.DESKTOP_USER_AGENT;
+ }
+ if (this.userAgentMode === USER_AGENT_MODE_VR) {
+ return lazy.VR_USER_AGENT;
+ }
+ return null;
+ }
+
+ set useTrackingProtection(aUse) {
+ this.browsingContext.useTrackingProtection = aUse;
+ }
+
+ set viewportMode(aViewportMode) {
+ this.browsingContext.forceDesktopViewport =
+ aViewportMode == VIEWPORT_MODE_DESKTOP;
+ }
+
+ get userAgentMode() {
+ return this._userAgentMode;
+ }
+
+ set userAgentMode(aMode) {
+ if (this.userAgentMode === aMode) {
+ return;
+ }
+ this._userAgentMode = aMode;
+ this.browsingContext.customUserAgent = this.customUserAgent;
+ }
+
+ get browsingContext() {
+ return this.browser.browsingContext.top;
+ }
+
+ get userAgentOverride() {
+ return this._userAgentOverride;
+ }
+
+ set userAgentOverride(aUserAgent) {
+ if (aUserAgent === this.userAgentOverride) {
+ return;
+ }
+ this._userAgentOverride = aUserAgent;
+ this.browsingContext.customUserAgent = this.customUserAgent;
+ }
+
+ get suspendMediaWhenInactive() {
+ return this.browser.suspendMediaWhenInactive;
+ }
+
+ set suspendMediaWhenInactive(aSuspendMediaWhenInactive) {
+ if (aSuspendMediaWhenInactive != this.browser.suspendMediaWhenInactive) {
+ this.browser.suspendMediaWhenInactive = aSuspendMediaWhenInactive;
+ }
+ }
+
+ displayModeSettingToValue(aSetting) {
+ switch (aSetting) {
+ case DISPLAY_MODE_BROWSER:
+ return "browser";
+ case DISPLAY_MODE_MINIMAL_UI:
+ return "minimal-ui";
+ case DISPLAY_MODE_STANDALONE:
+ return "standalone";
+ case DISPLAY_MODE_FULLSCREEN:
+ return "fullscreen";
+ default:
+ warn`Invalid displayMode value ${aSetting}.`;
+ return "browser";
+ }
+ }
+
+ set displayMode(aMode) {
+ this.browsingContext.displayMode = this.displayModeSettingToValue(aMode);
+ }
+
+ set sessionContextId(aAttribute) {
+ this._sessionContextId = aAttribute;
+ }
+
+ get sessionContextId() {
+ return this._sessionContextId;
+ }
+}
+
+const { debug, warn } = GeckoViewSettings.initLogging("GeckoViewSettings");
diff --git a/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs
new file mode 100644
index 0000000000..e69ad3b973
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs
@@ -0,0 +1,351 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ PrincipalsCollector: "resource://gre/modules/PrincipalsCollector.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serviceMode",
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_DISABLED
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serviceModePBM",
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED
+);
+
+const { debug, warn } = GeckoViewUtils.initLogging(
+ "GeckoViewStorageController"
+);
+
+// Keep in sync with StorageController.ClearFlags and nsIClearDataService.idl.
+const ClearFlags = [
+ [
+ // COOKIES
+ 1 << 0,
+ Ci.nsIClearDataService.CLEAR_COOKIES |
+ Ci.nsIClearDataService.CLEAR_MEDIA_DEVICES |
+ Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD |
+ Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE |
+ Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE,
+ ],
+ [
+ // NETWORK_CACHE
+ 1 << 1,
+ Ci.nsIClearDataService.CLEAR_NETWORK_CACHE,
+ ],
+ [
+ // IMAGE_CACHE
+ 1 << 2,
+ Ci.nsIClearDataService.CLEAR_IMAGE_CACHE,
+ ],
+ [
+ // HISTORY
+ 1 << 3,
+ Ci.nsIClearDataService.CLEAR_HISTORY |
+ Ci.nsIClearDataService.CLEAR_SESSION_HISTORY,
+ ],
+ [
+ // DOM_STORAGES
+ 1 << 4,
+ Ci.nsIClearDataService.CLEAR_DOM_QUOTA |
+ Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS |
+ Ci.nsIClearDataService.CLEAR_REPORTS |
+ Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD |
+ Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE |
+ Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE,
+ ],
+ [
+ // AUTH_SESSIONS
+ 1 << 5,
+ Ci.nsIClearDataService.CLEAR_AUTH_TOKENS |
+ Ci.nsIClearDataService.CLEAR_AUTH_CACHE,
+ ],
+ [
+ // PERMISSIONS
+ 1 << 6,
+ Ci.nsIClearDataService.CLEAR_PERMISSIONS,
+ ],
+ [
+ // SITE_SETTINGS
+ 1 << 7,
+ Ci.nsIClearDataService.CLEAR_CONTENT_PREFERENCES |
+ Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS |
+ // former a part of SECURITY_SETTINGS_CLEANER
+ Ci.nsIClearDataService.CLEAR_CLIENT_AUTH_REMEMBER_SERVICE |
+ Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE,
+ ],
+ [
+ // SITE_DATA
+ 1 << 8,
+ Ci.nsIClearDataService.CLEAR_EME,
+ // former a part of SECURITY_SETTINGS_CLEANER
+ Ci.nsIClearDataService.CLEAR_HSTS,
+ ],
+ [
+ // ALL
+ 1 << 9,
+ Ci.nsIClearDataService.CLEAR_ALL,
+ ],
+];
+
+function convertFlags(aJavaFlags) {
+ const flags = ClearFlags.filter(cf => {
+ return cf[0] & aJavaFlags;
+ }).reduce((acc, cf) => {
+ return acc | cf[1];
+ }, 0);
+ return flags;
+}
+
+export const GeckoViewStorageController = {
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:ClearData": {
+ this.clearData(aData.flags, aCallback);
+ break;
+ }
+ case "GeckoView:ClearSessionContextData": {
+ this.clearSessionContextData(aData.contextId);
+ break;
+ }
+ case "GeckoView:ClearHostData": {
+ this.clearHostData(aData.host, aData.flags, aCallback);
+ break;
+ }
+ case "GeckoView:ClearBaseDomainData": {
+ this.clearBaseDomainData(aData.baseDomain, aData.flags, aCallback);
+ break;
+ }
+ case "GeckoView:GetAllPermissions": {
+ const rawPerms = Services.perms.all;
+ const permissions = rawPerms.map(p => {
+ return {
+ uri: Services.io.createExposableURI(p.principal.URI).displaySpec,
+ principal: lazy.E10SUtils.serializePrincipal(p.principal),
+ perm: p.type,
+ value: p.capability,
+ contextId: p.principal.originAttributes.geckoViewSessionContextId,
+ privateMode: p.principal.privateBrowsingId != 0,
+ };
+ });
+ aCallback.onSuccess({ permissions });
+ break;
+ }
+ case "GeckoView:GetPermissionsByURI": {
+ const uri = Services.io.newURI(aData.uri);
+ const principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ aData.contextId
+ ? {
+ geckoViewSessionContextId: aData.contextId,
+ privateBrowsingId: aData.privateBrowsingId,
+ }
+ : { privateBrowsingId: aData.privateBrowsingId }
+ );
+ const rawPerms = Services.perms.getAllForPrincipal(principal);
+ const permissions = rawPerms.map(p => {
+ return {
+ uri: Services.io.createExposableURI(p.principal.URI).displaySpec,
+ principal: lazy.E10SUtils.serializePrincipal(p.principal),
+ perm: p.type,
+ value: p.capability,
+ contextId: p.principal.originAttributes.geckoViewSessionContextId,
+ privateMode: p.principal.privateBrowsingId != 0,
+ };
+ });
+ aCallback.onSuccess({ permissions });
+ break;
+ }
+ case "GeckoView:SetPermission": {
+ const principal = lazy.E10SUtils.deserializePrincipal(aData.principal);
+ let key = aData.perm;
+ if (key == "storage-access") {
+ key = "3rdPartyFrameStorage^" + aData.thirdPartyOrigin;
+ }
+ if (aData.allowPermanentPrivateBrowsing) {
+ Services.perms.addFromPrincipalAndPersistInPrivateBrowsing(
+ principal,
+ key,
+ aData.newValue
+ );
+ } else {
+ const expirePolicy = aData.privateMode
+ ? Ci.nsIPermissionManager.EXPIRE_SESSION
+ : Ci.nsIPermissionManager.EXPIRE_NEVER;
+ Services.perms.addFromPrincipal(
+ principal,
+ key,
+ aData.newValue,
+ expirePolicy
+ );
+ }
+ break;
+ }
+ case "GeckoView:SetPermissionByURI": {
+ const uri = Services.io.newURI(aData.uri);
+ const expirePolicy = aData.privateId
+ ? Ci.nsIPermissionManager.EXPIRE_SESSION
+ : Ci.nsIPermissionManager.EXPIRE_NEVER;
+ const principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {
+ geckoViewSessionContextId: aData.contextId ?? undefined,
+ privateBrowsingId: aData.privateId,
+ }
+ );
+ Services.perms.addFromPrincipal(
+ principal,
+ aData.perm,
+ aData.newValue,
+ expirePolicy
+ );
+ break;
+ }
+
+ case "GeckoView:SetCookieBannerModeForDomain": {
+ let exceptionLabel = "SetCookieBannerModeForDomain";
+ try {
+ const uri = Services.io.newURI(aData.uri);
+ if (aData.allowPermanentPrivateBrowsing) {
+ exceptionLabel = "setDomainPrefAndPersistInPrivateBrowsing";
+ Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing(
+ uri,
+ aData.mode
+ );
+ } else {
+ Services.cookieBanners.setDomainPref(
+ uri,
+ aData.mode,
+ aData.isPrivateBrowsing
+ );
+ }
+ aCallback.onSuccess();
+ } catch (ex) {
+ debug`Failed ${exceptionLabel} ${ex}`;
+ }
+ break;
+ }
+
+ case "GeckoView:RemoveCookieBannerModeForDomain": {
+ try {
+ const uri = Services.io.newURI(aData.uri);
+ Services.cookieBanners.removeDomainPref(uri, aData.isPrivateBrowsing);
+ aCallback.onSuccess();
+ } catch (ex) {
+ debug`Failed RemoveCookieBannerModeForDomain ${ex}`;
+ }
+ break;
+ }
+
+ case "GeckoView:GetCookieBannerModeForDomain": {
+ try {
+ let globalMode;
+ if (aData.isPrivateBrowsing) {
+ globalMode = lazy.serviceModePBM;
+ } else {
+ globalMode = lazy.serviceMode;
+ }
+
+ if (globalMode === Ci.nsICookieBannerService.MODE_DISABLED) {
+ aCallback.onSuccess({ mode: globalMode });
+ return;
+ }
+
+ const uri = Services.io.newURI(aData.uri);
+ const mode = Services.cookieBanners.getDomainPref(
+ uri,
+ aData.isPrivateBrowsing
+ );
+ if (mode !== Ci.nsICookieBannerService.MODE_UNSET) {
+ aCallback.onSuccess({ mode });
+ } else {
+ aCallback.onSuccess({ mode: globalMode });
+ }
+ } catch (ex) {
+ aCallback.onError(`Unexpected error: ${ex}`);
+ debug`Failed GetCookieBannerModeForDomain ${ex}`;
+ }
+ break;
+ }
+ }
+ },
+
+ async clearData(aFlags, aCallback) {
+ const flags = convertFlags(aFlags);
+
+ // storageAccessAPI permissions record every site that the user
+ // interacted with and thus mirror history quite closely. It makes
+ // sense to clear them when we clear history. However, since their absence
+ // indicates that we can purge cookies and site data for tracking origins without
+ // user interaction, we need to ensure that we only delete those permissions that
+ // do not have any existing storage.
+ if (flags & Ci.nsIClearDataService.CLEAR_HISTORY) {
+ const principalsCollector = new lazy.PrincipalsCollector();
+ const principals = await principalsCollector.getAllPrincipals();
+ await new Promise(resolve => {
+ Services.clearData.deleteUserInteractionForClearingHistory(
+ principals,
+ 0,
+ resolve
+ );
+ });
+ }
+
+ new Promise(resolve => {
+ Services.clearData.deleteData(flags, resolve);
+ }).then(resultFlags => {
+ aCallback.onSuccess();
+ });
+ },
+
+ clearHostData(aHost, aFlags, aCallback) {
+ new Promise(resolve => {
+ Services.clearData.deleteDataFromHost(
+ aHost,
+ /* isUserRequest */ true,
+ convertFlags(aFlags),
+ resolve
+ );
+ }).then(resultFlags => {
+ aCallback.onSuccess();
+ });
+ },
+
+ clearBaseDomainData(aBaseDomain, aFlags, aCallback) {
+ new Promise(resolve => {
+ Services.clearData.deleteDataFromBaseDomain(
+ aBaseDomain,
+ /* isUserRequest */ true,
+ convertFlags(aFlags),
+ resolve
+ );
+ }).then(resultFlags => {
+ aCallback.onSuccess();
+ });
+ },
+
+ clearSessionContextData(aContextId) {
+ const pattern = { geckoViewSessionContextId: aContextId };
+ debug`clearSessionContextData ${pattern}`;
+ Services.clearData.deleteDataFromOriginAttributesPattern(pattern);
+ // Call QMS explicitly to work around bug 1537882.
+ Services.qms.clearStoragesForOriginAttributesPattern(
+ JSON.stringify(pattern)
+ );
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs
new file mode 100644
index 0000000000..53f43f153c
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs
@@ -0,0 +1,219 @@
+/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const { ExtensionError } = ExtensionUtils;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
+});
+
+class Tab {
+ constructor(window) {
+ this.id = GeckoViewTabBridge.windowIdToTabId(window.docShell.outerWindowID);
+ this.browser = window.browser;
+ this.active = false;
+ }
+
+ get linkedBrowser() {
+ return this.browser;
+ }
+
+ getActive() {
+ return this.active;
+ }
+
+ get userContextId() {
+ return this.browser.ownerGlobal.moduleManager.settings
+ .unsafeSessionContextId;
+ }
+}
+
+// Because of bug 1410749, we can't use 0, though, and just to be safe
+// we choose a value that is unlikely to overlap with Fennec's tab IDs.
+const TAB_ID_BASE = 10000;
+
+export const GeckoViewTabBridge = {
+ /**
+ * Converts windowId to tabId as in GeckoView every browser window has exactly one tab.
+ *
+ * @param {number} windowId outerWindowId
+ *
+ * @returns {number} tabId
+ */
+ windowIdToTabId(windowId) {
+ return TAB_ID_BASE + windowId;
+ },
+
+ /**
+ * Converts tabId to windowId.
+ *
+ * @param {number} tabId
+ *
+ * @returns {number}
+ * outerWindowId of browser window to which the tab belongs.
+ */
+ tabIdToWindowId(tabId) {
+ return tabId - TAB_ID_BASE;
+ },
+
+ /**
+ * Delegates openOptionsPage handling to the app.
+ *
+ * @param {number} extensionId
+ * The ID of the extension requesting the options menu.
+ *
+ * @returns {Promise<Void>}
+ * A promise resolved after successful handling.
+ */
+ async openOptionsPage(extensionId) {
+ debug`openOptionsPage for extensionId ${extensionId}`;
+
+ try {
+ await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:OpenOptionsPage",
+ extensionId,
+ });
+ } catch (errorMessage) {
+ // The error message coming from GeckoView is about :OpenOptionsPage not
+ // being registered so we need to have one that's extension friendly
+ // here.
+ throw new ExtensionError("runtime.openOptionsPage is not supported");
+ }
+ },
+
+ /**
+ * Request the GeckoView App to create a new tab (GeckoSession).
+ *
+ * @param {object} options
+ * @param {string} options.extensionId
+ * The ID of the extension that requested a new tab.
+ * @param {object} options.createProperties
+ * The properties for the new tab, see tabs.create reference for details.
+ *
+ * @returns {Promise<Tab>}
+ * A promise resolved to the newly created tab.
+ * @throws {Error}
+ * Throws an error if the GeckoView app doesn't support tabs.create or fails to handle the request.
+ */
+ async createNewTab({ extensionId, createProperties } = {}) {
+ debug`createNewTab`;
+
+ const newSessionId = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1)
+ .replace(/-/g, "");
+
+ // The window might already be open by the time we get the response, so we
+ // need to start waiting before we send the message.
+ const windowPromise = new Promise(resolve => {
+ const handler = {
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic === "geckoview-window-created" &&
+ aSubject.name === newSessionId
+ ) {
+ Services.obs.removeObserver(handler, "geckoview-window-created");
+ resolve(aSubject);
+ }
+ },
+ };
+ Services.obs.addObserver(handler, "geckoview-window-created");
+ });
+
+ let didOpenSession = false;
+ try {
+ didOpenSession = await lazy.EventDispatcher.instance.sendRequestForResult(
+ {
+ type: "GeckoView:WebExtension:NewTab",
+ extensionId,
+ createProperties,
+ newSessionId,
+ }
+ );
+ } catch (errorMessage) {
+ // The error message coming from GeckoView is about :NewTab not being
+ // registered so we need to have one that's extension friendly here.
+ throw new ExtensionError("tabs.create is not supported");
+ }
+
+ if (!didOpenSession) {
+ throw new ExtensionError("Cannot create new tab");
+ }
+
+ const window = await windowPromise;
+ if (!window.tab) {
+ window.tab = new Tab(window);
+ }
+ return window.tab;
+ },
+
+ /**
+ * Request the GeckoView App to close a tab (GeckoSession).
+ *
+ *
+ * @param {object} options
+ * @param {Window} options.window The window owning the tab to close
+ * @param {string} options.extensionId
+ *
+ * @returns {Promise<Void>}
+ * A promise resolved after GeckoSession is closed.
+ * @throws {Error}
+ * Throws an error if the GeckoView app doesn't allow extension to close tab.
+ */
+ async closeTab({ window, extensionId } = {}) {
+ try {
+ await window.WindowEventDispatcher.sendRequestForResult({
+ type: "GeckoView:WebExtension:CloseTab",
+ extensionId,
+ });
+ } catch (errorMessage) {
+ throw new ExtensionError(errorMessage);
+ }
+ },
+
+ async updateTab({ window, extensionId, updateProperties } = {}) {
+ try {
+ await window.WindowEventDispatcher.sendRequestForResult({
+ type: "GeckoView:WebExtension:UpdateTab",
+ extensionId,
+ updateProperties,
+ });
+ } catch (errorMessage) {
+ throw new ExtensionError(errorMessage);
+ }
+ },
+};
+
+export class GeckoViewTab extends GeckoViewModule {
+ onInit() {
+ const { window } = this;
+ if (!window.tab) {
+ window.tab = new Tab(window);
+ }
+
+ this.registerListener(["GeckoView:WebExtension:SetTabActive"]);
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:SetTabActive": {
+ const { active } = aData;
+ lazy.mobileWindowTracker.setTabActive(this.window, active);
+ break;
+ }
+ }
+ }
+}
+
+const { debug, warn } = GeckoViewTab.initLogging("GeckoViewTab");
diff --git a/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs
new file mode 100644
index 0000000000..bb7074ced8
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewTelemetry.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/. */
+
+export var InitializationTracker = {
+ initialized: false,
+ onInitialized(profilerTime) {
+ if (!this.initialized) {
+ this.initialized = true;
+ ChromeUtils.addProfilerMarker(
+ "GeckoView Initialization END",
+ profilerTime
+ );
+ }
+ },
+};
+
+// A helper for timing_distribution metrics.
+export class GleanStopwatch {
+ constructor(aTimingDistribution) {
+ this._metric = aTimingDistribution;
+ }
+
+ isRunning() {
+ return !!this._timerId;
+ }
+
+ start() {
+ if (this.isRunning()) {
+ this.cancel();
+ }
+ this._timerId = this._metric.start();
+ }
+
+ finish() {
+ this._metric.stopAndAccumulate(this._timerId);
+ this._timerId = null;
+ }
+
+ cancel() {
+ this._metric.cancel(this._timerId);
+ this._timerId = null;
+ }
+}
diff --git a/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs
new file mode 100644
index 0000000000..6c5113cdc2
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+});
+
+export const GeckoViewTabUtil = {
+ /**
+ * Creates a new tab through service worker delegate.
+ * Needs to be ran in a parent process.
+ *
+ * @param {string} url
+ * @returns {Tab}
+ * @throws {Error} Throws an error if the tab cannot be created.
+ */
+ async createNewTab(url = "about:blank") {
+ let sessionId = "";
+ const windowPromise = new Promise(resolve => {
+ const openingObserver = (subject, topic, data) => {
+ if (subject.name === sessionId) {
+ Services.obs.removeObserver(
+ openingObserver,
+ "geckoview-window-created"
+ );
+ resolve(subject);
+ }
+ };
+ Services.obs.addObserver(openingObserver, "geckoview-window-created");
+ });
+
+ try {
+ sessionId = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:Test:NewTab",
+ url,
+ });
+ } catch (errorMessage) {
+ throw new Error(
+ errorMessage + " GeckoView:Test:NewTab is not supported."
+ );
+ }
+
+ if (!sessionId) {
+ throw new Error("Could not open a session for the new tab.");
+ }
+
+ const window = await windowPromise;
+
+ // Immediately load the URI in the browser after creating the new tab to
+ // load into. This isn't done from the Java side to align with the
+ // ServiceWorkerOpenWindow infrastructure which this is built on top of.
+ window.browser.fixupAndLoadURIString(url, {
+ flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ return window.tab;
+ },
+};
diff --git a/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs
new file mode 100644
index 0000000000..3db694a64a
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs
@@ -0,0 +1,572 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs",
+});
+
+import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
+
+export class GeckoViewTranslations extends GeckoViewModule {
+ onInit() {
+ debug`onInit`;
+ this.registerListener([
+ "GeckoView:Translations:Translate",
+ "GeckoView:Translations:RestorePage",
+ "GeckoView:Translations:GetNeverTranslateSite",
+ "GeckoView:Translations:SetNeverTranslateSite",
+ ]);
+ }
+
+ onEnable() {
+ debug`onEnable`;
+ this.window.addEventListener("TranslationsParent:OfferTranslation", this);
+ this.window.addEventListener("TranslationsParent:LanguageState", this);
+ }
+
+ onDisable() {
+ debug`onDisable`;
+ this.window.removeEventListener(
+ "TranslationsParent:OfferTranslation",
+ this
+ );
+ this.window.removeEventListener("TranslationsParent:LanguageState", this);
+ }
+
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent: event=${aEvent}, data=${aData}`;
+ switch (aEvent) {
+ case "GeckoView:Translations:Translate":
+ try {
+ const fromLanguage =
+ GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize(
+ aData.fromLanguage
+ );
+ const toLanguage =
+ GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize(
+ aData.toLanguage
+ );
+ try {
+ this.getActor("Translations").translate(fromLanguage, toLanguage);
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(`Could not translate: ${error}`);
+ }
+ } catch (error) {
+ aCallback.onError(
+ `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}`
+ );
+ }
+ break;
+
+ case "GeckoView:Translations:RestorePage":
+ try {
+ this.getActor("Translations").restorePage();
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(`Could not restore page: ${error}`);
+ }
+ break;
+
+ case "GeckoView:Translations:GetNeverTranslateSite":
+ try {
+ var value = this.getActor("Translations").shouldNeverTranslateSite();
+ aCallback.onSuccess(value);
+ } catch (error) {
+ aCallback.onError(`Could not set site setting: ${error}`);
+ }
+ break;
+
+ case "GeckoView:Translations:SetNeverTranslateSite":
+ try {
+ this.getActor("Translations").setNeverTranslateSitePermissions(
+ aData.neverTranslate
+ );
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(`Could not set site setting: ${error}`);
+ }
+ break;
+ }
+ }
+
+ handleEvent(aEvent) {
+ debug`handleEvent: ${aEvent.type}`;
+ switch (aEvent.type) {
+ case "TranslationsParent:OfferTranslation":
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:Translations:Offer",
+ });
+ break;
+ case "TranslationsParent:LanguageState":
+ const {
+ detectedLanguages,
+ requestedTranslationPair,
+ error,
+ isEngineReady,
+ } = aEvent.detail.actor.languageState;
+
+ const data = {
+ detectedLanguages,
+ requestedTranslationPair,
+ error,
+ isEngineReady,
+ };
+
+ this.eventDispatcher.sendRequest({
+ type: "GeckoView:Translations:StateChange",
+ data,
+ });
+ break;
+ }
+ }
+}
+
+// Runtime functionality
+export const GeckoViewTranslationsSettings = {
+ // Helper method for retrieving language setting state and corresponding string name.
+ _getLanguageSettingName(langTag) {
+ const isAlways = lazy.TranslationsParent.shouldAlwaysTranslateLanguage({
+ docLangTag: langTag,
+ userLangTag: new Intl.Locale(Services.locale.appLocaleAsBCP47).language,
+ });
+ const isNever =
+ lazy.TranslationsParent.shouldNeverTranslateLanguage(langTag);
+ // Default setting is offer.
+ var setting = "offer";
+
+ if (isAlways & !isNever) {
+ setting = "always";
+ }
+
+ if (isNever & !isAlways) {
+ setting = "never";
+ }
+ return setting;
+ },
+
+ // Helper method to validate BCP 47 tags and reduced to only the language portion. For example, en-US will be reduced to en.
+ _checkValidLanguageTagAndMinimize(langTag) {
+ // Formats the langTag into a locale, may throw an error
+ var canonicalTag = new Intl.Locale(Intl.getCanonicalLocales(langTag)[0]);
+ return canonicalTag.minimize().toString();
+ },
+ /* eslint-disable complexity */
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:Translations:IsTranslationEngineSupported": {
+ try {
+ aCallback.onSuccess(
+ lazy.TranslationsParent.getIsTranslationsEngineSupported()
+ );
+ } catch (error) {
+ aCallback.onError(
+ `An issue occurred while checking the translations engine: ${error}`
+ );
+ }
+ return;
+ }
+ case "GeckoView:Translations:PreferredLanguages": {
+ aCallback.onSuccess({
+ preferredLanguages: lazy.TranslationsParent.getPreferredLanguages(),
+ });
+ return;
+ }
+ case "GeckoView:Translations:ManageModel": {
+ const { language, operation, operationLevel } = aData;
+ if (operation === "delete") {
+ if (operationLevel === "all") {
+ lazy.TranslationsParent.deleteAllLanguageFiles().then(
+ function (value) {
+ aCallback.onSuccess();
+ },
+ function (error) {
+ aCallback.onError(
+ `COULD_NOT_DELETE - An issue occurred while deleting all language files: ${error}`
+ );
+ }
+ );
+ return;
+ }
+ if (operationLevel === "language") {
+ if (language === undefined) {
+ aCallback.onError(
+ `LANGUAGE_REQUIRED - A specified language is required language level operations.`
+ );
+ return;
+ }
+ lazy.TranslationsParent.deleteLanguageFiles(language).then(
+ function (value) {
+ aCallback.onSuccess();
+ },
+ function (error) {
+ aCallback.onError(
+ `COULD_NOT_DELETE - An issue occurred while deleting a language file: ${error}`
+ );
+ }
+ );
+ return;
+ }
+ }
+ if (operation === "download") {
+ if (operationLevel === "all") {
+ lazy.TranslationsParent.downloadAllFiles().then(
+ function (value) {
+ aCallback.onSuccess();
+ },
+ function (error) {
+ aCallback.onError(
+ `COULD_NOT_DOWNLOAD - An issue occurred while downloading all language files: ${error}`
+ );
+ }
+ );
+ return;
+ }
+ if (operationLevel === "language") {
+ if (language === undefined) {
+ aCallback.onError(
+ `LANGUAGE_REQUIRED - A specified language is required language level operations.`
+ );
+ return;
+ }
+ lazy.TranslationsParent.downloadLanguageFiles(language).then(
+ function (value) {
+ aCallback.onSuccess();
+ },
+ function (error) {
+ aCallback.onError(
+ `COULD_NOT_DOWNLOAD - An issue occurred while downloading a language files: ${error}`
+ );
+ }
+ );
+ }
+ }
+ break;
+ }
+ case "GeckoView:Translations:TranslationInformation": {
+ if (
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref(
+ "browser.translations.geckoview.enableAllTestMocks",
+ false
+ )
+ ) {
+ const mockResult = {
+ languagePairs: [
+ { fromLang: "en", toLang: "es" },
+ { fromLang: "es", toLang: "en" },
+ ],
+ fromLanguages: [
+ { langTag: "en", displayName: "English" },
+ { langTag: "es", displayName: "Spanish" },
+ ],
+ toLanguages: [
+ { langTag: "en", displayName: "English" },
+ { langTag: "es", displayName: "Spanish" },
+ ],
+ };
+ aCallback.onSuccess(mockResult);
+ return;
+ }
+
+ lazy.TranslationsParent.getSupportedLanguages().then(
+ function (value) {
+ aCallback.onSuccess(value);
+ },
+ function (error) {
+ aCallback.onError(
+ `Could not retrieve requested information: ${error}`
+ );
+ }
+ );
+ break;
+ }
+ case "GeckoView:Translations:ModelInformation": {
+ if (
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref(
+ "browser.translations.geckoview.enableAllTestMocks",
+ false
+ )
+ ) {
+ const mockResult = {
+ models: [
+ {
+ langTag: "es",
+ displayName: "Spanish",
+ isDownloaded: false,
+ size: 12345,
+ },
+ {
+ langTag: "de",
+ displayName: "German",
+ isDownloaded: false,
+ size: 12345,
+ },
+ ],
+ };
+ aCallback.onSuccess(mockResult);
+ return;
+ }
+
+ // Helper function to process remote server records size and download state for GV use
+ async function _processLanguageModelData(language, remoteRecords) {
+ // Aggregate size of downloads, e.g., one language has many model binary files
+ var size = 0;
+ remoteRecords.forEach(item => {
+ size += parseInt(item.attachment.size);
+ });
+ // Check if required files are downloaded
+ var isDownloaded =
+ await lazy.TranslationsParent.hasAllFilesForLanguage(
+ language.langTag
+ );
+ var model = {
+ langTag: language.langTag,
+ displayName: language.displayName,
+ isDownloaded,
+ size,
+ };
+ return model;
+ }
+
+ // Main call to toolkit
+ lazy.TranslationsParent.getSupportedLanguages().then(
+ // Retrieve supported languages
+ async function (supportedLanguages) {
+ // Get language display information,
+ const languageList =
+ lazy.TranslationsParent.getLanguageList(supportedLanguages);
+ var results = [];
+ // For each language, process the related remote server model records
+ languageList.forEach(language => {
+ const recordsResult =
+ lazy.TranslationsParent.getRecordsForTranslatingToAndFromAppLanguage(
+ language.langTag,
+ false
+ ).then(
+ async function (records) {
+ return _processLanguageModelData(language, records);
+ },
+ function (recordError) {
+ aCallback.onError(
+ `An issue occurred while aggregating information: ${recordError}`
+ );
+ },
+ language
+ );
+ results.push(recordsResult);
+ });
+ // Aggregate records
+ Promise.all(results).then(models => {
+ var response = [];
+ models.forEach(item => {
+ response.push(item);
+ });
+ aCallback.onSuccess({ models: response });
+ });
+ },
+ function (languageError) {
+ aCallback.onError(
+ `An issue occurred while retrieving the supported languages: ${languageError}`
+ );
+ }
+ );
+ break;
+ }
+
+ case "GeckoView:Translations:GetLanguageSetting": {
+ if (
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref(
+ "browser.translations.geckoview.enableAllTestMocks",
+ false
+ )
+ ) {
+ aCallback.onSuccess("always");
+ return;
+ }
+
+ try {
+ var setting = this._getLanguageSettingName(aData.language);
+ aCallback.onSuccess(setting);
+ } catch (error) {
+ aCallback.onError(`Could not get language setting: ${error}`);
+ }
+ break;
+ }
+
+ case "GeckoView:Translations:GetLanguageSettings": {
+ if (
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref(
+ "browser.translations.geckoview.enableAllTestMocks",
+ false
+ )
+ ) {
+ const mockResult = {
+ settings: [
+ { langTag: "fr", displayName: "French", setting: "always" },
+ { langTag: "de", displayName: "German", setting: "offer" },
+ { langTag: "es", displayName: "Spanish", setting: "never" },
+ ],
+ };
+ aCallback.onSuccess(mockResult);
+ return;
+ }
+
+ lazy.TranslationsParent.getSupportedLanguages().then(
+ function (supportedLanguages) {
+ const languageList =
+ lazy.TranslationsParent.getLanguageList(supportedLanguages);
+
+ languageList.forEach(language => {
+ language.setting = this._getLanguageSettingName(language.langTag);
+ });
+
+ aCallback.onSuccess({ settings: languageList });
+ }.bind(this),
+ function (error) {
+ aCallback.onError(
+ `Could not retrieve language setting information: ${error}`
+ );
+ }
+ );
+ break;
+ }
+
+ case "GeckoView:Translations:SetLanguageSettings": {
+ var { language, languageSetting } = aData;
+ languageSetting = languageSetting.toLowerCase();
+
+ try {
+ language = this._checkValidLanguageTagAndMinimize(language);
+ } catch (error) {
+ aCallback.onError(
+ `The language tag ${language} is not valid: ${error}`
+ );
+ return;
+ }
+
+ const ALWAYS = lazy.TranslationsParent.ALWAYS_TRANSLATE_LANGS_PREF;
+ const NEVER = lazy.TranslationsParent.NEVER_TRANSLATE_LANGS_PREF;
+
+ switch (languageSetting) {
+ case "always": {
+ try {
+ lazy.TranslationsParent.removeLangTagFromPref(language, NEVER);
+ lazy.TranslationsParent.addLangTagToPref(language, ALWAYS);
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(
+ `Could not set language preference to always: ${error}`
+ );
+ }
+ break;
+ }
+
+ case "never": {
+ try {
+ lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS);
+ lazy.TranslationsParent.addLangTagToPref(language, NEVER);
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(
+ `Could not set language preference to never: ${error}`
+ );
+ }
+ break;
+ }
+
+ case "offer": {
+ try {
+ // Reverting to default settings, so ensure nothing is set.
+ lazy.TranslationsParent.removeLangTagFromPref(language, NEVER);
+ lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS);
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(
+ `Could not set language preference to offer: ${error}`
+ );
+ }
+ break;
+ }
+ }
+ break;
+ }
+
+ case "GeckoView:Translations:GetNeverTranslateSpecifiedSites":
+ try {
+ const neverTranslateList =
+ lazy.TranslationsParent.listNeverTranslateSites();
+ aCallback.onSuccess({ sites: neverTranslateList });
+ } catch (error) {
+ aCallback.onError(
+ `Could not get list of never translate sites: ${error}`
+ );
+ }
+ break;
+
+ case "GeckoView:Translations:SetNeverTranslateSpecifiedSite":
+ try {
+ lazy.TranslationsParent.setNeverTranslateSiteByOrigin(
+ aData.neverTranslate,
+ aData.origin
+ );
+ aCallback.onSuccess();
+ } catch (error) {
+ aCallback.onError(
+ `Could not set never translate site setting: ${error}`
+ );
+ }
+ break;
+ case "GeckoView:Translations:GetTranslateDownloadSize": {
+ if (
+ Cu.isInAutomation &&
+ Services.prefs.getBoolPref(
+ "browser.translations.geckoview.enableAllTestMocks",
+ false
+ )
+ ) {
+ aCallback.onSuccess({ bytes: 1234567 });
+ return;
+ }
+
+ try {
+ const fromLanguage = this._checkValidLanguageTagAndMinimize(
+ aData.fromLanguage
+ );
+ const toLanguage = this._checkValidLanguageTagAndMinimize(
+ aData.toLanguage
+ );
+
+ lazy.TranslationsParent.getExpectedTranslationDownloadSize(
+ fromLanguage,
+ toLanguage
+ ).then(
+ function (bytes) {
+ aCallback.onSuccess({ bytes });
+ },
+ function (error) {
+ aCallback.onError(`Could not get the download size: ${error}`);
+ }
+ );
+ } catch (error) {
+ aCallback.onError(
+ `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}`
+ );
+ }
+ break;
+ }
+ }
+ },
+};
+
+const { debug, warn } = GeckoViewTranslations.initLogging(
+ "GeckoViewTranslations"
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs
new file mode 100644
index 0000000000..ddfb40c1a1
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs
@@ -0,0 +1,510 @@
+/* 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 { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AndroidLog: "resource://gre/modules/AndroidLog.sys.mjs",
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+/**
+ * A formatter that does not prepend time/name/level information to messages,
+ * because those fields are logged separately when using the Android logger.
+ */
+class AndroidFormatter extends Log.BasicFormatter {
+ format(message) {
+ return this.formatText(message);
+ }
+}
+
+/*
+ * AndroidAppender
+ * Logs to Android logcat using AndroidLog.jsm
+ */
+class AndroidAppender extends Log.Appender {
+ constructor(aFormatter) {
+ super(aFormatter || new AndroidFormatter());
+ this._name = "AndroidAppender";
+
+ // Map log level to AndroidLog.foo method.
+ this._mapping = {
+ [Log.Level.Fatal]: "e",
+ [Log.Level.Error]: "e",
+ [Log.Level.Warn]: "w",
+ [Log.Level.Info]: "i",
+ [Log.Level.Config]: "d",
+ [Log.Level.Debug]: "d",
+ [Log.Level.Trace]: "v",
+ };
+ }
+
+ append(aMessage) {
+ if (!aMessage) {
+ return;
+ }
+
+ // AndroidLog.jsm always prepends "Gecko" to the tag, so we strip any
+ // leading "Gecko" here. Also strip dots to save space.
+ const tag = aMessage.loggerName.replace(/^Gecko|\./g, "");
+ const msg = this._formatter.format(aMessage);
+ lazy.AndroidLog[this._mapping[aMessage.level]](tag, msg);
+ }
+}
+
+export var GeckoViewUtils = {
+ /**
+ * Define a lazy getter that loads an object from external code, and
+ * optionally handles observer and/or message manager notifications for the
+ * object, so the object only loads when a notification is received.
+ *
+ * @param scope Scope for holding the loaded object.
+ * @param name Name of the object to load.
+ * @param service If specified, load the object from a JS component; the
+ * component must include the line
+ * "this.wrappedJSObject = this;" in its constructor.
+ * @param module If specified, load the object from a JS module.
+ * @param init Optional post-load initialization function.
+ * @param observers If specified, listen to specified observer notifications.
+ * @param ppmm If specified, listen to specified process messages.
+ * @param mm If specified, listen to specified frame messages.
+ * @param ged If specified, listen to specified global EventDispatcher events.
+ * @param once if true, only listen to the specified
+ * events/messages/notifications once.
+ */
+ addLazyGetter(
+ scope,
+ name,
+ { service, module, handler, observers, ppmm, mm, ged, init, once }
+ ) {
+ ChromeUtils.defineLazyGetter(scope, name, _ => {
+ let ret = undefined;
+ if (module) {
+ ret = ChromeUtils.importESModule(module)[name];
+ } else if (service) {
+ ret = Cc[service].getService(Ci.nsISupports).wrappedJSObject;
+ } else if (typeof handler === "function") {
+ ret = {
+ handleEvent: handler,
+ observe: handler,
+ onEvent: handler,
+ receiveMessage: handler,
+ };
+ } else if (handler) {
+ ret = handler;
+ }
+ if (ret && init) {
+ init.call(scope, ret);
+ }
+ return ret;
+ });
+
+ if (observers) {
+ const observer = (subject, topic, data) => {
+ Services.obs.removeObserver(observer, topic);
+ if (!once) {
+ Services.obs.addObserver(scope[name], topic);
+ }
+ scope[name].observe(subject, topic, data); // Explicitly notify new observer
+ };
+ observers.forEach(topic => Services.obs.addObserver(observer, topic));
+ }
+
+ if (!this.IS_PARENT_PROCESS) {
+ // ppmm, mm, and ged are only available in the parent process.
+ return;
+ }
+
+ const addMMListener = (target, names) => {
+ const listener = msg => {
+ target.removeMessageListener(msg.name, listener);
+ if (!once) {
+ target.addMessageListener(msg.name, scope[name]);
+ }
+ scope[name].receiveMessage(msg);
+ };
+ names.forEach(msg => target.addMessageListener(msg, listener));
+ };
+ if (ppmm) {
+ addMMListener(Services.ppmm, ppmm);
+ }
+ if (mm) {
+ addMMListener(Services.mm, mm);
+ }
+
+ if (ged) {
+ const listener = (event, data, callback) => {
+ lazy.EventDispatcher.instance.unregisterListener(listener, event);
+ if (!once) {
+ lazy.EventDispatcher.instance.registerListener(scope[name], event);
+ }
+ scope[name].onEvent(event, data, callback);
+ };
+ lazy.EventDispatcher.instance.registerListener(listener, ged);
+ }
+ },
+
+ _addLazyListeners(events, handler, scope, name, addFn, handleFn) {
+ if (!handler) {
+ handler = _ =>
+ Array.isArray(name) ? name.map(n => scope[n]) : scope[name];
+ }
+ const listener = (...args) => {
+ let handlers = handler(...args);
+ if (!handlers) {
+ return;
+ }
+ if (!Array.isArray(handlers)) {
+ handlers = [handlers];
+ }
+ handleFn(handlers, listener, args);
+ };
+ if (Array.isArray(events)) {
+ addFn(events, listener);
+ } else {
+ addFn([events], listener);
+ }
+ },
+
+ /**
+ * Add lazy event listeners that only load the actual handler when an event
+ * is being handled.
+ *
+ * @param target Event target for the event listeners.
+ * @param events Event name as a string or array.
+ * @param handler If specified, function that, for a given event, returns the
+ * actual event handler as an object or an array of objects.
+ * If handler is not specified, the actual event handler is
+ * specified using the scope and name pair.
+ * @param scope See handler.
+ * @param name See handler.
+ * @param options Options for addEventListener.
+ */
+ addLazyEventListener(target, events, { handler, scope, name, options }) {
+ this._addLazyListeners(
+ events,
+ handler,
+ scope,
+ name,
+ (events, listener) => {
+ events.forEach(event =>
+ target.addEventListener(event, listener, options)
+ );
+ },
+ (handlers, listener, args) => {
+ if (!options || !options.once) {
+ target.removeEventListener(args[0].type, listener, options);
+ handlers.forEach(handler =>
+ target.addEventListener(args[0].type, handler, options)
+ );
+ }
+ handlers.forEach(handler => handler.handleEvent(args[0]));
+ }
+ );
+ },
+
+ /**
+ * Add lazy pref observers, and only load the actual handler once the pref
+ * value changes from default, and every time the pref value changes
+ * afterwards.
+ *
+ * @param aPrefs Prefs as an object or array. Each pref object has fields
+ * "name" and "default", indicating the name and default value
+ * of the pref, respectively.
+ * @param handler If specified, function that, for a given pref, returns the
+ * actual event handler as an object or an array of objects.
+ * If handler is not specified, the actual event handler is
+ * specified using the scope and name pair.
+ * @param scope See handler.
+ * @param name See handler.
+ * @param once If true, only observe the specified prefs once.
+ */
+ addLazyPrefObserver(aPrefs, { handler, scope, name, once }) {
+ this._addLazyListeners(
+ aPrefs,
+ handler,
+ scope,
+ name,
+ (prefs, observer) => {
+ prefs.forEach(pref => Services.prefs.addObserver(pref.name, observer));
+ prefs.forEach(pref => {
+ if (pref.default === undefined) {
+ return;
+ }
+ let value;
+ switch (typeof pref.default) {
+ case "string":
+ value = Services.prefs.getCharPref(pref.name, pref.default);
+ break;
+ case "number":
+ value = Services.prefs.getIntPref(pref.name, pref.default);
+ break;
+ case "boolean":
+ value = Services.prefs.getBoolPref(pref.name, pref.default);
+ break;
+ }
+ if (pref.default !== value) {
+ // Notify observer if value already changed from default.
+ observer(Services.prefs, "nsPref:changed", pref.name);
+ }
+ });
+ },
+ (handlers, observer, args) => {
+ if (!once) {
+ Services.prefs.removeObserver(args[2], observer);
+ handlers.forEach(handler =>
+ Services.prefs.addObserver(args[2], observer)
+ );
+ }
+ handlers.forEach(handler => handler.observe(...args));
+ }
+ );
+ },
+
+ getRootDocShell(aWin) {
+ if (!aWin) {
+ return null;
+ }
+ let docShell;
+ try {
+ docShell = aWin.QueryInterface(Ci.nsIDocShell);
+ } catch (e) {
+ docShell = aWin.docShell;
+ }
+ return docShell.rootTreeItem.QueryInterface(Ci.nsIInterfaceRequestor);
+ },
+
+ /**
+ * Return the outermost chrome DOM window (the XUL window) for a given DOM
+ * window, in the parent process.
+ *
+ * @param aWin a DOM window.
+ */
+ getChromeWindow(aWin) {
+ const docShell = this.getRootDocShell(aWin);
+ return docShell && docShell.domWindow;
+ },
+
+ /**
+ * Return the content frame message manager (aka the frame script global
+ * object) for a given DOM window, in a child process.
+ *
+ * @param aWin a DOM window.
+ */
+ getContentFrameMessageManager(aWin) {
+ const docShell = this.getRootDocShell(aWin);
+ return docShell && docShell.getInterface(Ci.nsIBrowserChild).messageManager;
+ },
+
+ /**
+ * Return the per-nsWindow EventDispatcher for a given DOM window, in either
+ * the parent process or a child process.
+ *
+ * @param aWin a DOM window.
+ */
+ getDispatcherForWindow(aWin) {
+ try {
+ if (!this.IS_PARENT_PROCESS) {
+ const mm = this.getContentFrameMessageManager(aWin.top || aWin);
+ return mm && lazy.EventDispatcher.forMessageManager(mm);
+ }
+ const win = this.getChromeWindow(aWin.top || aWin);
+ if (!win.closed) {
+ return win.WindowEventDispatcher || lazy.EventDispatcher.for(win);
+ }
+ } catch (e) {}
+ return null;
+ },
+
+ /**
+ * Return promise for waiting for finishing PanZoomState.
+ *
+ * @param aWindow a DOM window.
+ * @return promise
+ */
+ waitForPanZoomState(aWindow) {
+ return new Promise((resolve, reject) => {
+ if (
+ !aWindow?.windowUtils.asyncPanZoomEnabled ||
+ !Services.prefs.getBoolPref("apz.zoom-to-focused-input.enabled")
+ ) {
+ // No zoomToFocusedInput.
+ resolve();
+ return;
+ }
+
+ let timerId = 0;
+
+ const panZoomState = (aSubject, aTopic, aData) => {
+ if (timerId != 0) {
+ // aWindow may be dead object now.
+ try {
+ lazy.clearTimeout(timerId);
+ } catch (e) {}
+ timerId = 0;
+ }
+
+ if (aData === "NOTHING") {
+ Services.obs.removeObserver(panZoomState, "PanZoom:StateChange");
+ resolve();
+ }
+ };
+
+ Services.obs.addObserver(panZoomState, "PanZoom:StateChange");
+
+ // "GeckoView:ZoomToInput" has the timeout as 500ms when window isn't
+ // resized (it means on-screen-keyboard is already shown).
+ // So after up to 500ms, APZ event is sent. So we need to wait for more
+ // 500ms.
+ timerId = lazy.setTimeout(() => {
+ // PanZoom state isn't changed. zoomToFocusedInput will return error.
+ Services.obs.removeObserver(panZoomState, "PanZoom:StateChange");
+ reject();
+ }, 600);
+ });
+ },
+
+ /**
+ * Add logging functions to the specified scope that forward to the given
+ * Log.sys.mjs logger. Currently "debug" and "warn" functions are supported. To
+ * log something, call the function through a template literal:
+ *
+ * function foo(bar, baz) {
+ * debug `hello world`;
+ * debug `foo called with ${bar} as bar`;
+ * warn `this is a warning for ${baz}`;
+ * }
+ *
+ * An inline format can also be used for logging:
+ *
+ * let bar = 42;
+ * do_something(bar); // No log.
+ * do_something(debug.foo = bar); // Output "foo = 42" to the log.
+ *
+ * @param aTag Name of the Log.jsm logger to forward logs to.
+ * @param aScope Scope to add the logging functions to.
+ */
+ initLogging(aTag, aScope) {
+ aScope = aScope || {};
+ const tag = "GeckoView." + aTag.replace(/^GeckoView\.?/, "");
+
+ // Only provide two levels for simplicity.
+ // For "info", use "debug" instead.
+ // For "error", throw an actual JS error instead.
+ for (const level of ["DEBUG", "WARN"]) {
+ const log = (strings, ...exprs) =>
+ this._log(log.logger, level, strings, exprs);
+
+ ChromeUtils.defineLazyGetter(log, "logger", _ => {
+ const logger = Log.repository.getLogger(tag);
+ logger.parent = this.rootLogger;
+ return logger;
+ });
+
+ aScope[level.toLowerCase()] = new Proxy(log, {
+ set: (obj, prop, value) => obj([prop + " = ", ""], value) || true,
+ });
+ }
+ return aScope;
+ },
+
+ get rootLogger() {
+ if (!this._rootLogger) {
+ this._rootLogger = Log.repository.getLogger("GeckoView");
+ this._rootLogger.addAppender(new AndroidAppender());
+ this._rootLogger.manageLevelFromPref("geckoview.logging");
+ }
+ return this._rootLogger;
+ },
+
+ _log(aLogger, aLevel, aStrings, aExprs) {
+ if (!Array.isArray(aStrings)) {
+ const [, file, line] = new Error().stack.match(/.*\n.*\n.*@(.*):(\d+):/);
+ throw Error(
+ `Expecting template literal: ${aLevel} \`foo \${bar}\``,
+ file,
+ +line
+ );
+ }
+
+ if (aLogger.level > Log.Level.Numbers[aLevel]) {
+ // Log disabled.
+ return;
+ }
+
+ // Do some GeckoView-specific formatting:
+ // * Remove newlines so long log lines can be put into multiple lines:
+ // debug `foo=${foo}
+ // bar=${bar}`;
+ const strs = Array.from(aStrings);
+ const regex = /\n\s*/g;
+ for (let i = 0; i < strs.length; i++) {
+ strs[i] = strs[i].replace(regex, " ");
+ }
+
+ // * Heuristically format flags as hex.
+ // * Heuristically format nsresult as string name or hex.
+ for (let i = 0; i < aExprs.length; i++) {
+ const expr = aExprs[i];
+ switch (typeof expr) {
+ case "number":
+ if (expr > 0 && /\ba?[fF]lags?[\s=:]+$/.test(strs[i])) {
+ // Likely a flag; display in hex.
+ aExprs[i] = `0x${expr.toString(0x10)}`;
+ } else if (expr >= 0 && /\b(a?[sS]tatus|rv)[\s=:]+$/.test(strs[i])) {
+ // Likely an nsresult; display in name or hex.
+ aExprs[i] = `0x${expr.toString(0x10)}`;
+ for (const name in Cr) {
+ if (expr === Cr[name]) {
+ aExprs[i] = name;
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ aLogger[aLevel.toLowerCase()](strs, ...aExprs);
+ },
+
+ /**
+ * Checks whether the principal is supported for permissions.
+ *
+ * @param {nsIPrincipal} principal
+ * The principal to check.
+ *
+ * @return {boolean} if the principal is supported.
+ */
+ isSupportedPermissionsPrincipal(principal) {
+ if (!principal) {
+ return false;
+ }
+ if (!(principal instanceof Ci.nsIPrincipal)) {
+ throw new Error(
+ "Argument passed as principal is not an instance of Ci.nsIPrincipal"
+ );
+ }
+ return this.isSupportedPermissionsScheme(principal.scheme);
+ },
+
+ /**
+ * Checks whether we support managing permissions for a specific scheme.
+ * @param {string} scheme - Scheme to test.
+ * @returns {boolean} Whether the scheme is supported.
+ */
+ isSupportedPermissionsScheme(scheme) {
+ return ["http", "https", "moz-extension", "file"].includes(scheme);
+ },
+};
+
+ChromeUtils.defineLazyGetter(
+ GeckoViewUtils,
+ "IS_PARENT_PROCESS",
+ _ => Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT
+);
diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs
new file mode 100644
index 0000000000..ae821a3656
--- /dev/null
+++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs
@@ -0,0 +1,1367 @@
+/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const PRIVATE_BROWSING_PERMISSION = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+};
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ ExtensionProcessCrashObserver: "resource://gre/modules/Extension.sys.mjs",
+ GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const { debug, warn } = GeckoViewUtils.initLogging("Console");
+
+export var DownloadTracker = new (class extends EventEmitter {
+ constructor() {
+ super();
+
+ // maps numeric IDs to DownloadItem objects
+ this._downloads = new Map();
+ }
+
+ onEvent(event, data, callback) {
+ switch (event) {
+ case "GeckoView:WebExtension:DownloadChanged": {
+ const downloadItem = this.getDownloadItemById(data.downloadItemId);
+
+ if (!downloadItem) {
+ callback.onError("Error: Trying to update unknown download");
+ return;
+ }
+
+ const delta = downloadItem.update(data);
+ if (delta) {
+ this.emit("download-changed", {
+ delta,
+ downloadItem,
+ });
+ }
+ }
+ }
+ }
+
+ addDownloadItem(item) {
+ this._downloads.set(item.id, item);
+ }
+
+ /**
+ * Finds and returns a DownloadItem with a certain numeric ID
+ *
+ * @param {number} id
+ * @returns {DownloadItem} download item
+ */
+ getDownloadItemById(id) {
+ return this._downloads.get(id);
+ }
+})();
+
+/** Provides common logic between page and browser actions */
+export class ExtensionActionHelper {
+ constructor({
+ tabTracker,
+ windowTracker,
+ tabContext,
+ properties,
+ extension,
+ }) {
+ this.tabTracker = tabTracker;
+ this.windowTracker = windowTracker;
+ this.tabContext = tabContext;
+ this.properties = properties;
+ this.extension = extension;
+ }
+
+ getTab(aTabId) {
+ if (aTabId !== null) {
+ return this.tabTracker.getTab(aTabId);
+ }
+ return null;
+ }
+
+ getWindow(aWindowId) {
+ if (aWindowId !== null) {
+ return this.windowTracker.getWindow(aWindowId);
+ }
+ return null;
+ }
+
+ extractProperties(aAction) {
+ const merged = {};
+ for (const p of this.properties) {
+ merged[p] = aAction[p];
+ }
+ return merged;
+ }
+
+ eventDispatcherFor(aTabId) {
+ if (!aTabId) {
+ return lazy.EventDispatcher.instance;
+ }
+
+ const windowId = lazy.GeckoViewTabBridge.tabIdToWindowId(aTabId);
+ const window = this.windowTracker.getWindow(windowId);
+ return window.WindowEventDispatcher;
+ }
+
+ sendRequest(aTabId, aData) {
+ return this.eventDispatcherFor(aTabId).sendRequest({
+ ...aData,
+ aTabId,
+ extensionId: this.extension.id,
+ });
+ }
+}
+
+class EmbedderPort {
+ constructor(portId, messenger) {
+ this.id = portId;
+ this.messenger = messenger;
+ this.dispatcher = lazy.EventDispatcher.byName(`port:${portId}`);
+ this.dispatcher.registerListener(this, [
+ "GeckoView:WebExtension:PortMessageFromApp",
+ "GeckoView:WebExtension:PortDisconnect",
+ ]);
+ }
+ close() {
+ this.dispatcher.unregisterListener(this, [
+ "GeckoView:WebExtension:PortMessageFromApp",
+ "GeckoView:WebExtension:PortDisconnect",
+ ]);
+ }
+ onPortDisconnect() {
+ this.dispatcher.sendRequest({
+ type: "GeckoView:WebExtension:Disconnect",
+ sender: this.sender,
+ });
+ this.close();
+ }
+ onPortMessage(holder) {
+ this.dispatcher.sendRequest({
+ type: "GeckoView:WebExtension:PortMessage",
+ data: holder.deserialize({}),
+ });
+ }
+ onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:PortMessageFromApp": {
+ const holder = new StructuredCloneHolder(
+ "GeckoView:WebExtension:PortMessageFromApp",
+ null,
+ aData.message
+ );
+ this.messenger.sendPortMessage(this.id, holder);
+ break;
+ }
+
+ case "GeckoView:WebExtension:PortDisconnect": {
+ this.messenger.sendPortDisconnect(this.id);
+ this.close();
+ break;
+ }
+ }
+ }
+}
+
+export class GeckoViewConnection {
+ constructor(sender, target, nativeApp, allowContentMessaging) {
+ this.sender = sender;
+ this.target = target;
+ this.nativeApp = nativeApp;
+ this.allowContentMessaging = allowContentMessaging;
+
+ if (!allowContentMessaging && sender.envType !== "addon_child") {
+ throw new Error(`Unexpected messaging sender: ${JSON.stringify(sender)}`);
+ }
+ }
+
+ get dispatcher() {
+ if (this.sender.envType === "addon_child") {
+ // If this is a WebExtension Page we will have a GeckoSession associated
+ // to it and thus a dispatcher.
+ const dispatcher = GeckoViewUtils.getDispatcherForWindow(
+ this.target.ownerGlobal
+ );
+ if (dispatcher) {
+ return dispatcher;
+ }
+
+ // No dispatcher means this message is coming from a background script,
+ // use the global event handler
+ return lazy.EventDispatcher.instance;
+ } else if (
+ this.sender.envType === "content_child" &&
+ this.allowContentMessaging
+ ) {
+ // If this message came from a content script, send the message to
+ // the corresponding tab messenger so that GeckoSession can pick it
+ // up.
+ return GeckoViewUtils.getDispatcherForWindow(this.target.ownerGlobal);
+ }
+
+ throw new Error(`Uknown sender envType: ${this.sender.envType}`);
+ }
+
+ _sendMessage({ type, portId, data }) {
+ const message = {
+ type,
+ sender: this.sender,
+ data,
+ portId,
+ extensionId: this.sender.id,
+ nativeApp: this.nativeApp,
+ };
+
+ return this.dispatcher.sendRequestForResult(message);
+ }
+
+ sendMessage(data) {
+ return this._sendMessage({
+ type: "GeckoView:WebExtension:Message",
+ data: data.deserialize({}),
+ });
+ }
+
+ onConnect(portId, messenger) {
+ const port = new EmbedderPort(portId, messenger);
+
+ this._sendMessage({
+ type: "GeckoView:WebExtension:Connect",
+ data: {},
+ portId: port.id,
+ });
+
+ return port;
+ }
+}
+
+async function filterPromptPermissions(aPermissions) {
+ if (!aPermissions) {
+ return [];
+ }
+ const promptPermissions = [];
+ for (const permission of aPermissions) {
+ if (!(await lazy.Extension.shouldPromptFor(permission))) {
+ continue;
+ }
+ promptPermissions.push(permission);
+ }
+ return promptPermissions;
+}
+
+// Keep in sync with WebExtension.java
+const FLAG_NONE = 0;
+const FLAG_ALLOW_CONTENT_MESSAGING = 1 << 0;
+
+function exportFlags(aPolicy) {
+ let flags = FLAG_NONE;
+ if (!aPolicy) {
+ return flags;
+ }
+ const { extension } = aPolicy;
+ if (extension.hasPermission("nativeMessagingFromContent")) {
+ flags |= FLAG_ALLOW_CONTENT_MESSAGING;
+ }
+ return flags;
+}
+
+async function exportExtension(aAddon, aPermissions, aSourceURI) {
+ // First, let's make sure the policy is ready if present
+ let policy = WebExtensionPolicy.getByID(aAddon.id);
+ if (policy?.readyPromise) {
+ policy = await policy.readyPromise;
+ }
+ const {
+ amoListingURL,
+ averageRating,
+ blocklistState,
+ creator,
+ description,
+ embedderDisabled,
+ fullDescription,
+ homepageURL,
+ icons,
+ id,
+ incognito,
+ isActive,
+ isBuiltin,
+ isCorrectlySigned,
+ isRecommended,
+ name,
+ optionsType,
+ optionsURL,
+ reviewCount,
+ reviewURL,
+ signedState,
+ sourceURI,
+ temporarilyInstalled,
+ userDisabled,
+ version,
+ } = aAddon;
+ let creatorName = null;
+ let creatorURL = null;
+ if (creator) {
+ const { name, url } = creator;
+ creatorName = name;
+ creatorURL = url;
+ }
+ const openOptionsPageInTab =
+ optionsType === lazy.AddonManager.OPTIONS_TYPE_TAB;
+ const disabledFlags = [];
+ if (userDisabled) {
+ disabledFlags.push("userDisabled");
+ }
+ if (blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ disabledFlags.push("blocklistDisabled");
+ }
+ if (embedderDisabled) {
+ disabledFlags.push("appDisabled");
+ }
+ // Add-ons without an `isCorrectlySigned` property are correctly signed as
+ // they aren't the correct type for signing.
+ if (lazy.AddonSettings.REQUIRE_SIGNING && isCorrectlySigned === false) {
+ disabledFlags.push("signatureDisabled");
+ }
+ if (lazy.AddonManager.checkCompatibility && !aAddon.isCompatible) {
+ disabledFlags.push("appVersionDisabled");
+ }
+ const baseURL = policy ? policy.getURL() : "";
+ const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false;
+ const promptPermissions = aPermissions
+ ? await filterPromptPermissions(aPermissions.permissions)
+ : [];
+
+ let updateDate;
+ try {
+ updateDate = aAddon.updateDate?.toISOString();
+ } catch {
+ // `installDate` is used as a fallback for `updateDate` but only when the
+ // add-on is installed. Before that, `installDate` might be undefined,
+ // which would cause `updateDate` (and `installDate`) to be an "invalid
+ // date".
+ updateDate = null;
+ }
+
+ return {
+ webExtensionId: id,
+ locationURI: aSourceURI != null ? aSourceURI.spec : "",
+ isBuiltIn: isBuiltin,
+ webExtensionFlags: exportFlags(policy),
+ metaData: {
+ amoListingURL,
+ averageRating,
+ baseURL,
+ blocklistState,
+ creatorName,
+ creatorURL,
+ description,
+ disabledFlags,
+ downloadUrl: sourceURI?.displaySpec,
+ enabled: isActive,
+ fullDescription,
+ homepageURL,
+ icons,
+ incognito,
+ isRecommended,
+ name,
+ openOptionsPageInTab,
+ optionsPageURL: optionsURL,
+ origins: aPermissions ? aPermissions.origins : [],
+ privateBrowsingAllowed,
+ promptPermissions,
+ reviewCount,
+ reviewURL,
+ signedState,
+ temporary: temporarilyInstalled,
+ updateDate,
+ version,
+ },
+ };
+}
+
+class ExtensionInstallListener {
+ constructor(aResolve, aInstall, aInstallId) {
+ this.install = aInstall;
+ this.installId = aInstallId;
+ this.resolve = result => {
+ aResolve(result);
+ lazy.EventDispatcher.instance.unregisterListener(this, [
+ "GeckoView:WebExtension:CancelInstall",
+ ]);
+ };
+ lazy.EventDispatcher.instance.registerListener(this, [
+ "GeckoView:WebExtension:CancelInstall",
+ ]);
+ }
+
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:CancelInstall": {
+ const { installId } = aData;
+ if (this.installId !== installId) {
+ return;
+ }
+ this.cancelling = true;
+ let cancelled = false;
+ try {
+ this.install.cancel();
+ cancelled = true;
+ } catch (ex) {
+ // install may have already failed or been cancelled
+ debug`Unable to cancel the install installId ${installId}, Error: ${ex}`;
+ // When we attempt to cancel an install but the cancellation fails for
+ // some reasons (e.g., because it is too late), we need to revert this
+ // boolean property to allow another cancellation to be possible.
+ // Otherwise, events like `onDownloadCancelled` won't resolve and that
+ // will cause problems in the embedder.
+ this.cancelling = false;
+ }
+ aCallback.onSuccess({ cancelled });
+ break;
+ }
+ }
+ }
+
+ onDownloadCancelled(aInstall) {
+ debug`onDownloadCancelled state=${aInstall.state}`;
+ // Do not resolve we were told to CancelInstall,
+ // to prevent racing with that handler.
+ if (!this.cancelling) {
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+ }
+
+ onDownloadFailed(aInstall) {
+ debug`onDownloadFailed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ onDownloadEnded() {
+ // Nothing to do
+ }
+
+ onInstallCancelled(aInstall, aCancelledByUser) {
+ debug`onInstallCancelled state=${aInstall.state} cancelledByUser=${aCancelledByUser}`;
+ // Do not resolve we were told to CancelInstall,
+ // to prevent racing with that handler.
+ if (!this.cancelling) {
+ const { error: installError, state } = aInstall;
+ // An install can be cancelled by the user OR something else, e.g. when
+ // the blocklist prevents the install of a blocked add-on.
+ this.resolve({ installError, state, cancelledByUser: aCancelledByUser });
+ }
+ }
+
+ onInstallFailed(aInstall) {
+ debug`onInstallFailed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ onInstallPostponed(aInstall) {
+ debug`onInstallPostponed state=${aInstall.state}`;
+ const { error: installError, state } = aInstall;
+ this.resolve({ installError, state });
+ }
+
+ async onInstallEnded(aInstall, aAddon) {
+ debug`onInstallEnded addonId=${aAddon.id}`;
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ aInstall.sourceURI
+ );
+ this.resolve({ extension });
+ }
+}
+
+class ExtensionPromptObserver {
+ constructor() {
+ Services.obs.addObserver(this, "webextension-permission-prompt");
+ Services.obs.addObserver(this, "webextension-optional-permission-prompt");
+ }
+
+ async permissionPrompt(aInstall, aAddon, aInfo) {
+ const { sourceURI } = aInstall;
+ const { permissions } = aInfo;
+ const extension = await exportExtension(aAddon, permissions, sourceURI);
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:InstallPrompt",
+ extension,
+ });
+
+ if (response.allow) {
+ aInfo.resolve();
+ } else {
+ aInfo.reject();
+ }
+ }
+
+ async optionalPermissionPrompt(aExtensionId, aPermissions, resolve) {
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:OptionalPrompt",
+ extensionId: aExtensionId,
+ permissions: aPermissions,
+ });
+ resolve(response.allow);
+ }
+
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+
+ switch (aTopic) {
+ case "webextension-permission-prompt": {
+ const { info } = aSubject.wrappedJSObject;
+ const { addon, install } = info;
+ this.permissionPrompt(install, addon, info);
+ break;
+ }
+ case "webextension-optional-permission-prompt": {
+ const { id, permissions, resolve } = aSubject.wrappedJSObject;
+ this.optionalPermissionPrompt(id, permissions, resolve);
+ break;
+ }
+ }
+ }
+}
+
+class AddonInstallObserver {
+ constructor() {
+ Services.obs.addObserver(this, "addon-install-failed");
+ }
+
+ async onInstallationFailed(aAddon, aAddonName, aError) {
+ // aAddon could be null if we have a network error where we can't download the xpi file.
+ // aAddon could also be a valid object without an ID when the xpi file is corrupt.
+ let extension = null;
+ if (aAddon?.id) {
+ extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ }
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstallationFailed",
+ extension,
+ addonName: aAddonName,
+ error: aError,
+ });
+ }
+
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+ switch (aTopic) {
+ case "addon-install-failed": {
+ aSubject.wrappedJSObject.installs.forEach(install => {
+ const { addon, error, name } = install;
+ // For some errors, we have a valid `addon` but not the `name` set on
+ // the `install` object yet so we check both here.
+ const addonName = name || addon?.name;
+
+ this.onInstallationFailed(addon, addonName, error);
+ });
+ break;
+ }
+ }
+ }
+}
+
+new ExtensionPromptObserver();
+new AddonInstallObserver();
+
+class AddonManagerListener {
+ constructor() {
+ lazy.AddonManager.addAddonListener(this);
+ // Some extension properties are not going to be available right away after the extension
+ // have been installed (e.g. in particular metaData.optionsPageURL), the GeckoView event
+ // dispatched from onExtensionReady listener will be providing updated extension metadata to
+ // the GeckoView side when it is actually going to be available.
+ this.onExtensionReady = this.onExtensionReady.bind(this);
+ lazy.Management.on("ready", this.onExtensionReady);
+ }
+
+ async onExtensionReady(name, extInstance) {
+ // In xpcshell tests there wil be test extensions that trigger this event while the
+ // AddonManager has not been started at all, on the contrary on a regular browser
+ // instance the AddonManager is expected to be already fully started for an extension
+ // for the extension to be able to reach the "ready" state, and so we just silently
+ // early exit here if the AddonManager is not ready.
+ if (!lazy.AddonManager.isReady) {
+ return;
+ }
+
+ debug`onExtensionReady ${extInstance.id}`;
+
+ const addonWrapper = await lazy.AddonManager.getAddonByID(extInstance.id);
+ if (!addonWrapper) {
+ return;
+ }
+
+ const extension = await exportExtension(
+ addonWrapper,
+ addonWrapper.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnReady",
+ extension,
+ });
+ }
+
+ async onDisabling(aAddon) {
+ debug`onDisabling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabling",
+ extension,
+ });
+ }
+
+ async onDisabled(aAddon) {
+ debug`onDisabled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabled",
+ extension,
+ });
+ }
+
+ async onEnabling(aAddon) {
+ debug`onEnabling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnEnabling",
+ extension,
+ });
+ }
+
+ async onEnabled(aAddon) {
+ debug`onEnabled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnEnabled",
+ extension,
+ });
+ }
+
+ async onUninstalling(aAddon) {
+ debug`onUninstalling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnUninstalling",
+ extension,
+ });
+ }
+
+ async onUninstalled(aAddon) {
+ debug`onUninstalled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnUninstalled",
+ extension,
+ });
+ }
+
+ async onInstalling(aAddon) {
+ debug`onInstalling ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstalling",
+ extension,
+ });
+ }
+
+ async onInstalled(aAddon) {
+ debug`onInstalled ${aAddon.id}`;
+
+ const extension = await exportExtension(
+ aAddon,
+ aAddon.userPermissions,
+ /* aSourceURI */ null
+ );
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnInstalled",
+ extension,
+ });
+ }
+}
+
+new AddonManagerListener();
+
+class ExtensionProcessListener {
+ constructor() {
+ this.onExtensionProcessCrash = this.onExtensionProcessCrash.bind(this);
+ lazy.Management.on("extension-process-crash", this.onExtensionProcessCrash);
+
+ lazy.EventDispatcher.instance.registerListener(this, [
+ "GeckoView:WebExtension:EnableProcessSpawning",
+ "GeckoView:WebExtension:DisableProcessSpawning",
+ ]);
+ }
+
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:WebExtension:EnableProcessSpawning": {
+ debug`Extension process crash -> re-enable process spawning`;
+ lazy.ExtensionProcessCrashObserver.enableProcessSpawning();
+ break;
+ }
+ }
+ }
+
+ async onExtensionProcessCrash(name, { childID, processSpawningDisabled }) {
+ debug`Extension process crash -> childID=${childID} processSpawningDisabled=${processSpawningDisabled}`;
+
+ // When an extension process has crashed too many times, Gecko will set
+ // `processSpawningDisabled` and no longer allow the extension process
+ // spawning. We only want to send a request to the embedder when we are
+ // disabling the process spawning. If process spawning is still enabled
+ // then we short circuit and don't notify the embedder.
+ if (!processSpawningDisabled) {
+ return;
+ }
+
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:OnDisabledProcessSpawning",
+ });
+ }
+}
+
+new ExtensionProcessListener();
+
+class MobileWindowTracker extends EventEmitter {
+ constructor() {
+ super();
+ this._topWindow = null;
+ this._topNonPBWindow = null;
+ }
+
+ get topWindow() {
+ if (this._topWindow) {
+ return this._topWindow.get();
+ }
+ return null;
+ }
+
+ get topNonPBWindow() {
+ if (this._topNonPBWindow) {
+ return this._topNonPBWindow.get();
+ }
+ return null;
+ }
+
+ setTabActive(aWindow, aActive) {
+ const { browser, tab: nativeTab, docShell } = aWindow;
+ nativeTab.active = aActive;
+
+ if (aActive) {
+ this._topWindow = Cu.getWeakReference(aWindow);
+ const isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
+ if (!isPrivate) {
+ this._topNonPBWindow = this._topWindow;
+ }
+ this.emit("tab-activated", {
+ windowId: docShell.outerWindowID,
+ tabId: nativeTab.id,
+ isPrivate,
+ nativeTab,
+ });
+ }
+ }
+}
+
+export var mobileWindowTracker = new MobileWindowTracker();
+
+async function updatePromptHandler(aInfo) {
+ const oldPerms = aInfo.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, let it proceed
+ return;
+ }
+
+ const newPerms = aInfo.addon.userPermissions;
+
+ const difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
+
+ // We only care about permissions that we can prompt the user for
+ const newPermissions = await filterPromptPermissions(difference.permissions);
+ const { origins: newOrigins } = difference;
+
+ // If there are no new permissions, just proceed
+ if (!newOrigins.length && !newPermissions.length) {
+ return;
+ }
+
+ const currentlyInstalled = await exportExtension(
+ aInfo.existingAddon,
+ oldPerms
+ );
+ const updatedExtension = await exportExtension(aInfo.addon, newPerms);
+ const response = await lazy.EventDispatcher.instance.sendRequestForResult({
+ type: "GeckoView:WebExtension:UpdatePrompt",
+ currentlyInstalled,
+ updatedExtension,
+ newPermissions,
+ newOrigins,
+ });
+
+ if (!response.allow) {
+ throw new Error("Extension update rejected.");
+ }
+}
+
+export var GeckoViewWebExtension = {
+ observe(aSubject, aTopic, aData) {
+ debug`observe ${aTopic}`;
+
+ switch (aTopic) {
+ case "testing-installed-addon":
+ case "testing-uninstalled-addon": {
+ // We pretend devtools installed/uninstalled this addon so we don't
+ // have to add an API just for internal testing.
+ // TODO: assert this is under a test
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:DebuggerListUpdated",
+ });
+ break;
+ }
+
+ case "devtools-installed-addon": {
+ lazy.EventDispatcher.instance.sendRequest({
+ type: "GeckoView:WebExtension:DebuggerListUpdated",
+ });
+ break;
+ }
+ }
+ },
+
+ async extensionById(aId) {
+ const addon = await lazy.AddonManager.getAddonByID(aId);
+ if (!addon) {
+ debug`Could not find extension with id=${aId}`;
+ return null;
+ }
+ return addon;
+ },
+
+ async ensureBuiltIn(aUri, aId) {
+ await lazy.AddonManager.readyPromise;
+ // Although the add-on is privileged in practice due to it being installed
+ // as a built-in extension, we pass isPrivileged=false since the exact flag
+ // doesn't matter as we are only using ExtensionData to read the version.
+ const extensionData = new lazy.ExtensionData(aUri, false);
+ const [extensionVersion, extension] = await Promise.all([
+ extensionData.getExtensionVersionWithoutValidation(),
+ this.extensionById(aId),
+ ]);
+
+ if (!extension || extensionVersion != extension.version) {
+ return this.installBuiltIn(aUri);
+ }
+
+ const exported = await exportExtension(
+ extension,
+ extension.userPermissions,
+ aUri
+ );
+ return { extension: exported };
+ },
+
+ async installBuiltIn(aUri) {
+ await lazy.AddonManager.readyPromise;
+ const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec);
+ const exported = await exportExtension(addon, addon.userPermissions, aUri);
+ return { extension: exported };
+ },
+
+ async installWebExtension(aInstallId, aUri, installMethod) {
+ const install = await lazy.AddonManager.getInstallForURL(aUri.spec, {
+ telemetryInfo: {
+ source: "geckoview-app",
+ method: installMethod || undefined,
+ },
+ });
+ const promise = new Promise(resolve => {
+ install.addListener(
+ new ExtensionInstallListener(resolve, install, aInstallId)
+ );
+ });
+
+ lazy.AddonManager.installAddonFromAOM(null, aUri, install);
+
+ return promise;
+ },
+
+ async setPrivateBrowsingAllowed(aId, aAllowed) {
+ if (aAllowed) {
+ await lazy.ExtensionPermissions.add(aId, PRIVATE_BROWSING_PERMISSION);
+ } else {
+ await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMISSION);
+ }
+
+ // Reload the extension if it is already enabled. This ensures any change
+ // on the private browsing permission is properly handled.
+ const addon = await this.extensionById(aId);
+ if (addon.isActive) {
+ await addon.reload();
+ }
+
+ return exportExtension(addon, addon.userPermissions, /* aSourceURI */ null);
+ },
+
+ async uninstallWebExtension(aId) {
+ const extension = await this.extensionById(aId);
+ if (!extension) {
+ throw new Error(`Could not find an extension with id='${aId}'.`);
+ }
+
+ return extension.uninstall();
+ },
+
+ async browserActionClick(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ return undefined;
+ }
+
+ const browserAction = this.browserActions.get(policy.extension);
+ if (!browserAction) {
+ return undefined;
+ }
+
+ return browserAction.triggerClickOrPopup();
+ },
+
+ async pageActionClick(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ return undefined;
+ }
+
+ const pageAction = this.pageActions.get(policy.extension);
+ if (!pageAction) {
+ return undefined;
+ }
+
+ return pageAction.triggerClickOrPopup();
+ },
+
+ async actionDelegateAttached(aId) {
+ const policy = WebExtensionPolicy.getByID(aId);
+ if (!policy) {
+ debug`Could not find extension with id=${aId}`;
+ return;
+ }
+
+ const { extension } = policy;
+
+ const browserAction = this.browserActions.get(extension);
+ if (browserAction) {
+ // Send information about this action to the delegate
+ browserAction.updateOnChange(null);
+ }
+
+ const pageAction = this.pageActions.get(extension);
+ if (pageAction) {
+ pageAction.updateOnChange(null);
+ }
+ },
+
+ async enableWebExtension(aId, aSource) {
+ const extension = await this.extensionById(aId);
+ if (aSource === "user") {
+ await extension.enable();
+ } else if (aSource === "app") {
+ await extension.setEmbedderDisabled(false);
+ }
+ return exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ );
+ },
+
+ async disableWebExtension(aId, aSource) {
+ const extension = await this.extensionById(aId);
+ if (aSource === "user") {
+ await extension.disable();
+ } else if (aSource === "app") {
+ await extension.setEmbedderDisabled(true);
+ }
+ return exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ );
+ },
+
+ /**
+ * @return A promise resolved with either an AddonInstall object if an update
+ * is available or null if no update is found.
+ */
+ checkForUpdate(aAddon) {
+ return new Promise(resolve => {
+ const listener = {
+ onUpdateAvailable(aAddon, install) {
+ install.promptHandler = updatePromptHandler;
+ resolve(install);
+ },
+ onNoUpdateAvailable() {
+ resolve(null);
+ },
+ };
+ aAddon.findUpdates(
+ listener,
+ lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+ },
+
+ async updateWebExtension(aId) {
+ // Refresh the cached metadata when necessary. This allows us to always
+ // export relatively recent metadata to the embedder.
+ if (lazy.AddonRepository.isMetadataStale()) {
+ // We use a promise to avoid more than one call to `backgroundUpdateCheck()`
+ // when `updateWebExtension()` is called for multiple add-ons in parallel.
+ if (!this._promiseAddonRepositoryUpdate) {
+ this._promiseAddonRepositoryUpdate =
+ lazy.AddonRepository.backgroundUpdateCheck().finally(() => {
+ this._promiseAddonRepositoryUpdate = null;
+ });
+ }
+ await this._promiseAddonRepositoryUpdate;
+ }
+
+ // Early-return when extension updates are disabled.
+ if (!lazy.AddonManager.updateEnabled) {
+ return null;
+ }
+
+ const extension = await this.extensionById(aId);
+
+ const install = await this.checkForUpdate(extension);
+ if (!install) {
+ return null;
+ }
+ const promise = new Promise(resolve => {
+ install.addListener(new ExtensionInstallListener(resolve));
+ });
+ install.install();
+ return promise;
+ },
+
+ validateBuiltInLocation(aLocationUri, aCallback) {
+ let uri;
+ try {
+ uri = Services.io.newURI(aLocationUri);
+ } catch (ex) {
+ aCallback.onError(`Could not parse uri: ${aLocationUri}. Error: ${ex}`);
+ return null;
+ }
+
+ if (uri.scheme !== "resource" || uri.host !== "android") {
+ aCallback.onError(`Only resource://android/... URIs are allowed.`);
+ return null;
+ }
+
+ if (uri.fileName !== "") {
+ aCallback.onError(
+ `This URI does not point to a folder. Note: folders URIs must end with a "/".`
+ );
+ return null;
+ }
+
+ return uri;
+ },
+
+ /* eslint-disable complexity */
+ async onEvent(aEvent, aData, aCallback) {
+ debug`onEvent ${aEvent} ${aData}`;
+
+ switch (aEvent) {
+ case "GeckoView:BrowserAction:Click": {
+ const popupUrl = await this.browserActionClick(aData.extensionId);
+ aCallback.onSuccess(popupUrl);
+ break;
+ }
+ case "GeckoView:PageAction:Click": {
+ const popupUrl = await this.pageActionClick(aData.extensionId);
+ aCallback.onSuccess(popupUrl);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuClick": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuShow": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+ case "GeckoView:WebExtension:MenuHide": {
+ aCallback.onError(`Not implemented`);
+ break;
+ }
+
+ case "GeckoView:ActionDelegate:Attached": {
+ this.actionDelegateAttached(aData.extensionId);
+ break;
+ }
+
+ case "GeckoView:WebExtension:Get": {
+ const extension = await this.extensionById(aData.extensionId);
+ if (!extension) {
+ aCallback.onError(
+ `Could not find extension with id: ${aData.extensionId}`
+ );
+ return;
+ }
+
+ aCallback.onSuccess({
+ extension: await exportExtension(
+ extension,
+ extension.userPermissions,
+ /* aSourceURI */ null
+ ),
+ });
+ break;
+ }
+
+ case "GeckoView:WebExtension:SetPBAllowed": {
+ const { extensionId, allowed } = aData;
+ try {
+ const extension = await this.setPrivateBrowsingAllowed(
+ extensionId,
+ allowed
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Install": {
+ const { locationUri, installId, installMethod } = aData;
+ let uri;
+ try {
+ uri = Services.io.newURI(locationUri);
+ } catch (ex) {
+ aCallback.onError(`Could not parse uri: ${locationUri}`);
+ return;
+ }
+
+ try {
+ const result = await this.installWebExtension(
+ installId,
+ uri,
+ installMethod
+ );
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:EnsureBuiltIn": {
+ const { locationUri, webExtensionId } = aData;
+ const uri = this.validateBuiltInLocation(locationUri, aCallback);
+ if (!uri) {
+ return;
+ }
+
+ try {
+ const result = await this.ensureBuiltIn(uri, webExtensionId);
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:InstallBuiltIn": {
+ const uri = this.validateBuiltInLocation(aData.locationUri, aCallback);
+ if (!uri) {
+ return;
+ }
+
+ try {
+ const result = await this.installBuiltIn(uri);
+ if (result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Install exception error ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+
+ break;
+ }
+
+ case "GeckoView:WebExtension:Uninstall": {
+ try {
+ await this.uninstallWebExtension(aData.webExtensionId);
+ aCallback.onSuccess();
+ } catch (ex) {
+ debug`Failed uninstall ${ex}`;
+ aCallback.onError(
+ `This extension cannot be uninstalled. Error: ${ex}.`
+ );
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Enable": {
+ try {
+ const { source, webExtensionId } = aData;
+ if (source !== "user" && source !== "app") {
+ throw new Error("Illegal source parameter");
+ }
+ const extension = await this.enableWebExtension(
+ webExtensionId,
+ source
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ debug`Failed enable ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Disable": {
+ try {
+ const { source, webExtensionId } = aData;
+ if (source !== "user" && source !== "app") {
+ throw new Error("Illegal source parameter");
+ }
+ const extension = await this.disableWebExtension(
+ webExtensionId,
+ source
+ );
+ aCallback.onSuccess({ extension });
+ } catch (ex) {
+ debug`Failed disable ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:List": {
+ try {
+ await lazy.AddonManager.readyPromise;
+ const addons = await lazy.AddonManager.getAddonsByTypes([
+ "extension",
+ ]);
+ const extensions = await Promise.all(
+ addons.map(addon =>
+ exportExtension(addon, addon.userPermissions, null)
+ )
+ );
+
+ aCallback.onSuccess({ extensions });
+ } catch (ex) {
+ debug`Failed list ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+
+ case "GeckoView:WebExtension:Update": {
+ try {
+ const { webExtensionId } = aData;
+ const result = await this.updateWebExtension(webExtensionId);
+ if (result === null || result.extension) {
+ aCallback.onSuccess(result);
+ } else {
+ aCallback.onError(result);
+ }
+ } catch (ex) {
+ debug`Failed update ${ex}`;
+ aCallback.onError(`Unexpected error: ${ex}`);
+ }
+ break;
+ }
+ }
+ },
+};
+
+// WeakMap[Extension -> BrowserAction]
+GeckoViewWebExtension.browserActions = new WeakMap();
+// WeakMap[Extension -> PageAction]
+GeckoViewWebExtension.pageActions = new WeakMap();
diff --git a/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs
new file mode 100644
index 0000000000..5769fcafe8
--- /dev/null
+++ b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs
@@ -0,0 +1,99 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* 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";
+
+const { debug, warn } = GeckoViewUtils.initLogging("LoadURIDelegate");
+
+export const LoadURIDelegate = {
+ // Delegate URI loading to the app.
+ // Return whether the loading has been handled.
+ load(aWindow, aEventDispatcher, aUri, aWhere, aFlags, aTriggeringPrincipal) {
+ if (!aWindow) {
+ return false;
+ }
+
+ const triggerUri =
+ aTriggeringPrincipal &&
+ (aTriggeringPrincipal.isNullPrincipal ? null : aTriggeringPrincipal.URI);
+
+ const message = {
+ type: "GeckoView:OnLoadRequest",
+ uri: aUri ? aUri.displaySpec : "",
+ where: aWhere,
+ flags: aFlags,
+ triggerUri: triggerUri && triggerUri.displaySpec,
+ hasUserGesture: aWindow.document.hasValidTransientUserGestureActivation,
+ };
+
+ let handled = undefined;
+ aEventDispatcher.sendRequestForResult(message).then(
+ response => {
+ handled = response;
+ },
+ () => {
+ // There was an error or listener was not registered in GeckoSession,
+ // treat as unhandled.
+ handled = false;
+ }
+ );
+ Services.tm.spinEventLoopUntil(
+ "LoadURIDelegate.jsm:load",
+ () => aWindow.closed || handled !== undefined
+ );
+
+ return handled || false;
+ },
+
+ handleLoadError(aWindow, aEventDispatcher, aUri, aError, aErrorModule) {
+ let errorClass = 0;
+ try {
+ const nssErrorsService = Cc[
+ "@mozilla.org/nss_errors_service;1"
+ ].getService(Ci.nsINSSErrorsService);
+ errorClass = nssErrorsService.getErrorClass(aError);
+ } catch (e) {}
+
+ const msg = {
+ type: "GeckoView:OnLoadError",
+ uri: aUri && aUri.spec,
+ error: aError,
+ errorModule: aErrorModule,
+ errorClass,
+ };
+
+ let errorPageURI = undefined;
+ aEventDispatcher.sendRequestForResult(msg).then(
+ response => {
+ try {
+ errorPageURI = response ? Services.io.newURI(response) : null;
+ } catch (e) {
+ warn`Failed to parse URI '${response}`;
+ errorPageURI = null;
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ }
+ },
+ e => {
+ errorPageURI = null;
+ Components.returnCode = Cr.NS_ERROR_ABORT;
+ }
+ );
+ Services.tm.spinEventLoopUntil(
+ "LoadURIDelegate.jsm:handleLoadError",
+ () => aWindow.closed || errorPageURI !== undefined
+ );
+
+ return errorPageURI;
+ },
+
+ isSafeBrowsingError(aError) {
+ return (
+ aError === Cr.NS_ERROR_PHISHING_URI ||
+ aError === Cr.NS_ERROR_MALWARE_URI ||
+ aError === Cr.NS_ERROR_HARMFUL_URI ||
+ aError === Cr.NS_ERROR_UNWANTED_URI
+ );
+ },
+};
diff --git a/mobile/android/modules/geckoview/MediaUtils.sys.mjs b/mobile/android/modules/geckoview/MediaUtils.sys.mjs
new file mode 100644
index 0000000000..81dc35a567
--- /dev/null
+++ b/mobile/android/modules/geckoview/MediaUtils.sys.mjs
@@ -0,0 +1,79 @@
+/* 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/. */
+
+export const MediaUtils = {
+ getMetadata(aElement) {
+ if (!aElement) {
+ return null;
+ }
+ return {
+ src: aElement.currentSrc ?? aElement.src,
+ width: aElement.videoWidth ?? 0,
+ height: aElement.videoHeight ?? 0,
+ duration: aElement.duration,
+ seekable: !!aElement.seekable,
+ audioTrackCount:
+ aElement.audioTracks?.length ??
+ aElement.mozHasAudio ??
+ aElement.webkitAudioDecodedByteCount ??
+ MediaUtils.isAudioElement(aElement)
+ ? 1
+ : 0,
+ videoTrackCount:
+ aElement.videoTracks?.length ?? MediaUtils.isVideoElement(aElement)
+ ? 1
+ : 0,
+ };
+ },
+
+ isVideoElement(aElement) {
+ return (
+ aElement && ChromeUtils.getClassName(aElement) === "HTMLVideoElement"
+ );
+ },
+
+ isAudioElement(aElement) {
+ return (
+ aElement && ChromeUtils.getClassName(aElement) === "HTMLAudioElement"
+ );
+ },
+
+ isMediaElement(aElement) {
+ return (
+ MediaUtils.isVideoElement(aElement) || MediaUtils.isAudioElement(aElement)
+ );
+ },
+
+ findMediaElement(aElement) {
+ return (
+ MediaUtils.findVideoElement(aElement) ??
+ MediaUtils.findAudioElement(aElement)
+ );
+ },
+
+ findVideoElement(aElement) {
+ if (!aElement) {
+ return null;
+ }
+ if (MediaUtils.isVideoElement(aElement)) {
+ return aElement;
+ }
+ const childrenMedia = aElement.getElementsByTagName("video");
+ if (childrenMedia && childrenMedia.length) {
+ return childrenMedia[0];
+ }
+ return null;
+ },
+
+ findAudioElement(aElement) {
+ if (!aElement || MediaUtils.isAudioElement(aElement)) {
+ return aElement;
+ }
+ const childrenMedia = aElement.getElementsByTagName("audio");
+ if (childrenMedia && childrenMedia.length) {
+ return childrenMedia[0];
+ }
+ return null;
+ },
+};
diff --git a/mobile/android/modules/geckoview/Messaging.sys.mjs b/mobile/android/modules/geckoview/Messaging.sys.mjs
new file mode 100644
index 0000000000..e67161fede
--- /dev/null
+++ b/mobile/android/modules/geckoview/Messaging.sys.mjs
@@ -0,0 +1,319 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const IS_PARENT_PROCESS =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+class ChildActorDispatcher {
+ constructor(actor) {
+ this._actor = actor;
+ }
+
+ // TODO: Bug 1658980
+ registerListener(aListener, aEvents) {
+ throw new Error("Cannot registerListener in child actor");
+ }
+ unregisterListener(aListener, aEvents) {
+ throw new Error("Cannot registerListener in child actor");
+ }
+
+ /**
+ * Sends a request to Java.
+ *
+ * @param aMsg Message to send; must be an object with a "type" property
+ */
+ sendRequest(aMsg) {
+ this._actor.sendAsyncMessage("DispatcherMessage", aMsg);
+ }
+
+ /**
+ * Sends a request to Java, returning a Promise that resolves to the response.
+ *
+ * @param aMsg Message to send; must be an object with a "type" property
+ * @return A Promise resolving to the response
+ */
+ sendRequestForResult(aMsg) {
+ return this._actor.sendQuery("DispatcherQuery", aMsg);
+ }
+}
+
+function DispatcherDelegate(aDispatcher, aMessageManager) {
+ this._dispatcher = aDispatcher;
+ this._messageManager = aMessageManager;
+
+ if (!aDispatcher) {
+ // Child process.
+ // TODO: this doesn't work with Fission, remove this code path once every
+ // consumer has been migrated. Bug 1569360.
+ this._replies = new Map();
+ (aMessageManager || Services.cpmm).addMessageListener(
+ "GeckoView:MessagingReply",
+ this
+ );
+ }
+}
+
+DispatcherDelegate.prototype = {
+ /**
+ * Register a listener to be notified of event(s).
+ *
+ * @param aListener Target listener implementing nsIAndroidEventListener.
+ * @param aEvents String or array of strings of events to listen to.
+ */
+ registerListener(aListener, aEvents) {
+ if (!this._dispatcher) {
+ throw new Error("Can only listen in parent process");
+ }
+ this._dispatcher.registerListener(aListener, aEvents);
+ },
+
+ /**
+ * Unregister a previously-registered listener.
+ *
+ * @param aListener Registered listener implementing nsIAndroidEventListener.
+ * @param aEvents String or array of strings of events to stop listening to.
+ */
+ unregisterListener(aListener, aEvents) {
+ if (!this._dispatcher) {
+ throw new Error("Can only listen in parent process");
+ }
+ this._dispatcher.unregisterListener(aListener, aEvents);
+ },
+
+ /**
+ * Dispatch an event to registered listeners for that event, and pass an
+ * optional data object and/or a optional callback interface to the
+ * listeners.
+ *
+ * @param aEvent Name of event to dispatch.
+ * @param aData Optional object containing data for the event.
+ * @param aCallback Optional callback implementing nsIAndroidEventCallback.
+ * @param aFinalizer Optional finalizer implementing nsIAndroidEventFinalizer.
+ */
+ dispatch(aEvent, aData, aCallback, aFinalizer) {
+ if (this._dispatcher) {
+ this._dispatcher.dispatch(aEvent, aData, aCallback, aFinalizer);
+ return;
+ }
+
+ const mm = this._messageManager || Services.cpmm;
+ const forwardData = {
+ global: !this._messageManager,
+ event: aEvent,
+ data: aData,
+ };
+
+ if (aCallback) {
+ const uuid = Services.uuid.generateUUID().toString();
+ this._replies.set(uuid, {
+ callback: aCallback,
+ finalizer: aFinalizer,
+ });
+ forwardData.uuid = uuid;
+ }
+
+ mm.sendAsyncMessage("GeckoView:Messaging", forwardData);
+ },
+
+ /**
+ * Sends a request to Java.
+ *
+ * @param aMsg Message to send; must be an object with a "type" property
+ * @param aCallback Optional callback implementing nsIAndroidEventCallback.
+ */
+ sendRequest(aMsg, aCallback) {
+ const type = aMsg.type;
+ aMsg.type = undefined;
+ this.dispatch(type, aMsg, aCallback);
+ },
+
+ /**
+ * Sends a request to Java, returning a Promise that resolves to the response.
+ *
+ * @param aMsg Message to send; must be an object with a "type" property
+ * @return A Promise resolving to the response
+ */
+ sendRequestForResult(aMsg) {
+ return new Promise((resolve, reject) => {
+ const type = aMsg.type;
+ aMsg.type = undefined;
+
+ // Manually release the resolve/reject functions after one callback is
+ // received, so the JS GC is not tied up with the Java GC.
+ const onCallback = (callback, ...args) => {
+ if (callback) {
+ callback(...args);
+ }
+ resolve = undefined;
+ reject = undefined;
+ };
+ const callback = {
+ onSuccess: result => onCallback(resolve, result),
+ onError: error => onCallback(reject, error),
+ onFinalize: _ => onCallback(reject),
+ };
+ this.dispatch(type, aMsg, callback, callback);
+ });
+ },
+
+ finalize() {
+ if (!this._replies) {
+ return;
+ }
+ this._replies.forEach(reply => {
+ if (typeof reply.finalizer === "function") {
+ reply.finalizer();
+ } else if (reply.finalizer) {
+ reply.finalizer.onFinalize();
+ }
+ });
+ this._replies.clear();
+ },
+
+ receiveMessage(aMsg) {
+ const { uuid, type } = aMsg.data;
+ const reply = this._replies.get(uuid);
+ if (!reply) {
+ return;
+ }
+
+ if (type === "success") {
+ reply.callback.onSuccess(aMsg.data.response);
+ } else if (type === "error") {
+ reply.callback.onError(aMsg.data.response);
+ } else if (type === "finalize") {
+ if (typeof reply.finalizer === "function") {
+ reply.finalizer();
+ } else if (reply.finalizer) {
+ reply.finalizer.onFinalize();
+ }
+ this._replies.delete(uuid);
+ } else {
+ throw new Error("invalid reply type");
+ }
+ },
+};
+
+export var EventDispatcher = {
+ instance: new DispatcherDelegate(
+ IS_PARENT_PROCESS ? Services.androidBridge : undefined
+ ),
+
+ /**
+ * Return an EventDispatcher instance for a chrome DOM window. In a content
+ * process, return a proxy through the message manager that automatically
+ * forwards events to the main process.
+ *
+ * To force using a message manager proxy (for example in a frame script
+ * environment), call forMessageManager.
+ *
+ * @param aWindow a chrome DOM window.
+ */
+ for(aWindow) {
+ const view =
+ aWindow &&
+ aWindow.arguments &&
+ aWindow.arguments[0] &&
+ aWindow.arguments[0].QueryInterface(Ci.nsIAndroidView);
+
+ if (!view) {
+ const mm = !IS_PARENT_PROCESS && aWindow && aWindow.messageManager;
+ if (!mm) {
+ throw new Error(
+ "window is not a GeckoView-connected window and does" +
+ " not have a message manager"
+ );
+ }
+ return this.forMessageManager(mm);
+ }
+
+ return new DispatcherDelegate(view);
+ },
+
+ /**
+ * Returns a named EventDispatcher, which can communicate with the
+ * corresponding EventDispatcher on the java side.
+ */
+ byName(aName) {
+ if (!IS_PARENT_PROCESS) {
+ return undefined;
+ }
+ const dispatcher = Services.androidBridge.getDispatcherByName(aName);
+ return new DispatcherDelegate(dispatcher);
+ },
+
+ /**
+ * Return an EventDispatcher instance for a message manager associated with a
+ * window.
+ *
+ * @param aWindow a message manager.
+ */
+ forMessageManager(aMessageManager) {
+ return new DispatcherDelegate(null, aMessageManager);
+ },
+
+ /**
+ * Return the EventDispatcher instance associated with an actor.
+ *
+ * @param aActor an actor
+ */
+ forActor(aActor) {
+ return new ChildActorDispatcher(aActor);
+ },
+
+ receiveMessage(aMsg) {
+ // aMsg.data includes keys: global, event, data, uuid
+ let callback;
+ if (aMsg.data.uuid) {
+ const reply = (type, response) => {
+ const mm = aMsg.data.global ? aMsg.target : aMsg.target.messageManager;
+ if (!mm) {
+ if (type === "finalize") {
+ // It's normal for the finalize call to come after the browser has
+ // been destroyed. We can gracefully handle that case despite
+ // having no message manager.
+ return;
+ }
+ throw Error(
+ `No message manager for ${aMsg.data.event}:${type} reply`
+ );
+ }
+ mm.sendAsyncMessage("GeckoView:MessagingReply", {
+ type,
+ response,
+ uuid: aMsg.data.uuid,
+ });
+ };
+ callback = {
+ onSuccess: response => reply("success", response),
+ onError: error => reply("error", error),
+ onFinalize: () => reply("finalize"),
+ };
+ }
+
+ try {
+ if (aMsg.data.global) {
+ this.instance.dispatch(
+ aMsg.data.event,
+ aMsg.data.data,
+ callback,
+ callback
+ );
+ return;
+ }
+
+ const win = aMsg.target.ownerGlobal;
+ const dispatcher = win.WindowEventDispatcher || this.for(win);
+ dispatcher.dispatch(aMsg.data.event, aMsg.data.data, callback, callback);
+ } catch (e) {
+ callback?.onError(`Error getting dispatcher: ${e}`);
+ throw e;
+ }
+ },
+};
+
+if (IS_PARENT_PROCESS) {
+ Services.mm.addMessageListener("GeckoView:Messaging", EventDispatcher);
+ Services.ppmm.addMessageListener("GeckoView:Messaging", EventDispatcher);
+}
diff --git a/mobile/android/modules/geckoview/metrics.yaml b/mobile/android/modules/geckoview/metrics.yaml
new file mode 100644
index 0000000000..3b8cdd0dc9
--- /dev/null
+++ b/mobile/android/modules/geckoview/metrics.yaml
@@ -0,0 +1,153 @@
+# 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"
+
+geckoview:
+ page_load_progress_time:
+ type: timing_distribution
+ time_unit: millisecond
+ telemetry_mirror: GV_PAGE_LOAD_PROGRESS_MS
+ description: >
+ Time between page load progress starts (0) and completion (100).
+ (Migrated from the geckoview metric of the same name).
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: never
+
+ page_load_time:
+ type: timing_distribution
+ time_unit: millisecond
+ telemetry_mirror: GV_PAGE_LOAD_MS
+ description: >
+ The time taken to load a page. This includes all static contents, no
+ dynamic content.
+ Loading of about: pages is not counted.
+ Back back navigation (sometimes via BFCache) is included which is a
+ source of bimodality due to the <50ms load times.
+ (Migrated from the geckoview metric of the same name).
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: never
+
+ page_reload_time:
+ type: timing_distribution
+ time_unit: millisecond
+ telemetry_mirror: GV_PAGE_RELOAD_MS
+ description: >
+ Time taken to reload a page.
+ This includes all static contents, no dynamic content.
+ Loading of about: pages is not counted.
+ (Migrated from the geckoview metric of the same name).
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1549519
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10
+ notification_emails:
+ - android-probes@mozilla.com
+ - sefeng@mozilla.com
+ - perf-telemetry-alerts@mozilla.com
+ expires: never
+
+ document_site_origins:
+ type: custom_distribution
+ description: >
+ When a document is loaded, report the
+ number of [site origins](https://searchfox.org/
+ mozilla-central/rev/
+ 3300072e993ae05d50d5c63d815260367eaf9179/
+ caps/nsIPrincipal.idl#264) of the entire browser
+ if it has been at least 5 minutes since last
+ time we collect this data.
+ (Migrated from the geckoview metric of the same name).
+ range_min: 0
+ range_max: 100
+ bucket_count: 50
+ histogram_type: exponential
+ unit: number of site_origin
+ telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700#c5
+ notification_emails:
+ - sefeng@mozilla.com
+ - perf-telemetry-alerts@mozilla.com
+ expires: never
+
+ per_document_site_origins:
+ type: custom_distribution
+ description: >
+ When a document is unloaded, report the highest number of
+ [site origins](https://searchfox.org/
+ mozilla-central/rev/
+ 3300072e993ae05d50d5c63d815260367eaf9179/
+ caps/nsIPrincipal.idl#264) loaded simultaneously in that
+ document.
+ (Migrated from the geckoview metric of the same name).
+ range_min: 0
+ range_max: 100
+ bucket_count: 50
+ histogram_type: exponential
+ unit: number of site origins per document
+ telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_DOCUMENT
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185#c13
+ notification_emails:
+ - barret@mozilla.com
+ - perf-telemetry-alerts@mozilla.com
+ expires: never
+
+ startup_runtime:
+ type: timing_distribution
+ time_unit: millisecond
+ description: >
+ The time taken to initialize GeckoRuntime.
+ (Migrated from the geckoview metric of the same name).
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: never
+
+ content_process_lifetime:
+ type: timing_distribution
+ time_unit: millisecond
+ description: >
+ The uptime of content processes.
+ (Migrated from the geckoview metric of the same name).
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325#c2
+ notification_emails:
+ - android-probes@mozilla.com
+ expires: never
diff --git a/mobile/android/modules/geckoview/moz.build b/mobile/android/modules/geckoview/moz.build
new file mode 100644
index 0000000000..9c2693c85e
--- /dev/null
+++ b/mobile/android/modules/geckoview/moz.build
@@ -0,0 +1,45 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES += [
+ "AndroidLog.sys.mjs",
+ "BrowserUsageTelemetry.sys.mjs",
+ "ChildCrashHandler.sys.mjs",
+ "DelayedInit.sys.mjs",
+ "GeckoViewActorChild.sys.mjs",
+ "GeckoViewActorManager.sys.mjs",
+ "GeckoViewActorParent.sys.mjs",
+ "GeckoViewAutocomplete.sys.mjs",
+ "GeckoViewAutofill.sys.mjs",
+ "GeckoViewChildModule.sys.mjs",
+ "GeckoViewClipboardPermission.sys.mjs",
+ "GeckoViewConsole.sys.mjs",
+ "GeckoViewContent.sys.mjs",
+ "GeckoViewContentBlocking.sys.mjs",
+ "GeckoViewIdentityCredential.sys.mjs",
+ "GeckoViewMediaControl.sys.mjs",
+ "GeckoViewModule.sys.mjs",
+ "GeckoViewNavigation.sys.mjs",
+ "GeckoViewProcessHangMonitor.sys.mjs",
+ "GeckoViewProgress.sys.mjs",
+ "GeckoViewPushController.sys.mjs",
+ "GeckoViewRemoteDebugger.sys.mjs",
+ "GeckoViewSelectionAction.sys.mjs",
+ "GeckoViewSessionStore.sys.mjs",
+ "GeckoViewSettings.sys.mjs",
+ "GeckoViewStorageController.sys.mjs",
+ "GeckoViewTab.sys.mjs",
+ "GeckoViewTelemetry.sys.mjs",
+ "GeckoViewTestUtils.sys.mjs",
+ "GeckoViewTranslations.sys.mjs",
+ "GeckoViewUtils.sys.mjs",
+ "GeckoViewWebExtension.sys.mjs",
+ "LoadURIDelegate.sys.mjs",
+ "MediaUtils.sys.mjs",
+ "Messaging.sys.mjs",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
diff --git a/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js
new file mode 100644
index 0000000000..0e07937ed3
--- /dev/null
+++ b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ChildCrashHandler: "resource://gre/modules/ChildCrashHandler.sys.mjs",
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+});
+
+const { makeFakeAppDir } = ChromeUtils.importESModule(
+ "resource://testing-common/AppData.sys.mjs"
+);
+
+add_setup(async function () {
+ await makeFakeAppDir();
+ // The test harness sets MOZ_CRASHREPORTER_NO_REPORT, which disables crash
+ // reports. This test needs them enabled.
+ const noReport = Services.env.get("MOZ_CRASHREPORTER_NO_REPORT");
+ Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", "");
+
+ registerCleanupFunction(function () {
+ Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", noReport);
+ });
+});
+
+add_task(async function test_remoteType() {
+ const childID = 123;
+ const remoteType = "webIsolated=https://example.com";
+ // Force-set a remote type for the process that we are going to "crash" next.
+ lazy.ChildCrashHandler.childMap.set(childID, remoteType);
+
+ // Mock a process crash being notified.
+ const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ propertyBag.setPropertyAsBool("abnormal", true);
+ propertyBag.setPropertyAsAString("dumpID", "a-dump-id");
+
+ // Set up a listener to receive the crash report event emitted by the handler.
+ let listener;
+ const crashReportPromise = new Promise(resolve => {
+ listener = {
+ onEvent(aEvent, aData, aCallback) {
+ resolve([aEvent, aData]);
+ },
+ };
+ });
+ lazy.EventDispatcher.instance.registerListener(listener, [
+ "GeckoView:ChildCrashReport",
+ ]);
+
+ // Simulate a crash.
+ lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID);
+
+ const [aEvent, aData] = await crashReportPromise;
+ Assert.equal(
+ "GeckoView:ChildCrashReport",
+ aEvent,
+ "expected a child crash report"
+ );
+ Assert.equal("webIsolated", aData?.remoteType, "expected remote type prefix");
+});
+
+add_task(async function test_extensions_process_crash() {
+ const childID = 123;
+ const remoteType = "extension";
+ // Force-set a remote type for the process that we are going to "crash" next.
+ lazy.ChildCrashHandler.childMap.set(childID, remoteType);
+
+ // Mock a process crash being notified.
+ const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ propertyBag.setPropertyAsBool("abnormal", true);
+ propertyBag.setPropertyAsAString("dumpID", "a-dump-id");
+
+ // Set up a listener to receive the crash report event emitted by the handler.
+ let listener;
+ const crashReportPromise = new Promise(resolve => {
+ listener = {
+ onEvent(aEvent, aData, aCallback) {
+ resolve([aEvent, aData]);
+ },
+ };
+ });
+ lazy.EventDispatcher.instance.registerListener(listener, [
+ "GeckoView:ChildCrashReport",
+ ]);
+
+ // Simulate a crash.
+ lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID);
+
+ const [aEvent, aData] = await crashReportPromise;
+ Assert.equal(
+ "GeckoView:ChildCrashReport",
+ aEvent,
+ "expected a child crash report"
+ );
+ Assert.equal("extension", aData?.remoteType, "expected remote type");
+ Assert.equal("BACKGROUND_CHILD", aData?.processType, "expected process type");
+});
diff --git a/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..174a258d24
--- /dev/null
+++ b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+firefox-appdir = "browser"
+run-if = ["os == 'android'"]
+
+["test_ChildCrashHandler.js"]