diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPlugin.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPlugin.kt index fcb221a83d2a..572c87efdc35 100644 --- a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPlugin.kt +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPlugin.kt @@ -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 { @@ -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): String? = scriptlets diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageBottomSheetFragment.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageBottomSheetFragment.kt new file mode 100644 index 000000000000..daa17c6ac0fe --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageBottomSheetFragment.kt @@ -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() + } +} diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageHandler.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageHandler.kt new file mode 100644 index 000000000000..48d8ebfac765 --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageHandler.kt @@ -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 + if (!shouldShow(url)) return + shownInSession = true + view.show(webView) + appScope.launch(dispatchers.io()) { store.setShown() } + } + + 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 + } +} diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageView.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageView.kt new file mode 100644 index 000000000000..33c3618428c7 --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ContingencyMessageView.kt @@ -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) } + } +} diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayer.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayer.kt index 0e9d012d8c87..73a5b7d95e72 100644 --- a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayer.kt +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayer.kt @@ -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 @@ -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 @@ -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, @@ -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 @@ -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) } } } diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageDataStoreModule.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageDataStoreModule.kt new file mode 100644 index 000000000000..a8ea1d1f6856 --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageDataStoreModule.kt @@ -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 by preferencesDataStore( + name = "ad_blocking_contingency_message", + ) + + @Provides + @ContingencyMessage + fun provideContingencyMessageDataStore(context: Context): DataStore = context.contingencyMessageDataStore +} + +@Qualifier +internal annotation class ContingencyMessage diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageStore.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageStore.kt new file mode 100644 index 000000000000..95135cb1e2f1 --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/remoteconfig/ContingencyMessageStore.kt @@ -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.remoteconfig + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +interface ContingencyMessageStore { + /** + * Whether the contingency message has already been shown to the user. Cached so it can be + * read synchronously ([StateFlow.value]) from the UI thread. + */ + val shown: StateFlow + + suspend fun setShown() + + suspend fun reset() +} + +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class SharedPreferencesContingencyMessageStore @Inject constructor( + @ContingencyMessage private val store: DataStore, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : ContingencyMessageStore { + + private object Keys { + val CONTINGENCY_MESSAGE_SHOWN = booleanPreferencesKey(name = "CONTINGENCY_MESSAGE_SHOWN") + } + + override val shown: StateFlow = store.data + .map { prefs -> prefs[Keys.CONTINGENCY_MESSAGE_SHOWN] ?: false } + .distinctUntilChanged() + .stateIn(appCoroutineScope, SharingStarted.Eagerly, false) + + override suspend fun setShown() { + store.edit { prefs -> prefs[Keys.CONTINGENCY_MESSAGE_SHOWN] = true } + } + + override suspend fun reset() { + store.edit { prefs -> prefs.remove(Keys.CONTINGENCY_MESSAGE_SHOWN) } + } +} diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsV2Activity.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsV2Activity.kt index a313d7b2706e..11eda246131c 100644 --- a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsV2Activity.kt +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsV2Activity.kt @@ -24,8 +24,10 @@ import com.duckduckgo.adblocking.impl.R import com.duckduckgo.adblocking.impl.databinding.ActivityAdBlockingSettingsV2Binding import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.listitem.DaxListItem import com.duckduckgo.common.ui.view.listitem.OneLineListItem +import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.di.scopes.ActivityScope @@ -53,7 +55,7 @@ class AdBlockingSettingsV2Activity : BaseAdBlockingSettingsActivity() { override fun render(state: AdBlockingSettingsViewModel.ViewState) { super.render(state) - binding.adBlockingStatusIndicator.setStatus(state.isEnabled) + binding.adBlockingStatusIndicator.setStatus(state.isStatusIndicatorOn) binding.duckPlayerEntry.setSecondaryText( when (state.duckPlayerMode) { Enabled -> getString(R.string.duck_player_mode_always) @@ -61,5 +63,13 @@ class AdBlockingSettingsV2Activity : BaseAdBlockingSettingsActivity() { else -> getString(R.string.duck_player_mode_always_ask) }, ) + if (state.isContingencyMode) { + binding.blockAdsToggle.gone() + binding.adBlockingDescription.gone() + binding.contingencyModeItem.show() + } else { + binding.contingencyModeItem.gone() + binding.blockAdsToggle.show() + } } } diff --git a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModel.kt b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModel.kt index 18f73bd95f4e..8f041da6fa3b 100644 --- a/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModel.kt +++ b/ad-blocking/ad-blocking-impl/src/main/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModel.kt @@ -30,6 +30,7 @@ import com.duckduckgo.adblocking.impl.AdBlockingPixelNames.AD_BLOCKING_SETTINGS_ import com.duckduckgo.adblocking.impl.AdBlockingSettingsRepository import com.duckduckgo.adblocking.impl.domain.AdBlockingState import com.duckduckgo.adblocking.impl.domain.AdBlockingStatusChecker +import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature import com.duckduckgo.adblocking.impl.ui.AdBlockingSettingsViewModel.Command.OpenDuckPlayerSettings import com.duckduckgo.adblocking.impl.ui.AdBlockingSettingsViewModel.Command.OpenLearnMore import com.duckduckgo.anvil.annotations.ContributesViewModel @@ -49,6 +50,7 @@ import javax.inject.Inject @ContributesViewModel(ActivityScope::class) class AdBlockingSettingsViewModel @Inject constructor( statusChecker: AdBlockingStatusChecker, + feature: AdBlockingExtensionFeature, private val repository: AdBlockingSettingsRepository, private val pixel: Pixel, duckPlayer: DuckPlayer, @@ -63,6 +65,8 @@ class AdBlockingSettingsViewModel @Inject constructor( val isEnabled: Boolean = false, val showConsentDescription: Boolean? = null, val duckPlayerMode: PrivatePlayerMode = AlwaysAsk, + val isContingencyMode: Boolean = false, + val isStatusIndicatorOn: Boolean = false, ) sealed class Command { @@ -73,10 +77,16 @@ class AdBlockingSettingsViewModel @Inject constructor( val viewState: StateFlow = combine( statusChecker.observeState(), duckPlayer.observeUserPreferences(), - ) { state, duckPlayerPreferences -> + feature.adBlockingUXImprovements().enabled(), + feature.enableContingencyMode().enabled(), + ) { state, duckPlayerPreferences, uxImprovements, contingencyModeOn -> + val isEnabled = state is AdBlockingState.Enabled + val isContingencyMode = uxImprovements && contingencyModeOn ViewState( - isEnabled = state is AdBlockingState.Enabled, + isEnabled = isEnabled, showConsentDescription = state !is AdBlockingState.Enabled.Default, + isContingencyMode = isContingencyMode, + isStatusIndicatorOn = isEnabled && !isContingencyMode, duckPlayerMode = duckPlayerPreferences.privatePlayerMode, ) } diff --git a/ad-blocking/ad-blocking-impl/src/main/res/layout/activity_ad_blocking_settings_v2.xml b/ad-blocking/ad-blocking-impl/src/main/res/layout/activity_ad_blocking_settings_v2.xml index ba896bfe641e..440f89d1ec51 100644 --- a/ad-blocking/ad-blocking-impl/src/main/res/layout/activity_ad_blocking_settings_v2.xml +++ b/ad-blocking/ad-blocking-impl/src/main/res/layout/activity_ad_blocking_settings_v2.xml @@ -96,6 +96,17 @@ app:textType="secondary" app:typography="body2" /> + + + + + + + + + + + + + + + + diff --git a/ad-blocking/ad-blocking-impl/src/main/res/values/donottranslate.xml b/ad-blocking/ad-blocking-impl/src/main/res/values/donottranslate.xml index 539c36aa560d..8f456e49d9ab 100644 --- a/ad-blocking/ad-blocking-impl/src/main/res/values/donottranslate.xml +++ b/ad-blocking/ad-blocking-impl/src/main/res/values/donottranslate.xml @@ -17,4 +17,8 @@ Ad Blocking Blocks invasive trackers before they load, effectively eliminating ads that rely on tracking + YouTube Ad Blocking Unavailable + Ad Blocking has been affected by recent changes to YouTube. We\'re working to fix these issues and appreciate your understanding + Got It + Close diff --git a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPluginTest.kt b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPluginTest.kt index 5c6f67e9a2bd..908d8dcc0c3f 100644 --- a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPluginTest.kt +++ b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/AdBlockingExtensionJsInjectorPluginTest.kt @@ -50,6 +50,7 @@ class AdBlockingExtensionJsInjectorPluginTest { on { scriptletsFlow() } doReturn scriptletsFlow } private val webView: WebView = mock() + private val contingencyMessageHandler: ContingencyMessageHandler = mock() private val testScope = CoroutineScope(UnconfinedTestDispatcher()) private val isolatedName = "scriptlets/isolated/ublock-filters.js" @@ -64,6 +65,7 @@ class AdBlockingExtensionJsInjectorPluginTest { AdBlockingExtensionJsInjectorPlugin( statusChecker = statusChecker, repository = repository, + contingencyMessageHandler = contingencyMessageHandler, appScope = testScope, ) } @@ -232,4 +234,13 @@ class AdBlockingExtensionJsInjectorPluginTest { verify(webView, never()).evaluateJavascript(any(), isNull()) } + + @Test + fun whenOnPageFinishedCalledThenContingencyMessageHandlerIsNotified() { + val url = "https://youtube.com/page" + + plugin.onPageFinished(webView, url = url, site = null) + + verify(contingencyMessageHandler).onPageLoaded(webView, url) + } } diff --git a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/RealContingencyMessageHandlerTest.kt b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/RealContingencyMessageHandlerTest.kt new file mode 100644 index 000000000000..a2d0ec2a417c --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/RealContingencyMessageHandlerTest.kt @@ -0,0 +1,224 @@ +/* + * 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.annotation.SuppressLint +import android.webkit.WebView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature +import com.duckduckgo.adblocking.impl.remoteconfig.ContingencyMessageStore +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +@OptIn(ExperimentalCoroutinesApi::class) +@SuppressLint("DenyListedApi") // setRawStoredState +@RunWith(AndroidJUnit4::class) +class RealContingencyMessageHandlerTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val feature = FakeFeatureToggleFactory.create(AdBlockingExtensionFeature::class.java) + + private val shownFlow = MutableStateFlow(false) + private val store = object : ContingencyMessageStore { + override val shown: StateFlow = shownFlow + override suspend fun setShown() { + shownFlow.value = true + } + + override suspend fun reset() { + shownFlow.value = false + } + } + + private val view = FakeContingencyMessageView() + + private val handler = RealContingencyMessageHandler( + feature = feature, + store = store, + view = view, + appScope = coroutineRule.testScope, + dispatchers = coroutineRule.testDispatcherProvider, + ) + + private fun setToggles(uxImprovements: Boolean, contingency: Boolean) { + feature.adBlockingUXImprovements().setRawStoredState(Toggle.State(remoteEnableState = uxImprovements)) + feature.enableContingencyMode().setRawStoredState(Toggle.State(remoteEnableState = contingency)) + } + + @Test + fun whenAllGatesOpenAndYouTubeAndNotShownThenShouldShow() { + setToggles(uxImprovements = true, contingency = true) + + assertTrue(handler.shouldShow("https://youtube.com/watch?v=abc")) + } + + @Test + fun whenYouTubeSubdomainThenShouldShow() { + setToggles(uxImprovements = true, contingency = true) + + assertTrue(handler.shouldShow("https://m.youtube.com/watch?v=abc")) + } + + @Test + fun whenUxImprovementsDisabledThenShouldNotShow() { + setToggles(uxImprovements = false, contingency = true) + + assertFalse(handler.shouldShow("https://youtube.com/watch?v=abc")) + } + + @Test + fun whenContingencyModeDisabledThenShouldNotShow() { + setToggles(uxImprovements = true, contingency = false) + + assertFalse(handler.shouldShow("https://youtube.com/watch?v=abc")) + } + + @Test + fun whenUrlIsNotYouTubeThenShouldNotShow() { + setToggles(uxImprovements = true, contingency = true) + + assertFalse(handler.shouldShow("https://example.com/watch?v=abc")) + } + + @Test + fun whenUrlIsNullThenShouldNotShow() { + setToggles(uxImprovements = true, contingency = true) + + assertFalse(handler.shouldShow(null)) + } + + @Test + fun whenAlreadyShownThenShouldNotShow() { + setToggles(uxImprovements = true, contingency = true) + shownFlow.value = true + + assertFalse(handler.shouldShow("https://youtube.com/watch?v=abc")) + } + + @Test + fun whenContingencyModeDisabledThenShownIsReset() = runTest { + shownFlow.value = true + + handler.onContingencyModeChanged(contingencyEnabled = false) + + assertFalse(shownFlow.value) + } + + @Test + fun whenContingencyModeEnabledThenShownIsNotReset() = runTest { + shownFlow.value = true + + handler.onContingencyModeChanged(contingencyEnabled = true) + + assertTrue(shownFlow.value) + } + + @Test + fun whenPageLoadedRepeatedlyBeforeStoreWriteReflectsThenShowsOnlyOnce() = runTest { + setToggles(uxImprovements = true, contingency = true) + // Model DataStore write latency: setShown() never flips the observable value during the + // test, so only the in-memory guard can prevent a repeat show. + val handler = handlerWith(laggingStore()) + val webView = foregroundWebView() + + handler.onPageLoaded(webView, YOUTUBE_URL) + handler.onPageLoaded(webView, YOUTUBE_URL) + handler.onPageLoaded(webView, YOUTUBE_URL) + + assertEquals(1, view.shownCount) + } + + @Test + fun whenContingencyDisabledThenReenabledThenShowsAgain() = runTest { + setToggles(uxImprovements = true, contingency = true) + val handler = handlerWith(laggingStore()) + val webView = foregroundWebView() + + handler.onPageLoaded(webView, YOUTUBE_URL) + handler.onContingencyModeChanged(contingencyEnabled = false) + handler.onPageLoaded(webView, YOUTUBE_URL) + + assertEquals(2, view.shownCount) + } + + @Test + fun whenWebViewNotShownThenDoesNotShow() = runTest { + setToggles(uxImprovements = true, contingency = true) + val handler = handlerWith(laggingStore()) + + handler.onPageLoaded(backgroundWebView(), YOUTUBE_URL) + + assertEquals(0, view.shownCount) + } + + @Test + fun whenBackgroundLoadThenOneOffNotConsumedAndForegroundStillShows() = runTest { + setToggles(uxImprovements = true, contingency = true) + val store = laggingStore() + val handler = handlerWith(store) + + handler.onPageLoaded(backgroundWebView(), YOUTUBE_URL) + handler.onPageLoaded(foregroundWebView(), YOUTUBE_URL) + + assertEquals(1, view.shownCount) + } + + private fun foregroundWebView() = mock { on { isShown } doReturn true } + + private fun backgroundWebView() = mock { on { isShown } doReturn false } + + private fun laggingStore() = object : ContingencyMessageStore { + private val value = MutableStateFlow(false) + override val shown: StateFlow = value + override suspend fun setShown() { /* write is async; observable value lags */ } + override suspend fun reset() { value.value = false } + } + + private fun handlerWith(store: ContingencyMessageStore) = RealContingencyMessageHandler( + feature = feature, + store = store, + view = view, + appScope = coroutineRule.testScope, + dispatchers = coroutineRule.testDispatcherProvider, + ) + + private class FakeContingencyMessageView : ContingencyMessageView { + var shownCount = 0 + override fun show(webView: WebView) { + shownCount++ + } + } + + private companion object { + private const val YOUTUBE_URL = "https://youtube.com/watch?v=abc" + } +} diff --git a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayerTest.kt b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayerTest.kt index 323a4355a215..e2e8cd681ca2 100644 --- a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayerTest.kt +++ b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/duckplayer/RealDuckPlayerTest.kt @@ -24,6 +24,7 @@ import android.webkit.MimeTypeMap import android.webkit.WebResourceRequest import android.webkit.WebView import androidx.core.net.toUri +import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer.DuckPlayerOrigin.AUTO import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer.DuckPlayerState.DISABLED @@ -50,6 +51,8 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.UrlScheme.Companion.duck import com.duckduckgo.common.utils.UrlScheme.Companion.https import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles import com.duckduckgo.feature.toggles.api.Toggle.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -89,7 +92,13 @@ class RealDuckPlayerTest { private val mockDuckPlayerLocalFilesPath: DuckPlayerLocalFilesPath = mock() private val mimeType: MimeTypeMap = mock() private val dispatcherProvider = coroutineRule.testDispatcherProvider - private val adBlockingExtensionFeature = FakeFeatureToggleFactory.create(AdBlockingExtensionFeature::class.java) + private val adBlockingExtensionFeature = FeatureToggles.Builder() + .store(FakeToggleStore()) + .appVersionProvider { Int.MAX_VALUE } + .featureName("adBlockingExtension") + .ioDispatcher(coroutineRule.testDispatcher) + .build() + .create(AdBlockingExtensionFeature::class.java) private val mockAppBuildConfig: AppBuildConfig = mock() private val storedUserPreferencesFlow = MutableStateFlow(StoredUserPreferences(false, null)) @@ -217,13 +226,15 @@ class RealDuckPlayerTest { //endregion - // region ad-blocking rollout default (driven by combine() in init) + // region ad-blocking rollout default (driven by combine() started in onCreate) @Test fun whenNotNewInstall_combineDoesNotPersistRolloutDefault() = runTest { whenever(mockAppBuildConfig.isNewInstall()).thenReturn(false) adBlockingExtensionFeature.self().setRawStoredState(State(true)) adBlockingExtensionFeature.enabledByDefault().setRawStoredState(State(true)) + testee.onCreate(mock(LifecycleOwner::class.java)) + verify(mockDuckPlayerFeatureRepository, never()).setUserPreferences(any()) } @@ -233,6 +244,8 @@ class RealDuckPlayerTest { adBlockingExtensionFeature.self().setRawStoredState(State(true)) adBlockingExtensionFeature.enabledByDefault().setRawStoredState(State(false)) + testee.onCreate(mock(LifecycleOwner::class.java)) + verify(mockDuckPlayerFeatureRepository, never()).setUserPreferences(any()) } @@ -242,16 +255,22 @@ class RealDuckPlayerTest { adBlockingExtensionFeature.self().setRawStoredState(State(false)) adBlockingExtensionFeature.enabledByDefault().setRawStoredState(State(true)) + testee.onCreate(mock(LifecycleOwner::class.java)) + verify(mockDuckPlayerFeatureRepository, never()).setUserPreferences(any()) } + // Runs on the rule's dispatcher so advanceUntilIdle() drives the onCreate combine collector (which lives on + // coroutineRule.testScope); the fresh runTest scope keeps its own Job so the collector isn't policed for completion. @OptIn(ExperimentalCoroutinesApi::class) @Test - fun whenNewInstallAndRolloutOnAndNoStoredMode_combinePersistsDisabled() = runTest { + fun whenNewInstallAndRolloutOnAndNoStoredMode_combinePersistsDisabled() = runTest(coroutineRule.testDispatcher) { whenever(mockAppBuildConfig.isNewInstall()).thenReturn(true) storedUserPreferencesFlow.value = StoredUserPreferences(true, null) adBlockingExtensionFeature.self().setRawStoredState(State(true)) adBlockingExtensionFeature.enabledByDefault().setRawStoredState(State(true)) + + testee.onCreate(mock(LifecycleOwner::class.java)) advanceUntilIdle() // atLeastOnce because the mocked repo doesn't propagate the write back through @@ -266,6 +285,8 @@ class RealDuckPlayerTest { adBlockingExtensionFeature.self().setRawStoredState(State(true)) adBlockingExtensionFeature.enabledByDefault().setRawStoredState(State(true)) + testee.onCreate(mock(LifecycleOwner::class.java)) + verify(mockDuckPlayerFeatureRepository, never()).setUserPreferences(any()) } //endregion diff --git a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/remoteconfig/SharedPreferencesContingencyMessageStoreTest.kt b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/remoteconfig/SharedPreferencesContingencyMessageStoreTest.kt new file mode 100644 index 000000000000..da826794de89 --- /dev/null +++ b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/remoteconfig/SharedPreferencesContingencyMessageStoreTest.kt @@ -0,0 +1,72 @@ +/* + * 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.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.common.test.CoroutineTestRule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SharedPreferencesContingencyMessageStoreTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val testDataStore: DataStore = + PreferenceDataStoreFactory.create( + scope = coroutineRule.testScope, + produceFile = { context.preferencesDataStoreFile("ad_blocking_contingency_message_test") }, + ) + + private val testee = SharedPreferencesContingencyMessageStore(testDataStore, coroutineRule.testScope) + + @Test + fun whenNothingStoredThenShownIsFalse() = runTest { + assertFalse(testee.shown.first()) + } + + @Test + fun whenSetShownThenShownIsTrue() = runTest { + testee.setShown() + + assertTrue(testee.shown.first()) + } + + @Test + fun whenResetAfterSetShownThenShownIsFalse() = runTest { + testee.setShown() + assertTrue(testee.shown.first()) + + testee.reset() + + assertFalse(testee.shown.first()) + } +} diff --git a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModelTest.kt b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModelTest.kt index 3acc1ef556be..097807f80bd5 100644 --- a/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModelTest.kt +++ b/ad-blocking/ad-blocking-impl/src/test/java/com/duckduckgo/adblocking/impl/ui/AdBlockingSettingsViewModelTest.kt @@ -16,6 +16,7 @@ package com.duckduckgo.adblocking.impl.ui +import android.annotation.SuppressLint import app.cash.turbine.test import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer import com.duckduckgo.adblocking.api.duckplayer.DuckPlayer.UserPreferences @@ -27,8 +28,12 @@ import com.duckduckgo.adblocking.impl.AdBlockingPixelNames import com.duckduckgo.adblocking.impl.AdBlockingSettingsRepository import com.duckduckgo.adblocking.impl.domain.AdBlockingState import com.duckduckgo.adblocking.impl.domain.AdBlockingStatusChecker +import com.duckduckgo.adblocking.impl.remoteconfig.AdBlockingExtensionFeature import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -40,19 +45,32 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@SuppressLint("DenyListedApi") // setRawStoredState class AdBlockingSettingsViewModelTest { @get:Rule val coroutineRule = CoroutineTestRule() private val statusChecker: AdBlockingStatusChecker = mock() + private val feature = FeatureToggles.Builder() + .store(FakeToggleStore()) + .appVersionProvider { Int.MAX_VALUE } + .featureName("adBlockingExtension") + .ioDispatcher(coroutineRule.testDispatcher) + .build() + .create(AdBlockingExtensionFeature::class.java) private val repository: AdBlockingSettingsRepository = mock() private val pixel: Pixel = mock() private val duckPlayer: DuckPlayer = mock() private fun createViewModel(duckPlayerMode: PrivatePlayerMode = AlwaysAsk): AdBlockingSettingsViewModel { whenever(duckPlayer.observeUserPreferences()).thenReturn(flowOf(userPreferences(duckPlayerMode))) - return AdBlockingSettingsViewModel(statusChecker, repository, pixel, duckPlayer) + return AdBlockingSettingsViewModel(statusChecker, feature, repository, pixel, duckPlayer) + } + + private fun setToggles(uxImprovements: Boolean, contingency: Boolean) { + feature.adBlockingUXImprovements().setRawStoredState(Toggle.State(remoteEnableState = uxImprovements)) + feature.enableContingencyMode().setRawStoredState(Toggle.State(remoteEnableState = contingency)) } private fun userPreferences(privatePlayerMode: PrivatePlayerMode) = @@ -109,6 +127,68 @@ class AdBlockingSettingsViewModelTest { } } + @Test + fun whenContingencyModeAndUxImprovementsOnThenIsContingencyMode() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Enabled.Default)) + setToggles(uxImprovements = true, contingency = true) + + createViewModel().viewState.test { + assertTrue(expectMostRecentItem().isContingencyMode) + } + } + + @Test + fun whenContingencyModeOnButUxImprovementsOffThenNotContingencyMode() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Enabled.Default)) + setToggles(uxImprovements = false, contingency = true) + + createViewModel().viewState.test { + assertFalse(expectMostRecentItem().isContingencyMode) + } + } + + @Test + fun whenContingencyModeOffThenNotContingencyMode() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Enabled.Default)) + setToggles(uxImprovements = true, contingency = false) + + createViewModel().viewState.test { + assertFalse(expectMostRecentItem().isContingencyMode) + } + } + + @Test + fun whenEnabledAndNotContingencyModeThenStatusIndicatorOn() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Enabled.Default)) + setToggles(uxImprovements = true, contingency = false) + + createViewModel().viewState.test { + assertTrue(expectMostRecentItem().isStatusIndicatorOn) + } + } + + @Test + fun whenContingencyModeOnThenStatusIndicatorOff() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Enabled.Default)) + setToggles(uxImprovements = true, contingency = true) + + createViewModel().viewState.test { + val state = expectMostRecentItem() + assertTrue(state.isEnabled) + assertFalse(state.isStatusIndicatorOn) + } + } + + @Test + fun whenDisabledThenStatusIndicatorOff() = runTest { + whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Disabled)) + setToggles(uxImprovements = true, contingency = false) + + createViewModel().viewState.test { + assertFalse(expectMostRecentItem().isStatusIndicatorOn) + } + } + @Test fun whenViewModelCreatedThenFiresSettingsOpenedPixels() = runTest { whenever(statusChecker.observeState()).thenReturn(flowOf(AdBlockingState.Disabled))