Skip to content
79 changes: 79 additions & 0 deletions .rules/add-document-start-javascript.md
Original file line number Diff line number Diff line change
@@ -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 <YourFeature>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<String>
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 <YourFeature>AddDocumentStartJavaScriptBrowserPlugin @Inject constructor(
private val <yourFeature>AddDocumentStartJavaScript: <YourFeature>AddDocumentStartJavaScript,
) : AddDocumentStartJavaScriptBrowserPlugin {
override fun addDocumentStartJavaScript(): AddDocumentStartJavaScript =
<yourFeature>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(<Scope>)` with appropriate scoping to make sure only one instance of `WebMessaging` exists per `WebView`
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -7512,11 +7513,11 @@ class BrowserTabViewModelTest {
runTest {
val mockCallback = mock<WebViewCompatMessageCallback>()
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
Expand Down Expand Up @@ -7833,9 +7834,13 @@ class BrowserTabViewModelTest {
override fun getCustomHeaders(url: String): Map<String, String> = 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

Expand All @@ -7844,13 +7849,19 @@ class BrowserTabViewModelTest {
}
}

class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint<AddDocumentStartJavaScriptPlugin> {
val cssPlugin = FakeAddDocumentStartJavaScriptPlugin("contentScopeScripts")
val otherPlugin = FakeAddDocumentStartJavaScriptPlugin("test")
class FakeAddDocumentStartJavaScriptPluginPoint : PluginPoint<AddDocumentStartJavaScriptBrowserPlugin> {
val cssPlugin = FakeAddDocumentStartJavaScriptBrowserPlugin("contentScopeScripts")
val otherPlugin = FakeAddDocumentStartJavaScriptBrowserPlugin("test")

override fun getPlugins() = listOf(cssPlugin, otherPlugin)
}

class FakePostMessageWrapperPluginPoint : PluginPoint<PostMessageWrapperPlugin> {
val plugin = FakePostMessageWrapperPlugin()

override fun getPlugins(): Collection<PostMessageWrapperPlugin> = listOf(plugin)
}

class FakeWebMessagingPlugin : WebMessagingPlugin {
var registered = false
private set
Expand Down Expand Up @@ -7892,14 +7903,7 @@ class BrowserTabViewModelTest {
) {
postMessageCalled = true
}

override val context: String
get() = "contentScopeScripts"
}

class FakePostMessageWrapperPluginPoint : PluginPoint<PostMessageWrapperPlugin> {
val plugin = FakePostMessageWrapperPlugin()

override fun getPlugins(): Collection<PostMessageWrapperPlugin> = listOf(plugin)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -489,7 +489,7 @@ class BrowserTabViewModel @Inject constructor(
private val externalIntentProcessingState: ExternalIntentProcessingState,
private val vpnMenuStateProvider: VpnMenuStateProvider,
private val webViewCompatWrapper: WebViewCompatWrapper,
private val addDocumentStartJavascriptPlugins: PluginPoint<AddDocumentStartJavaScriptPlugin>,
private val addDocumentStartJavascriptPlugins: PluginPoint<AddDocumentStartJavaScriptBrowserPlugin>,
private val webMessagingPlugins: PluginPoint<WebMessagingPlugin>,
private val postMessageWrapperPlugins: PluginPoint<PostMessageWrapperPlugin>,
) : ViewModel(),
Expand Down Expand Up @@ -4248,16 +4248,15 @@ class BrowserTabViewModel @Inject constructor(

private suspend fun addDocumentStartJavaScript(webView: WebView) {
addDocumentStartJavascriptPlugins.getPlugins().forEach {
it.addDocumentStartJavaScript(
webView,
)
it.addDocumentStartJavaScript().addDocumentStartJavaScript(webView)
}
}

suspend fun privacyProtectionsUpdated(webView: WebView) {
if (withContext(dispatchers.io()) { !androidBrowserConfig.updateScriptOnPageFinished().isEnabled() }) {
addDocumentStartJavascriptPlugins
.getPlugins()
.map { it.addDocumentStartJavaScript() }
.filter { plugin ->
(plugin.context == "contentScopeScripts")
}.forEach {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions browser-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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<String> = setOf("*")

override val context: String
get() = "contentScopeScripts"
},
)
Loading
Loading