diff --git a/.rules/add-document-start-javascript.md b/.rules/add-document-start-javascript.md new file mode 100644 index 000000000000..94cf17890066 --- /dev/null +++ b/.rules/add-document-start-javascript.md @@ -0,0 +1,79 @@ +--- +title: "AddDocumentStartJavaScript" +description: "How we use AddDocumentStartJavaScript" +keywords: ["adddocumentstartjavascript", "addDocumentStartJavaScript", "AddDocumentStartJavaScript", "add document start javascript", "css", "c-s-s", "CSS", "C-S-S", "WebView", "webview", "contentscopescripts", "content-scope-scripts", "ContentScopeScripts", "contentScopeScripts"] +alwaysApply: false +--- + +# Adding a new script using `addDocumentStartJavaScript` + +There are 2 ways of adding a new script using +1. (Recommended) Using `AddDocumentStartScriptDelegate` to implement `AddDocumentStartJavaScript` via delegation pattern. This approach already takes care of +preventing adding the same script more than once, and removing the previous script and adding a new one if needed. It also adds checks on the WebView lifecycle to minimize native crashes. +2. (Manual approach, only recommended if more flexibility than the one provided by the delegate is needed) Manually implementing `AddDocumentStartJavaScript` + +## Using `AddDocumentStartScriptDelegate` to implement `AddDocumentStartJavaScript` via delegation pattern +``` +class AddDocumentStartJavaScript @Inject constructor( + scriptInjectorDelegate: AddDocumentStartScriptDelegate, +) : AddDocumentStartJavaScript by scriptInjectorDelegate.createPlugin( + object : AddDocumentStartJavaScriptScriptStrategy { + override suspend fun canInject(): Boolean { + TODO("Implement logic to determine if the script can be added (i.e. checking RC flags or user settings)" + + "or return true if always applicable") + } + + override suspend fun getScriptString(): String { + TODO("Return the script to be injected") + } + + override val allowedOriginRules: Set + get() = TODO("Return the set of allowed origin rules. For example:" + + "- if the script should be injected on all origins, return setOf(\"*\")" + + "- if the script should be injected only on specific origins, return setOf(\"https://example.com\", \"https://another.com\")" + + "- if the script should be injected on all subdomains of a domain, return setOf(\"https://*.example.com\")") + + override val context: String + get() = TODO("Return a string representing the context of this script, e.g., \"YourFeature\"") + }, +) +``` + +## Manually implementing `AddDocumentStartJavaScript` +Since the `AddDocumentStartScriptDelegate` already solves most of the issues and dangers of working with the `addDocumentStartJavaScript` API, manual implementation isn't recommended. If absolutely necessary, having a look at `RealAddDocumentStartScriptDelegate` is recommended, in order to replicate some best practices: +* If a script has already been added, don't add it again unless the content has changed +* If the content has changed, remove the previous `ScriptHandler` before adding the new script. Call `remove` on the main thread +* Use `WebViewCompatWrapper` instead of calling `WebViewCompat` directly, as it includes several checks on the `WebView` lifecycle and ensures proper threading is used + +# Adding a new script to the browser (DuckDuckGoWebView/BrowserTabFragment) + +If you need your script to be executed on the main browser WebView, you need to create a browser plugin that wraps your `AddDocumentStartJavaScript` implementation. + +## Step 1: Create the core implementation + +Follow the patterns described in the [delegation pattern section](#using-adddocumentstartscriptdelegate-to-implement-adddocumentstartjavascript-via-delegation-pattern) above. + +## Step 2: Create the browser plugin wrapper + +```kotlin +@ContributesMultibinding(FragmentScope::class) +class AddDocumentStartJavaScriptBrowserPlugin @Inject constructor( + private val AddDocumentStartJavaScript: AddDocumentStartJavaScript, +) : AddDocumentStartJavaScriptBrowserPlugin { + override fun addDocumentStartJavaScript(): AddDocumentStartJavaScript = + AddDocumentStartJavaScript +} +``` + +## How it works + +The browser automatically calls your script's `addDocumentStartJavaScript(webView)` method when: +- A new page loads +- If you also need it to be called when Privacy protections are updated, you need to add your implementation context to `BrowserTabViewModel#privacyProtectionsUpdated` +- If you need your script to be re-added in any other circumstances, follow the same pattern as `BrowserTabViewModel#privacyProtectionsUpdated`, so we only update the necessary scripts, not all of them. However, this is strongly discouraged by the Chromium team, and should only be done if messaging is not viable (for example, out of performance concerns) + +The `AddDocumentStartScriptDelegate` handles lifecycle management, script deduplication, and WebView safety checks. + +## Important Notes + +- Use appropriate scoping and consider using `@SingleInstanceIn()` with appropriate scoping to make sure only one instance of `WebMessaging` exists per `WebView` diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index cf77eee3da7c..4fd3554a142c 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -208,6 +208,7 @@ import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonito import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.brokensite.api.RefreshPattern +import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.autocomplete.AutoComplete import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteSuggestion.AutoCompleteDefaultSuggestion @@ -255,7 +256,7 @@ import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.history.api.HistoryEntry.VisitedPage import com.duckduckgo.history.api.NavigationHistory -import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.PostMessageWrapperPlugin import com.duckduckgo.js.messaging.api.SubscriptionEventData @@ -6987,8 +6988,8 @@ class BrowserTabViewModelTest { testee.pageFinished(mockWebView, webViewNavState, url) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.addDocumentStartJavaScript().countInitted) } @Test @@ -7000,8 +7001,8 @@ class BrowserTabViewModelTest { testee.pageFinished(mockWebView, webViewNavState, url) - assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) - assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.countInitted) + assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) + assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.addDocumentStartJavaScript().countInitted) } @Test @@ -7011,8 +7012,8 @@ class BrowserTabViewModelTest { testee.privacyProtectionsUpdated(mockWebView) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.addDocumentStartJavaScript().countInitted) } @Test @@ -7022,8 +7023,8 @@ class BrowserTabViewModelTest { testee.privacyProtectionsUpdated(mockWebView) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) - assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) + assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.otherPlugin.addDocumentStartJavaScript().countInitted) } @Test @@ -7512,11 +7513,11 @@ class BrowserTabViewModelTest { runTest { val mockCallback = mock() val webView = DuckDuckGoWebView(context) - assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) + assertEquals(0, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) testee.configureWebView(webView, mockCallback) - assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.countInitted) + assertEquals(1, fakeAddDocumentStartJavaScriptPlugins.cssPlugin.addDocumentStartJavaScript().countInitted) } @UiThreadTest @@ -7833,9 +7834,13 @@ class BrowserTabViewModelTest { override fun getCustomHeaders(url: String): Map = headers } - class FakeAddDocumentStartJavaScriptPlugin( - override val context: String, - ) : AddDocumentStartJavaScriptPlugin { + class FakeAddDocumentStartJavaScriptBrowserPlugin(val context: String) : AddDocumentStartJavaScriptBrowserPlugin { + private val addDocumentStartJavaScript = FakeAddDocumentStartJavaScript(context) + + override fun addDocumentStartJavaScript(): FakeAddDocumentStartJavaScript = addDocumentStartJavaScript + } + + class FakeAddDocumentStartJavaScript(override val context: String) : AddDocumentStartJavaScript { var countInitted = 0 private set @@ -7844,13 +7849,19 @@ class BrowserTabViewModelTest { } } - class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint { - val cssPlugin = FakeAddDocumentStartJavaScriptPlugin("contentScopeScripts") - val otherPlugin = FakeAddDocumentStartJavaScriptPlugin("test") + class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint { + val cssPlugin = FakeAddDocumentStartJavaScriptBrowserPlugin("contentScopeScripts") + val otherPlugin = FakeAddDocumentStartJavaScriptBrowserPlugin("test") override fun getPlugins() = listOf(cssPlugin, otherPlugin) } + class FakePostMessageWrapperPluginPoint : PluginPoint { + val plugin = FakePostMessageWrapperPlugin() + + override fun getPlugins(): Collection = listOf(plugin) + } + class FakeWebMessagingPlugin : WebMessagingPlugin { var registered = false private set @@ -7892,14 +7903,7 @@ class BrowserTabViewModelTest { ) { postMessageCalled = true } - override val context: String get() = "contentScopeScripts" } - - class FakePostMessageWrapperPluginPoint : PluginPoint { - val plugin = FakePostMessageWrapperPlugin() - - override fun getPlugins(): Collection = listOf(plugin) - } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 3e7601239751..54eb64147206 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -274,6 +274,7 @@ import com.duckduckgo.autofill.api.passwordgeneration.AutomaticSavedLoginsMonito import com.duckduckgo.autofill.impl.AutofillFireproofDialogSuppressor import com.duckduckgo.brokensite.api.BrokenSitePrompt import com.duckduckgo.brokensite.api.RefreshPattern +import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.browser.api.autocomplete.AutoComplete import com.duckduckgo.browser.api.autocomplete.AutoComplete.AutoCompleteResult @@ -319,7 +320,6 @@ import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.duckplayer.api.DuckPlayer.DuckPlayerState.ENABLED import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.history.api.NavigationHistory -import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin import com.duckduckgo.js.messaging.api.JsCallbackData import com.duckduckgo.js.messaging.api.PostMessageWrapperPlugin import com.duckduckgo.js.messaging.api.SubscriptionEventData @@ -489,7 +489,7 @@ class BrowserTabViewModel @Inject constructor( private val externalIntentProcessingState: ExternalIntentProcessingState, private val vpnMenuStateProvider: VpnMenuStateProvider, private val webViewCompatWrapper: WebViewCompatWrapper, - private val addDocumentStartJavascriptPlugins: PluginPoint, + private val addDocumentStartJavascriptPlugins: PluginPoint, private val webMessagingPlugins: PluginPoint, private val postMessageWrapperPlugins: PluginPoint, ) : ViewModel(), @@ -4248,9 +4248,7 @@ class BrowserTabViewModel @Inject constructor( private suspend fun addDocumentStartJavaScript(webView: WebView) { addDocumentStartJavascriptPlugins.getPlugins().forEach { - it.addDocumentStartJavaScript( - webView, - ) + it.addDocumentStartJavaScript().addDocumentStartJavaScript(webView) } } @@ -4258,6 +4256,7 @@ class BrowserTabViewModel @Inject constructor( if (withContext(dispatchers.io()) { !androidBrowserConfig.updateScriptOnPageFinished().isEnabled() }) { addDocumentStartJavascriptPlugins .getPlugins() + .map { it.addDocumentStartJavaScript() } .filter { plugin -> (plugin.context == "contentScopeScripts") }.forEach { diff --git a/app/src/main/java/com/duckduckgo/app/browser/ContentScopeScriptsAddDocumentStartJavaScriptBrowserPlugin.kt b/app/src/main/java/com/duckduckgo/app/browser/ContentScopeScriptsAddDocumentStartJavaScriptBrowserPlugin.kt new file mode 100644 index 000000000000..7c9b52935f22 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/ContentScopeScriptsAddDocumentStartJavaScriptBrowserPlugin.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser + +import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin +import com.duckduckgo.contentscopescripts.impl.ContentScopeScriptsAddDocumentStartJavaScript +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(FragmentScope::class) +class ContentScopeScriptsAddDocumentStartJavaScriptBrowserPlugin @Inject constructor( + private val contentScopeScriptsAddDocumentStartJavaScript: ContentScopeScriptsAddDocumentStartJavaScript, +) : AddDocumentStartJavaScriptBrowserPlugin { + override fun addDocumentStartJavaScript(): AddDocumentStartJavaScript = contentScopeScriptsAddDocumentStartJavaScript +} diff --git a/app/src/main/java/com/duckduckgo/app/plugins/AddDocumentStartJavaScriptPluginPoint.kt b/app/src/main/java/com/duckduckgo/app/plugins/AddDocumentStartJavaScriptPluginPoint.kt index c3ee19845bed..34358e919c6f 100644 --- a/app/src/main/java/com/duckduckgo/app/plugins/AddDocumentStartJavaScriptPluginPoint.kt +++ b/app/src/main/java/com/duckduckgo/app/plugins/AddDocumentStartJavaScriptPluginPoint.kt @@ -17,12 +17,12 @@ package com.duckduckgo.app.plugins import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.browser.api.AddDocumentStartJavaScriptBrowserPlugin import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin @ContributesPluginPoint( scope = AppScope::class, - boundType = AddDocumentStartJavaScriptPlugin::class, + boundType = AddDocumentStartJavaScriptBrowserPlugin::class, ) @Suppress("unused") interface UnusedJAddDocumentStartJavaScriptPluginPoint diff --git a/browser-api/build.gradle b/browser-api/build.gradle index 5e07d42d6f83..8260f2325bb5 100644 --- a/browser-api/build.gradle +++ b/browser-api/build.gradle @@ -26,6 +26,7 @@ dependencies { implementation project(path: ':design-system') implementation project(path: ':common-utils') implementation project(':feature-toggles-api') + implementation project(':js-messaging-api') implementation AndroidX.core.ktx implementation AndroidX.webkit implementation KotlinX.coroutines.core diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScriptPlugin.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/AddDocumentStartJavaScriptBrowserPlugin.kt similarity index 51% rename from js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScriptPlugin.kt rename to browser-api/src/main/java/com/duckduckgo/browser/api/AddDocumentStartJavaScriptBrowserPlugin.kt index 811a0197425d..0b55dca69c2c 100644 --- a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScriptPlugin.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/AddDocumentStartJavaScriptBrowserPlugin.kt @@ -14,17 +14,19 @@ * limitations under the License. */ -package com.duckduckgo.js.messaging.api +package com.duckduckgo.browser.api -import android.webkit.WebView +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript /** - * Plugin interface for injecting JavaScript code that executes at document start. - * * Allows plugins to inject JavaScript that will be executed before any other scripts on the page. - * Useful for privacy protections and that need to run as early as possible and/or on iframes. +* Interface to provide implementations of [AddDocumentStartJavaScript] to the browser, through +* [PluginPoint]<[AddDocumentStartJavaScript]> */ -interface AddDocumentStartJavaScriptPlugin { - suspend fun addDocumentStartJavaScript(webView: WebView) - - val context: String +interface AddDocumentStartJavaScriptBrowserPlugin { + /** + * Provides an implementation of [AddDocumentStartJavaScript] to be used by the browser. + * @return an instance of [AddDocumentStartJavaScript] + */ + fun addDocumentStartJavaScript(): AddDocumentStartJavaScript } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScript.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScript.kt new file mode 100644 index 000000000000..b346d1c38a16 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScript.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.contentscopescripts.impl + +import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments +import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptScriptStrategy +import com.duckduckgo.js.messaging.api.AddDocumentStartScriptDelegate +import dagger.SingleInstanceIn +import javax.inject.Inject + +@SingleInstanceIn(FragmentScope::class) +class ContentScopeScriptsAddDocumentStartJavaScript @Inject constructor( + webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts, + contentScopeExperiments: ContentScopeExperiments, + scriptInjectorDelegate: AddDocumentStartScriptDelegate, +) : AddDocumentStartJavaScript by scriptInjectorDelegate.createPlugin( + object : AddDocumentStartJavaScriptScriptStrategy { + override suspend fun canInject(): Boolean = webViewCompatContentScopeScripts.isEnabled() + + override suspend fun getScriptString(): String { + val activeExperiments = contentScopeExperiments.getActiveExperiments() + return webViewCompatContentScopeScripts.getScript(activeExperiments) + } + + override val allowedOriginRules: Set = setOf("*") + + override val context: String + get() = "contentScopeScripts" + }, +) diff --git a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPlugin.kt b/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPlugin.kt deleted file mode 100644 index 2177582656b2..000000000000 --- a/content-scope-scripts/content-scope-scripts-impl/src/main/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPlugin.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.contentscopescripts.impl - -import android.annotation.SuppressLint -import android.webkit.WebView -import androidx.webkit.ScriptHandler -import com.duckduckgo.app.browser.api.WebViewCapabilityChecker -import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript -import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments -import com.duckduckgo.di.scopes.FragmentScope -import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptPlugin -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.SingleInstanceIn -import kotlinx.coroutines.withContext -import javax.inject.Inject - -@SingleInstanceIn(FragmentScope::class) -@ContributesMultibinding(FragmentScope::class) -class ContentScopeScriptsAddDocumentStartJavaScriptPlugin @Inject constructor( - private val webViewCompatContentScopeScripts: WebViewCompatContentScopeScripts, - private val dispatcherProvider: DispatcherProvider, - private val webViewCapabilityChecker: WebViewCapabilityChecker, - private val webViewCompatWrapper: WebViewCompatWrapper, - private val contentScopeExperiments: ContentScopeExperiments, -) : AddDocumentStartJavaScriptPlugin { - private var script: ScriptHandler? = null - private var currentScriptString: String? = null - - @SuppressLint("RequiresFeature") - override suspend fun addDocumentStartJavaScript(webView: WebView) { - if (!webViewCompatContentScopeScripts.isEnabled() || - !webViewCapabilityChecker.isSupported( - DocumentStartJavaScript, - ) - ) { - return - } - - val activeExperiments = contentScopeExperiments.getActiveExperiments() - val scriptString = webViewCompatContentScopeScripts.getScript(activeExperiments) - if (scriptString == currentScriptString) { - return - } - script?.let { - withContext(dispatcherProvider.main()) { - it.remove() - } - script = null - } - - webViewCompatWrapper - .addDocumentStartJavaScript( - webView, - scriptString, - setOf("*"), - )?.let { - script = it - currentScriptString = scriptString - } - } - - override val context: String - get() = "contentScopeScripts" -} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPluginTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPluginTest.kt deleted file mode 100644 index 48b6d8646555..000000000000 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptPluginTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.duckduckgo.contentscopescripts.impl - -import android.webkit.WebView -import com.duckduckgo.app.browser.api.WebViewCapabilityChecker -import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper -import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -class ContentScopeScriptsAddDocumentStartJavaScriptPluginTest { - @get:Rule - var coroutineRule = CoroutineTestRule() - - private val mockWebViewCompatContentScopeScripts: WebViewCompatContentScopeScripts = mock() - private val mockWebViewCapabilityChecker: WebViewCapabilityChecker = mock() - private val mockWebViewCompatWrapper: WebViewCompatWrapper = mock() - private val mockWebView: WebView = mock() - private val mockActiveContentScopeExperiments: ContentScopeExperiments = mock() - - private lateinit var testee: ContentScopeScriptsAddDocumentStartJavaScriptPlugin - - @Before - fun setUp() = - runTest { - whenever(mockActiveContentScopeExperiments.getActiveExperiments()).thenReturn(listOf()) - testee = - ContentScopeScriptsAddDocumentStartJavaScriptPlugin( - mockWebViewCompatContentScopeScripts, - coroutineRule.testDispatcherProvider, - mockWebViewCapabilityChecker, - mockWebViewCompatWrapper, - mockActiveContentScopeExperiments, - ) - } - - @Test - fun whenFeatureIsEnabledAndCapabilitySupportedThenCallScriptInjectionWithCorrectParams() = - runTest { - whenever(mockWebViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) - whenever(mockWebViewCompatContentScopeScripts.getScript(any())).thenReturn("script") - - testee.addDocumentStartJavaScript(mockWebView) - - verify(mockWebViewCompatWrapper).addDocumentStartJavaScript(mockWebView, "script", setOf("*")) - } - - @Test - fun whenFeatureIsDisabledAndCapabilitySupportedThenDoNotCallScriptInjection() = - runTest { - whenever(mockWebViewCompatContentScopeScripts.isEnabled()).thenReturn(false) - whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) - whenever(mockWebViewCompatContentScopeScripts.getScript(any())).thenReturn("script") - - testee.addDocumentStartJavaScript(mockWebView) - - verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) - } - - @Test - fun whenFeatureIsEnabledAndCapabilityNotSupportedThenDoNotCallScriptInjection() = - runTest { - whenever(mockWebViewCompatContentScopeScripts.isEnabled()).thenReturn(true) - whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(false) - whenever(mockWebViewCompatContentScopeScripts.getScript(any())).thenReturn("script") - - testee.addDocumentStartJavaScript(mockWebView) - - verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) - } -} diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptTest.kt new file mode 100644 index 000000000000..1985b7f40389 --- /dev/null +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/ContentScopeScriptsAddDocumentStartJavaScriptTest.kt @@ -0,0 +1,55 @@ +package com.duckduckgo.contentscopescripts.impl + +import android.webkit.WebView +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.contentscopescripts.api.contentscopeExperiments.ContentScopeExperiments +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript +import com.duckduckgo.js.messaging.api.AddDocumentStartScriptDelegate +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ContentScopeScriptsAddDocumentStartJavaScriptTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockWebViewCompatContentScopeScripts: WebViewCompatContentScopeScripts = mock() + private val mockWebView: WebView = mock() + private val mockActiveContentScopeExperiments: ContentScopeExperiments = mock() + private val mockAddDocumentStartScriptDelegate: AddDocumentStartScriptDelegate = mock() + private val mockAddDocumentStartJavaScript: AddDocumentStartJavaScript = mock() + + private lateinit var testee: ContentScopeScriptsAddDocumentStartJavaScript + + @Before + fun setUp() = + runTest { + whenever(mockActiveContentScopeExperiments.getActiveExperiments()).thenReturn(listOf()) + whenever(mockAddDocumentStartScriptDelegate.createPlugin(any())).thenReturn(mockAddDocumentStartJavaScript) + testee = + ContentScopeScriptsAddDocumentStartJavaScript( + mockWebViewCompatContentScopeScripts, + mockActiveContentScopeExperiments, + mockAddDocumentStartScriptDelegate, + ) + } + + @Test + fun whenAddDocumentStartJavaScriptCalledThenDelegateToCreatedPlugin() = + runTest { + testee.addDocumentStartJavaScript(mockWebView) + + verify(mockAddDocumentStartJavaScript).addDocumentStartJavaScript(mockWebView) + } + + @Test + fun whenConstructedThenCreatePluginWithCorrectStrategy() = + runTest { + verify(mockAddDocumentStartScriptDelegate).createPlugin(any()) + } +} diff --git a/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScript.kt b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScript.kt new file mode 100644 index 000000000000..6411f73d7eda --- /dev/null +++ b/js-messaging/js-messaging-api/src/main/java/com/duckduckgo/js/messaging/api/AddDocumentStartJavaScript.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.js.messaging.api + +import android.webkit.WebView + +/** + * Interface for adding JavaScript code that executes at document start, before any other scripts on the page. + * Useful for privacy protections and that need to run as early as possible and/or on iframes. + */ +interface AddDocumentStartJavaScript { + /** + * Adds JavaScript code into the provided [WebView] to be executed at document start. + * Notes: + * - If a different script already exists in this instance, it will be replaced. + * - It's not recommended to call this multiple times on the same WebView instance. + * If possible, we should rely on messaging to update the script behavior instead. + * + * @param webView the WebView where the script will be added + */ + suspend fun addDocumentStartJavaScript(webView: WebView) + + val context: String +} + +/** + * Strategy interface for script injection logic. + * Allows different implementations to provide their own injection behavior. + */ +interface AddDocumentStartJavaScriptScriptStrategy { + /** + * Determines whether script injection should proceed (i.e. by checking feature flags). + * @return true if injection is allowed, false otherwise + */ + suspend fun canInject(): Boolean + + /** + * Provides the script string to be injected. + * @return the JavaScript code to inject + */ + suspend fun getScriptString(): String + + /** + * Defines the allowed origin rules for script injection. + * @return set of allowed origin patterns + */ + val allowedOriginRules: Set + + /** + * The context of the script + * @return context string + */ + val context: String +} + +interface AddDocumentStartScriptDelegate { + /** + * Creates an [AddDocumentStartJavaScript] implementation with the given [AddDocumentStartJavaScriptScriptStrategy]. + * @param strategy the strategy to use for determining injection behavior + * @return [AddDocumentStartJavaScript] implementation + */ + fun createPlugin(strategy: AddDocumentStartJavaScriptScriptStrategy): AddDocumentStartJavaScript +} diff --git a/js-messaging/js-messaging-impl/build.gradle b/js-messaging/js-messaging-impl/build.gradle index 26b4a1b31d2b..f187bf4d0397 100644 --- a/js-messaging/js-messaging-impl/build.gradle +++ b/js-messaging/js-messaging-impl/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation KotlinX.coroutines.core implementation Google.dagger implementation Square.retrofit2.converter.moshi + implementation AndroidX.webkit // Testing dependencies testImplementation "org.mockito.kotlin:mockito-kotlin:_" diff --git a/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegate.kt b/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegate.kt new file mode 100644 index 000000000000..bffc65e04aba --- /dev/null +++ b/js-messaging/js-messaging-impl/src/main/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegate.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.js.messaging.impl + +import android.annotation.SuppressLint +import android.webkit.WebView +import androidx.webkit.ScriptHandler +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptScriptStrategy +import com.duckduckgo.js.messaging.api.AddDocumentStartScriptDelegate +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Delegate class that implements AddDocumentStartJavaScriptPlugin and handles + * the common script injection logic + */ +@ContributesBinding(AppScope::class) +class RealAddDocumentStartScriptDelegate @Inject constructor( + private val webViewCapabilityChecker: WebViewCapabilityChecker, + private val dispatcherProvider: DispatcherProvider, + private val webViewCompatWrapper: WebViewCompatWrapper, +) : AddDocumentStartScriptDelegate { + override fun createPlugin(strategy: AddDocumentStartJavaScriptScriptStrategy): AddDocumentStartJavaScript { + return object : AddDocumentStartJavaScript { + private var script: ScriptHandler? = null + private var currentScriptString: String? = null + + @SuppressLint("RequiresFeature") + override suspend fun addDocumentStartJavaScript(webView: WebView) { + if (!strategy.canInject() || + !webViewCapabilityChecker.isSupported( + WebViewCapabilityChecker.WebViewCapability.DocumentStartJavaScript, + ) + ) { + return + } + + val scriptString = strategy.getScriptString() + if (scriptString == currentScriptString) { + return + } + script?.let { + withContext(dispatcherProvider.main()) { + it.remove() + } + script = null + } + + webViewCompatWrapper + .addDocumentStartJavaScript( + webView, + scriptString, + strategy.allowedOriginRules, + )?.let { + script = it + currentScriptString = scriptString + } + } + + override val context: String + get() = strategy.context + } + } +} diff --git a/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegateTest.kt b/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegateTest.kt new file mode 100644 index 000000000000..0c51c0dcc85d --- /dev/null +++ b/js-messaging/js-messaging-impl/src/test/java/com/duckduckgo/js/messaging/impl/RealAddDocumentStartScriptDelegateTest.kt @@ -0,0 +1,128 @@ +package com.duckduckgo.js.messaging.impl + +import android.webkit.WebView +import androidx.webkit.ScriptHandler +import com.duckduckgo.app.browser.api.WebViewCapabilityChecker +import com.duckduckgo.browser.api.webviewcompat.WebViewCompatWrapper +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScript +import com.duckduckgo.js.messaging.api.AddDocumentStartJavaScriptScriptStrategy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class RealAddDocumentStartScriptDelegateTest { + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val mockWebViewCapabilityChecker: WebViewCapabilityChecker = mock() + private val mockWebViewCompatWrapper: WebViewCompatWrapper = mock() + private val mockWebView: WebView = mock() + private val mockScriptHandler: ScriptHandler = mock() + private lateinit var testee: RealAddDocumentStartScriptDelegate + private lateinit var plugin: AddDocumentStartJavaScript + + @Before + fun setUp() = + runTest { + testee = + RealAddDocumentStartScriptDelegate( + mockWebViewCapabilityChecker, + coroutineRule.testDispatcherProvider, + mockWebViewCompatWrapper, + ) + } + + @Test + fun whenFeatureEnabledAndCapabilitySupportedThenInjectScript() = + runTest { + val mockStrategy = createMockStrategy(canInject = true, scriptString = "test script") + plugin = testee.createPlugin(mockStrategy) + whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) + whenever(mockWebViewCompatWrapper.addDocumentStartJavaScript(any(), any(), any())).thenReturn(mockScriptHandler) + + plugin.addDocumentStartJavaScript(mockWebView) + + verify(mockWebViewCompatWrapper).addDocumentStartJavaScript(mockWebView, "test script", setOf("*")) + } + + @Test + fun whenFeatureDisabledThenDoNotInjectScript() = + runTest { + val mockStrategy = createMockStrategy(canInject = false, scriptString = "test script") + plugin = testee.createPlugin(mockStrategy) + whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) + + plugin.addDocumentStartJavaScript(mockWebView) + + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } + + @Test + fun whenCapabilityNotSupportedThenDoNotInjectScript() = + runTest { + val mockStrategy = createMockStrategy(canInject = true, scriptString = "test script") + plugin = testee.createPlugin(mockStrategy) + whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(false) + + plugin.addDocumentStartJavaScript(mockWebView) + + verify(mockWebViewCompatWrapper, never()).addDocumentStartJavaScript(any(), any(), any()) + } + + @Test + fun whenScriptStringSameAsCurrentThenDoNotReinject() = + runTest { + val mockStrategy = createMockStrategy(canInject = true, scriptString = "test script") + plugin = testee.createPlugin(mockStrategy) + whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) + whenever(mockWebViewCompatWrapper.addDocumentStartJavaScript(any(), any(), any())).thenReturn(mockScriptHandler) + + plugin.addDocumentStartJavaScript(mockWebView) + plugin.addDocumentStartJavaScript(mockWebView) + + verify(mockWebViewCompatWrapper).addDocumentStartJavaScript(mockWebView, "test script", setOf("*")) + } + + @Test + fun whenScriptStringDifferentThenRemoveOldAndInjectNew() = + runTest { + val mockStrategy: AddDocumentStartJavaScriptScriptStrategy = mock() + whenever(mockStrategy.canInject()).thenReturn(true) + whenever(mockStrategy.getScriptString()).thenReturn("script 1") + whenever(mockStrategy.allowedOriginRules).thenReturn(setOf("*")) + plugin = testee.createPlugin(mockStrategy) + whenever(mockWebViewCapabilityChecker.isSupported(any())).thenReturn(true) + whenever(mockWebViewCompatWrapper.addDocumentStartJavaScript(any(), any(), any())).thenReturn(mockScriptHandler) + + plugin.addDocumentStartJavaScript(mockWebView) + + whenever(mockStrategy.getScriptString()).thenReturn("script 2") + + plugin.addDocumentStartJavaScript(mockWebView) + + verify(mockScriptHandler).remove() + verify(mockWebViewCompatWrapper).addDocumentStartJavaScript(mockWebView, "script 2", setOf("*")) + } + + private fun createMockStrategy( + canInject: Boolean, + scriptString: String, + ): AddDocumentStartJavaScriptScriptStrategy = + object : AddDocumentStartJavaScriptScriptStrategy { + override suspend fun canInject(): Boolean = canInject + + override suspend fun getScriptString(): String = scriptString + + override val allowedOriginRules: Set = setOf("*") + + override val context: String + get() = "test" + } +}