summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/support/webextensions
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/support/webextensions
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/support/webextensions')
-rw-r--r--mobile/android/android-components/components/support/webextensions/README.md37
-rw-r--r--mobile/android/android-components/components/support/webextensions/build.gradle55
-rw-r--r--mobile/android/android-components/components/support/webextensions/proguard-rules.pro21
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt52
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionController.kt179
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionPopupObserver.kt48
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionSupport.kt535
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/facts/WebExtensionFacts.kt51
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionControllerTest.kt238
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionPopupObserverTest.kt55
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt1077
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/support/webextensions/src/test/resources/robolectric.properties1
14 files changed, 2355 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/support/webextensions/README.md b/mobile/android/android-components/components/support/webextensions/README.md
new file mode 100644
index 0000000000..fdbefaae18
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/README.md
@@ -0,0 +1,37 @@
+# [Android Components](../../../README.md) > Support > Webextensions
+
+A component containing building blocks for features implemented as web extensions.
+
+Usually this component never needs to be added to application projects manually. Other components may have a transitive dependency on some of the classes and interfaces in this component.
+
+## Usage
+
+### Setting up the dependency
+
+Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)):
+
+```Groovy
+implementation "org.mozilla.components:support-webextensions:{latest-version}"
+```
+
+## Facts
+
+This component emits the following [Facts](../../support/base/README.md#Facts):
+
+| Action | Item | Extras | Description |
+|-------------|----------------------------|-------------------|---------------------------------|
+| Interaction | web_extensions_initialized | `extensionExtras` | Web extensions are initialized. |
+
+
+#### `extensionExtras`
+
+| Key | Type | Value |
+|-------------|--------------|---------------------------------------|
+| "enabled" | List<String> | List of enabled web extension ids. |
+| "installed" | List<String> | List of installed web extensions ids. |
+
+## License
+
+ 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/
diff --git a/mobile/android/android-components/components/support/webextensions/build.gradle b/mobile/android/android-components/components/support/webextensions/build.gradle
new file mode 100644
index 0000000000..87413a7c4b
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/build.gradle
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+ }
+
+ lint {
+ warningsAsErrors true
+ abortOnError true
+ }
+
+ namespace 'mozilla.components.support.webextensions'
+}
+
+tasks.withType(KotlinCompile).configureEach {
+ kotlinOptions.freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
+}
+
+dependencies {
+ implementation project(':concept-engine')
+ implementation project(':browser-state')
+ implementation project(':support-base')
+ implementation project(':support-ktx')
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_coroutines
+ testImplementation ComponentsDependencies.testing_robolectric
+}
+
+apply from: '../../../android-lint.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description)
diff --git a/mobile/android/android-components/components/support/webextensions/proguard-rules.pro b/mobile/android/android-components/components/support/webextensions/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/AndroidManifest.xml b/mobile/android/android-components/components/support/webextensions/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..41078a7325
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt
new file mode 100644
index 0000000000..acb3ac84f9
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt
@@ -0,0 +1,52 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * Observes the [BrowserStore] state for when the extensions process spawning has been disabled and
+ * the user should be prompted. This requires running in both the foreground and background.
+ *
+ * @property store the application's [BrowserStore].
+ * @property shouldCancelOnStop If false, this observer will run indefinitely to be able to react
+ * to state changes when the app is either in the foreground or in the background.
+ * Please note to not have any references to Activity or it's context in an observer where this
+ * is false. Defaults to true.
+ * @property onShowExtensionsProcessDisabledPrompt a callback invoked when the application should
+ * open a prompt.
+ */
+open class ExtensionsProcessDisabledPromptObserver(
+ private val store: BrowserStore,
+ private val shouldCancelOnStop: Boolean = true,
+ private val onShowExtensionsProcessDisabledPrompt: () -> Unit,
+) : LifecycleAwareFeature {
+ private var scope: CoroutineScope? = null
+
+ override fun start() {
+ if (scope == null) {
+ scope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.showExtensionsProcessDisabledPrompt }
+ .collect { state ->
+ if (state.showExtensionsProcessDisabledPrompt) {
+ onShowExtensionsProcessDisabledPrompt()
+ }
+ }
+ }
+ }
+ }
+
+ override fun stop() {
+ if (shouldCancelOnStop) {
+ scope?.cancel()
+ scope = null
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionController.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionController.kt
new file mode 100644
index 0000000000..a1acbd3200
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionController.kt
@@ -0,0 +1,179 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import androidx.annotation.VisibleForTesting
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.webextension.MessageHandler
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.concept.engine.webextension.WebExtensionRuntime
+import mozilla.components.support.base.log.logger.Logger
+import org.json.JSONObject
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Provides functionality to feature modules that need to interact with a web extension.
+ *
+ * @property extensionId the unique ID of the web extension e.g. mozacReaderview.
+ * @property extensionUrl the url pointing to a resources path for locating the
+ * extension within the APK file e.g. resource://android/assets/extensions/my_web_ext.
+ * @property defaultPort the name of the default port used to exchange messages
+ * between extension scripts and the application. Extensions can open multiple ports
+ * so [sendContentMessage] and [sendBackgroundMessage] allow specifying an
+ * alternative port, if needed.
+ */
+class WebExtensionController(
+ private val extensionId: String,
+ private val extensionUrl: String,
+ private val defaultPort: String,
+) {
+ private val logger = Logger("mozac-webextensions")
+ private var registerContentMessageHandler: (WebExtension) -> Unit? = { }
+ private var registerBackgroundMessageHandler: (WebExtension) -> Unit? = { }
+
+ /**
+ * Makes sure the web extension is installed in the provided runtime. If a
+ * content message handler was registered (see
+ * [registerContentMessageHandler]) before install completed, registration
+ * will happen upon successful installation.
+ *
+ * @param runtime the [WebExtensionRuntime] the web extension should be installed in.
+ * @param onSuccess (optional) callback invoked if the extension was installed successfully
+ * or is already installed.
+ * @param onError (optional) callback invoked if there was an error installing the extension.
+ */
+ fun install(
+ runtime: WebExtensionRuntime,
+ onSuccess: ((WebExtension) -> Unit) = { },
+ onError: ((Throwable) -> Unit) = { _ -> },
+ ) {
+ val installedExtension = installedExtensions[extensionId]
+ if (installedExtension == null) {
+ runtime.installBuiltInWebExtension(
+ extensionId,
+ extensionUrl,
+ onSuccess = {
+ logger.debug("Installed extension: ${it.id}")
+ synchronized(this@WebExtensionController) {
+ registerContentMessageHandler(it)
+ registerBackgroundMessageHandler(it)
+ installedExtensions[extensionId] = it
+ onSuccess(it)
+ }
+ },
+ onError = { throwable ->
+ logger.error("Failed to install extension: $extensionId", throwable)
+ onError(throwable)
+ },
+ )
+ } else {
+ onSuccess(installedExtension)
+ }
+ }
+
+ /**
+ * Registers a content message handler for the provided session. Currently only one
+ * handler can be registered per session. An existing handler will be replaced and
+ * there is no need to unregister.
+ *
+ * @param engineSession the session the content message handler should be registered with.
+ * @param messageHandler the message handler to register.
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ */
+ fun registerContentMessageHandler(
+ engineSession: EngineSession,
+ messageHandler: MessageHandler,
+ name: String = defaultPort,
+ ) {
+ synchronized(this) {
+ registerContentMessageHandler = {
+ it.registerContentMessageHandler(engineSession, name, messageHandler)
+ }
+
+ installedExtensions[extensionId]?.let { registerContentMessageHandler(it) }
+ }
+ }
+
+ /**
+ * Registers a background message handler for this extension. An existing handler
+ * will be replaced and there is no need to unregister.
+ *
+ * @param messageHandler the message handler to register.
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ * */
+ fun registerBackgroundMessageHandler(
+ messageHandler: MessageHandler,
+ name: String = defaultPort,
+ ) {
+ synchronized(this) {
+ registerBackgroundMessageHandler = {
+ it.registerBackgroundMessageHandler(name, messageHandler)
+ }
+
+ installedExtensions[extensionId]?.let { registerBackgroundMessageHandler(it) }
+ }
+ }
+
+ /**
+ * Sends a content message to the provided session.
+ *
+ * @param msg the message to send
+ * @param engineSession the session to send the content message to.
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ */
+ fun sendContentMessage(msg: JSONObject, engineSession: EngineSession?, name: String = defaultPort) {
+ engineSession?.let { session ->
+ installedExtensions[extensionId]?.let { ext ->
+ val port = ext.getConnectedPort(name, session)
+ port?.postMessage(msg)
+ ?: logger.error("No port with name $name connected for provided session. Message $msg not sent.")
+ }
+ }
+ }
+
+ /**
+ * Sends a background message to the provided extension.
+ *
+ * @param msg the message to send
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ */
+ fun sendBackgroundMessage(
+ msg: JSONObject,
+ name: String = defaultPort,
+ ) {
+ installedExtensions[extensionId]?.let { ext ->
+ val port = ext.getConnectedPort(name)
+ port?.postMessage(msg)
+ ?: logger.error("No port connected for provided extension. Message $msg not sent.")
+ }
+ }
+
+ /**
+ * Checks whether or not a port is connected for the provided session.
+ *
+ * @param engineSession the session the port should be connected to or null for a port to a background script.
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ */
+ fun portConnected(engineSession: EngineSession?, name: String = defaultPort): Boolean {
+ return installedExtensions[extensionId]?.let { ext ->
+ ext.getConnectedPort(name, engineSession) != null
+ } ?: false
+ }
+
+ /**
+ * Disconnects the port of the provided session.
+ *
+ * @param engineSession the session the port is connected to or null for a port to a background script.
+ * @param name (optional) name of the port, if not specified [defaultPort] will be used.
+ */
+ fun disconnectPort(engineSession: EngineSession?, name: String = defaultPort) {
+ installedExtensions[extensionId]?.disconnectPort(name, engineSession)
+ }
+
+ companion object {
+ @VisibleForTesting
+ val installedExtensions = ConcurrentHashMap<String, WebExtension>()
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionPopupObserver.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionPopupObserver.kt
new file mode 100644
index 0000000000..2dfadf856e
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionPopupObserver.kt
@@ -0,0 +1,48 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.map
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+/**
+ * Feature implementation that opens popups for web extensions browser actions.
+ *
+ * @property store the application's [BrowserStore].
+ * @property onOpenPopup a callback invoked when the application should open a
+ * popup. This is a lambda accepting the [WebExtensionState] of the extension
+ * that wants to open a popup.
+ */
+class WebExtensionPopupObserver(
+ private val store: BrowserStore,
+ private val onOpenPopup: (WebExtensionState) -> Unit = { },
+) : LifecycleAwareFeature {
+ private var popupScope: CoroutineScope? = null
+
+ override fun start() {
+ popupScope = store.flowScoped { flow ->
+ flow.distinctUntilChangedBy { it.extensions }
+ .map { it.extensions.filterValues { extension -> extension.popupSession != null } }
+ .distinctUntilChanged()
+ .collect { extensionStates ->
+ if (extensionStates.values.isNotEmpty()) {
+ // We currently limit to one active popup session at a time
+ onOpenPopup(extensionStates.values.first())
+ }
+ }
+ }
+ }
+
+ override fun stop() {
+ popupScope?.cancel()
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionSupport.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionSupport.kt
new file mode 100644
index 0000000000..b4b78d6272
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/WebExtensionSupport.kt
@@ -0,0 +1,535 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import androidx.annotation.VisibleForTesting
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.ExtensionsProcessAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.WebExtensionAction
+import mozilla.components.browser.state.selector.allTabs
+import mozilla.components.browser.state.selector.findTabOrCustomTab
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.engine.webextension.ActionHandler
+import mozilla.components.concept.engine.webextension.TabHandler
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.concept.engine.webextension.WebExtensionDelegate
+import mozilla.components.concept.engine.webextension.WebExtensionInstallException
+import mozilla.components.concept.engine.webextension.WebExtensionRuntime
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.ktx.kotlin.isExtensionUrl
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.filterChanged
+import mozilla.components.support.webextensions.facts.emitWebExtensionsInitializedFact
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Function to relay the permission request to the app / user.
+ */
+typealias onUpdatePermissionRequest = (
+ current: WebExtension,
+ updated: WebExtension,
+ newPermissions: List<String>,
+ onPermissionsGranted: ((Boolean) -> Unit),
+) -> Unit
+
+/**
+ * Provides functionality to make sure web extension related events in the
+ * [WebExtensionRuntime] are reflected in the browser state by dispatching the
+ * corresponding actions to the [BrowserStore].
+ *
+ * Note that this class can be removed once the browser-state migration
+ * is completed and the [WebExtensionRuntime] (engine) has direct access to the
+ * [BrowserStore]: https://github.com/orgs/mozilla-mobile/projects/31
+ */
+object WebExtensionSupport {
+ private val logger = Logger("mozac-webextensions")
+ private var onUpdatePermissionRequest: onUpdatePermissionRequest? = null
+ private var onExtensionsLoaded: ((List<WebExtension>) -> Unit)? = null
+ private var onCloseTabOverride: ((WebExtension?, String) -> Unit)? = null
+ private var onSelectTabOverride: ((WebExtension?, String) -> Unit)? = null
+
+ val installedExtensions = ConcurrentHashMap<String, WebExtension>()
+
+ /**
+ * A [Deferred] completed during [initialize] once the state of all
+ * installed extensions is known.
+ */
+ private val initializationResult = CompletableDeferred<Unit>()
+
+ /**
+ * [ActionHandler] for session-specific overrides. Forwards actions to the
+ * the provided [store].
+ */
+ private class SessionActionHandler(
+ private val store: BrowserStore,
+ private val sessionId: String,
+ ) : ActionHandler {
+
+ override fun onBrowserAction(extension: WebExtension, session: EngineSession?, action: Action) {
+ store.dispatch(WebExtensionAction.UpdateTabBrowserAction(sessionId, extension.id, action))
+ }
+
+ override fun onPageAction(extension: WebExtension, session: EngineSession?, action: Action) {
+ store.dispatch(WebExtensionAction.UpdateTabPageAction(sessionId, extension.id, action))
+ }
+ }
+
+ /**
+ * [TabHandler] for session-specific tab events. Forwards actions to the
+ * the provided [store].
+ */
+ private class SessionTabHandler(
+ private val store: BrowserStore,
+ private val sessionId: String,
+ private val onCloseTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ private val onSelectTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ ) : TabHandler {
+
+ override fun onCloseTab(webExtension: WebExtension, engineSession: EngineSession): Boolean {
+ val tab = store.state.findTabOrCustomTab(sessionId)
+ return if (tab != null) {
+ closeTab(tab.id, tab.isCustomTab(), store, onCloseTabOverride, webExtension)
+ true
+ } else {
+ false
+ }
+ }
+
+ override fun onUpdateTab(
+ webExtension: WebExtension,
+ engineSession: EngineSession,
+ active: Boolean,
+ url: String?,
+ ): Boolean {
+ val tab = store.state.findTabOrCustomTab(sessionId)
+ return if (tab != null) {
+ if (active && !tab.isCustomTab()) {
+ onSelectTabOverride?.invoke(webExtension, tab.id)
+ ?: store.dispatch(TabListAction.SelectTabAction(tab.id))
+ }
+ url?.let {
+ engineSession.loadUrl(it)
+ }
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ /**
+ * Registers a listener for web extension related events on the provided
+ * [WebExtensionRuntime] and reacts by dispatching the corresponding actions to the
+ * provided [BrowserStore].
+ *
+ * @param runtime the browser [WebExtensionRuntime] to use.
+ * @param store the application's [BrowserStore].
+ * @param openPopupInTab (optional) flag to determine whether a browser or page action would
+ * display a web extension popup in a tab or not. Defaults to false.
+ * @param onNewTabOverride (optional) override of behaviour that should
+ * be triggered when web extensions open a new tab e.g. when dispatching
+ * to the store isn't sufficient while migrating from browser-session
+ * to browser-state. This is a lambda accepting the [WebExtension], the
+ * [EngineSession] to use, as well as the URL to load, return the ID of
+ * the created session.
+ * @param onCloseTabOverride (optional) override of behaviour that should
+ * be triggered when web extensions close tabs e.g. when dispatching
+ * to the store isn't sufficient while migrating from browser-session
+ * to browser-state. This is a lambda accepting the [WebExtension] and
+ * the session/tab ID to close.
+ * @param onSelectTabOverride (optional) override of behaviour that should
+ * be triggered when a tab is selected to display a web extension popup.
+ * This is a lambda accepting the [WebExtension] and the session/tab ID to
+ * select.
+ * @param onUpdatePermissionRequest (optional) Invoked when a web extension has changed its
+ * permissions while trying to update to a new version. This requires user interaction as
+ * the updated extension will not be installed, until the user grants the new permissions.
+ * @param onExtensionsLoaded (optional) callback invoked when the extensions are loaded by the
+ * engine. Note that the UI (browser/page actions etc.) may not be initialized at this point.
+ * System add-ons (built-in extensions) will not be passed along.
+ */
+ fun initialize(
+ runtime: WebExtensionRuntime,
+ store: BrowserStore,
+ openPopupInTab: Boolean = false,
+ onNewTabOverride: ((WebExtension?, EngineSession, String) -> String)? = null,
+ onCloseTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ onSelectTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ onUpdatePermissionRequest: onUpdatePermissionRequest? = { _, _, _, _ -> },
+ onExtensionsLoaded: ((List<WebExtension>) -> Unit)? = null,
+ ) {
+ this.onUpdatePermissionRequest = onUpdatePermissionRequest
+ this.onExtensionsLoaded = onExtensionsLoaded
+ this.onCloseTabOverride = onCloseTabOverride
+ this.onSelectTabOverride = onSelectTabOverride
+
+ // Queries the runtime for installed extensions and adds them to the store
+ registerInstalledExtensions(store, runtime)
+
+ // Observes the store and registers action and tab handlers for newly added engine sessions
+ registerHandlersForNewSessions(store)
+
+ runtime.registerWebExtensionDelegate(
+ object : WebExtensionDelegate {
+ override fun onNewTab(extension: WebExtension, engineSession: EngineSession, active: Boolean, url: String) {
+ openTab(store, onNewTabOverride, onSelectTabOverride, extension, engineSession, url, active)
+ }
+
+ override fun onBrowserActionDefined(extension: WebExtension, action: Action) {
+ store.dispatch(WebExtensionAction.UpdateBrowserAction(extension.id, action))
+ }
+
+ override fun onPageActionDefined(extension: WebExtension, action: Action) {
+ store.dispatch(WebExtensionAction.UpdatePageAction(extension.id, action))
+ }
+
+ override fun onToggleActionPopup(
+ extension: WebExtension,
+ engineSession: EngineSession,
+ action: Action,
+ ): EngineSession? {
+ return if (!openPopupInTab) {
+ store.dispatch(WebExtensionAction.UpdatePopupSessionAction(extension.id, null, engineSession))
+ engineSession
+ } else {
+ val popupSessionId = store.state.extensions[extension.id]?.popupSessionId
+ if (popupSessionId != null && store.state.tabs.find { it.id == popupSessionId } != null) {
+ if (popupSessionId == store.state.selectedTabId) {
+ closeTab(popupSessionId, false, store, onCloseTabOverride, extension)
+ } else {
+ onSelectTabOverride?.invoke(extension, popupSessionId)
+ ?: store.dispatch(TabListAction.SelectTabAction(popupSessionId))
+ }
+ null
+ } else {
+ val sessionId = openTab(store, onNewTabOverride, onSelectTabOverride, extension, engineSession)
+ store.dispatch(WebExtensionAction.UpdatePopupSessionAction(extension.id, sessionId))
+ engineSession
+ }
+ }
+ }
+
+ override fun onInstalled(extension: WebExtension) {
+ logger.debug("onInstalled ${extension.id}")
+ // Built-in extensions are not installed by users, they are not aware of them
+ // for this reason we don't show any UI related to built-in extensions. Also,
+ // when the add-on has already been installed, we don't need to show anything
+ // either.
+ val shouldDispatchAction = !installedExtensions.containsKey(extension.id) && !extension.isBuiltIn()
+ registerInstalledExtension(store, extension)
+ if (shouldDispatchAction) {
+ store.dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.PostInstallation(extension),
+ ),
+ )
+ }
+ }
+
+ override fun onInstallationFailedRequest(
+ extension: WebExtension?,
+ exception: WebExtensionInstallException,
+ ) {
+ logger.error("onInstallationFailedRequest ${extension?.id}", exception)
+ store.dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.BeforeInstallation.InstallationFailed(
+ extension,
+ exception,
+ ),
+ ),
+ )
+ }
+
+ override fun onUninstalled(extension: WebExtension) {
+ installedExtensions.remove(extension.id)
+ store.dispatch(WebExtensionAction.UninstallWebExtensionAction(extension.id))
+ }
+
+ override fun onEnabled(extension: WebExtension) {
+ installedExtensions[extension.id] = extension
+ store.dispatch(WebExtensionAction.UpdateWebExtensionEnabledAction(extension.id, true))
+ }
+
+ override fun onDisabled(extension: WebExtension) {
+ installedExtensions[extension.id] = extension
+ store.dispatch(WebExtensionAction.UpdateWebExtensionEnabledAction(extension.id, false))
+ }
+
+ override fun onReady(extension: WebExtension) {
+ installedExtensions[extension.id] = extension
+ }
+
+ override fun onAllowedInPrivateBrowsingChanged(extension: WebExtension) {
+ installedExtensions[extension.id] = extension
+ store.dispatch(
+ WebExtensionAction.UpdateWebExtensionAllowedInPrivateBrowsingAction(
+ extension.id,
+ extension.isAllowedInPrivateBrowsing(),
+ ),
+ )
+ }
+
+ override fun onInstallPermissionRequest(
+ extension: WebExtension,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) {
+ store.dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.Permissions.Required(
+ extension,
+ onPermissionsGranted,
+ ),
+ ),
+ )
+ }
+
+ override fun onUpdatePermissionRequest(
+ current: WebExtension,
+ updated: WebExtension,
+ newPermissions: List<String>,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) {
+ this@WebExtensionSupport.onUpdatePermissionRequest?.invoke(
+ current,
+ updated,
+ newPermissions,
+ onPermissionsGranted,
+ )
+ }
+
+ override fun onOptionalPermissionsRequest(
+ extension: WebExtension,
+ permissions: List<String>,
+ onPermissionsGranted: ((Boolean) -> Unit),
+ ) {
+ store.dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(
+ extension,
+ permissions,
+ onPermissionsGranted,
+ ),
+ ),
+ )
+ }
+
+ override fun onExtensionListUpdated() {
+ installedExtensions.clear()
+ store.dispatch(WebExtensionAction.UninstallAllWebExtensionsAction)
+ registerInstalledExtensions(store, runtime)
+ }
+
+ override fun onDisabledExtensionProcessSpawning() {
+ store.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true))
+ }
+ },
+ )
+ }
+
+ /**
+ * Awaits for completion of the initialization process (completes when the
+ * state of all installed extensions is known).
+ */
+ suspend fun awaitInitialization() = initializationResult.await()
+
+ /**
+ * Queries the [WebExtensionRuntime] for installed web extensions and adds them to the [store].
+ */
+ private fun registerInstalledExtensions(store: BrowserStore, runtime: WebExtensionRuntime) {
+ runtime.listInstalledWebExtensions(
+ onSuccess = {
+ extensions ->
+ extensions.forEach { registerInstalledExtension(store, it) }
+ emitWebExtensionsInitializedFact(extensions)
+ closeUnsupportedTabs(store, extensions)
+ initializationResult.complete(Unit)
+ onExtensionsLoaded?.invoke(extensions.filter { !it.isBuiltIn() })
+ },
+ onError = {
+ throwable ->
+ logger.error("Failed to query installed extension", throwable)
+ initializationResult.completeExceptionally(throwable)
+ },
+ )
+ }
+
+ /**
+ * Marks the provided [webExtension] as installed by adding it to the [store].
+ */
+ private fun registerInstalledExtension(store: BrowserStore, webExtension: WebExtension) {
+ installedExtensions[webExtension.id] = webExtension
+ store.dispatch(WebExtensionAction.InstallWebExtensionAction(webExtension.toState()))
+
+ // Register action handler for all existing engine sessions on the new extension,
+ // an issue was filed to get us an API, so we don't have to do this per extension:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1603559
+ store.state.allTabs
+ .forEach { tab ->
+ tab.engineState.engineSession?.let { session ->
+ registerSessionHandlers(webExtension, store, session, tab.id)
+ }
+ }
+ }
+
+ /**
+ * Closes any leftover extensions tabs from extensions that are no longer
+ * installed/registered. When an extension is uninstalled, all extension
+ * pages will be closed. So, in theory, there should never be any
+ * leftover tabs. However, since we support temporary registered
+ * extensions and also recently migrated built-in extensions from the
+ * transient registerWebExtensions to the persistent installBuiltIn, we
+ * should handle this case to make sure we don't have any unloadable tabs
+ * around.
+ */
+ private fun closeUnsupportedTabs(store: BrowserStore, extensions: List<WebExtension>) {
+ val supportedUrls = extensions.mapNotNull { it.getMetadata()?.baseUrl }
+
+ // We only need to do this a single time, once tabs are restored. We need to observe the
+ // store (instead of querying it directly), as tabs can be restored asynchronously on
+ // startup and might not be ready yet.
+ var scope: CoroutineScope? = null
+ scope = store.flowScoped { flow ->
+ flow.map { state -> state.tabs.filter { it.restored }.size }
+ .distinctUntilChanged()
+ .collect { size ->
+ if (size > 0) {
+ store.state.tabs.forEach { tab ->
+ val tabUrl = tab.content.url
+ if (tabUrl.isExtensionUrl() && supportedUrls.none { tabUrl.startsWith(it) }) {
+ closeTab(tab.id, false, store, onCloseTabOverride)
+ }
+ }
+ scope?.cancel()
+ }
+ }
+ }
+ }
+
+ /**
+ * Marks the provided [updatedExtension] as updated in the [store].
+ */
+ fun markExtensionAsUpdated(store: BrowserStore, updatedExtension: WebExtension) {
+ installedExtensions[updatedExtension.id] = updatedExtension
+ store.dispatch(WebExtensionAction.UpdateWebExtensionAction(updatedExtension.toState()))
+
+ // Register action handler for all existing engine sessions on the new extension
+ store.state.allTabs.forEach { tab ->
+ tab.engineState.engineSession?.let { session ->
+ registerSessionHandlers(updatedExtension, store, session, tab.id)
+ }
+ }
+ }
+
+ /**
+ * Observes the provided store to register session-specific [ActionHandler]s
+ * for all installed extensions on newly added sessions.
+ */
+ private fun registerHandlersForNewSessions(store: BrowserStore) {
+ // We need to observe for the entire lifetime of the application,
+ // as web extension support is not tied to any particular view.
+ store.flowScoped { flow ->
+ flow.mapNotNull { state -> state.allTabs }
+ .filterChanged {
+ it.engineState.engineSession
+ }
+ .collect { state ->
+ state.engineState.engineSession?.let { session ->
+ installedExtensions.values.forEach { extension ->
+ registerSessionHandlers(extension, store, session, state.id)
+ }
+ }
+ }
+ }
+ }
+
+ private fun registerSessionHandlers(
+ extension: WebExtension,
+ store: BrowserStore,
+ session: EngineSession,
+ sessionId: String,
+ ) {
+ if (extension.supportActions && !extension.hasActionHandler(session)) {
+ val actionHandler = SessionActionHandler(store, sessionId)
+ extension.registerActionHandler(session, actionHandler)
+ }
+
+ if (!extension.hasTabHandler(session)) {
+ val tabHandler = SessionTabHandler(store, sessionId, onCloseTabOverride, onSelectTabOverride)
+ extension.registerTabHandler(session, tabHandler)
+ }
+ }
+
+ private fun openTab(
+ store: BrowserStore,
+ onNewTabOverride: ((WebExtension?, EngineSession, String) -> String)? = null,
+ onSelectTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ webExtension: WebExtension?,
+ engineSession: EngineSession,
+ url: String = "",
+ selected: Boolean = true,
+ ): String {
+ return if (onNewTabOverride != null) {
+ val sessionId = onNewTabOverride.invoke(webExtension, engineSession, url)
+ if (selected) {
+ onSelectTabOverride?.invoke(webExtension, sessionId)
+ }
+ sessionId
+ } else {
+ val tab = createTab(url)
+ store.dispatch(TabListAction.AddTabAction(tab, selected))
+ store.dispatch(EngineAction.LinkEngineSessionAction(tab.id, engineSession))
+ tab.id
+ }
+ }
+
+ private fun closeTab(
+ id: String,
+ customTab: Boolean,
+ store: BrowserStore,
+ onCloseTabOverride: ((WebExtension?, String) -> Unit)? = null,
+ webExtension: WebExtension? = null,
+ ) {
+ if (onCloseTabOverride != null) {
+ onCloseTabOverride.invoke(webExtension, id)
+ } else {
+ val action = if (customTab) {
+ CustomTabListAction.RemoveCustomTabAction(id)
+ } else {
+ TabListAction.RemoveTabAction(id)
+ }
+
+ store.dispatch(action)
+ }
+ }
+
+ @VisibleForTesting
+ internal fun WebExtension.toState() =
+ WebExtensionState(
+ id,
+ url,
+ getMetadata()?.name,
+ isEnabled(),
+ isAllowedInPrivateBrowsing(),
+ )
+
+ private fun SessionState.isCustomTab() = this is CustomTabSessionState
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/facts/WebExtensionFacts.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/facts/WebExtensionFacts.kt
new file mode 100644
index 0000000000..ae15ab85c6
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/facts/WebExtensionFacts.kt
@@ -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/. */
+
+package mozilla.components.support.webextensions.facts
+
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+
+/**
+ * Facts emitted for telemetry related to the Addons feature.
+ */
+class WebExtensionFacts {
+ /**
+ * Items that specify which portion of the web extension events were invoked.
+ */
+ object Items {
+ const val WEB_EXTENSIONS_INITIALIZED = "web_extensions_initialized"
+ }
+}
+
+private fun emitWebExtensionFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null,
+) {
+ Fact(
+ Component.SUPPORT_WEBEXTENSIONS,
+ action,
+ item,
+ value,
+ metadata,
+ ).collect()
+}
+
+internal fun emitWebExtensionsInitializedFact(extensions: List<WebExtension>) {
+ val installedAddons = extensions.filter { !it.isBuiltIn() }
+ val enabledAddons = installedAddons.filter { it.isEnabled() }
+ emitWebExtensionFact(
+ Action.INTERACTION,
+ WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED,
+ metadata = mapOf(
+ "installed" to installedAddons.map { it.id },
+ "enabled" to enabledAddons.map { it.id },
+ ),
+ )
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionControllerTest.kt b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionControllerTest.kt
new file mode 100644
index 0000000000..6851025fdb
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionControllerTest.kt
@@ -0,0 +1,238 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.webextension.MessageHandler
+import mozilla.components.concept.engine.webextension.Port
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.whenever
+import org.json.JSONObject
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class WebExtensionControllerTest {
+ private val extensionId = "test-id"
+ private val defaultPort = "test-messaging-id"
+ private val extensionUrl = "test-url"
+
+ @Before
+ fun setup() {
+ WebExtensionController.installedExtensions.clear()
+ }
+
+ @Test
+ fun `install webextension - installs and invokes success and error callbacks`() {
+ val engine: Engine = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ var onSuccessInvoked = false
+ var onErrorInvoked = false
+ controller.install(engine, onSuccess = { onSuccessInvoked = true }, onError = { onErrorInvoked = true })
+
+ val onSuccess = argumentCaptor<((WebExtension) -> Unit)>()
+ val onError = argumentCaptor<((Throwable) -> Unit)>()
+ verify(engine, times(1)).installBuiltInWebExtension(
+ eq(extensionId),
+ eq(extensionUrl),
+ onSuccess.capture(),
+ onError.capture(),
+ )
+ assertFalse(WebExtensionController.installedExtensions.containsKey(extensionId))
+
+ onSuccess.value.invoke(mock())
+ assertTrue(onSuccessInvoked)
+ assertFalse(onErrorInvoked)
+ assertTrue(WebExtensionController.installedExtensions.containsKey(extensionId))
+
+ controller.install(engine)
+ verify(engine, times(1)).installBuiltInWebExtension(
+ eq(extensionId),
+ eq(extensionUrl),
+ onSuccess.capture(),
+ onError.capture(),
+ )
+
+ onError.value.invoke(mock())
+ assertTrue(onErrorInvoked)
+ }
+
+ @Test
+ fun `install webextension - invokes success callback if extension already installed`() {
+ val engine: Engine = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ var onSuccessInvoked = false
+ var onErrorInvoked = false
+ WebExtensionController.installedExtensions[extensionId] = mock()
+ controller.install(engine, onSuccess = { onSuccessInvoked = true }, onError = { onErrorInvoked = true })
+
+ val onSuccess = argumentCaptor<((WebExtension) -> Unit)>()
+ val onError = argumentCaptor<((Throwable) -> Unit)>()
+ verify(engine, never()).installBuiltInWebExtension(
+ eq(extensionId),
+ eq(extensionUrl),
+ onSuccess.capture(),
+ onError.capture(),
+ )
+ assertTrue(onSuccessInvoked)
+ assertFalse(onErrorInvoked)
+ }
+
+ @Test
+ fun `register content message handler if extension installed`() {
+ val extension: WebExtension = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+ WebExtensionController.installedExtensions[extensionId] = extension
+
+ val session: EngineSession = mock()
+ val messageHandler: MessageHandler = mock()
+ controller.registerContentMessageHandler(session, messageHandler)
+ verify(extension).registerContentMessageHandler(session, defaultPort, messageHandler)
+ }
+
+ @Test
+ fun `register content message handler before extension is installed`() {
+ val engine: Engine = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+ controller.install(engine)
+
+ val onSuccess = argumentCaptor<((WebExtension) -> Unit)>()
+ val onError = argumentCaptor<((Throwable) -> Unit)>()
+ verify(engine, times(1)).installBuiltInWebExtension(
+ eq(extensionId),
+ eq(extensionUrl),
+ onSuccess.capture(),
+ onError.capture(),
+ )
+
+ val session: EngineSession = mock()
+ val messageHandler: MessageHandler = mock()
+ controller.registerContentMessageHandler(session, messageHandler)
+
+ val extension: WebExtension = mock()
+ onSuccess.value.invoke(extension)
+ verify(extension).registerContentMessageHandler(session, defaultPort, messageHandler)
+ }
+
+ @Test
+ fun `send content message`() {
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ val message: JSONObject = mock()
+ val extension: WebExtension = mock()
+ val session: EngineSession = mock()
+ val port: Port = mock()
+ whenever(extension.getConnectedPort(defaultPort, session)).thenReturn(port)
+
+ controller.sendContentMessage(message, null)
+ verify(port, never()).postMessage(message)
+
+ controller.sendContentMessage(message, session)
+ verify(port, never()).postMessage(message)
+
+ WebExtensionController.installedExtensions[extensionId] = extension
+
+ controller.sendContentMessage(message, session)
+ verify(port, times(1)).postMessage(message)
+ }
+
+ @Test
+ fun `register background message handler if extension installed`() {
+ val extension: WebExtension = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+ WebExtensionController.installedExtensions[extensionId] = extension
+
+ val messageHandler: MessageHandler = mock()
+ controller.registerBackgroundMessageHandler(messageHandler)
+ verify(extension).registerBackgroundMessageHandler(defaultPort, messageHandler)
+ }
+
+ @Test
+ fun `register background message handler before extension is installed`() {
+ val engine: Engine = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+ controller.install(engine)
+
+ val onSuccess = argumentCaptor<((WebExtension) -> Unit)>()
+ val onError = argumentCaptor<((Throwable) -> Unit)>()
+ verify(engine, times(1)).installBuiltInWebExtension(
+ eq(extensionId),
+ eq(extensionUrl),
+ onSuccess.capture(),
+ onError.capture(),
+ )
+
+ val messageHandler: MessageHandler = mock()
+ controller.registerBackgroundMessageHandler(messageHandler)
+
+ val extension: WebExtension = mock()
+ onSuccess.value.invoke(extension)
+ verify(extension).registerBackgroundMessageHandler(defaultPort, messageHandler)
+ }
+
+ @Test
+ fun `send background message`() {
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ val message: JSONObject = mock()
+ val extension: WebExtension = mock()
+ val port: Port = mock()
+ whenever(extension.getConnectedPort(defaultPort)).thenReturn(port)
+
+ controller.sendBackgroundMessage(message)
+ verify(port, never()).postMessage(message)
+
+ WebExtensionController.installedExtensions[extensionId] = extension
+
+ controller.sendBackgroundMessage(message)
+ verify(port, times(1)).postMessage(message)
+ }
+
+ @Test
+ fun `check if port connected`() {
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ val extension: WebExtension = mock()
+ val session: EngineSession = mock()
+ whenever(extension.getConnectedPort(defaultPort, session)).thenReturn(mock())
+
+ assertFalse(controller.portConnected(null))
+ assertFalse(controller.portConnected(mock()))
+ assertFalse(controller.portConnected(session))
+
+ WebExtensionController.installedExtensions[extensionId] = extension
+
+ assertTrue(controller.portConnected(session))
+ assertFalse(controller.portConnected(session, "invalid"))
+ }
+
+ @Test
+ fun `disconnect port`() {
+ val extension: WebExtension = mock()
+ val controller = WebExtensionController(extensionId, extensionUrl, defaultPort)
+
+ controller.disconnectPort(null)
+ verify(extension, never()).disconnectPort(eq(defaultPort), any())
+
+ val session: EngineSession = mock()
+ controller.disconnectPort(session)
+ verify(extension, never()).disconnectPort(eq(defaultPort), eq(session))
+
+ WebExtensionController.installedExtensions[extensionId] = extension
+ controller.disconnectPort(session)
+ verify(extension, times(1)).disconnectPort(eq(defaultPort), eq(session))
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionPopupObserverTest.kt b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionPopupObserverTest.kt
new file mode 100644
index 0000000000..4a367fe3e6
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionPopupObserverTest.kt
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.support.webextensions
+
+import mozilla.components.browser.state.action.WebExtensionAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+
+class WebExtensionPopupObserverTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun `observes and forwards request to open popup`() {
+ val extensionId = "ext1"
+ val engineSession: EngineSession = mock()
+ val store = BrowserStore(
+ BrowserState(
+ extensions = mapOf(extensionId to WebExtensionState(extensionId)),
+ ),
+ )
+
+ var extensionOpeningPopup: WebExtensionState? = null
+ val observer = WebExtensionPopupObserver(store) {
+ extensionOpeningPopup = it
+ }
+
+ observer.start()
+ assertNull(extensionOpeningPopup)
+
+ store.dispatch(WebExtensionAction.UpdatePopupSessionAction(extensionId, popupSession = engineSession)).joinBlocking()
+ assertNotNull(extensionOpeningPopup)
+ assertEquals(extensionId, extensionOpeningPopup!!.id)
+ assertEquals(engineSession, extensionOpeningPopup!!.popupSession)
+
+ // Verify that stopped feature does not observe and forward requests to open popup
+ extensionOpeningPopup = null
+ observer.stop()
+ store.dispatch(WebExtensionAction.UpdatePopupSessionAction(extensionId, popupSession = mock())).joinBlocking()
+ assertNull(extensionOpeningPopup)
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
new file mode 100644
index 0000000000..b4e45b3f55
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/test/java/mozilla/components/support/webextensions/WebExtensionSupportTest.kt
@@ -0,0 +1,1077 @@
+/* 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/. */
+
+package mozilla.components.support.webextensions
+
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.action.WebExtensionAction
+import mozilla.components.browser.state.selector.findTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.WebExtensionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.state.extension.WebExtensionPromptRequest
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.Engine
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.webextension.Action
+import mozilla.components.concept.engine.webextension.ActionHandler
+import mozilla.components.concept.engine.webextension.Incognito
+import mozilla.components.concept.engine.webextension.Metadata
+import mozilla.components.concept.engine.webextension.TabHandler
+import mozilla.components.concept.engine.webextension.WebExtension
+import mozilla.components.concept.engine.webextension.WebExtensionDelegate
+import mozilla.components.concept.engine.webextension.WebExtensionInstallException
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import mozilla.components.support.webextensions.WebExtensionSupport.toState
+import mozilla.components.support.webextensions.facts.WebExtensionFacts.Items.WEB_EXTENSIONS_INITIALIZED
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import mozilla.components.support.base.facts.Action as FactsAction
+
+class WebExtensionSupportTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @After
+ fun tearDown() {
+ WebExtensionSupport.installedExtensions.clear()
+ }
+
+ @Test
+ fun `sets web extension delegate on engine`() {
+ val engine: Engine = mock()
+ val store = BrowserStore()
+
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(any())
+ }
+
+ @Test
+ fun `queries engine for installed extensions and adds state to the store`() {
+ val store = spy(BrowserStore())
+
+ val ext1: WebExtension = mock()
+ val ext1Meta: Metadata = mock()
+ whenever(ext1Meta.name).thenReturn("ext1")
+ val ext2: WebExtension = mock()
+ whenever(ext1.id).thenReturn("1")
+ whenever(ext1.url).thenReturn("url1")
+ whenever(ext1.getMetadata()).thenReturn(ext1Meta)
+ whenever(ext1.isEnabled()).thenReturn(true)
+ whenever(ext1.isAllowedInPrivateBrowsing()).thenReturn(true)
+
+ whenever(ext2.id).thenReturn("2")
+ whenever(ext2.url).thenReturn("url2")
+ whenever(ext2.isEnabled()).thenReturn(false)
+ whenever(ext2.isAllowedInPrivateBrowsing()).thenReturn(false)
+
+ val engine: Engine = mock()
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(listOf(ext1, ext2))
+ }
+
+ CollectionProcessor.withFactCollection { facts ->
+ WebExtensionSupport.initialize(engine, store)
+
+ val interactionFact = facts[0]
+ assertEquals(FactsAction.INTERACTION, interactionFact.action)
+ assertEquals(Component.SUPPORT_WEBEXTENSIONS, interactionFact.component)
+ assertEquals(WEB_EXTENSIONS_INITIALIZED, interactionFact.item)
+ assertEquals(2, interactionFact.metadata?.size)
+ assertTrue(interactionFact.metadata?.containsKey("installed")!!)
+ assertTrue(interactionFact.metadata?.containsKey("enabled")!!)
+ assertEquals(listOf(ext1.id, ext2.id), interactionFact.metadata?.get("installed"))
+ assertEquals(listOf(ext1.id), interactionFact.metadata?.get("enabled"))
+ }
+ assertEquals(ext1, WebExtensionSupport.installedExtensions[ext1.id])
+ assertEquals(ext2, WebExtensionSupport.installedExtensions[ext2.id])
+
+ val actionCaptor = argumentCaptor<WebExtensionAction.InstallWebExtensionAction>()
+ verify(store, times(2)).dispatch(actionCaptor.capture())
+ assertEquals(
+ WebExtensionState(ext1.id, ext1.url, "ext1", true, true),
+ actionCaptor.allValues[0].extension,
+ )
+ assertEquals(
+ WebExtensionState(ext2.id, ext2.url, null, false, false),
+ actionCaptor.allValues[1].extension,
+ )
+ }
+
+ @Test
+ fun `reacts to new tab being opened by adding tab to store`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val engineSession: EngineSession = mock()
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org")
+ val actionCaptor = argumentCaptor<mozilla.components.browser.state.action.BrowserAction>()
+ verify(store, times(2)).dispatch(actionCaptor.capture())
+ assertEquals(
+ "https://mozilla.org",
+ (actionCaptor.allValues.first() as TabListAction.AddTabAction).tab.content.url,
+ )
+ assertEquals(
+ engineSession,
+ (actionCaptor.allValues.last() as EngineAction.LinkEngineSessionAction).engineSession,
+ )
+ }
+
+ @Test
+ fun `allows overriding onNewTab behaviour`() {
+ val store = BrowserStore()
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val engineSession: EngineSession = mock()
+ var onNewTabCalled = false
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(
+ engine,
+ store,
+ onNewTabOverride = { _, _, _ ->
+ onNewTabCalled = true
+ "123"
+ },
+ )
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onNewTab(ext, engineSession, true, "https://mozilla.org")
+ assertTrue(onNewTabCalled)
+ }
+
+ @Test
+ fun `reacts to tab being closed by removing tab from store`() {
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ whenever(ext.hasTabHandler(any())).thenReturn(false, true)
+ val engineSession: EngineSession = mock()
+ val tabId = "testTabId"
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = tabId, url = "https://www.mozilla.org", engineSession = engineSession),
+ ),
+ ),
+ ),
+ )
+ val installedList = mutableListOf(ext)
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ WebExtensionSupport.initialize(engine, store)
+
+ store.waitUntilIdle()
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ tabHandlerCaptor.value.onCloseTab(ext, engineSession)
+ verify(store).dispatch(TabListAction.RemoveTabAction(tabId))
+ }
+
+ @Test
+ fun `reacts to custom tab being closed by removing tab from store`() {
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ whenever(ext.hasTabHandler(any())).thenReturn(false, true)
+ val engineSession: EngineSession = mock()
+ val tabId = "testTabId"
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab(id = tabId, url = "https://www.mozilla.org", engineSession = engineSession, source = SessionState.Source.Internal.CustomTab),
+ ),
+ ),
+ ),
+ )
+ val installedList = mutableListOf(ext)
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ WebExtensionSupport.initialize(engine, store)
+
+ store.waitUntilIdle()
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ tabHandlerCaptor.value.onCloseTab(ext, engineSession)
+ verify(store).dispatch(CustomTabListAction.RemoveCustomTabAction(tabId))
+ }
+
+ @Test
+ fun `allows overriding onCloseTab behaviour`() {
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ whenever(ext.hasTabHandler(any())).thenReturn(false, true)
+ val engineSession: EngineSession = mock()
+ var onCloseTabCalled = false
+ val tabId = "testTabId"
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = tabId, url = "https://www.mozilla.org", engineSession = engineSession),
+ ),
+ ),
+ ),
+ )
+
+ val installedList = mutableListOf(ext)
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ WebExtensionSupport.initialize(
+ engine,
+ store,
+ onSelectTabOverride = { _, _ -> },
+ onCloseTabOverride = { _, _ -> onCloseTabCalled = true },
+ )
+
+ store.waitUntilIdle()
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ tabHandlerCaptor.value.onCloseTab(ext, engineSession)
+ assertTrue(onCloseTabCalled)
+ }
+
+ @Test
+ fun `reacts to tab being updated`() {
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ whenever(ext.hasTabHandler(any())).thenReturn(false, true)
+ val engineSession: EngineSession = mock()
+ val tabId = "testTabId"
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = tabId, url = "https://www.mozilla.org", engineSession = engineSession),
+ ),
+ ),
+ )
+
+ val installedList = mutableListOf(ext)
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ WebExtensionSupport.initialize(engine, store)
+
+ // Update tab to select it
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ assertNull(store.state.selectedTabId)
+ assertTrue(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, null))
+ store.waitUntilIdle()
+ assertEquals("testTabId", store.state.selectedTabId)
+
+ // Update URL of tab
+ assertTrue(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, false, "url"))
+ verify(engineSession).loadUrl("url")
+
+ // Update non-existing tab
+ store.dispatch(TabListAction.RemoveTabAction(tabId)).joinBlocking()
+ assertFalse(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, "url"))
+ }
+
+ @Test
+ fun `reacts to custom tab being updated`() {
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ whenever(ext.hasTabHandler(any())).thenReturn(false, true)
+ val engineSession: EngineSession = mock()
+ val tabId = "testTabId"
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab(id = tabId, url = "https://www.mozilla.org", engineSession = engineSession, source = SessionState.Source.Internal.CustomTab),
+ ),
+ ),
+ )
+
+ val installedList = mutableListOf(ext)
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ WebExtensionSupport.initialize(engine, store)
+
+ // Update tab to select it
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ assertNull(store.state.selectedTabId)
+ assertTrue(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, null))
+ store.waitUntilIdle()
+
+ // Update URL of tab
+ assertTrue(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, false, "url"))
+ verify(engineSession).loadUrl("url")
+
+ // Update non-existing tab
+ store.dispatch(CustomTabListAction.RemoveCustomTabAction(tabId)).joinBlocking()
+ assertFalse(tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, "url"))
+ }
+
+ @Test
+ fun `reacts to new extension being installed`() {
+ val engineSession: EngineSession = mock()
+ val tab =
+ createTab(id = "1", url = "https://www.mozilla.org", engineSession = engineSession)
+
+ val customTabEngineSession: EngineSession = mock()
+ val customTab =
+ createCustomTab(id = "2", url = "https://www.mozilla.org", engineSession = customTabEngineSession, source = SessionState.Source.Internal.CustomTab)
+
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ customTabs = listOf(customTab),
+ ),
+ ),
+ )
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+ whenever(ext.isBuiltIn()).thenReturn(false)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Verify that we dispatch to the store and mark the extension as installed
+ delegateCaptor.value.onInstalled(ext)
+ verify(store).dispatch(
+ WebExtensionAction.InstallWebExtensionAction(
+ WebExtensionState(ext.id, ext.url, ext.getMetadata()?.name, ext.isEnabled()),
+ ),
+ )
+ verify(store).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.PostInstallation(ext),
+ ),
+ )
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+
+ // Verify that we register action and tab handlers for all existing sessions on the extension
+ val actionHandlerCaptor = argumentCaptor<ActionHandler>()
+ val webExtensionActionCaptor = argumentCaptor<WebExtensionAction>()
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ val selectTabActionCaptor = argumentCaptor<TabListAction.SelectTabAction>()
+ verify(ext).registerActionHandler(eq(customTabEngineSession), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(customTabEngineSession), tabHandlerCaptor.capture())
+ verify(ext).registerActionHandler(eq(engineSession), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+
+ // Verify we only register the handlers once
+ whenever(ext.hasActionHandler(engineSession)).thenReturn(true)
+ whenever(ext.hasTabHandler(engineSession)).thenReturn(true)
+
+ actionHandlerCaptor.value.onBrowserAction(ext, engineSession, mock())
+ verify(store, times(3)).dispatch(webExtensionActionCaptor.capture())
+ assertEquals(ext.id, (webExtensionActionCaptor.allValues.last() as WebExtensionAction.UpdateTabBrowserAction).extensionId)
+
+ store.dispatch(ContentAction.UpdateUrlAction(sessionId = "1", url = "https://www.firefox.com")).joinBlocking()
+ verify(ext, times(1)).registerActionHandler(eq(engineSession), actionHandlerCaptor.capture())
+ verify(ext, times(1)).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+
+ reset(store)
+
+ tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, null)
+ verify(store).dispatch(selectTabActionCaptor.capture())
+ assertEquals("1", selectTabActionCaptor.value.tabId)
+ tabHandlerCaptor.value.onUpdateTab(ext, engineSession, true, "url")
+ verify(engineSession).loadUrl("url")
+ }
+
+ @Test
+ fun `reacts to install permission request`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val onPermissionsGranted: ((Boolean) -> Unit) = mock()
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Verify they we confirm the permission request
+ delegateCaptor.value.onInstallPermissionRequest(ext, onPermissionsGranted)
+
+ verify(store).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.Permissions.Required(ext, onPermissionsGranted),
+ ),
+ )
+ }
+
+ @Test
+ fun `reacts to extension being uninstalled`() {
+ val store = spy(BrowserStore())
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onInstalled(ext)
+ verify(store).dispatch(
+ WebExtensionAction.InstallWebExtensionAction(
+ WebExtensionState(ext.id, ext.url, ext.getMetadata()?.name, ext.isEnabled()),
+ ),
+ )
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+
+ // Verify that we dispatch to the store and mark the extension as uninstalled
+ delegateCaptor.value.onUninstalled(ext)
+ verify(store).dispatch(WebExtensionAction.UninstallWebExtensionAction(ext.id))
+ assertNull(WebExtensionSupport.installedExtensions[ext.id])
+ }
+
+ @Test
+ fun `GIVEN BuiltIn extension WHEN calling onInstalled THEN do not show the PostInstallation prompt`() {
+ val store = spy(BrowserStore())
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+ whenever(ext.isBuiltIn()).thenReturn(true)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onInstalled(ext)
+ verify(store, times(0)).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.PostInstallation(ext),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN already installed extension WHEN calling onInstalled THEN do not show the PostInstallation prompt`() {
+ val store = spy(BrowserStore())
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // We simulate a first install...
+ delegateCaptor.value.onInstalled(ext)
+ // ... and then an update, which also calls `onInstalled()`.
+ delegateCaptor.value.onInstalled(ext)
+
+ verify(store, times(1)).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.PostInstallation(ext),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN extension WHEN calling onInstallationFailedRequest THEN show the installation prompt error`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val exception = WebExtensionInstallException.Blocklisted(throwable = Exception())
+
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+ whenever(ext.isBuiltIn()).thenReturn(false)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onInstallationFailedRequest(ext, exception)
+
+ verify(store, times(1)).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.BeforeInstallation.InstallationFailed(ext, exception),
+ ),
+ )
+ }
+
+ @Test
+ fun `reacts to extension being enabled`() {
+ val store = spy(BrowserStore())
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onEnabled(ext)
+ verify(store).dispatch(WebExtensionAction.UpdateWebExtensionEnabledAction(ext.id, true))
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+ }
+
+ @Test
+ fun `reacts to extension being disabled`() {
+ val store = spy(BrowserStore())
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onDisabled(ext)
+ verify(store).dispatch(WebExtensionAction.UpdateWebExtensionEnabledAction(ext.id, false))
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+ }
+
+ @Test
+ fun `observes store and registers handlers on new engine sessions`() {
+ val tab = createTab(id = "1", url = "https://www.mozilla.org")
+ val customTab = createCustomTab(id = "2", url = "https://www.mozilla.org", source = SessionState.Source.Internal.CustomTab)
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ customTabs = listOf(customTab),
+ ),
+ ),
+ )
+
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+
+ // Install extension
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+ delegateCaptor.value.onInstalled(ext)
+
+ // Verify that action/tab handler is registered when a new engine session is created
+ val actionHandlerCaptor = argumentCaptor<ActionHandler>()
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ verify(ext, never()).registerActionHandler(any(), any())
+ verify(ext, never()).registerTabHandler(
+ session = any(),
+ tabHandler = any(),
+ )
+
+ val engineSession1: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction(tab.id, engineSession1)).joinBlocking()
+ verify(ext).registerActionHandler(eq(engineSession1), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(engineSession1), tabHandlerCaptor.capture())
+
+ val engineSession2: EngineSession = mock()
+ store.dispatch(EngineAction.LinkEngineSessionAction(customTab.id, engineSession2)).joinBlocking()
+ verify(ext).registerActionHandler(eq(engineSession2), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(engineSession2), tabHandlerCaptor.capture())
+ }
+
+ @Test
+ fun `reacts to browser action being defined by dispatching to the store`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val browserAction: Action = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onBrowserActionDefined(ext, browserAction)
+ val actionCaptor = argumentCaptor<WebExtensionAction.UpdateBrowserAction>()
+ verify(store).dispatch(actionCaptor.capture())
+ assertEquals("test", actionCaptor.value.extensionId)
+ assertEquals(browserAction, actionCaptor.value.browserAction)
+ }
+
+ @Test
+ fun `reacts to page action being defined by dispatching to the store`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val pageAction: Action = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onPageActionDefined(ext, pageAction)
+ val actionCaptor = argumentCaptor<WebExtensionAction.UpdatePageAction>()
+ verify(store).dispatch(actionCaptor.capture())
+ assertEquals("test", actionCaptor.value.extensionId)
+ assertEquals(pageAction, actionCaptor.value.pageAction)
+ }
+
+ @Test
+ fun `reacts to action popup being toggled by opening tab as needed`() {
+ val engine: Engine = mock()
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val engineSession: EngineSession = mock()
+ val browserAction: Action = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ extensions = mapOf(ext.id to WebExtensionState(ext.id)),
+ ),
+ ),
+ )
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store, openPopupInTab = true)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Toggling should open tab
+ delegateCaptor.value.onToggleActionPopup(ext, engineSession, browserAction)
+ var actionCaptor = argumentCaptor<mozilla.components.browser.state.action.BrowserAction>()
+ verify(store, times(3)).dispatch(actionCaptor.capture())
+ var values = actionCaptor.allValues
+ assertEquals("", (values[0] as TabListAction.AddTabAction).tab.content.url)
+ assertEquals(engineSession, (values[1] as EngineAction.LinkEngineSessionAction).engineSession)
+ assertEquals("test", (values[2] as WebExtensionAction.UpdatePopupSessionAction).extensionId)
+ val popupSessionId = (values[2] as WebExtensionAction.UpdatePopupSessionAction).popupSessionId
+ assertNotNull(popupSessionId)
+ }
+
+ @Test
+ fun `reacts to action popup being toggled by selecting tab as needed`() {
+ val engine: Engine = mock()
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val engineSession: EngineSession = mock()
+ val browserAction: Action = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab(id = "popupTab", url = "https://www.mozilla.org")),
+ extensions = mapOf(ext.id to WebExtensionState(ext.id, popupSessionId = "popupTab")),
+ ),
+ ),
+ )
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store, openPopupInTab = true)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Toggling again should select popup tab
+ var actionCaptor = argumentCaptor<mozilla.components.browser.state.action.BrowserAction>()
+ delegateCaptor.value.onToggleActionPopup(ext, engineSession, browserAction)
+
+ store.waitUntilIdle()
+ verify(store, times(1)).dispatch(actionCaptor.capture())
+ assertEquals("popupTab", (actionCaptor.value as TabListAction.SelectTabAction).tabId)
+ }
+
+ @Test
+ fun `reacts to action popup being toggled by closing tab as needed`() {
+ val engine: Engine = mock()
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val engineSession: EngineSession = mock()
+ val browserAction: Action = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(createTab(id = "popupTab", url = "https://www.mozilla.org")),
+ selectedTabId = "popupTab",
+ extensions = mapOf(ext.id to WebExtensionState(ext.id, popupSessionId = "popupTab")),
+ ),
+ ),
+ )
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store, openPopupInTab = true)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Toggling again should close tab
+ var actionCaptor = argumentCaptor<mozilla.components.browser.state.action.BrowserAction>()
+ delegateCaptor.value.onToggleActionPopup(ext, engineSession, browserAction)
+ store.waitUntilIdle()
+
+ verify(store).dispatch(actionCaptor.capture())
+ assertEquals("popupTab", (actionCaptor.value as TabListAction.RemoveTabAction).tabId)
+ }
+
+ @Test
+ fun `reacts to action popup being toggled by opening a popup`() {
+ val engine: Engine = mock()
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+
+ val engineSession: EngineSession = mock()
+ val browserAction: Action = mock()
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ extensions = mapOf(ext.id to WebExtensionState(ext.id)),
+ ),
+ ),
+ )
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Toggling should allow state to have popup EngineSession instance
+ delegateCaptor.value.onToggleActionPopup(ext, engineSession, browserAction)
+ val actionCaptor = argumentCaptor<mozilla.components.browser.state.action.BrowserAction>()
+ verify(store).dispatch(actionCaptor.capture())
+
+ val value = actionCaptor.value
+ assertNotNull((value as WebExtensionAction.UpdatePopupSessionAction).popupSession)
+ }
+
+ @Test
+ fun `invokes onUpdatePermissionRequest callback`() {
+ var executed = false
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ extensions = mapOf(ext.id to WebExtensionState(ext.id)),
+ ),
+ ),
+ )
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(
+ runtime = engine,
+ store = store,
+ onUpdatePermissionRequest = { _, _, _, _ ->
+ executed = true
+ },
+ )
+
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+ delegateCaptor.value.onUpdatePermissionRequest(mock(), mock(), mock(), mock())
+ assertTrue(executed)
+ }
+
+ @Test
+ fun `invokes onExtensionsLoaded callback`() {
+ var executed = false
+ val engine: Engine = mock()
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isBuiltIn()).thenReturn(false)
+
+ val builtInExt: WebExtension = mock()
+ whenever(builtInExt.id).thenReturn("test2")
+ whenever(builtInExt.isBuiltIn()).thenReturn(true)
+
+ val store = spy(BrowserStore(BrowserState(extensions = mapOf(ext.id to WebExtensionState(ext.id)))))
+
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(listOf(ext, builtInExt))
+ }
+
+ val onExtensionsLoaded: ((List<WebExtension>) -> Unit) = {
+ assertEquals(1, it.size)
+ assertEquals(ext, it[0])
+ executed = true
+ }
+ WebExtensionSupport.initialize(runtime = engine, store = store, onExtensionsLoaded = onExtensionsLoaded)
+ assertTrue(executed)
+ }
+
+ @Test
+ fun `reacts to extension list being updated in the engine`() {
+ val store = spy(BrowserStore())
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ val installedList = mutableListOf(ext)
+
+ val engine: Engine = mock()
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ assertEquals(1, WebExtensionSupport.installedExtensions.size)
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onExtensionListUpdated()
+ store.waitUntilIdle()
+
+ val actionCaptor = argumentCaptor<WebExtensionAction>()
+ verify(store, times(3)).dispatch(actionCaptor.capture())
+ assertEquals(3, actionCaptor.allValues.size)
+ // Initial install
+ assertTrue(actionCaptor.allValues[0] is WebExtensionAction.InstallWebExtensionAction)
+ assertEquals(WebExtensionState(ext.id), (actionCaptor.allValues[0] as WebExtensionAction.InstallWebExtensionAction).extension)
+
+ // Uninstall all
+ assertTrue(actionCaptor.allValues[1] is WebExtensionAction.UninstallAllWebExtensionsAction)
+
+ // Reinstall
+ assertTrue(actionCaptor.allValues[2] is WebExtensionAction.InstallWebExtensionAction)
+ assertEquals(WebExtensionState(ext.id), (actionCaptor.allValues[2] as WebExtensionAction.InstallWebExtensionAction).extension)
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+
+ // Verify installed extensions are cleared
+ installedList.clear()
+ delegateCaptor.value.onExtensionListUpdated()
+ store.waitUntilIdle()
+ assertTrue(WebExtensionSupport.installedExtensions.isEmpty())
+ }
+
+ @Test
+ fun `reacts to WebExtensionDelegate onReady by updating the extension details stored in the installedExtensions map`() {
+ val store = spy(BrowserStore())
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("test")
+ whenever(ext.isEnabled()).thenReturn(true)
+ val extMeta: Metadata = mock()
+ whenever(extMeta.incognito).thenReturn(Incognito.SPANNING)
+ whenever(ext.getMetadata()).thenReturn(extMeta)
+ val installedList = mutableListOf(ext)
+
+ val engine: Engine = mock()
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(installedList)
+ }
+
+ // Initialize WebExtensionSupport and expect the extension metadata
+ // to be the one coming from the first mock WebExtension instance.
+ WebExtensionSupport.initialize(engine, store)
+ assertEquals(1, WebExtensionSupport.installedExtensions.size)
+ assertEquals(ext, WebExtensionSupport.installedExtensions[ext.id])
+ assertEquals(extMeta, WebExtensionSupport.installedExtensions[ext.id]?.getMetadata())
+
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ // Mock a call to the WebExtensionDelegate.onReady delegate and the
+ // extension and its metadata instances stored in the installExtensions
+ // map to have been updated as a side-effect of that.
+ val extOnceReady: WebExtension = mock()
+ whenever(extOnceReady.id).thenReturn("test")
+ whenever(extOnceReady.isEnabled()).thenReturn(true)
+ val extOnceReadyMeta: Metadata = mock()
+ whenever(extOnceReady.getMetadata()).thenReturn(extOnceReadyMeta)
+
+ delegateCaptor.value.onReady(extOnceReady)
+
+ assertEquals(1, WebExtensionSupport.installedExtensions.size)
+ assertEquals(extOnceReady, WebExtensionSupport.installedExtensions[ext.id])
+ assertEquals(extOnceReadyMeta, WebExtensionSupport.installedExtensions[ext.id]?.getMetadata())
+
+ store.waitUntilIdle()
+ }
+
+ @Test
+ fun `reacts to extensions process spawning disabled`() {
+ val store = BrowserStore()
+ val engine: Engine = mock()
+
+ assertFalse(store.state.showExtensionsProcessDisabledPrompt)
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onDisabledExtensionProcessSpawning()
+ store.waitUntilIdle()
+
+ assertTrue(store.state.showExtensionsProcessDisabledPrompt)
+ }
+
+ @Test
+ fun `closes tabs from unsupported extensions`() {
+ val store = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab(id = "1", url = "https://www.mozilla.org", restored = true),
+ createTab(id = "2", url = "moz-extension://1234-5678/test", restored = true),
+ createTab(id = "3", url = "moz-extension://1234-5678-9/", restored = true),
+ ),
+ ),
+ )
+
+ val ext1: WebExtension = mock()
+ val ext1Meta: Metadata = mock()
+ whenever(ext1Meta.baseUrl).thenReturn("moz-extension://1234-5678/")
+ whenever(ext1.id).thenReturn("1")
+ whenever(ext1.url).thenReturn("url1")
+ whenever(ext1.getMetadata()).thenReturn(ext1Meta)
+ whenever(ext1.isEnabled()).thenReturn(true)
+ whenever(ext1.isAllowedInPrivateBrowsing()).thenReturn(true)
+
+ val ext2: WebExtension = mock()
+ whenever(ext2.id).thenReturn("2")
+ whenever(ext2.url).thenReturn("url2")
+ whenever(ext2.isEnabled()).thenReturn(true)
+ whenever(ext2.isAllowedInPrivateBrowsing()).thenReturn(false)
+
+ val engine: Engine = mock()
+ val callbackCaptor = argumentCaptor<((List<WebExtension>) -> Unit)>()
+ whenever(engine.listInstalledWebExtensions(callbackCaptor.capture(), any())).thenAnswer {
+ callbackCaptor.value.invoke(listOf(ext1, ext2))
+ }
+
+ WebExtensionSupport.initialize(engine, store)
+
+ store.waitUntilIdle()
+ assertNotNull(store.state.findTab("1"))
+ assertNotNull(store.state.findTab("2"))
+ assertNull(store.state.findTab("3"))
+
+ // Make sure we're running a single cleanup and stop the scope after
+ store.dispatch(TabListAction.AddTabAction(createTab(id = "4", url = "moz-extension://1234-5678-90/")))
+ .joinBlocking()
+
+ store.waitUntilIdle()
+ assertNotNull(store.state.findTab("4"))
+ }
+
+ @Test
+ fun `marks extensions as updated`() {
+ val engineSession: EngineSession = mock()
+ val tab =
+ createTab(id = "1", url = "https://www.mozilla.org", engineSession = engineSession)
+
+ val customTabEngineSession: EngineSession = mock()
+ val customTab =
+ createCustomTab(id = "2", url = "https://www.mozilla.org", engineSession = customTabEngineSession, source = SessionState.Source.Internal.CustomTab)
+
+ val store = spy(
+ BrowserStore(
+ BrowserState(
+ tabs = listOf(tab),
+ customTabs = listOf(customTab),
+ ),
+ ),
+ )
+
+ val ext: WebExtension = mock()
+ whenever(ext.id).thenReturn("extensionId")
+ whenever(ext.url).thenReturn("url")
+ whenever(ext.supportActions).thenReturn(true)
+
+ WebExtensionSupport.markExtensionAsUpdated(store, ext)
+ assertSame(ext, WebExtensionSupport.installedExtensions[ext.id])
+ verify(store).dispatch(WebExtensionAction.UpdateWebExtensionAction(ext.toState()))
+
+ // Verify that we register new action and tab handlers for the updated extension
+ val actionHandlerCaptor = argumentCaptor<ActionHandler>()
+ val tabHandlerCaptor = argumentCaptor<TabHandler>()
+ verify(ext).registerActionHandler(eq(customTabEngineSession), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(customTabEngineSession), tabHandlerCaptor.capture())
+ verify(ext).registerActionHandler(eq(engineSession), actionHandlerCaptor.capture())
+ verify(ext).registerTabHandler(eq(engineSession), tabHandlerCaptor.capture())
+ }
+
+ @Test
+ fun `reacts to optional permissions request`() {
+ val store = spy(BrowserStore())
+ val engine: Engine = mock()
+ val ext: WebExtension = mock()
+ val permissions = listOf("perm1", "perm2")
+ val onPermissionsGranted: ((Boolean) -> Unit) = mock()
+ val delegateCaptor = argumentCaptor<WebExtensionDelegate>()
+ WebExtensionSupport.initialize(engine, store)
+ verify(engine).registerWebExtensionDelegate(delegateCaptor.capture())
+
+ delegateCaptor.value.onOptionalPermissionsRequest(ext, permissions, onPermissionsGranted)
+ verify(store).dispatch(
+ WebExtensionAction.UpdatePromptRequestWebExtensionAction(
+ WebExtensionPromptRequest.AfterInstallation.Permissions.Optional(ext, permissions, onPermissionsGranted),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/support/webextensions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
diff --git a/mobile/android/android-components/components/support/webextensions/src/test/resources/robolectric.properties b/mobile/android/android-components/components/support/webextensions/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/support/webextensions/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28