tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 72c331806e9eb09df4f757bd32410681306a75ea
parent 98a7f035de8ad358602f0071d9ca800648493c8b
Author: mcarare <48995920+mcarare@users.noreply.github.com>
Date:   Thu, 27 Nov 2025 15:27:49 +0000

Bug 2002724 - Refactor CfrMiddleware to use dependency injection. r=android-reviewers,giorga

This patch refactors `CfrMiddleware` to take `AppStore` and `Settings` as constructor arguments instead of `Context`. This removes the need for `Robolectric` in tests, allowing `CfrMiddlewareTest` to be rewritten as a standard JUnit test with Mockito.

Additionally, the local `isCurrentTabSecure` state in `CfrMiddleware` is removed. The security status is now derived directly from the `BrowserState` when determining whether to show the tracking protection CFR.

Differential Revision: https://phabricator.services.mozilla.com/D274262

Diffstat:
Mmobile/android/focus-android/app/src/main/java/org/mozilla/focus/Components.kt | 2+-
Mmobile/android/focus-android/app/src/main/java/org/mozilla/focus/cfr/CfrMiddleware.kt | 77+++++++++++++++++++++++++++++++++++++++++------------------------------------
Mmobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt | 326++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
3 files changed, 299 insertions(+), 106 deletions(-)

diff --git a/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/Components.kt b/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/Components.kt @@ -207,7 +207,7 @@ class Components( AdsTelemetryMiddleware(adsTelemetry), BlockedTrackersMiddleware(context), RecordingDevicesMiddleware(context, notificationsDelegate), - CfrMiddleware(context), + CfrMiddleware(appStore, settings), FileUploadsDirCleanerMiddleware(fileUploadsDirCleaner), ) + EngineMiddleware.create( engine, diff --git a/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/cfr/CfrMiddleware.kt b/mobile/android/focus-android/app/src/main/java/org/mozilla/focus/cfr/CfrMiddleware.kt @@ -4,10 +4,9 @@ package org.mozilla.focus.cfr -import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import mozilla.components.browser.state.action.BrowserAction -import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.action.CookieBannerAction import mozilla.components.browser.state.action.TrackingProtectionAction import mozilla.components.browser.state.selector.findTabOrCustomTabOrSelectedTab @@ -16,23 +15,26 @@ import mozilla.components.concept.engine.EngineSession import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.MiddlewareContext import mozilla.telemetry.glean.private.NoExtras +import org.mozilla.experiments.nimbus.internal.FeatureHolder import org.mozilla.focus.GleanMetrics.CookieBanner import org.mozilla.focus.cookiebanner.CookieBannerOption -import org.mozilla.focus.ext.components -import org.mozilla.focus.ext.settings import org.mozilla.focus.ext.truncatedHost import org.mozilla.focus.nimbus.FocusNimbus import org.mozilla.focus.nimbus.Onboarding import org.mozilla.focus.state.AppAction +import org.mozilla.focus.state.AppStore +import org.mozilla.focus.utils.Settings /** * Middleware used to intercept browser store actions in order to decide when should we display a specific CFR */ -class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, BrowserAction> { - private val onboardingFeature = FocusNimbus.features.onboarding - private lateinit var onboardingConfig: Onboarding - private val components = appContext.components - private var isCurrentTabSecure = false +class CfrMiddleware( + private val appStore: AppStore, + private val settings: Settings, + private val onboardingProvider: () -> FeatureHolder<Onboarding> = { + FocusNimbus.features.onboarding + }, +) : Middleware<BrowserState, BrowserAction> { private var tpExposureAlreadyRecorded = false override fun invoke( @@ -40,13 +42,11 @@ class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, next: (BrowserAction) -> Unit, action: BrowserAction, ) { - onboardingConfig = onboardingFeature.value() - if (onboardingConfig.isCfrEnabled) { - next(action) + next(action) + + if (onboardingProvider().value().isCfrEnabled) { showCookieBannerCfr(action) - showTrackingProtectionCfr(action, context) - } else { - next(action) + showTrackingProtectionCfr(action, context.state) } } @@ -58,7 +58,7 @@ class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, otherCfrHasBeenShown() ) { CookieBanner.cookieBannerCfrShown.record(NoExtras()) - components.appStore.dispatch( + appStore.dispatch( AppAction.ShowCookieBannerCfrChange(true), ) } @@ -66,12 +66,9 @@ class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, private fun showTrackingProtectionCfr( action: BrowserAction, - context: MiddlewareContext<BrowserState, BrowserAction>, + state: BrowserState, ) { - if (action is ContentAction.UpdateSecurityInfoAction) { - isCurrentTabSecure = action.securityInfo.secure - } - if (shouldShowCfrForTrackingProtection(action = action, browserState = context.state)) { + if (shouldShowCfrForTrackingProtection(action = action, browserState = state)) { if (tpExposureAlreadyRecorded) { // do not record exposure twice } else { @@ -79,7 +76,7 @@ class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, tpExposureAlreadyRecorded = true } - components.appStore.dispatch( + appStore.dispatch( AppAction.ShowTrackingProtectionCfrChange( mapOf((action as TrackingProtectionAction.TrackerBlockedAction).tabId to true), ), @@ -87,38 +84,46 @@ class CfrMiddleware(private val appContext: Context) : Middleware<BrowserState, } } - private fun isMozillaUrl(browserState: BrowserState): Boolean { + @VisibleForTesting + internal fun isMozillaUrl(browserState: BrowserState): Boolean { return browserState.findTabOrCustomTabOrSelectedTab( browserState.selectedTabId, )?.content?.url?.toUri()?.truncatedHost()?.substringBefore(".") == ("mozilla") } - private fun isActionSecure(action: BrowserAction) = - action is TrackingProtectionAction.TrackerBlockedAction && isCurrentTabSecure + private fun isActionSecure(action: BrowserAction, browserState: BrowserState) = + action is TrackingProtectionAction.TrackerBlockedAction && + action.tabId == browserState.selectedTabId && + isSessionSecure(browserState) + + private fun isSessionSecure(browserState: BrowserState) = + browserState.findTabOrCustomTabOrSelectedTab( + browserState.selectedTabId, + )?.content?.securityInfo?.secure == true private fun shouldShowCfrForTrackingProtection( action: BrowserAction, browserState: BrowserState, ) = ( - isActionSecure(action = action) && - !isMozillaUrl(browserState = browserState) && - components.settings.shouldShowCfrForTrackingProtection && - !components.appStore.state.showEraseTabsCfr - ) + isActionSecure(action = action, browserState = browserState) && + !isMozillaUrl(browserState = browserState) && + settings.shouldShowCfrForTrackingProtection && + !appStore.state.showEraseTabsCfr + ) private fun otherCfrHasBeenShown(): Boolean { return ( - !appContext.settings.shouldShowCfrForTrackingProtection && - !components.appStore.state.showEraseTabsCfr + !settings.shouldShowCfrForTrackingProtection && + !appStore.state.showEraseTabsCfr ) } private fun shouldShowCookieBannerCfr(action: CookieBannerAction.UpdateStatusAction): Boolean { return ( - !appContext.settings.isFirstRun && - appContext.settings.shouldShowCookieBannerCfr && - appContext.settings.isCookieBannerEnable && - appContext.settings.getCurrentCookieBannerOptionFromSharePref() == + !settings.isFirstRun && + settings.shouldShowCookieBannerCfr && + settings.isCookieBannerEnable && + settings.getCurrentCookieBannerOptionFromSharePref() == CookieBannerOption.CookieBannerRejectAll() && action.status == EngineSession.CookieBannerHandlingStatus.HANDLED ) diff --git a/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt b/mobile/android/focus-android/app/src/test/java/org/mozilla/focus/cfr/CfrMiddlewareTest.kt @@ -4,106 +4,294 @@ package org.mozilla.focus.cfr import mozilla.components.browser.state.action.ContentAction +import mozilla.components.browser.state.action.CookieBannerAction import mozilla.components.browser.state.action.TabListAction import mozilla.components.browser.state.action.TrackingProtectionAction +import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.SecurityInfoState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.content.blocking.Tracker -import mozilla.components.support.test.robolectric.testContext -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import mozilla.components.support.test.any +import mozilla.components.support.test.whenever import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations -import org.mozilla.focus.TestFocusApplication -import org.mozilla.focus.ext.components -import org.mozilla.focus.nimbus.FocusNimbus +import org.mozilla.experiments.nimbus.internal.FeatureHolder +import org.mozilla.focus.cookiebanner.CookieBannerOption import org.mozilla.focus.nimbus.Onboarding +import org.mozilla.focus.state.AppAction +import org.mozilla.focus.state.AppState import org.mozilla.focus.state.AppStore -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config +import org.mozilla.focus.utils.Settings -@RunWith(RobolectricTestRunner::class) -@Config(application = TestFocusApplication::class) class CfrMiddlewareTest { - private lateinit var onboardingExperiment: Onboarding - private val browserStore: BrowserStore = testContext.components.store - private val appStore: AppStore = testContext.components.appStore + + @Mock + private lateinit var onboardingExperiment: FeatureHolder<Onboarding> + + @Mock + private lateinit var onboardingConfig: Onboarding + + @Mock + private lateinit var appStore: AppStore + + @Mock + private lateinit var appState: AppState + + @Mock + private lateinit var settings: Settings + private lateinit var browserStore: BrowserStore + private lateinit var cfrMiddleware: CfrMiddleware @Before fun setUp() { MockitoAnnotations.openMocks(this) - onboardingExperiment = FocusNimbus.features.onboarding.value() + whenever(onboardingExperiment.value()).thenReturn(onboardingConfig) + whenever(onboardingConfig.isCfrEnabled).thenReturn(true) + + whenever(settings.isFirstRun).thenReturn(false) + whenever(settings.shouldShowCfrForTrackingProtection).thenReturn(true) + whenever(settings.shouldShowCookieBannerCfr).thenReturn(true) + whenever(settings.isCookieBannerEnable).thenReturn(true) + whenever(settings.getCurrentCookieBannerOptionFromSharePref()).thenReturn(CookieBannerOption.CookieBannerRejectAll()) + + whenever(appStore.state).thenReturn(appState) + whenever(appState.showEraseTabsCfr).thenReturn(false) + + cfrMiddleware = spy(CfrMiddleware(appStore, settings) { onboardingExperiment }) + + browserStore = BrowserStore( + initialState = BrowserState( + tabs = listOf(), + ), + middleware = listOf(cfrMiddleware), + ) } @Test fun `GIVEN shouldShowCfrForTrackingProtection is true WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is changed to true`() { - if (onboardingExperiment.isCfrEnabled) { - val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( - "1", - SecurityInfoState( - secure = true, - host = "test.org", - issuer = "Test", - ), - ) - val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( - tabId = "1", - tracker = Tracker( - url = "test.org", - trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), - cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), - ), - ) - - browserStore.dispatch(updateSecurityInfoAction) - browserStore.dispatch(trackerBlockedAction) - - assertTrue(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) - } + doReturn(false).`when`(cfrMiddleware).isMozillaUrl(any()) + + val tab = createTab(tabId = 1, isSecure = true) + browserStore.dispatch(TabListAction.AddTabAction(tab)) + browserStore.dispatch(TabListAction.SelectTabAction(tab.id)) + + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + + browserStore.dispatch(trackerBlockedAction) + + verify(appStore).dispatch( + AppAction.ShowTrackingProtectionCfrChange(mapOf("1" to true)), + ) } @Test fun `GIVEN insecure tab WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is not changed to true`() { - if (onboardingExperiment.isCfrEnabled) { - val insecureTab = createTab(isSecure = false) - val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( - "1", - SecurityInfoState( - secure = false, - host = "test.org", - issuer = "Test", - ), - ) - - browserStore.dispatch(TabListAction.AddTabAction(insecureTab)) - browserStore.dispatch(updateSecurityInfoAction) - - assertFalse(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) - } + doReturn(false).`when`(cfrMiddleware).isMozillaUrl(any()) + + val insecureTab = createTab(isSecure = false) + + browserStore.dispatch(TabListAction.AddTabAction(insecureTab)) + browserStore.dispatch(TabListAction.SelectTabAction(insecureTab.id)) + + val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( + "1", + SecurityInfoState( + secure = false, + host = "test.org", + issuer = "Test", + ), + ) + browserStore.dispatch(updateSecurityInfoAction) + + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + browserStore.dispatch(trackerBlockedAction) + + verify(appStore, never()).dispatch( + AppAction.ShowTrackingProtectionCfrChange(mapOf("1" to true)), + ) } @Test fun `GIVEN mozilla tab WHEN UpdateSecurityInfoAction is intercepted THEN showTrackingProtectionCfr is not changed to true`() { - if (onboardingExperiment.isCfrEnabled) { - val mozillaTab = createTab(tabId = 1, tabUrl = "https://www.mozilla.org") - val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( - "1", - SecurityInfoState( - secure = true, - host = "test.org", - issuer = "Test", - ), - ) - browserStore.dispatch(TabListAction.AddTabAction(mozillaTab)) - browserStore.dispatch(updateSecurityInfoAction) - - assertFalse(appStore.state.showTrackingProtectionCfrForTab.getOrDefault("1", false)) - } + doReturn(true).`when`(cfrMiddleware).isMozillaUrl(any()) + + val mozillaTab = createTab(tabId = 1, tabUrl = "https://www.mozilla.org", isSecure = true) + + browserStore.dispatch(TabListAction.AddTabAction(mozillaTab)) + browserStore.dispatch(TabListAction.SelectTabAction(mozillaTab.id)) + + val updateSecurityInfoAction = ContentAction.UpdateSecurityInfoAction( + "1", + SecurityInfoState( + secure = true, + host = "test.org", + issuer = "Test", + ), + ) + browserStore.dispatch(updateSecurityInfoAction) + + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + browserStore.dispatch(trackerBlockedAction) + + verify(appStore, never()).dispatch( + AppAction.ShowTrackingProtectionCfrChange(mapOf("1" to true)), + ) + } + + @Test + fun `GIVEN settings prevent CFR WHEN TrackerBlockedAction is intercepted THEN showTrackingProtectionCfr is not dispatched`() { + whenever(settings.shouldShowCfrForTrackingProtection).thenReturn(false) + doReturn(false).`when`(cfrMiddleware).isMozillaUrl(any()) + + val tab = createTab(tabId = 1, isSecure = true) + browserStore.dispatch(TabListAction.AddTabAction(tab)) + browserStore.dispatch(TabListAction.SelectTabAction(tab.id)) + + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + browserStore.dispatch(trackerBlockedAction) + + verify(appStore, never()).dispatch( + AppAction.ShowTrackingProtectionCfrChange(mapOf("1" to true)), + ) + } + + @Test + fun `GIVEN erase tabs CFR is shown WHEN TrackerBlockedAction is intercepted THEN showTrackingProtectionCfr is not dispatched`() { + whenever(appState.showEraseTabsCfr).thenReturn(true) + doReturn(false).`when`(cfrMiddleware).isMozillaUrl(any()) + + val tab = createTab(tabId = 1, isSecure = true) + browserStore.dispatch(TabListAction.AddTabAction(tab)) + browserStore.dispatch(TabListAction.SelectTabAction(tab.id)) + + val trackerBlockedAction = TrackingProtectionAction.TrackerBlockedAction( + tabId = "1", + tracker = Tracker( + url = "test.org", + trackingCategories = listOf(EngineSession.TrackingProtectionPolicy.TrackingCategory.CRYPTOMINING), + cookiePolicies = listOf(EngineSession.TrackingProtectionPolicy.CookiePolicy.ACCEPT_NONE), + ), + ) + browserStore.dispatch(trackerBlockedAction) + + verify(appStore, never()).dispatch( + AppAction.ShowTrackingProtectionCfrChange(mapOf("1" to true)), + ) + } + + @Test + fun `GIVEN cookie banner action handled WHEN conditions met THEN show cookie banner CFR`() { + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.HANDLED, + ) + + whenever(settings.shouldShowCfrForTrackingProtection).thenReturn(false) + + browserStore.dispatch(action) + + verify(appStore).dispatch(AppAction.ShowCookieBannerCfrChange(true)) + } + + @Test + fun `GIVEN cookie banner action not handled WHEN conditions met THEN do not show cookie banner CFR`() { + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.DETECTED, + ) + + browserStore.dispatch(action) + + verify(appStore, never()).dispatch(AppAction.ShowCookieBannerCfrChange(true)) + } + + @Test + fun `GIVEN cookie banner action handled but first run WHEN conditions met THEN do not show cookie banner CFR`() { + whenever(settings.isFirstRun).thenReturn(true) + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.HANDLED, + ) + + browserStore.dispatch(action) + + verify(appStore, never()).dispatch(AppAction.ShowCookieBannerCfrChange(true)) + } + + @Test + fun `GIVEN cookie banner action handled but setting disabled WHEN conditions met THEN do not show cookie banner CFR`() { + whenever(settings.shouldShowCookieBannerCfr).thenReturn(false) + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.HANDLED, + ) + + browserStore.dispatch(action) + + verify(appStore, never()).dispatch(AppAction.ShowCookieBannerCfrChange(true)) + } + + @Test + fun `GIVEN cookie banner action handled but feature disabled WHEN conditions met THEN do not show cookie banner CFR`() { + whenever(settings.isCookieBannerEnable).thenReturn(false) + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.HANDLED, + ) + + browserStore.dispatch(action) + + verify(appStore, never()).dispatch(AppAction.ShowCookieBannerCfrChange(true)) + } + + @Test + fun `GIVEN cookie banner action handled but option not reject all WHEN conditions met THEN do not show cookie banner CFR`() { + whenever(settings.getCurrentCookieBannerOptionFromSharePref()).thenReturn(CookieBannerOption.CookieBannerDisabled()) + val action = CookieBannerAction.UpdateStatusAction( + "1", + EngineSession.CookieBannerHandlingStatus.HANDLED, + ) + + browserStore.dispatch(action) + + verify(appStore, never()).dispatch(AppAction.ShowCookieBannerCfrChange(true)) } private fun createTab(