tor-browser

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

commit 8e80ffe041c35da088b69d7e28487a53d85997f1
parent d64059f7275a2a99fa0805d11b0054650ecf41ce
Author: mcarare <48995920+mcarare@users.noreply.github.com>
Date:   Thu, 11 Dec 2025 12:49:35 +0000

Bug 2005172 - Refactor ExtensionsProcessDisabled controllers tests to use StandardTestDispatcher. r=android-reviewers,avirvara

This patch injects `CoroutineDispatcher` into `ExtensionsProcessDisabledPromptObserver`, `ExtensionsProcessDisabledBackgroundController`, and `ExtensionsProcessDisabledForegroundController`.

This allows replacing `MainCoroutineRule` with `StandardTestDispatcher` in the corresponding tests.

Additionally, removes the unnecessary `Handler` wrapper when killing the app process in `ExtensionsProcessDisabledBackgroundController`.

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

Diffstat:
Mmobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt | 10++++++++--
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundController.kt | 11++++++-----
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundController.kt | 5+++++
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt | 76+++++++++++++++++++++++++++++++++++++++-------------------------------------
Mmobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt | 221++++++++++++++++++++++++++++++++++++++++---------------------------------------
5 files changed, 170 insertions(+), 153 deletions(-)

diff --git a/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt b/mobile/android/android-components/components/support/webextensions/src/main/java/mozilla/components/support/webextensions/ExtensionsProcessDisabledPromptObserver.kt @@ -4,7 +4,9 @@ package mozilla.components.support.webextensions +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChangedBy import mozilla.components.browser.state.store.BrowserStore @@ -18,21 +20,25 @@ import mozilla.components.support.base.feature.LifecycleAwareFeature * @property store the application's [BrowserStore]. * @property shouldCancelOnStop If false, this observer will run indefinitely to be able to react * to state changes when the app is either in the foreground or in the background. - * Please note to not have any references to Activity or it's context in an observer where this + * Please note to not have any references to Activity or its context in an observer where this * is false. Defaults to true. + * @property dispatcher The [CoroutineDispatcher] on which the observation flow will be collected. + * Defaults to [Dispatchers.Main]. * @property onShowExtensionsProcessDisabledPrompt a callback invoked when the application should * open a prompt. + */ open class ExtensionsProcessDisabledPromptObserver( private val store: BrowserStore, private val shouldCancelOnStop: Boolean = true, + private val dispatcher: CoroutineDispatcher = Dispatchers.Main, private val onShowExtensionsProcessDisabledPrompt: () -> Unit, ) : LifecycleAwareFeature { private var scope: CoroutineScope? = null override fun start() { if (scope == null) { - scope = store.flowScoped { flow -> + scope = store.flowScoped(dispatcher = dispatcher) { flow -> flow.distinctUntilChangedBy { it.showExtensionsProcessDisabledPrompt } .collect { state -> if (state.showExtensionsProcessDisabledPrompt) { diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundController.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundController.kt @@ -4,8 +4,8 @@ package org.mozilla.fenix.addons -import android.os.Handler -import android.os.Looper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import mozilla.components.browser.state.store.BrowserStore import mozilla.components.support.webextensions.ExtensionsProcessDisabledPromptObserver import org.mozilla.fenix.components.AppStore @@ -18,16 +18,19 @@ import kotlin.system.exitProcess * * @param browserStore The [BrowserStore] which holds the state for showing the dialog. * @param appStore The [AppStore] containing the application state. + * @param dispatcher The [CoroutineDispatcher] on which the observer operations will run. * @param onExtensionsProcessDisabled Invoked when the app is in background and extensions process * is disabled. */ class ExtensionsProcessDisabledBackgroundController( browserStore: BrowserStore, appStore: AppStore, + dispatcher: CoroutineDispatcher = Dispatchers.Main, onExtensionsProcessDisabled: () -> Unit = { killApp() }, ) : ExtensionsProcessDisabledPromptObserver( store = browserStore, shouldCancelOnStop = false, + dispatcher = dispatcher, onShowExtensionsProcessDisabledPrompt = { if (!appStore.state.isForeground) { onExtensionsProcessDisabled() @@ -41,9 +44,7 @@ class ExtensionsProcessDisabledBackgroundController( * be killed to prevent leaking network data without extensions enabled. */ private fun killApp() { - Handler(Looper.getMainLooper()).post { - exitProcess(0) - } + exitProcess(0) } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundController.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundController.kt @@ -11,6 +11,8 @@ import android.widget.TextView import androidx.annotation.UiContext import androidx.lifecycle.LifecycleOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import mozilla.components.browser.state.action.ExtensionsProcessAction import mozilla.components.browser.state.store.BrowserStore import mozilla.components.support.ktx.android.content.appName @@ -29,6 +31,7 @@ import org.mozilla.fenix.ext.components * @param appStore The [AppStore] containing the application state * @param builder to use for creating the dialog which can be styled as needed * @param appName to be added to the message. Optional and mainly relevant for testing + * @param dispatcher The [CoroutineDispatcher] to use for the observer logic. */ class ExtensionsProcessDisabledForegroundController( @UiContext context: Context, @@ -36,9 +39,11 @@ class ExtensionsProcessDisabledForegroundController( appStore: AppStore = context.components.appStore, builder: MaterialAlertDialogBuilder = MaterialAlertDialogBuilder(context), appName: String = context.appName, + dispatcher: CoroutineDispatcher = Dispatchers.Main, ) : ExtensionsProcessDisabledPromptObserver( store = browserStore, shouldCancelOnStop = true, + dispatcher = dispatcher, { if (appStore.state.isForeground) { presentDialog(context, browserStore, builder, appName) diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledBackgroundControllerTest.kt @@ -4,64 +4,66 @@ package org.mozilla.fenix.addons +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.ExtensionsProcessAction import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Rule import org.junit.Test import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.appstate.AppState class ExtensionsProcessDisabledBackgroundControllerTest { - @get:Rule - val coroutinesTestRule = MainCoroutineRule() - private val dispatcher = coroutinesTestRule.testDispatcher + private val dispatcher = StandardTestDispatcher() @Test - fun `WHEN app is backgrounded AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is invoked`() { - val browserStore = BrowserStore(BrowserState()) - val appStore = AppStore(AppState(isForeground = false)) - var invoked = false + fun `WHEN app is backgrounded AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is invoked`() = + runTest(dispatcher) { + val browserStore = BrowserStore(BrowserState()) + val appStore = AppStore(AppState(isForeground = false)) + var invoked = false - val controller = ExtensionsProcessDisabledBackgroundController( - browserStore, - appStore, - onExtensionsProcessDisabled = { - invoked = true - }, - ) + val controller = ExtensionsProcessDisabledBackgroundController( + browserStore, + appStore, + dispatcher, + onExtensionsProcessDisabled = { + invoked = true + }, + ) - controller.start() + controller.start() - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() - assertTrue(invoked) - } + assertTrue(invoked) + } @Test - fun `WHEN app is in foreground AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is not invoked`() { - val browserStore = BrowserStore(BrowserState()) - val appStore = AppStore(AppState(isForeground = true)) - var invoked = false + fun `WHEN app is in foreground AND extension process spawning threshold is exceeded THEN onExtensionsProcessDisabled is not invoked`() = + runTest(dispatcher) { + val browserStore = BrowserStore(BrowserState()) + val appStore = AppStore(AppState(isForeground = true)) + var invoked = false - val controller = ExtensionsProcessDisabledBackgroundController( - browserStore, - appStore, - onExtensionsProcessDisabled = { - invoked = true - }, - ) + val controller = ExtensionsProcessDisabledBackgroundController( + browserStore, + appStore, + dispatcher, + onExtensionsProcessDisabled = { + invoked = true + }, + ) - controller.start() + controller.start() - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() - assertFalse(invoked) - } + assertFalse(invoked) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/addons/ExtensionsProcessDisabledForegroundControllerTest.kt @@ -8,15 +8,15 @@ import android.view.View import android.widget.Button import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import mozilla.components.browser.state.action.ExtensionsProcessAction import mozilla.components.browser.state.store.BrowserStore import mozilla.components.support.test.argumentCaptor import mozilla.components.support.test.robolectric.testContext -import mozilla.components.support.test.rule.MainCoroutineRule import mozilla.components.support.test.whenever 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.Mockito.mock @@ -29,117 +29,120 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class ExtensionsProcessDisabledForegroundControllerTest { - - @get:Rule - val coroutinesTestRule = MainCoroutineRule() - private val dispatcher = coroutinesTestRule.testDispatcher + private val dispatcher = StandardTestDispatcher() @Test - fun `WHEN showExtensionsProcessDisabledPrompt is true AND positive button clicked then enable extension process spawning`() { - val browserStore = BrowserStore() - val dialog: AlertDialog = mock() - val builder: MaterialAlertDialogBuilder = mock() - val controller = ExtensionsProcessDisabledForegroundController( - context = testContext, - appStore = AppStore(AppState(isForeground = true)), - browserStore = browserStore, - builder = builder, - appName = "TestApp", - ) - val buttonsContainerCaptor = argumentCaptor<View>() - - controller.start() - - whenever(builder.show()).thenReturn(dialog) - - assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) - assertFalse(browserStore.state.extensionsProcessDisabled) - - // Pretend the process has been disabled and we show the dialog. - browserStore.dispatch(ExtensionsProcessAction.DisabledAction) - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() - assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt) - assertTrue(browserStore.state.extensionsProcessDisabled) - - verify(builder).setView(buttonsContainerCaptor.capture()) - verify(builder).show() - - buttonsContainerCaptor.value.findViewById<Button>(R.id.positive).performClick() - - assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) - assertFalse(browserStore.state.extensionsProcessDisabled) - verify(dialog).dismiss() - } + fun `WHEN showExtensionsProcessDisabledPrompt is true AND positive button clicked then enable extension process spawning`() = + runTest(dispatcher) { + val browserStore = BrowserStore() + val dialog: AlertDialog = mock() + val builder: MaterialAlertDialogBuilder = mock() + val controller = ExtensionsProcessDisabledForegroundController( + context = testContext, + appStore = AppStore(AppState(isForeground = true)), + browserStore = browserStore, + builder = builder, + appName = "TestApp", + dispatcher = dispatcher, + ) + val buttonsContainerCaptor = argumentCaptor<View>() + + controller.start() + + whenever(builder.show()).thenReturn(dialog) + + assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) + assertFalse(browserStore.state.extensionsProcessDisabled) + + // Pretend the process has been disabled and we show the dialog. + browserStore.dispatch(ExtensionsProcessAction.DisabledAction) + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() + assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt) + assertTrue(browserStore.state.extensionsProcessDisabled) + + verify(builder).setView(buttonsContainerCaptor.capture()) + verify(builder).show() + + buttonsContainerCaptor.value.findViewById<Button>(R.id.positive).performClick() + + assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) + assertFalse(browserStore.state.extensionsProcessDisabled) + verify(dialog).dismiss() + } @Test - fun `WHEN showExtensionsProcessDisabledPrompt is true AND negative button clicked then dismiss without enabling extension process spawning`() { - val browserStore = BrowserStore() - val dialog: AlertDialog = mock() - val builder: MaterialAlertDialogBuilder = mock() - val controller = ExtensionsProcessDisabledForegroundController( - context = testContext, - appStore = AppStore(AppState(isForeground = true)), - browserStore = browserStore, - builder = builder, - appName = "TestApp", - ) - val buttonsContainerCaptor = argumentCaptor<View>() - - controller.start() - - whenever(builder.show()).thenReturn(dialog) - - assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) - assertFalse(browserStore.state.extensionsProcessDisabled) - - // Pretend the process has been disabled and we show the dialog. - browserStore.dispatch(ExtensionsProcessAction.DisabledAction) - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() - assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt) - assertTrue(browserStore.state.extensionsProcessDisabled) - - verify(builder).setView(buttonsContainerCaptor.capture()) - verify(builder).show() - - buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick() - - assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) - assertTrue(browserStore.state.extensionsProcessDisabled) - verify(dialog).dismiss() - } + fun `WHEN showExtensionsProcessDisabledPrompt is true AND negative button clicked then dismiss without enabling extension process spawning`() = + runTest(dispatcher) { + val browserStore = BrowserStore() + val dialog: AlertDialog = mock() + val builder: MaterialAlertDialogBuilder = mock() + val controller = ExtensionsProcessDisabledForegroundController( + context = testContext, + appStore = AppStore(AppState(isForeground = true)), + browserStore = browserStore, + builder = builder, + appName = "TestApp", + dispatcher = dispatcher, + ) + val buttonsContainerCaptor = argumentCaptor<View>() + + controller.start() + + whenever(builder.show()).thenReturn(dialog) + + assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) + assertFalse(browserStore.state.extensionsProcessDisabled) + + // Pretend the process has been disabled and we show the dialog. + browserStore.dispatch(ExtensionsProcessAction.DisabledAction) + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() + assertTrue(browserStore.state.showExtensionsProcessDisabledPrompt) + assertTrue(browserStore.state.extensionsProcessDisabled) + + verify(builder).setView(buttonsContainerCaptor.capture()) + verify(builder).show() + + buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick() + + assertFalse(browserStore.state.showExtensionsProcessDisabledPrompt) + assertTrue(browserStore.state.extensionsProcessDisabled) + verify(dialog).dismiss() + } @Test - fun `WHEN dispatching the same event twice THEN the dialog should only be created once`() { - val browserStore = BrowserStore() - val dialog: AlertDialog = mock() - val builder: MaterialAlertDialogBuilder = mock() - val controller = ExtensionsProcessDisabledForegroundController( - context = testContext, - appStore = AppStore(AppState(isForeground = true)), - browserStore = browserStore, - builder = builder, - appName = "TestApp", - ) - val buttonsContainerCaptor = argumentCaptor<View>() - - controller.start() - - whenever(builder.show()).thenReturn(dialog) - - // First dispatch... - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() - - // Second dispatch... without having dismissed the dialog before! - browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) - dispatcher.scheduler.advanceUntilIdle() - - verify(builder).setView(buttonsContainerCaptor.capture()) - verify(builder, times(1)).show() - - // Click a button to dismiss the dialog. - buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick() - } + fun `WHEN dispatching the same event twice THEN the dialog should only be created once`() = + runTest(dispatcher) { + val browserStore = BrowserStore() + val dialog: AlertDialog = mock() + val builder: MaterialAlertDialogBuilder = mock() + val controller = ExtensionsProcessDisabledForegroundController( + context = testContext, + appStore = AppStore(AppState(isForeground = true)), + browserStore = browserStore, + builder = builder, + appName = "TestApp", + dispatcher = dispatcher, + ) + val buttonsContainerCaptor = argumentCaptor<View>() + + controller.start() + + whenever(builder.show()).thenReturn(dialog) + + // First dispatch... + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() + + // Second dispatch... without having dismissed the dialog before! + browserStore.dispatch(ExtensionsProcessAction.ShowPromptAction(show = true)) + dispatcher.scheduler.advanceUntilIdle() + + verify(builder).setView(buttonsContainerCaptor.capture()) + verify(builder, times(1)).show() + + // Click a button to dismiss the dialog. + buttonsContainerCaptor.value.findViewById<Button>(R.id.negative).performClick() + } }