commit 683d4f9f11276947c20861340f7944cdd58d06d9 parent 483d918796d7b3db934b48da392643df22b3da13 Author: Segun Famisa <sfamisa@mozilla.com> Date: Tue, 11 Nov 2025 23:13:51 +0000 Bug 1999014 - Extract biometric/credential authentication in logins for reuse r=android-reviewers,matt-tighe This patch refactors the biometric authentication logic for the saved logins screen into a more generic and reusable `SecureScreen` composable. Changes: - New `SecureScreen` Composable**: A new, self-contained `SecureScreen.kt` is introduced to handle the entire authentication lifecycle (locking on pause, prompting on resume, and displaying content on success). It manages its own state via `SecureScreenStore`. - State Management: `SecureScreenState`, `SecureScreenAction`, `SecureScreenReducer`, and `SecureScreenStore` are created to manage the authentication flow independently. - Moved and Renamed UI components: `UnlockLoginsScreen.kt` has been renamed to `UnlockScreen.kt` and moved to a generic `biometric.ui` package. - Removed Login-Specific Logic: The authentication state and related actions (`BiometricAuthenticationAction`, `LifecycleAction`, `UnlockScreenAction`) have been removed from the `LoginsState`, `LoginsReducer`, and `LoginsMiddleware`. - Integration in `SavedLoginsScreen`: The old `RequireAuthorization` composable is replaced with the new `SecureScreen` wrapper. - Tests: Unit tests for the new `SecureScreenReducer` have been added in `SecureScreenReducerTest.kt`. Differential Revision: https://phabricator.services.mozilla.com/D271826 Diffstat:
18 files changed, 776 insertions(+), 436 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/SecureScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/SecureScreen.kt @@ -0,0 +1,184 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui + +import android.view.Window +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import mozilla.components.lib.state.ext.observeAsState +import org.mozilla.fenix.components.lazyStore +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.biometric.ui.state.BiometricAuthenticationState +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.AuthenticationFlowAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.LifecycleAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.UnlockScreenAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenStore +import org.mozilla.fenix.settings.logins.ui.BiometricAuthenticationDialog + +/** + * A composable that wraps content requiring biometric or device credential authentication. + * It observes the authentication state from the provided [store] and displays the appropriate UI. + * + * @param store The [SecureScreenStore] that manages the state for this screen. + * @param title The title to be displayed on the authentication prompt. + * @param onExit A callback invoked when the user indicates to exit the secure screen. + * @param content The composable content to display after successful authentication. + */ +@Composable +fun SecureScreen( + store: SecureScreenStore = provideStore(), + title: String, + onExit: () -> Unit = {}, + content: @Composable () -> Unit, +) { + val state by store.observeAsState(store.state) { it } + + LaunchedEffect(state.shouldExit) { + if (state.shouldExit) { + onExit() + } + } + + SecureScreenImpl( + title = title, + state = state.authenticationState, + handleAction = { + store.dispatch(it) + }, + content = content, + ) +} + +@Composable +internal fun SecureScreenImpl( + title: String, + state: BiometricAuthenticationState, + handleAction: (SecureScreenAction) -> Unit, + content: @Composable () -> Unit, +) { + ObserveLifecycle( + onPause = { handleAction(LifecycleAction.OnPause) }, + onResume = { handleAction(LifecycleAction.OnResume) }, + onDispose = { handleAction(LifecycleAction.OnDispose) }, + ) + + when (state) { + is BiometricAuthenticationState.Inert -> { + StartAuthorization( + onStart = { + handleAction(AuthenticationFlowAction.Started) + }, + ) + } + + is BiometricAuthenticationState.InProgress -> { + BiometricAuthenticationDialog( + title = title, + onAuthSuccess = { + handleAction(AuthenticationFlowAction.Succeeded) + }, + onAuthFailure = { + handleAction(AuthenticationFlowAction.Failed) + }, + ) + } + + is BiometricAuthenticationState.ReadyToLock, + is BiometricAuthenticationState.Failed, + -> { + NotAuthorized( + title = title, + onUnlockClicked = { + handleAction(UnlockScreenAction.UnlockTapped) + }, + onLeaveClicked = { + handleAction(UnlockScreenAction.LeaveTapped) + }, + ) + } + + is BiometricAuthenticationState.Authorized -> { + content() + } + } +} + +@Composable +private fun StartAuthorization(onStart: () -> Unit) { + LaunchedEffect(Unit) { + onStart() + } +} + +@Composable +private fun NotAuthorized( + title: String, + onUnlockClicked: () -> Unit = {}, + onLeaveClicked: () -> Unit = {}, +) { + UnlockScreen( + title = title, + onUnlockClicked = onUnlockClicked, + onLeaveClicked = onLeaveClicked, + ) +} + +@Composable +private fun ObserveLifecycle( + onPause: () -> Unit = {}, + onResume: () -> Unit = {}, + onDispose: () -> Unit = {}, +) { + val activityContext = LocalActivity.current as ComponentActivity + + DisposableEffect(LocalLifecycleOwner.current) { + val lifecycle = ProcessLifecycleOwner.get().lifecycle + val observer = object : DefaultLifecycleObserver { + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + activityContext.window?.lock() + onPause() + } + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + if (activityContext.settings().allowScreenCaptureInSecureScreens) { + activityContext.window?.unlock() + } else { + activityContext.window?.lock() + } + onResume() + } + } + lifecycle.addObserver(observer) + + onDispose { + activityContext.window?.unlock() + onDispose() + lifecycle.removeObserver(observer) + } + } +} + +private fun Window.lock() = addFlags(WindowManager.LayoutParams.FLAG_SECURE) +private fun Window.unlock() = clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + +@Composable +private fun provideStore(): SecureScreenStore { + return LocalViewModelStoreOwner.current?.lazyStore { + SecureScreenStore() + }?.value ?: throw IllegalStateException("Expected LocalViewModelStoreOwner to be provided") +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/UnlockScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/UnlockScreen.kt @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import mozilla.components.compose.base.button.FilledButton +import mozilla.components.compose.base.button.TextButton +import mozilla.components.compose.base.utils.getResolvedAttrResId +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.isLargeWindow +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.theme.Theme + +private const val FILL_WIDTH_LARGE_WINDOW = 0.5f +private const val FILL_WIDTH_DEFAULT = 1.0f +private const val PHONE_WIDTH = 400 +private const val PHONE_HEIGHT = 640 +private const val TABLET_WIDTH = 700 +private const val TABLET_HEIGHT = 1280 + +/** + * A screen allowing users to unlock their logins. + * + * @param title The title of the screen. + * @param onUnlockClicked Invoked when the user taps the unlock button. + * @param onLeaveClicked Invoked when the user taps the leave logins text. + */ +@Composable +internal fun UnlockScreen( + title: String, + onUnlockClicked: () -> Unit, + onLeaveClicked: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(FirefoxTheme.colors.layer1) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Header(title) + + Footer(onUnlockClicked, onLeaveClicked) + } +} + +@Composable +private fun Header(title: String) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Logo() + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = title, + color = FirefoxTheme.colors.textPrimary, + textAlign = TextAlign.Center, + style = FirefoxTheme.typography.headline6, + maxLines = 1, + ) + } +} + +@Composable +private fun Logo() { + Row( + modifier = Modifier + .padding(32.dp) + .height(62.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + modifier = Modifier.padding(end = 14.dp), + painter = painterResource(getResolvedAttrResId(R.attr.fenixWordmarkLogo)), + contentDescription = null, + ) + + Image( + modifier = Modifier.height(28.dp), + painter = painterResource(getResolvedAttrResId(R.attr.fenixWordmarkText)), + contentDescription = stringResource(R.string.app_name), + ) + } +} + +@Composable +private fun Footer(onUnlockClicked: () -> Unit, onLeaveClicked: () -> Unit) { + val fillWidthFraction = if (LocalContext.current.isLargeWindow()) { + FILL_WIDTH_LARGE_WINDOW + } else { + FILL_WIDTH_DEFAULT + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(fillWidthFraction), + ) { + FilledButton( + text = stringResource(id = R.string.logins_biometric_unlock_button), + modifier = Modifier.fillMaxWidth(), + onClick = onUnlockClicked, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + text = stringResource(R.string.logins_biometric_leave_button), + onClick = onLeaveClicked, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) +@Composable +private fun ScreenPreviewLightPhone() = ScreenPreview(Theme.Light) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) +@Composable +private fun ScreenPreviewDarkPhone() = ScreenPreview(Theme.Dark) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) +@Composable +private fun ScreenPreviewPrivatePhone() = ScreenPreview(Theme.Private) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) +@Composable +private fun ScreenPreviewLightTablet() = ScreenPreview(Theme.Light) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) +@Composable +private fun ScreenPreviewDarkTablet() = ScreenPreview(Theme.Dark) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) +@Composable +private fun ScreenPreviewPrivateTablet() = ScreenPreview(Theme.Private) + +@Composable +private fun ScreenPreview(theme: Theme) { + FirefoxTheme(theme) { + UnlockScreen( + title = "Unlock to view your secure feature", + onUnlockClicked = {}, + onLeaveClicked = {}, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/BiometricAuthenticationState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/BiometricAuthenticationState.kt @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui.state + +/** + * Represents the various states of the biometric authentication process for accessing a secure screen. + */ +sealed class BiometricAuthenticationState { + /** + * The initial state, before any authentication process has started or after it has been reset. + */ + data object Inert : BiometricAuthenticationState() + + /** + * The state where the screen is ready to be locked, but the user has not yet initiated + * the authentication process. This is typically used to show an unlock button. + */ + data object ReadyToLock : BiometricAuthenticationState() + + /** + * The biometric authentication prompt is currently being displayed to the user. + */ + data object InProgress : BiometricAuthenticationState() + + /** + * The user has successfully authenticated. The saved logins can be displayed. + */ + data object Authorized : BiometricAuthenticationState() + + /** + * The authentication attempt failed. + */ + data object Failed : BiometricAuthenticationState() + + /** + * `true` if the current state is [Authorized]. + */ + val isAuthorized: Boolean + get() = this is Authorized + + /** + * `true` if the current state is [ReadyToLock]. + */ + val isReadyToLock: Boolean + get() = this is ReadyToLock +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenAction.kt @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui.state + +import mozilla.components.lib.state.Action + +/** + * This sealed interface represents all possible user actions or events that can occur on the secure screen. + * These actions are dispatched to the store to trigger state changes. + */ +sealed interface SecureScreenAction : Action { + + /** + * Represents actions related to the Android component lifecycle. + */ + sealed interface LifecycleAction : SecureScreenAction { + /** Dispatched when the component is paused. */ + data object OnPause : LifecycleAction + + /** Dispatched when the component is resumed. */ + data object OnResume : LifecycleAction + + /** Dispatched when the component is disposed or destroyed. */ + data object OnDispose : LifecycleAction + } + + /** + * Represents actions related to the biometric/authentication flow. + */ + sealed interface AuthenticationFlowAction : SecureScreenAction { + /** Dispatched when the authentication process has started. */ + data object Started : AuthenticationFlowAction + + /** Dispatched when the authentication was successful. */ + data object Succeeded : AuthenticationFlowAction + + /** Dispatched when the authentication has failed. */ + data object Failed : AuthenticationFlowAction + } + + /** + * Represents actions initiated by the user on the unlock screen UI. + */ + sealed interface UnlockScreenAction : SecureScreenAction { + /** Dispatched when the user taps the "Unlock" button. */ + data object UnlockTapped : UnlockScreenAction + + /** Dispatched when the user taps the "Leave" or exit button. */ + data object LeaveTapped : UnlockScreenAction + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenReducer.kt @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui.state + +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.AuthenticationFlowAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.LifecycleAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.UnlockScreenAction + +/** + * Reducer for the secure screen. This function takes the current [SecureScreenState] and a + * [SecureScreenAction], and returns a new state. It delegates the handling of specific action + * types to corresponding extension functions. + * + * @param state The current state of the secure screen. + * @param action The action to be processed. + * + * @return The new state after applying the action. + */ +fun secureScreenReducer( + state: SecureScreenState, + action: SecureScreenAction, +): SecureScreenState = + when (action) { + is AuthenticationFlowAction -> state.handleAuthenticationFlowAction(action) + is LifecycleAction -> state.handleLifecycleAction(action) + is UnlockScreenAction -> state.handleUnlockScreenAction(action) + } + +private fun SecureScreenState.handleUnlockScreenAction( + action: UnlockScreenAction, +): SecureScreenState { + return when (action) { + UnlockScreenAction.LeaveTapped -> copy(shouldExit = true) + UnlockScreenAction.UnlockTapped -> copy(authenticationState = BiometricAuthenticationState.InProgress) + } +} + +private fun SecureScreenState.handleAuthenticationFlowAction( + action: AuthenticationFlowAction, +): SecureScreenState { + return when (action) { + AuthenticationFlowAction.Failed -> copy(authenticationState = BiometricAuthenticationState.Failed) + AuthenticationFlowAction.Started -> copy(authenticationState = BiometricAuthenticationState.InProgress) + AuthenticationFlowAction.Succeeded -> copy(authenticationState = BiometricAuthenticationState.Authorized) + } +} + +private fun SecureScreenState.handleLifecycleAction( + action: LifecycleAction, +): SecureScreenState { + return when (action) { + LifecycleAction.OnPause -> copy( + authenticationState = if (authenticationState.isAuthorized) { + BiometricAuthenticationState.ReadyToLock + } else { + authenticationState + }, + ) + + LifecycleAction.OnDispose -> this + LifecycleAction.OnResume -> copy( + authenticationState = if (authenticationState.isReadyToLock) { + BiometricAuthenticationState.InProgress + } else { + authenticationState + }, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenState.kt @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui.state + +import mozilla.components.lib.state.State + +/** + * Represents the state of the authentication process for accessing a secure screen. + * + * @property authenticationState The current state of the authentication process. + * @property shouldExit `true` if the authentication process should be exited. + */ +data class SecureScreenState( + val authenticationState: BiometricAuthenticationState = BiometricAuthenticationState.Inert, + val shouldExit: Boolean = false, +) : State { + + companion object { + val Initial = SecureScreenState() + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/biometric/ui/state/SecureScreenStore.kt @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.biometric.ui.state + +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.UiStore + +/** + * The store for the secure screen. + * + * @param initialState The initial state of the secure screen. + * @param reducer The reducer for the secure screen. + */ +class SecureScreenStore( + initialState: SecureScreenState = SecureScreenState.Initial, + reducer: Reducer<SecureScreenState, SecureScreenAction> = ::secureScreenReducer, +) : UiStore<SecureScreenState, SecureScreenAction>( + initialState = initialState, + reducer = reducer, + middleware = emptyList(), +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -194,7 +194,7 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { ), ) }, - clipboardManager = requireActivity().getSystemService(), + clipboardManager = lifecycleHolder.homeActivity.getSystemService(), ), ), lifecycleHolder = lifecycleHolder, @@ -212,7 +212,12 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { } setContent { FirefoxTheme { - SavedLoginsScreen(buildStore = buildStore) + SavedLoginsScreen( + buildStore = buildStore, + exitLogins = { + findNavController().popBackStack() + }, + ) } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/BiometricAuthenticationHelper.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/BiometricAuthenticationHelper.kt @@ -12,55 +12,54 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity -import org.mozilla.fenix.R /** * The UI host for displaying the Biometric Authentication dialog - * @param store The [LoginsStore] + * + * @param title The title of the authentication prompt. + * @param onAuthSuccess Callback triggered when biometric authentication succeeds. + * @param onAuthFailure Callback triggered when biometric authentication fails. */ @Composable -internal fun BiometricAuthenticationDialog(store: LoginsStore) { +internal fun BiometricAuthenticationDialog( + title: String, + onAuthSuccess: () -> Unit, + onAuthFailure: () -> Unit, +) { val context = LocalContext.current val activity = context as? FragmentActivity ?: return if (DefaultBiometricUtils.canUseBiometricAuthentication(activity = activity)) { ShowBiometricAuthenticationDialog( + title = title, activity = activity, - onAuthSuccess = { - store.dispatch(BiometricAuthenticationAction.Succeeded) - }, - onAuthFailure = { - store.dispatch(BiometricAuthenticationAction.Failed) - }, + onAuthSuccess = onAuthSuccess, + onAuthFailure = onAuthFailure, ) } else if (DefaultBiometricUtils.canUsePinVerification(activity = activity)) { ShowPinVerificationDialog( + title = title, activity = activity, - onAuthSuccess = { - store.dispatch(BiometricAuthenticationAction.Succeeded) - }, - onAuthFailure = { - store.dispatch(BiometricAuthenticationAction.Failed) - }, + onAuthSuccess = onAuthSuccess, + onAuthFailure = onAuthFailure, ) } else { ShowPinWarningDialog( activity = activity, - onAuthSuccess = { - store.dispatch(BiometricAuthenticationAction.Succeeded) - }, + onAuthSuccess = onAuthSuccess, ) } } @Composable private fun ShowBiometricAuthenticationDialog( + title: String, activity: FragmentActivity, onAuthSuccess: () -> Unit, onAuthFailure: () -> Unit, ) { DefaultBiometricUtils.showBiometricPromptForCompose( - title = activity.resources.getString(R.string.logins_biometric_prompt_message_2), + title = title, activity = activity, onAuthSuccess = onAuthSuccess, onAuthFailure = onAuthFailure, @@ -69,6 +68,7 @@ private fun ShowBiometricAuthenticationDialog( @Composable private fun ShowPinVerificationDialog( + title: String, activity: FragmentActivity, onAuthSuccess: () -> Unit, onAuthFailure: () -> Unit, @@ -86,7 +86,7 @@ private fun ShowPinVerificationDialog( SideEffect { DefaultBiometricUtils.getConfirmDeviceCredentialIntent( - title = activity.resources.getString(R.string.logins_biometric_prompt_message_2), + title = title, activity = activity, )?.also { startForResult.launch(it) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsAction.kt @@ -10,7 +10,6 @@ import mozilla.components.lib.state.Action * Actions relating to the Logins list screen and its various subscreens. */ internal sealed interface LoginsAction : Action -internal data object ViewDisposed : LoginsAction internal data object LoginsListAppeared : LoginsAction internal data object LoginsListBackClicked : LoginsAction @@ -35,18 +34,6 @@ internal data object LearnMoreAboutSync : LoginsAction internal data class LoginClicked(val item: LoginItem) : LoginsAction -internal sealed class LifecycleAction : LoginsAction { - data object OnPause : LifecycleAction() - data object OnResume : LifecycleAction() -} - -internal sealed class BiometricAuthenticationAction : LoginsAction { - data object Started : BiometricAuthenticationAction() - data object Succeeded : BiometricAuthenticationAction() - - data object Failed : BiometricAuthenticationAction() -} - internal sealed class DetailLoginMenuAction : LoginsAction { data class EditLoginMenuItemClicked(val item: LoginItem) : DetailLoginMenuAction() data class DeleteLoginMenuItemClicked(val item: LoginItem) : DetailLoginMenuAction() @@ -68,11 +55,6 @@ internal sealed class EditLoginAction : LoginsAction { data class SaveEditClicked(val login: LoginItem) : EditLoginAction() } -internal sealed class UnlockScreenAction : LoginsAction { - data object UnlockTapped : UnlockScreenAction() - data object LeaveTapped : UnlockScreenAction() -} - internal sealed class AddLoginAction : LoginsAction { data object InitAdd : AddLoginAction() data object AddLoginSaveClicked : AddLoginAction() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddleware.kt @@ -120,12 +120,6 @@ internal class LoginsMiddleware( is EditLoginAction.SaveEditClicked -> { context.store.handleEditLogin(loginItem = action.login) } - is UnlockScreenAction.LeaveTapped -> exitLogins() - is UnlockScreenAction.UnlockTapped, - is LifecycleAction.OnResume, - is BiometricAuthenticationAction.Started, - is BiometricAuthenticationAction.Succeeded, - is BiometricAuthenticationAction.Failed, is LoginsLoaded, is EditLoginAction.UsernameChanged, is EditLoginAction.PasswordChanged, @@ -135,8 +129,6 @@ internal class LoginsMiddleware( is AddLoginAction.PasswordChanged, is DetailLoginMenuAction.DeleteLoginMenuItemClicked, is LoginDeletionDialogAction.CancelTapped, - is ViewDisposed, - is LifecycleAction.OnPause, -> Unit } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsReducer.kt @@ -52,25 +52,7 @@ internal fun loginsReducer(state: LoginsState, action: LoginsAction) = when (act is LoginsListBackClicked -> state.respondToLoginsListBackClick() is AddLoginBackClicked -> state.respondToAddLoginBackClick() is EditLoginBackClicked -> state.respondToEditLoginBackClick() - is BiometricAuthenticationAction.Succeeded -> state.copy( - biometricAuthenticationState = BiometricAuthenticationState.Authorized, - ) - is BiometricAuthenticationAction.Started -> state.copy( - biometricAuthenticationState = BiometricAuthenticationState.InProgress, - ) - is BiometricAuthenticationAction.Failed -> state.copy( - biometricAuthenticationState = BiometricAuthenticationState.Failed, - ) - is LifecycleAction.OnPause -> state.takeIf { it.biometricAuthenticationState.isAuthorized } - ?.copy(biometricAuthenticationState = BiometricAuthenticationState.ReadyToLock) ?: state - is LifecycleAction.OnResume -> state.takeIf { it.biometricAuthenticationState.isReadyToLock } - ?.copy(biometricAuthenticationState = BiometricAuthenticationState.InProgress) ?: state - - is UnlockScreenAction.UnlockTapped -> state.copy( - biometricAuthenticationState = BiometricAuthenticationState.InProgress, - ) - ViewDisposed, - is LoginsListAppeared, LearnMoreAboutSync, UnlockScreenAction.LeaveTapped, + is LoginsListAppeared, LearnMoreAboutSync, -> state } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsState.kt @@ -5,7 +5,6 @@ package org.mozilla.fenix.settings.logins.ui import mozilla.components.lib.state.State -import kotlin.collections.List /** * Represents the state of the Logins list screen and its various subscreens. @@ -13,7 +12,6 @@ import kotlin.collections.List * @property loginItems Login items to be displayed in the current list screen. * @property searchText The text to filter login items. * @property sortOrder The order to display the login items. - * @property biometricAuthenticationState State representing the biometric authentication state. * @property loginsListState State representing the list login subscreen, if visible. * @property loginsAddLoginState State representing the add login subscreen, if visible. * @property loginsEditLoginState State representing the edit login subscreen, if visible. @@ -25,7 +23,6 @@ internal data class LoginsState( val loginItems: List<LoginItem>, val searchText: String?, val sortOrder: LoginsSortOrder, - val biometricAuthenticationState: BiometricAuthenticationState, val loginsListState: LoginsListState?, val loginsAddLoginState: LoginsAddLoginState?, val loginsEditLoginState: LoginsEditLoginState?, @@ -38,7 +35,6 @@ internal data class LoginsState( loginItems = listOf(), searchText = null, sortOrder = LoginsSortOrder.default, - biometricAuthenticationState = BiometricAuthenticationState.Inert, loginsListState = null, loginsAddLoginState = null, loginsEditLoginState = null, @@ -49,20 +45,6 @@ internal data class LoginsState( } } -internal sealed class BiometricAuthenticationState { - data object Inert : BiometricAuthenticationState() - data object ReadyToLock : BiometricAuthenticationState() - data object InProgress : BiometricAuthenticationState() - data object Authorized : BiometricAuthenticationState() - data object Failed : BiometricAuthenticationState() - - val isAuthorized: Boolean - get() = this is Authorized - - val isReadyToLock: Boolean - get() = this is ReadyToLock -} - internal sealed class NewLoginState { data object None : NewLoginState() data object Duplicate : NewLoginState() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/RequireAuthorization.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/RequireAuthorization.kt @@ -1,91 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings.logins.ui - -import android.view.Window -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.compose.LocalLifecycleOwner -import mozilla.components.lib.state.ext.observeAsState -import org.mozilla.fenix.ext.settings - -@Composable -internal fun RequireAuthorization( - store: LoginsStore, - content: @Composable () -> Unit, -) { - val state by store.observeAsState(store.state.biometricAuthenticationState) { - it.biometricAuthenticationState - } - - ObserveLifecycle(store) - - when (state) { - is BiometricAuthenticationState.Inert -> StartAuthorization(store) - is BiometricAuthenticationState.ReadyToLock -> NotAuthorized(store) - is BiometricAuthenticationState.InProgress -> BiometricAuthenticationDialog(store) - is BiometricAuthenticationState.Failed -> NotAuthorized(store) - is BiometricAuthenticationState.Authorized -> content() - } -} - -@Composable -private fun StartAuthorization(store: LoginsStore) { - LaunchedEffect(Unit) { - store.dispatch(BiometricAuthenticationAction.Started) - } -} - -@Composable -private fun NotAuthorized(store: LoginsStore) { - UnlockLoginsScreen( - onUnlockClicked = { store.dispatch(UnlockScreenAction.UnlockTapped) }, - onLeaveClicked = { store.dispatch(UnlockScreenAction.LeaveTapped) }, - ) -} - -@Composable -private fun ObserveLifecycle(store: LoginsStore) { - val activityContext = LocalActivity.current as ComponentActivity - - DisposableEffect(LocalLifecycleOwner.current) { - val lifecycle = ProcessLifecycleOwner.get().lifecycle - val observer = object : DefaultLifecycleObserver { - override fun onPause(owner: LifecycleOwner) { - super.onPause(owner) - activityContext.window?.lock() - store.dispatch(LifecycleAction.OnPause) - } - - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - if (activityContext.settings().allowScreenCaptureInSecureScreens) { - activityContext.window?.unlock() - } else { - activityContext.window?.lock() - } - store.dispatch(LifecycleAction.OnResume) - } - } - lifecycle.addObserver(observer) - - onDispose { - activityContext.window?.unlock() - store.dispatch(ViewDisposed) - lifecycle.removeObserver(observer) - } - } -} - -private fun Window.lock() = addFlags(WindowManager.LayoutParams.FLAG_SECURE) -private fun Window.unlock() = clearFlags(WindowManager.LayoutParams.FLAG_SECURE) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/SavedLoginsScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/SavedLoginsScreen.kt @@ -61,6 +61,7 @@ import org.mozilla.fenix.compose.LinkText import org.mozilla.fenix.compose.LinkTextState import org.mozilla.fenix.compose.list.IconListItem import org.mozilla.fenix.compose.list.SelectableFaviconListItem +import org.mozilla.fenix.settings.biometric.ui.SecureScreen import org.mozilla.fenix.settings.logins.ui.LoginsSortOrder.Alphabetical.isGuidToDelete import org.mozilla.fenix.theme.FirefoxTheme import mozilla.components.ui.icons.R as iconsR @@ -70,17 +71,22 @@ import mozilla.components.ui.icons.R as iconsR * * @param buildStore A builder function to construct a [LoginsStore] using the NavController that's local * to the nav graph for the Logins view hierarchy. + * @param exitLogins A callback invoked when the user indicates to exit the secure screen. * @param startDestination the screen on which to initialize [SavedLoginsScreen] with. */ @Composable internal fun SavedLoginsScreen( buildStore: (NavHostController) -> LoginsStore, + exitLogins: () -> Unit = {}, startDestination: String = LoginsDestinations.LIST, ) { val navController = rememberNavController() val store = buildStore(navController) - RequireAuthorization(store) { + SecureScreen( + title = stringResource(R.string.logins_biometric_prompt_message_2), + onExit = exitLogins, + ) { NavHost( navController = navController, startDestination = startDestination, @@ -139,31 +145,29 @@ private fun LoginsList(store: LoginsStore) { modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (state.biometricAuthenticationState == BiometricAuthenticationState.Authorized) { - LazyColumn( - modifier = Modifier - .padding(paddingValues) - .width(FirefoxTheme.layout.size.containerMaxWidth) - .weight(1f, false) - .semantics { - collectionInfo = - CollectionInfo(rowCount = state.loginItems.size, columnCount = 1) - }, - ) { - itemsIndexed(state.loginItems) { _, item -> - - if (state.isGuidToDelete(item.guid)) { - return@itemsIndexed - } + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .width(FirefoxTheme.layout.size.containerMaxWidth) + .weight(1f, false) + .semantics { + collectionInfo = + CollectionInfo(rowCount = state.loginItems.size, columnCount = 1) + }, + ) { + itemsIndexed(state.loginItems) { _, item -> - SelectableFaviconListItem( - label = item.url.trimmed(), - url = item.url, - isSelected = false, - onClick = { store.dispatch(LoginClicked(item)) }, - description = item.username.trimmed(), - ) + if (state.isGuidToDelete(item.guid)) { + return@itemsIndexed } + + SelectableFaviconListItem( + label = item.url.trimmed(), + url = item.url, + isSelected = false, + onClick = { store.dispatch(LoginClicked(item)) }, + description = item.username.trimmed(), + ) } } @@ -440,7 +444,9 @@ private fun LoginsListScreenPreview() { FirefoxTheme { Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) { - SavedLoginsScreen(store) + SavedLoginsScreen( + buildStore = store, + ) } } } @@ -459,7 +465,9 @@ private fun EmptyLoginsListScreenPreview() { FirefoxTheme { Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) { - SavedLoginsScreen(store) + SavedLoginsScreen( + buildStore = store, + ) } } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/UnlockLoginsScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/UnlockLoginsScreen.kt @@ -1,173 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings.logins.ui - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import mozilla.components.compose.base.button.FilledButton -import mozilla.components.compose.base.button.TextButton -import mozilla.components.compose.base.utils.getResolvedAttrResId -import org.mozilla.fenix.R -import org.mozilla.fenix.ext.isLargeWindow -import org.mozilla.fenix.theme.FirefoxTheme -import org.mozilla.fenix.theme.Theme - -private const val FILL_WIDTH_LARGE_WINDOW = 0.5f -private const val FILL_WIDTH_DEFAULT = 1.0f -private const val PHONE_WIDTH = 400 -private const val PHONE_HEIGHT = 640 -private const val TABLET_WIDTH = 700 -private const val TABLET_HEIGHT = 1280 - -/** - * A screen allowing users to unlock their logins. - * - * @param onUnlockClicked Invoked when the user taps the unlock button. - * @param onLeaveClicked Invoked when the user taps the leave logins text. - */ -@Composable -internal fun UnlockLoginsScreen( - onUnlockClicked: () -> Unit, - onLeaveClicked: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(FirefoxTheme.colors.layer1) - .padding(bottom = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Spacer(modifier = Modifier.height(32.dp)) - - Header() - - Footer(onUnlockClicked, onLeaveClicked) - } -} - -@Composable -private fun Header() { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Logo() - - Spacer(modifier = Modifier.height(24.dp)) - - Text( - text = stringResource(id = R.string.logins_biometric_prompt_message_2), - color = FirefoxTheme.colors.textPrimary, - textAlign = TextAlign.Center, - style = FirefoxTheme.typography.headline6, - maxLines = 1, - ) - } -} - -@Composable -private fun Logo() { - Row( - modifier = Modifier - .padding(32.dp) - .height(62.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - modifier = Modifier.padding(end = 14.dp), - painter = painterResource(getResolvedAttrResId(R.attr.fenixWordmarkLogo)), - contentDescription = null, - ) - - Image( - modifier = Modifier.height(28.dp), - painter = painterResource(getResolvedAttrResId(R.attr.fenixWordmarkText)), - contentDescription = stringResource(R.string.app_name), - ) - } -} - -@Composable -private fun Footer(onUnlockClicked: () -> Unit, onLeaveClicked: () -> Unit) { - val fillWidthFraction = if (LocalContext.current.isLargeWindow()) { - FILL_WIDTH_LARGE_WINDOW - } else { - FILL_WIDTH_DEFAULT - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(fillWidthFraction), - ) { - FilledButton( - text = stringResource(id = R.string.logins_biometric_unlock_button), - modifier = Modifier.fillMaxWidth(), - onClick = onUnlockClicked, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - TextButton( - text = stringResource(R.string.logins_biometric_leave_button), - onClick = onLeaveClicked, - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) -@Composable -private fun ScreenPreviewLightPhone() = ScreenPreview(Theme.Light) - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) -@Composable -private fun ScreenPreviewDarkPhone() = ScreenPreview(Theme.Dark) - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = PHONE_WIDTH, heightDp = PHONE_HEIGHT) -@Composable -private fun ScreenPreviewPrivatePhone() = ScreenPreview(Theme.Private) - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) -@Composable -private fun ScreenPreviewLightTablet() = ScreenPreview(Theme.Light) - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) -@Composable -private fun ScreenPreviewDarkTablet() = ScreenPreview(Theme.Dark) - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = TABLET_WIDTH, heightDp = TABLET_HEIGHT) -@Composable -private fun ScreenPreviewPrivateTablet() = ScreenPreview(Theme.Private) - -@Composable -private fun ScreenPreview(theme: Theme) { - FirefoxTheme(theme) { - UnlockLoginsScreen( - onUnlockClicked = {}, - onLeaveClicked = {}, - ) - } -} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ui/SecureScreenReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/biometric/ui/SecureScreenReducerTest.kt @@ -0,0 +1,132 @@ +package org.mozilla.fenix.settings.biometric.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.settings.biometric.ui.state.BiometricAuthenticationState +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.AuthenticationFlowAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.LifecycleAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenAction.UnlockScreenAction +import org.mozilla.fenix.settings.biometric.ui.state.SecureScreenState +import org.mozilla.fenix.settings.biometric.ui.state.secureScreenReducer + +class SecureScreenReducerTest { + + @Test + fun `WHEN authentication flow starts THEN authentication state is set to in progress`() { + val result = secureScreenReducer( + state = createState(BiometricAuthenticationState.Inert), + action = AuthenticationFlowAction.Started, + ) + + assertEquals( + BiometricAuthenticationState.InProgress, + result.authenticationState, + ) + } + + @Test + fun `WHEN authentication flow succeeds THEN authentication state is set to authorized`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Inert), + action = AuthenticationFlowAction.Succeeded, + ) + + assertEquals( + BiometricAuthenticationState.Authorized, + result.authenticationState, + ) + } + + @Test + fun `WHEN authentication flow fails THEN authentication state is set to failed`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Inert), + action = AuthenticationFlowAction.Failed, + ) + + assertEquals( + BiometricAuthenticationState.Failed, + result.authenticationState, + ) + } + + @Test + fun `GIVEN previously ready to lock WHEN the lifecycle becomes resumed THEN authentication state is set to in progress`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.ReadyToLock), + action = LifecycleAction.OnResume, + ) + + assertEquals( + BiometricAuthenticationState.InProgress, + result.authenticationState, + ) + } + + @Test + fun `GIVEN previous state not ready to lock WHEN the lifecycle becomes resumed THEN authentication state is unchanged`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Authorized), + action = LifecycleAction.OnResume, + ) + + assertEquals( + BiometricAuthenticationState.Authorized, + result.authenticationState, + ) + } + + @Test + fun `GIVEN previously authorized WHEN the lifecycle becomes paused THEN state is set to ready to lock`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Authorized), + action = LifecycleAction.OnPause, + ) + + assertEquals( + BiometricAuthenticationState.ReadyToLock, + result.authenticationState, + ) + } + + @Test + fun `GIVEN a previous unauthorized state WHEN the lifecycle becomes paused THEN state remains unchanged`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Inert), + action = LifecycleAction.OnPause, + ) + + assertEquals( + BiometricAuthenticationState.Inert, + result.authenticationState, + ) + } + + @Test + fun `GIVEN previous state WHEN the unlock action is received THEN authentication state is set to in progress`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.ReadyToLock), + action = UnlockScreenAction.UnlockTapped, + ) + + assertEquals( + BiometricAuthenticationState.InProgress, + result.authenticationState, + ) + } + + @Test + fun `WHEN the leave action is received THEN state is set to exit`() { + val result = secureScreenReducer( + state = createState(authenticationState = BiometricAuthenticationState.Inert), + action = UnlockScreenAction.LeaveTapped, + ) + + assertTrue(result.shouldExit) + } + + private fun createState(authenticationState: BiometricAuthenticationState): SecureScreenState { + return SecureScreenState(authenticationState = authenticationState) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsReducerTest.kt @@ -128,7 +128,10 @@ class LoginsReducerTest { val filterUrl = loginsReducer(state, SearchLogins("url", itemsList)) assertEquals("url", filterUrl.searchText) assertEquals(5, filterUrl.loginItems.size) - assertEquals(listOf(itemsList[0], itemsList[2], itemsList[4], itemsList[6], itemsList[7]), filterUrl.loginItems) + assertEquals( + listOf(itemsList[0], itemsList[2], itemsList[4], itemsList[6], itemsList[7]), + filterUrl.loginItems, + ) } @Test @@ -452,64 +455,4 @@ class LoginsReducerTest { assertEquals(resultListStateAfterBackClick, expectedListStateAfterSaveClick) } - - @Test - fun `GIVEN a logins screen WHEN the biometric authentication becomes authorized THEN reflect that into the state`() { - val state = LoginsState.default.copy( - biometricAuthenticationState = BiometricAuthenticationState.Authorized, - ) - val result = loginsReducer( - state, - action = BiometricAuthenticationAction.Succeeded, - ) - assertEquals( - BiometricAuthenticationState.Authorized, - result.biometricAuthenticationState, - ) - } - - @Test - fun `GIVEN a logins screen WHEN the lifecycle action becomes paused THEN reflect that into the state`() { - val state = LoginsState.default.copy( - biometricAuthenticationState = BiometricAuthenticationState.Authorized, - ) - val result = loginsReducer( - state, - action = LifecycleAction.OnPause, - ) - assertEquals( - BiometricAuthenticationState.ReadyToLock, - result.biometricAuthenticationState, - ) - } - - @Test - fun `GIVEN a logins screen WHEN the lifecycle action becomes resumed THEN reflect that into the state`() { - val state = LoginsState.default.copy( - biometricAuthenticationState = BiometricAuthenticationState.ReadyToLock, - ) - val result = loginsReducer( - state, - action = LifecycleAction.OnResume, - ) - assertEquals( - BiometricAuthenticationState.InProgress, - result.biometricAuthenticationState, - ) - } - - @Test - fun `GIVEN the lock screen presenting WHEN the unlock button is tapped THEN reflect that into the state`() { - val state = LoginsState.default.copy( - biometricAuthenticationState = BiometricAuthenticationState.ReadyToLock, - ) - val result = loginsReducer( - state, - action = UnlockScreenAction.UnlockTapped, - ) - assertEquals( - BiometricAuthenticationState.InProgress, - result.biometricAuthenticationState, - ) - } }