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 @@ -42,6 +42,7 @@ import javax.inject.Inject
class AdBlockingExtensionJsInjectorPlugin @Inject constructor(
private val statusChecker: AdBlockingStatusChecker,
repository: AdBlockingExtensionRepository,
private val contingencyMessageHandler: ContingencyMessageHandler,
@AppCoroutineScope appScope: CoroutineScope,
) : JsInjectorPlugin {

Expand Down Expand Up @@ -75,7 +76,9 @@ class AdBlockingExtensionJsInjectorPlugin @Inject constructor(
webView.evaluateJavascript("javascript:$script", null)
}

override fun onPageFinished(webView: WebView, url: String?, site: Site?) = Unit
override fun onPageFinished(webView: WebView, url: String?, site: Site?) {
contingencyMessageHandler.onPageLoaded(webView, url)
}

private fun buildScript(scriptlets: List<Scriptlet>): String? =
scriptlets
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* 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.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.duckduckgo.adblocking.impl.databinding.BottomSheetContingencyMessageBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment

class ContingencyMessageBottomSheetFragment : BottomSheetDialogFragment() {

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val binding = BottomSheetContingencyMessageBinding.inflate(inflater, container, false)
binding.contingencyMessageCloseButton.setOnClickListener { dismiss() }
binding.contingencyMessagePrimaryButton.setOnClickListener { dismiss() }
return binding.root
}

companion object {
const val TAG = "ContingencyMessageBottomSheet"

fun newInstance() = ContingencyMessageBottomSheetFragment()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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 androidx.lifecycle.LifecycleOwner
import com.duckduckgo.adblocking.api.duckplayer.YOUTUBE_HOST
import com.duckduckgo.adblocking.api.duckplayer.YOUTUBE_MOBILE_HOST
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature
import com.duckduckgo.adblocking.impl.remoteconfig.ContingencyMessageStore
import com.duckduckgo.app.browser.UriString
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject

interface ContingencyMessageHandler {
/** Called on page load. Shows the contingency message if all conditions are met. */
@UiThread
fun onPageLoaded(webView: WebView, url: String?)
}

@SingleInstanceIn(AppScope::class)
@ContributesBinding(scope = AppScope::class, boundType = ContingencyMessageHandler::class)
@ContributesMultibinding(scope = AppScope::class, boundType = MainProcessLifecycleObserver::class)
class RealContingencyMessageHandler @Inject constructor(
private val feature: AdBlockingExtensionFeature,
private val store: ContingencyMessageStore,
private val view: ContingencyMessageView,
@AppCoroutineScope private val appScope: CoroutineScope,
private val dispatchers: DispatcherProvider,
) : ContingencyMessageHandler, MainProcessLifecycleObserver {

@Volatile
private var shownInSession = false

override fun onCreate(owner: LifecycleOwner) {
appScope.launch(dispatchers.io()) {
feature.enableContingencyMode().enabled()
.distinctUntilChanged()
.collect { onContingencyModeChanged(it) }
}
}

@UiThread
override fun onPageLoaded(webView: WebView, url: String?) {
if (!webView.isShown) return
Comment thread
CrisBarreiro marked this conversation as resolved.
if (!shouldShow(url)) return
shownInSession = true
view.show(webView)
appScope.launch(dispatchers.io()) { store.setShown() }
Comment thread
cursor[bot] marked this conversation as resolved.
}

internal suspend fun onContingencyModeChanged(contingencyEnabled: Boolean) {
val shown = store.shown.value
if (!contingencyEnabled) {
shownInSession = false
if (shown) store.reset()
}
}

internal fun shouldShow(url: String?): Boolean {
val uxImprovements = feature.adBlockingUXImprovements().isEnabled()
val contingency = feature.enableContingencyMode().isEnabled()
val shown = shownInSession || store.shown.value
val uri = url?.toUri()
val isYouTube = uri != null &&
(UriString.sameOrSubdomain(uri, YOUTUBE_HOST) || UriString.sameOrSubdomain(uri, YOUTUBE_MOBILE_HOST))
val result = uxImprovements && contingency && isYouTube && !shown
return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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.view.ViewTreeObserver
import android.webkit.WebView
import androidx.annotation.UiThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import kotlin.coroutines.resume

interface ContingencyMessageView {
@UiThread
fun show(webView: WebView)
}

@ContributesBinding(AppScope::class)
class RealContingencyMessageView @Inject constructor() : ContingencyMessageView {

override fun show(webView: WebView) {
val lifecycleOwner = webView.findViewTreeLifecycleOwner() ?: return
lifecycleOwner.lifecycleScope.launch {
webView.awaitWindowFocus()
val fragment: Fragment = FragmentManager.findFragment(webView)
val fragmentManager = fragment.childFragmentManager
if (fragmentManager.findFragmentByTag(ContingencyMessageBottomSheetFragment.TAG) == null) {
ContingencyMessageBottomSheetFragment.newInstance()
.show(fragmentManager, ContingencyMessageBottomSheetFragment.TAG)
}
}
}
}

private suspend fun WebView.awaitWindowFocus() {
if (hasWindowFocus()) return
suspendCancellableCoroutine { continuation ->
val observer = viewTreeObserver
val listener = object : ViewTreeObserver.OnWindowFocusChangeListener {
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
observer.removeOnWindowFocusChangeListener(this)
continuation.resume(Unit)
}
}
}
observer.addOnWindowFocusChangeListener(listener)
continuation.invokeOnCancellation { observer.removeOnWindowFocusChangeListener(listener) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer
import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer.*
import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer.DuckPlayerOrigin.AUTO
Expand Down Expand Up @@ -57,6 +58,7 @@ import com.duckduckgo.adblocking.impl.duckplayer.ui.DuckPlayerPrimeDialogFragmen
import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.di.IsMainProcess
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
Expand Down Expand Up @@ -112,6 +114,7 @@ interface DuckPlayerInternal : DuckPlayer {
@ContributesBinding(AppScope::class, boundType = DuckPlayer::class)
@ContributesBinding(AppScope::class, boundType = DuckPlayerInternal::class)
@ContributesMultibinding(AppScope::class, boundType = PrivacyConfigCallbackPlugin::class)
@ContributesMultibinding(AppScope::class, boundType = MainProcessLifecycleObserver::class)
class RealDuckPlayer @Inject constructor(
private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository,
private val duckPlayerFeature: DuckPlayerFeature,
Expand All @@ -123,7 +126,7 @@ class RealDuckPlayer @Inject constructor(
private val appBuildConfig: AppBuildConfig,
@IsMainProcess private val isMainProcess: Boolean,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : DuckPlayerInternal, PrivacyConfigCallbackPlugin {
) : DuckPlayerInternal, PrivacyConfigCallbackPlugin, MainProcessLifecycleObserver {

private var shouldForceYTNavigation = false
private var shouldHideOverlay = false
Expand All @@ -134,15 +137,19 @@ class RealDuckPlayer @Inject constructor(
init {
if (isMainProcess) {
loadToMemory()
appCoroutineScope.launch {
combine(
duckPlayerFeatureRepository.observeUserPreferences(),
adBlockingExtensionFeature.self().enabled(),
adBlockingExtensionFeature.enabledByDefault().enabled(),
::Triple,
).collect { (stored, selfEnabled, enabledByDefault) ->
applyAdBlockingRolloutDefaultIfEligible(stored, selfEnabled, enabledByDefault)
}
}
}

override fun onCreate(owner: LifecycleOwner) {
if (!isMainProcess) return
appCoroutineScope.launch {
combine(
duckPlayerFeatureRepository.observeUserPreferences(),
adBlockingExtensionFeature.self().enabled(),
adBlockingExtensionFeature.enabledByDefault().enabled(),
::Triple,
).collect { (stored, selfEnabled, enabledByDefault) ->
applyAdBlockingRolloutDefaultIfEligible(stored, selfEnabled, enabledByDefault)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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.remoteconfig

import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import javax.inject.Qualifier

@ContributesTo(AppScope::class)
@Module
object ContingencyMessageDataStoreModule {

private val Context.contingencyMessageDataStore: DataStore<Preferences> by preferencesDataStore(
name = "ad_blocking_contingency_message",
)

@Provides
@ContingencyMessage
fun provideContingencyMessageDataStore(context: Context): DataStore<Preferences> = context.contingencyMessageDataStore
}

@Qualifier
internal annotation class ContingencyMessage
Loading
Loading