Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ad-blocking/ad-blocking-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ android {
}

dependencies {
implementation AndroidX.core.ktx
implementation AndroidX.work.runtimeKtx
implementation AndroidX.room.runtime
ksp AndroidX.room.compiler
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2026 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.adblocking.impl

import android.webkit.WebView
import androidx.annotation.UiThread
import androidx.core.net.toUri
import com.duckduckgo.adblocking.impl.domain.AdBlockingStatusChecker
import com.duckduckgo.app.browser.Domain
import com.duckduckgo.app.browser.UriString
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.global.model.Site
import com.duckduckgo.browser.api.JsInjectorPlugin
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import logcat.logcat
import javax.inject.Inject

@SingleInstanceIn(AppScope::class)
@ContributesMultibinding(AppScope::class)
class AdBlockingExtensionJsInjectorPlugin @Inject constructor(
private val statusChecker: AdBlockingStatusChecker,
repository: AdBlockingExtensionRepository,
@AppCoroutineScope appScope: CoroutineScope,
) : JsInjectorPlugin {

private val payload: StateFlow<String?> = repository
.scriptletsFlow()
.map(::buildScript)
.stateIn(appScope, SharingStarted.Eagerly, initialValue = null)

@UiThread
override fun onPageStarted(
webView: WebView,
url: String?,
isDesktopMode: Boolean?,
activeExperiments: List<Toggle>,
Comment thread
CrisBarreiro marked this conversation as resolved.
) {
if (!statusChecker.canInject()) {
logcat { "Status checker rejected injection, skipping" }
return
}
val uri = url?.toUri() ?: return
if (domains.none { UriString.sameOrSubdomain(uri, it) }) {
logcat { "No domains matching, skipping" }
return
}
val script = payload.value ?: run {
logcat { "Empty payload, skipping" }
return
}

logcat { "Injecting script" }
webView.evaluateJavascript("javascript:$script", null)
}

override fun onPageFinished(webView: WebView, url: String?, site: Site?) = Unit

private fun buildScript(scriptlets: List<Scriptlet>): String? =
scriptlets
.takeUnless { it.isEmpty() }
?.sortedBy { it.name }
?.joinToString(separator = "\n") { it.content }

private val domains = listOf(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this expected to be local list or provided from the remote config?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, expected to be local. Discussed over zoom

Domain("youtube.com"),
Domain("youtube-nocookie.com"),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import com.duckduckgo.adblocking.impl.store.ScriptletEntity
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

interface AdBlockingExtensionRepository {
suspend fun getStoredVersion(): String?
suspend fun storeScriptlets(version: String, scriptlets: Map<String, ByteArray>)
fun scriptletsFlow(): Flow<List<Scriptlet>>
}

@SingleInstanceIn(AppScope::class)
Expand All @@ -44,4 +47,9 @@ class RealAdBlockingExtensionRepository @Inject constructor(
},
)
}

override fun scriptletsFlow(): Flow<List<Scriptlet>> =
dao.scriptletsFlow().map { rows ->
rows.map { row -> Scriptlet(name = row.name, content = String(row.content, Charsets.UTF_8)) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 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.adblocking.impl

data class Scriptlet(
val name: String,
val content: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 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.adblocking.impl.domain

import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import logcat.logcat
import javax.inject.Inject

interface AdBlockingStatusChecker {
fun canInject(): Boolean
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealAdBlockingStatusChecker @Inject constructor(
private val feature: AdBlockingExtensionFeature,
) : AdBlockingStatusChecker {

override fun canInject(): Boolean {
if (!feature.self().isEnabled()) {
logcat { "Kill-switch is off" }
return false
}
if (feature.enableContingencyMode().isEnabled()) {
logcat { "Contingency mode is on" }
return false
}
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright (c) 2026 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.adblocking.impl

import android.webkit.WebView
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.duckduckgo.adblocking.impl.domain.AdBlockingStatusChecker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.After
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class AdBlockingExtensionJsInjectorPluginTest {

private var canInject = true
private val scriptletsFlow = MutableStateFlow<List<Scriptlet>>(emptyList())

private val statusChecker: AdBlockingStatusChecker = mock {
on { canInject() } doAnswer { canInject }
}
private val repository: AdBlockingExtensionRepository = mock {
on { scriptletsFlow() } doReturn scriptletsFlow
}
private val webView: WebView = mock()
private val testScope = CoroutineScope(UnconfinedTestDispatcher())

private val singleScriptlet = listOf(Scriptlet(name = "a.js", content = "console.log('a')"))
private val twoScriptlets = listOf(
Scriptlet(name = "b.js", content = "console.log('b')"),
Scriptlet(name = "a.js", content = "console.log('a')"),
)

private val plugin by lazy {
AdBlockingExtensionJsInjectorPlugin(
statusChecker = statusChecker,
repository = repository,
appScope = testScope,
)
}

@After
fun tearDown() {
testScope.cancel()
}

@Test
fun whenAllGatesAreOpenThenScriptIsInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(
eq("javascript:console.log('a')"),
Comment thread
CrisBarreiro marked this conversation as resolved.
isNull(),
)
}

@Test
fun whenMultipleScriptletsThenAllAreInjectedInSortedOrder() {
scriptletsFlow.value = twoScriptlets

plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(
eq("javascript:console.log('a')\nconsole.log('b')"),
isNull(),
)
}

@Test
fun whenUrlIsSubdomainOfConfiguredDomainThenScriptIsInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://m.youtube.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(any(), isNull())
}

@Test
fun whenUrlHasWwwPrefixThenScriptIsInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://www.youtube.com", isDesktopMode = null)

verify(webView).evaluateJavascript(any(), isNull())
}

@Test
fun whenUrlMatchesSecondConfiguredDomainThenScriptIsInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://youtube-nocookie.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(any(), isNull())
}

@Test
fun whenUrlHostNotInConfiguredDomainsThenScriptIsNotInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://example.com/page", isDesktopMode = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}

@Test
fun whenStatusCheckerRejectsThenScriptIsNotInjected() {
canInject = false
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}

@Test
fun whenUrlIsNullThenScriptIsNotInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = null, isDesktopMode = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}

@Test
fun whenScriptletsListIsEmptyThenScriptIsNotInjected() {
scriptletsFlow.value = emptyList()

plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}

@Test
fun whenUrlHasNoHostThenScriptIsNotInjected() {
scriptletsFlow.value = singleScriptlet

plugin.onPageStarted(webView, url = "data:text/html,<p>hi</p>", isDesktopMode = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}

@Test
fun whenScriptletDataIsUpdatedThenNextInjectionUsesNewPayload() {
scriptletsFlow.value = singleScriptlet
plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

scriptletsFlow.value = listOf(Scriptlet(name = "a.js", content = "console.log('updated')"))
plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(
eq("javascript:console.log('updated')"),
isNull(),
)
}

@Test
fun whenStatusCheckerStartsRejectingMidSessionThenNextInjectionNoOps() {
scriptletsFlow.value = singleScriptlet
plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

canInject = false
plugin.onPageStarted(webView, url = "https://youtube.com/page", isDesktopMode = null)

verify(webView).evaluateJavascript(any(), isNull())
}

@Test
fun whenOnPageFinishedCalledThenNoInjection() {
scriptletsFlow.value = singleScriptlet

plugin.onPageFinished(webView, url = "https://youtube.com/page", site = null)

verify(webView, never()).evaluateJavascript(any(), isNull())
}
}
Loading
Loading