diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/support/webextensions | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-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')
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 |