Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.duckduckgo.adblocking.impl

import android.net.Uri
import androidx.core.net.toUri
import com.duckduckgo.app.browser.Domain
import com.duckduckgo.app.browser.UriString
Expand All @@ -32,9 +33,11 @@ class AdBlockingExtensionDomainMatcher @Inject constructor() {

fun matches(url: String?): Boolean {
val uri = url?.toUri() ?: return false
return DOMAINS.any { UriString.sameOrSubdomain(uri, it) }
return matches(uri)
}

fun matches(uri: Uri): Boolean = DOMAINS.any { UriString.sameOrSubdomain(uri, it) }

private companion object {
val DOMAINS = listOf(
Domain("youtube.com"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 android.net.Uri
import com.duckduckgo.adblocking.impl.AdBlockingExtensionDomainMatcher
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject

/**
* State of the YouTube ad-blocking browser-menu item for a given page. [Hidden] when the item should not
* appear at all; [Enabled]/[Disabled] reflect the effective blocking state so the item can label itself
* "Disable…"/"Enable…" respectively.
*/
sealed interface AdBlockingMenuState {
data object Hidden : AdBlockingMenuState
data object Enabled : AdBlockingMenuState
data object Disabled : AdBlockingMenuState
}

interface AdBlockingMenuStateProvider {
fun observe(url: Uri): Flow<AdBlockingMenuState>
}

@ContributesBinding(AppScope::class)
class RealAdBlockingMenuStateProvider @Inject constructor(
private val feature: AdBlockingExtensionFeature,
private val statusChecker: AdBlockingStatusChecker,
private val domainMatcher: AdBlockingExtensionDomainMatcher,
) : AdBlockingMenuStateProvider {

override fun observe(url: Uri): Flow<AdBlockingMenuState> {
if (!domainMatcher.matches(url)) return flowOf(AdBlockingMenuState.Hidden)
return combine(
feature.self().enabled(),
feature.adBlockingUXImprovements().enabled(),
feature.enableContingencyMode().enabled(),
statusChecker.observeState(),
) { killSwitchOn, phase2On, contingencyOn, state ->
when {
!killSwitchOn || !phase2On || contingencyOn -> AdBlockingMenuState.Hidden
state is AdBlockingState.Enabled -> AdBlockingMenuState.Enabled
else -> AdBlockingMenuState.Disabled
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* 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.menu

import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.widget.LinearLayout
import androidx.core.view.isGone
import com.duckduckgo.adblocking.impl.R
import com.duckduckgo.adblocking.impl.domain.AdBlockingMenuState
import com.duckduckgo.adblocking.impl.domain.AdBlockingMenuStateProvider
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.view.MenuItemView
import com.duckduckgo.common.ui.view.MenuItemViewSize
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ViewScope
import dagger.android.support.AndroidSupportInjection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject

@InjectWith(ViewScope::class)
class AdBlockingMenuItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : LinearLayout(context, attrs, defStyle) {

@Inject
lateinit var menuStateProvider: AdBlockingMenuStateProvider

@Inject
lateinit var dispatcherProvider: DispatcherProvider

private val menuItem: MenuItemView by lazy {
MenuItemView(context).apply {
setSize(MenuItemViewSize.MEDIUM)
setIcon(R.drawable.video_ad_blocked_24)
}
}

private var url: Uri? = null
private var onHostClick: (() -> Unit)? = null
private var scope: CoroutineScope? = null

init {
orientation = VERTICAL
addView(menuItem)
}

fun bind(url: Uri, onHostClick: () -> Unit) {
this.url = url
this.onHostClick = onHostClick
}

override fun onAttachedToWindow() {
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

val url = this.url ?: return
menuItem.setOnClickListener { onHostClick?.invoke() }

scope?.cancel()
scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main()).also { scope ->
menuStateProvider.observe(url)
.flowOn(dispatcherProvider.io())
.onEach { render(it) }
.launchIn(scope)
}
}

override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
super.onDetachedFromWindow()
}

private fun render(state: AdBlockingMenuState) {
when (state) {
AdBlockingMenuState.Hidden -> isGone = true
AdBlockingMenuState.Enabled -> {
isGone = false
menuItem.label(context.getString(R.string.ad_blocking_menu_disable))
}
AdBlockingMenuState.Disabled -> {
isGone = false
menuItem.label(context.getString(R.string.ad_blocking_menu_enable))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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.menu

import android.content.Context
import android.net.Uri
import android.view.View
import com.duckduckgo.anvil.annotations.PriorityKey
import com.duckduckgo.app.browser.menu.TopInContextSection
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding
import javax.inject.Inject

@ContributesMultibinding(AppScope::class)
@PriorityKey(100)
class YouTubeAdBlockingTopInContextSection @Inject constructor() : TopInContextSection {
override fun getView(url: Uri, context: Context, onClick: () -> Unit): View =
AdBlockingMenuItemView(context).also { it.bind(url, onHostClick = onClick) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!--
~ 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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M17,3C19.761,3 22,5.239 22,8V11.934C22,12.491 21.263,12.794 20.798,12.488C20.617,12.369 20.5,12.171 20.5,11.955V8C20.5,6.067 18.933,4.5 17,4.5H7C5.067,4.5 3.5,6.067 3.5,8V16C3.5,17.933 5.067,19.5 7,19.5H11.391C11.662,19.5 11.901,19.675 12.011,19.923C12.221,20.399 11.902,21 11.381,21H7C4.239,21 2,18.761 2,16V8C2,5.239 4.239,3 7,3H17Z"
android:fillColor="?attr/daxColorPrimaryIcon"/>
<path
android:pathData="M10.835,16C11.267,16 11.584,16.401 11.537,16.831C11.497,17.194 11.204,17.5 10.838,17.5H6C5.586,17.5 5.25,17.164 5.25,16.75C5.25,16.336 5.586,16 6,16H10.835Z"
android:fillColor="?attr/daxColorPrimaryIcon"/>
<path
android:pathData="M9.375,8.037C9.375,7.286 10.172,6.803 10.838,7.15L15.553,9.613C16.269,9.987 16.269,11.013 15.553,11.387L10.838,13.85C10.172,14.197 9.375,13.714 9.375,12.963V8.037Z"
android:fillColor="?attr/daxColorPrimaryIcon"/>
<path
android:pathData="M17.5,22C19.985,22 22,19.985 22,17.5C22,15.015 19.985,13 17.5,13C15.015,13 13,15.015 13,17.5C13,19.985 15.015,22 17.5,22ZM15.25,16.75C14.974,16.75 14.75,16.974 14.75,17.25V17.75C14.75,18.026 14.974,18.25 15.25,18.25H19.75C20.026,18.25 20.25,18.026 20.25,17.75V17.25C20.25,16.974 20.026,16.75 19.75,16.75H15.25Z"
android:fillColor="?attr/daxColorPrimaryIcon"
android:fillType="evenOdd"/>
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<resources>

<string name="ad_blocking_menu_disable">Disable YouTube Ad Blocking</string>
<string name="ad_blocking_menu_enable">Enable YouTube Ad Blocking</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* 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 androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.duckduckgo.adblocking.impl.AdBlockingExtensionDomainMatcher
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature
import com.duckduckgo.feature.toggles.api.Toggle
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock

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

private val killSwitchFlow = MutableStateFlow(true)
private val phase2Flow = MutableStateFlow(true)
private val contingencyFlow = MutableStateFlow(false)
private val stateFlow = MutableStateFlow<AdBlockingState>(AdBlockingState.Enabled.UserEnabled)

private val selfToggle: Toggle = mock { on { enabled() } doReturn killSwitchFlow }
private val phase2Toggle: Toggle = mock { on { enabled() } doReturn phase2Flow }
private val contingencyToggle: Toggle = mock { on { enabled() } doReturn contingencyFlow }
private val feature: AdBlockingExtensionFeature = mock {
on { self() } doReturn selfToggle
on { adBlockingUXImprovements() } doReturn phase2Toggle
on { enableContingencyMode() } doReturn contingencyToggle
}
private val statusChecker: AdBlockingStatusChecker = mock {
on { observeState() } doReturn stateFlow
}

private val youtubeUrl = "https://m.youtube.com/watch?v=abc".toUri()
private val nonYoutubeUrl = "https://example.com/watch".toUri()

private val domainMatcher: AdBlockingExtensionDomainMatcher = mock {
on { matches(youtubeUrl) } doReturn true
on { matches(nonYoutubeUrl) } doReturn false
}

private val provider = RealAdBlockingMenuStateProvider(feature, statusChecker, domainMatcher)

@Test
fun whenNonYoutubeUrlThenHidden() = runTest {
assertEquals(AdBlockingMenuState.Hidden, provider.observe(nonYoutubeUrl).first())
}

@Test
fun whenPhase2FlagOffThenHidden() = runTest {
phase2Flow.value = false

assertEquals(AdBlockingMenuState.Hidden, provider.observe(youtubeUrl).first())
}

@Test
fun whenKillSwitchOffThenHidden() = runTest {
killSwitchFlow.value = false

assertEquals(AdBlockingMenuState.Hidden, provider.observe(youtubeUrl).first())
}

@Test
fun whenContingencyModeOnThenHidden() = runTest {
contingencyFlow.value = true

assertEquals(AdBlockingMenuState.Hidden, provider.observe(youtubeUrl).first())
}

@Test
fun whenYoutubeAndUserEnabledThenEnabled() = runTest {
stateFlow.value = AdBlockingState.Enabled.UserEnabled

assertEquals(AdBlockingMenuState.Enabled, provider.observe(youtubeUrl).first())
}

@Test
fun whenYoutubeAndEnabledByDefaultThenEnabled() = runTest {
stateFlow.value = AdBlockingState.Enabled.Default

assertEquals(AdBlockingMenuState.Enabled, provider.observe(youtubeUrl).first())
}

@Test
fun whenYoutubeAndDisabledThenDisabled() = runTest {
stateFlow.value = AdBlockingState.Disabled

assertEquals(AdBlockingMenuState.Disabled, provider.observe(youtubeUrl).first())
}
}
Loading
Loading