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:
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()
+ }
}