Bug 1964789: Enabled and disable the private mode lock feature through shared pref r=android-reviewers,gmalekpour

Differential Revision: https://phabricator.services.mozilla.com/D248567
This commit is contained in:
mike a
2025-05-09 17:09:50 +00:00
committed by mavduevskiy@mozilla.com
parent 27f2438098
commit 0422c9dc82
5 changed files with 735 additions and 171 deletions

View File

@@ -17,7 +17,6 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import org.mozilla.fenix.GleanMetrics.PrivateBrowsingLocked
import org.mozilla.fenix.R
import org.mozilla.fenix.components.appstate.AppAction.PrivateBrowsingLockAction
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.tabstray.Page
@@ -94,11 +93,7 @@ class UnlockPrivateTabsFragment : Fragment() {
private fun onAuthSuccess() {
PrivateBrowsingLocked.authSuccess.record()
requireComponents.appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
),
)
requireComponents.privateBrowsingLockFeature.onSuccessfulAuthentication()
findNavController().popBackStack()
}

View File

@@ -56,6 +56,7 @@ import org.mozilla.fenix.home.middleware.HomeTelemetryMiddleware
import org.mozilla.fenix.home.setup.store.DefaultSetupChecklistRepository
import org.mozilla.fenix.home.setup.store.SetupChecklistPreferencesMiddleware
import org.mozilla.fenix.home.setup.store.SetupChecklistTelemetryMiddleware
import org.mozilla.fenix.lifecycle.DefaultPrivateBrowsingLockStorage
import org.mozilla.fenix.lifecycle.PrivateBrowsingLockFeature
import org.mozilla.fenix.messaging.state.MessagingMiddleware
import org.mozilla.fenix.nimbus.FxNimbus
@@ -198,7 +199,10 @@ class Components(private val context: Context) {
PrivateBrowsingLockFeature(
appStore = appStore,
browserStore = core.store,
settings = settings,
storage = DefaultPrivateBrowsingLockStorage(
preferences = settings.preferences,
privateBrowsingLockPrefKey = context.getString(R.string.pref_key_private_browsing_locked_enabled),
),
)
}

View File

@@ -5,12 +5,14 @@
package org.mozilla.fenix.lifecycle
import android.app.Activity
import android.content.SharedPreferences
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.Lifecycle.State.RESUMED
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -24,7 +26,59 @@ import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction.PrivateBrowsingLockAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.tabstray.TabsTrayFragment
import org.mozilla.fenix.utils.Settings
/**
* An interface to access and observe the enabled/disabled state of the Private Browsing Lock feature.
*/
interface PrivateBrowsingLockStorage {
/**
* Returns the current enabled state of the private browsing lock feature.
*/
val isFeatureEnabled: Boolean
/**
* Registers a listener that is invoked whenever the enabled state changes.
*
* @param listener A lambda that receives the new boolean value when it changes.
*/
fun addFeatureStateListener(listener: (Boolean) -> Unit)
/**
* Removes the previously registered listener.
*/
fun removeFeatureStateListener()
}
/**
* A default implementation of `PrivateBrowsingLockStorage`.
*
* @param preferences The [SharedPreferences] instance from which to read the preference value.
* @param privateBrowsingLockPrefKey The key in [SharedPreferences] representing the feature flag.
*/
class DefaultPrivateBrowsingLockStorage(
private val preferences: SharedPreferences,
private val privateBrowsingLockPrefKey: String,
) : PrivateBrowsingLockStorage {
private var listener: ((Boolean) -> Unit)? = null
private val onFeatureStateChanged = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
if (key == privateBrowsingLockPrefKey) {
listener?.invoke(prefs.getBoolean(privateBrowsingLockPrefKey, false))
}
}
override val isFeatureEnabled: Boolean
get() = preferences.getBoolean(privateBrowsingLockPrefKey, false)
override fun addFeatureStateListener(listener: (Boolean) -> Unit) {
this.listener = listener
preferences.registerOnSharedPreferenceChangeListener(onFeatureStateChanged)
}
override fun removeFeatureStateListener() {
preferences.unregisterOnSharedPreferenceChangeListener(onFeatureStateChanged)
listener = null
}
}
/**
* A lifecycle-aware feature that locks private browsing mode behind authentication
@@ -33,44 +87,104 @@ import org.mozilla.fenix.utils.Settings
class PrivateBrowsingLockFeature(
private val appStore: AppStore,
private val browserStore: BrowserStore,
private val settings: Settings,
private val storage: PrivateBrowsingLockStorage,
) : DefaultLifecycleObserver {
private var browserStoreScope: CoroutineScope? = null
private var appStoreScope: CoroutineScope? = null
private var isFeatureEnabled = false
init {
// When the app is initialized, if there are private tabs, we should lock the private mode.
if (settings.privateBrowsingLockedEnabled) {
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = browserStore.state.privateTabs.isNotEmpty(),
),
isFeatureEnabled = storage.isFeatureEnabled
updateFeatureState(
isFeatureEnabled = isFeatureEnabled,
isLocked = browserStore.state.privateTabs.isNotEmpty(),
)
observeFeatureStateUpdates()
}
/**
* Handles a successful authentication event by unlocking the private browsing mode.
*
* This should be called by biometric or password authentication mechanisms (e.g., fingerprint,
* face unlock, or PIN entry) once the user has successfully authenticated. It updates the app state
* to reflect that private browsing tabs are now accessible.
*/
fun onSuccessfulAuthentication() {
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(isLocked = false),
)
}
private fun observeFeatureStateUpdates() {
storage.addFeatureStateListener { isEnabled ->
isFeatureEnabled = isEnabled
updateFeatureState(
isFeatureEnabled = isFeatureEnabled,
isLocked = appStore.state.mode == BrowsingMode.Normal &&
browserStore.state.privateTabs.isNotEmpty(),
)
}
}
private fun updateFeatureState(
isFeatureEnabled: Boolean,
isLocked: Boolean,
) {
if (isFeatureEnabled) {
start(isLocked)
} else {
stop()
}
}
private fun start(isLocked: Boolean) {
observePrivateTabsClosure()
observeSwitchingToNormalMode()
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = isLocked,
),
)
}
private fun stop() {
browserStoreScope?.cancel()
appStoreScope?.cancel()
storage.removeFeatureStateListener()
browserStoreScope = null
appStoreScope = null
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
),
)
}
private fun observePrivateTabsClosure() {
browserStore.flowScoped { flow ->
browserStoreScope = browserStore.flowScoped { flow ->
flow
.map { it.privateTabs.size }
.distinctUntilChanged()
.filter { it == 0 }
.collect {
if (settings.privateBrowsingLockedEnabled) {
// When all private tabs are closed, we don't need to lock the private mode.
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
),
)
}
// When all private tabs are closed, we don't need to lock the private mode.
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
),
)
}
}
}
private fun observeSwitchingToNormalMode() {
appStore.flowScoped { flow ->
appStoreScope = appStore.flowScoped { flow ->
flow
.map { it.mode }
.distinctUntilChanged()
@@ -78,10 +192,8 @@ class PrivateBrowsingLockFeature(
.collect {
// When witching from private to normal mode with private tabs open,
// we lock the private mode.
val isPrivateModeLockEnabled = settings.privateBrowsingLockedEnabled
val hasPrivateTabs = browserStore.state.privateTabs.isNotEmpty()
if (isPrivateModeLockEnabled && hasPrivateTabs) {
if (hasPrivateTabs) {
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,
@@ -95,6 +207,8 @@ class PrivateBrowsingLockFeature(
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
if (!isFeatureEnabled) return
when (owner) {
// lock when activity hits onStop and it isnt a config-change restart
is Activity -> {
@@ -112,11 +226,10 @@ class PrivateBrowsingLockFeature(
private fun maybeLockPrivateModeOnStop() {
// When the app gets inactive in private mode with opened tabs, we lock the private mode.
val isPrivateModeLockEnabled = settings.privateBrowsingLockedEnabled
val hasPrivateTabs = browserStore.state.privateTabs.isNotEmpty()
val isPrivateMode = appStore.state.mode == BrowsingMode.Private
if (isPrivateModeLockEnabled && isPrivateMode && hasPrivateTabs) {
if (isPrivateMode && hasPrivateTabs) {
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,
@@ -126,11 +239,10 @@ class PrivateBrowsingLockFeature(
}
private fun maybeLockPrivateModeOnTabsTrayClosure() {
val isPrivateModeLockEnabled = settings.privateBrowsingLockedEnabled
val hasPrivateTabs = browserStore.state.privateTabs.isNotEmpty()
val isNormalMode = appStore.state.mode == BrowsingMode.Normal
if (isPrivateModeLockEnabled && isNormalMode && hasPrivateTabs) {
if (isNormalMode && hasPrivateTabs) {
appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,

View File

@@ -51,7 +51,7 @@ import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager
import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationNeededInfo
import org.mozilla.fenix.browser.tabstrip.isTabStripEnabled
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.appstate.AppAction.PrivateBrowsingLockAction
import org.mozilla.fenix.components.components
import org.mozilla.fenix.compose.core.Action
import org.mozilla.fenix.compose.snackbar.Snackbar
import org.mozilla.fenix.compose.snackbar.SnackbarState
@@ -780,9 +780,7 @@ class TabsTrayFragment : AppCompatDialogFragment() {
tabsTrayInteractor.onTrayPositionSelected(page.ordinal, false)
requireComponents.appStore.dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(isLocked = false),
)
requireComponents.privateBrowsingLockFeature.onSuccessfulAuthentication()
},
onAuthFailure = {
PrivateBrowsingLocked.authFailure.record()

View File

@@ -16,27 +16,21 @@ import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.state.createTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.never
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.PrivateBrowsingLockAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.tabstray.TabsTrayFragment
import org.mozilla.fenix.utils.Settings
@RunWith(AndroidJUnit4::class)
class PrivateBrowsingLockFeatureTest {
@@ -44,223 +38,674 @@ class PrivateBrowsingLockFeatureTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
private lateinit var appStore: AppStore
private lateinit var browserStore: BrowserStore
private lateinit var settings: Settings
@Before
fun setup() {
appStore = spy(AppStore())
browserStore = BrowserStore()
settings = mockk(relaxed = true)
}
// zero tabs cases
@Test
fun `GIVEN the feature is enabled WHEN number of private tabs reaches zero THEN we unlock private mode`() {
every { settings.privateBrowsingLockedEnabled } returns true
val store = createBrowserStore()
PrivateBrowsingLockFeature(appStore, store, settings)
fun `GIVEN feature is enabled and mode is normal WHEN number of private tabs reaches zero THEN we unlock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Normal
store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
verify(appStore).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertTrue(appStore.state.isPrivateScreenLocked)
browserStore.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is disabled WHEN number of private tabs reaches zero THEN we don't lock private mode`() {
every { settings.privateBrowsingLockedEnabled } returns false
val store = createBrowserStore()
PrivateBrowsingLockFeature(appStore, store, settings)
fun `GIVEN feature is enabled and mode is private WHEN authenticated and number of private tabs reaches zero THEN private mode is unchanged`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
store.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
verify(appStore, never()).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = false,
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
feature.onSuccessfulAuthentication()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
browserStore.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is on and there are private tabs WHEN initializing the app THEN we lock private mode`() {
every { settings.privateBrowsingLockedEnabled } returns true
val store = createBrowserStore(
fun `GIVEN the feature is disabled and mode is normal WHEN number of private tabs reaches zero THEN private mode is unchanged `() {
val isFeatureEnabled = false
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
browserStore.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is disabled and mode is private WHEN number of private tabs reaches zero THEN private mode is unchanged `() {
val isFeatureEnabled = false
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
browserStore.dispatch(TabListAction.RemoveAllPrivateTabsAction).joinBlocking()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
// initializing cases
@Test
fun `GIVEN feature is enabled and mode is private and there are private tabs WHEN initializing feature THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val appStore = spy(
AppStore(
initialState = AppState(mode = BrowsingMode.Private),
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
PrivateBrowsingLockFeature(appStore, store, settings)
verify(appStore).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,
),
)
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is off and there are private tabs WHEN initializing the app THEN we do not lock private mode`() {
every { settings.privateBrowsingLockedEnabled } returns false
val store = createBrowserStore(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
)
val appStore = spy(
AppStore(
initialState = AppState(mode = BrowsingMode.Private),
),
)
fun `GIVEN feature is enabled and mode is normal and there are private tabs WHEN initializing feature THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Normal
PrivateBrowsingLockFeature(appStore, store, settings)
verify(appStore, times(0)).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN there are private tabs WHEN switching to normal mode THEN we lock private mode`() {
every { settings.privateBrowsingLockedEnabled } returns true
val store = createBrowserStore(tabs = listOf(createTab("https://www.mozilla.org", id = "mozilla")))
val appStore = spy(AppStore(initialState = AppState(mode = BrowsingMode.Private)))
PrivateBrowsingLockFeature(appStore, store, settings)
fun `GIVEN feature is disabled and mode is private and there are private tabs WHEN initializing the app THEN we do not lock private mode`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is disabled and mode is normal and there are private tabs WHEN initializing the app THEN we do not lock private mode`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is enabled and mode is private and there are no private tabs WHEN initializing feature THEN we do not lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is enabled and mode is normal and there are no private tabs WHEN initializing feature THEN we do not lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is enabled and there are private tabs WHEN switching to normal mode THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
feature.onSuccessfulAuthentication()
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
store.dispatch(TabListAction.AddTabAction(createTab("https://www.firefox.com", id = "firefox", private = true))).joinBlocking()
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Normal)).joinBlocking()
appStore.waitUntilIdle()
verify(appStore, times(1)).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(
isLocked = true,
),
)
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN normal mode and enabled lock WHEN lifecycle is resumed THEN observing lock doesn't trigger`() {
val localScope = TestScope()
var isLocked = false
val mode = BrowsingMode.Normal
val isPrivateScreenLocked = true
var result = false
val appStore = AppStore(initialState = AppState(mode = mode, isPrivateScreenLocked = isPrivateScreenLocked))
observePrivateModeLock(
viewLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.RESUMED),
scope = localScope,
appStore = appStore,
onPrivateModeLocked = { isLocked = true },
onPrivateModeLocked = { result = true },
)
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Normal)).joinBlocking()
appStore.dispatch(PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(true)).joinBlocking()
localScope.advanceUntilIdle()
assertFalse(isLocked)
assertFalse(result)
}
@Test
fun `GIVEN normal mode and disabled lock WHEN lifecycle is resumed THEN observing lock doesn't trigger`() {
val localScope = TestScope()
var isLocked = false
val mode = BrowsingMode.Normal
val isPrivateScreenLocked = false
var result = false
val appStore = AppStore(initialState = AppState(mode = mode, isPrivateScreenLocked = isPrivateScreenLocked))
observePrivateModeLock(
viewLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.RESUMED),
scope = localScope,
appStore = appStore,
onPrivateModeLocked = { isLocked = true },
onPrivateModeLocked = { result = true },
)
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Normal)).joinBlocking()
appStore.dispatch(PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(true)).joinBlocking()
localScope.advanceUntilIdle()
assertFalse(isLocked)
assertFalse(result)
}
@Test
fun `GIVEN private mode and enabled lock WHEN lifecycle is resumed THEN observing lock triggers`() {
val localScope = TestScope()
var isLocked = false
val mode = BrowsingMode.Private
val isPrivateScreenLocked = true
var result = false
val appStore = AppStore(initialState = AppState(mode = mode, isPrivateScreenLocked = isPrivateScreenLocked))
observePrivateModeLock(
viewLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.RESUMED),
scope = localScope,
appStore = appStore,
onPrivateModeLocked = { isLocked = true },
onPrivateModeLocked = { result = true },
)
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Private)).joinBlocking()
appStore.dispatch(PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(true)).joinBlocking()
localScope.advanceUntilIdle()
assertTrue(isLocked)
assertTrue(result)
}
@Test
fun `GIVEN private mode and disabled lock WHEN lifecycle is resumed THEN observing lock doesn't trigger`() {
val localScope = TestScope()
var isLocked = false
val mode = BrowsingMode.Private
val isPrivateScreenLocked = false
var result = false
val appStore = AppStore(initialState = AppState(mode = mode, isPrivateScreenLocked = isPrivateScreenLocked))
observePrivateModeLock(
viewLifecycleOwner = MockedLifecycleOwner(Lifecycle.State.RESUMED),
scope = localScope,
appStore = appStore,
onPrivateModeLocked = { isLocked = true },
onPrivateModeLocked = { result = true },
)
appStore.dispatch(PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(false)).joinBlocking()
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Private)).joinBlocking()
localScope.advanceUntilIdle()
assertFalse(isLocked)
assertFalse(result)
}
@Test
fun `GIVEN private mode with private tabs WHEN Activity stops without config change THEN lock is triggered`() {
every { settings.privateBrowsingLockedEnabled } returns true
val store = createBrowserStore(
tabs = listOf(createTab("https://www.mozilla.org", id = "mozilla")),
selectedTabId = "mozilla",
fun `GIVEN feature is on and mode is private and there are private tabs WHEN Activity stops without config change THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val appStore = spy(AppStore(AppState(mode = BrowsingMode.Private)))
val feature = PrivateBrowsingLockFeature(appStore, store, settings)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
store.dispatch(TabListAction.AddTabAction(createTab("https://www.firefox.com", id = "firefox", private = true))).joinBlocking()
feature.onStop(activity)
appStore.waitUntilIdle()
verify(appStore).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(isLocked = true),
)
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN normal mode with private tabs WHEN TabsTrayFragment stops THEN lock is triggered`() {
every { settings.privateBrowsingLockedEnabled } returns true
val store = createBrowserStore(
tabs = listOf(createTab("https://www.mozilla.org", id = "mozilla")),
selectedTabId = "mozilla",
fun `GIVEN feature is on and mode is normal and there are private tabs WHEN Activity stops without config change THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val appStore = spy(AppStore(AppState(mode = BrowsingMode.Normal)))
val feature = PrivateBrowsingLockFeature(appStore, store, settings)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is on and mode is private and there are no private tabs WHEN Activity stops without config change THEN we do not lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is on and mode is normal and there are no private tabs WHEN Activity stops without config change THEN we do not lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is off and mode is private and there are private tabs WHEN Activity stops without config change THEN we do not lock private mode`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is on and mode is normal and there are private tabs WHEN Activity stops without config change THEN we do not lock private mode`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
// NB: this is an important case: we don't want to lock the screen on config change if the user is already in
// private mode because switching modes causes the activity to recreate; e.g., the user switches to private mode
// tab through the tabstray. the app goes into private mode and then activity shuts due to configuration change.
// if we lock the screen at that point, going into private mode will always lock it due to activity restart.
@Test
fun `GIVEN feature is on and mode is private and there are private tabs WHEN Activity stops with config change THEN we do not lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
feature.onSuccessfulAuthentication()
appStore.waitUntilIdle()
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns true
feature.onStop(activity)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN feature is on and mode is normal and there are private tabs WHEN TabsTrayFragment stops THEN we lock private mode`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = createStorage(isFeatureEnabled = isFeatureEnabled))
appStore.waitUntilIdle()
val fragment = mockk<TabsTrayFragment>(relaxed = true)
store.dispatch(TabListAction.AddTabAction(createTab("https://www.firefox.com", id = "firefox", private = true))).joinBlocking()
feature.onStop(fragment)
appStore.waitUntilIdle()
verify(appStore).dispatch(
PrivateBrowsingLockAction.UpdatePrivateBrowsingLock(isLocked = true),
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is on and there are private tabs WHEN we turn off the feature THEN we unlock private tabs and don't lock it`() {
val isFeatureEnabled = true
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val storage = createStorage(isFeatureEnabled = isFeatureEnabled)
val feature = createFeature(browserStore = browserStore, appStore = appStore, storage = storage)
appStore.waitUntilIdle()
assertTrue(appStore.state.isPrivateScreenLocked)
// verify that disabled feature state unlocks private mode
val sharedPrefUpdate = false
storage.listener?.invoke(sharedPrefUpdate)
appStore.waitUntilIdle()
assertTrue(browserStore.state.privateTabs.isNotEmpty())
assertFalse(appStore.state.isPrivateScreenLocked)
// verify that activity.onStop doesn't lock private mode
val activity = mockk<AppCompatActivity>(relaxed = true)
every { activity.isChangingConfigurations } returns false
feature.onStop(activity)
appStore.waitUntilIdle()
assertTrue(browserStore.state.privateTabs.isNotEmpty())
assertFalse(appStore.state.isPrivateScreenLocked)
// verify that going to normal mode doesn't lock private mode
appStore.dispatch(AppAction.ModeChange(mode = BrowsingMode.Normal)).joinBlocking()
assertTrue(browserStore.state.privateTabs.isNotEmpty())
assertFalse(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is off and mode is normal and there are private tabs WHEN we turn on the feature THEN we lock private tabs`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Normal
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val storage = createStorage(isFeatureEnabled = isFeatureEnabled)
createFeature(browserStore = browserStore, appStore = appStore, storage = storage)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
// verify that disabled feature state unlocks private mode
val sharedPrefUpdate = true
storage.listener?.invoke(sharedPrefUpdate)
appStore.waitUntilIdle()
assertTrue(browserStore.state.privateTabs.isNotEmpty())
assertTrue(appStore.state.isPrivateScreenLocked)
}
@Test
fun `GIVEN the feature is off and mode is private and there are private tabs WHEN we turn on the feature THEN we do not lock private tabs`() {
val isFeatureEnabled = false
val mode = BrowsingMode.Private
val appStore = AppStore(initialState = AppState(mode = mode))
val browserStore = BrowserStore(
BrowserState(
tabs = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId = "mozilla",
),
)
val storage = createStorage(isFeatureEnabled = isFeatureEnabled)
createFeature(browserStore = browserStore, appStore = appStore, storage = storage)
appStore.waitUntilIdle()
assertFalse(appStore.state.isPrivateScreenLocked)
// verify that disabled feature state unlocks private mode
val sharedPrefUpdate = true
storage.listener?.invoke(sharedPrefUpdate)
appStore.waitUntilIdle()
assertTrue(browserStore.state.privateTabs.isNotEmpty())
assertFalse(appStore.state.isPrivateScreenLocked)
}
internal class MockedLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
@@ -269,16 +714,26 @@ class PrivateBrowsingLockFeatureTest {
}
}
private fun createBrowserStore(
tabs: List<TabSessionState> = listOf(
createTab("https://www.firefox.com", id = "firefox", private = true),
createTab("https://www.mozilla.org", id = "mozilla"),
),
selectedTabId: String = "mozilla",
) = BrowserStore(
BrowserState(
tabs = tabs,
selectedTabId = selectedTabId,
),
internal class MockedPrivateBrowsingLockStorage(
override val isFeatureEnabled: Boolean = true,
) : PrivateBrowsingLockStorage {
var listener: ((Boolean) -> Unit)? = null
override fun addFeatureStateListener(listener: (Boolean) -> Unit) {
this.listener = listener
}
override fun removeFeatureStateListener() {}
}
private fun createFeature(
appStore: AppStore,
browserStore: BrowserStore,
storage: PrivateBrowsingLockStorage,
) = PrivateBrowsingLockFeature(
appStore = appStore,
browserStore = browserStore,
storage = storage,
)
private fun createStorage(isFeatureEnabled: Boolean = true) = MockedPrivateBrowsingLockStorage(isFeatureEnabled)
}