From b199a67b7d6151991a9b20f652305e1193548d69 Mon Sep 17 00:00:00 2001 From: David Perez Date: Sun, 28 Jan 2024 11:43:46 -0600 Subject: [PATCH] BIT-1630: Add unlock with biometrics flow (#827) --- .../feature/vaultunlock/VaultUnlockScreen.kt | 31 ++++ .../vaultunlock/VaultUnlockViewModel.kt | 40 ++++- .../vaultunlock/VaultUnlockScreenTest.kt | 44 ++++++ .../vaultunlock/VaultUnlockViewModelTest.kt | 149 ++++++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index 555665b399..6ade84225c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -43,12 +43,15 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLogoutConfirmationDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButton import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager +import com.x8bit.bitwarden.ui.platform.theme.LocalBiometricsManager import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -60,6 +63,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun VaultUnlockScreen( viewModel: VaultUnlockViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current @@ -113,6 +117,12 @@ fun VaultUnlockScreen( ) } + val onBiometricsUnlockClick: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) } + } + val onBiometricsLockOut: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) } + } // Content BitwardenScaffold( modifier = Modifier @@ -182,6 +192,27 @@ fun VaultUnlockScreen( .fillMaxWidth(), ) Spacer(modifier = Modifier.height(24.dp)) + if (state.isBiometricEnabled) { + BitwardenOutlinedButton( + label = stringResource(id = R.string.use_biometrics_to_unlock), + onClick = { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlockClick, + onCancel = { + // no-op + }, + onError = { + // no-op + }, + onLockOut = onBiometricsLockOut, + ) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + } BitwardenFilledButton( label = stringResource(id = R.string.unlock), onClick = remember(viewModel) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index b7ce27fc4d..0de53d004d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -87,6 +87,8 @@ class VaultUnlockViewModel @Inject constructor( is VaultUnlockAction.LockAccountClick -> handleLockAccountClick(action) is VaultUnlockAction.LogoutAccountClick -> handleLogoutAccountClick(action) is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action) + VaultUnlockAction.BiometricsLockOut -> handleBiometricsLockOut() + VaultUnlockAction.BiometricsUnlockClick -> handleBiometricsUnlockClick() VaultUnlockAction.UnlockClick -> handleUnlockClick() is VaultUnlockAction.Internal -> handleInternalAction(action) } @@ -122,6 +124,26 @@ class VaultUnlockViewModel @Inject constructor( authRepository.switchAccount(userId = action.accountSummary.userId) } + private fun handleBiometricsLockOut() { + // TODO: Handle biometrics lockout (BIT-1451) + sendEvent(VaultUnlockEvent.ShowToast("Lock out not yet implemented".asText())) + } + + private fun handleBiometricsUnlockClick() { + val activeUserId = authRepository.activeUserId ?: return + mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) } + viewModelScope.launch { + val vaultUnlockResult = vaultRepo.unlockVaultWithBiometrics() + sendAction( + VaultUnlockAction.Internal.ReceiveVaultUnlockResult( + userId = activeUserId, + vaultUnlockResult = vaultUnlockResult, + isBiometricLogin = true, + ), + ) + } + } + private fun handleUnlockClick() { val activeUserId = authRepository.activeUserId ?: return mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) } @@ -143,6 +165,7 @@ class VaultUnlockViewModel @Inject constructor( VaultUnlockAction.Internal.ReceiveVaultUnlockResult( userId = activeUserId, vaultUnlockResult = vaultUnlockResult, + isBiometricLogin = false, ), ) } @@ -175,7 +198,11 @@ class VaultUnlockViewModel @Inject constructor( mutableStateFlow.update { it.copy( dialog = VaultUnlockState.VaultUnlockDialog.Error( - state.vaultUnlockType.unlockScreenErrorMessage, + if (action.isBiometricLogin) { + R.string.generic_error_message.asText() + } else { + state.vaultUnlockType.unlockScreenErrorMessage + }, ), ) } @@ -327,6 +354,16 @@ sealed class VaultUnlockAction { val accountSummary: AccountSummary, ) : VaultUnlockAction() + /** + * The user has clicked the biometrics button. + */ + data object BiometricsUnlockClick : VaultUnlockAction() + + /** + * The user has attempted to login with biometrics too many times and has been locked out. + */ + data object BiometricsLockOut : VaultUnlockAction() + /** * The user has clicked the unlock button. */ @@ -342,6 +379,7 @@ sealed class VaultUnlockAction { data class ReceiveVaultUnlockResult( val userId: String, val vaultUnlockResult: VaultUnlockResult, + val isBiometricLogin: Boolean, ) : Internal() /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index 4dcfc1f9fa..78a545ab36 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary +import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertNoDialogExists @@ -30,7 +31,10 @@ import com.x8bit.bitwarden.ui.util.performLockAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -45,12 +49,26 @@ class VaultUnlockScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val captureBiometricsSuccess = slot<() -> Unit>() + private val captureBiometricsLockOut = slot<() -> Unit>() + private val biometricsManager: BiometricsManager = mockk { + every { isBiometricsSupported } returns true + every { + promptBiometrics( + onSuccess = capture(captureBiometricsSuccess), + onCancel = any(), + onLockOut = capture(captureBiometricsLockOut), + onError = any(), + ) + } just runs + } @Before fun setUp() { composeTestRule.setContent { VaultUnlockScreen( viewModel = viewModel, + biometricsManager = biometricsManager, ) } } @@ -338,6 +356,32 @@ class VaultUnlockScreenTest : BaseComposeTest() { viewModel.trySendAction(VaultUnlockAction.InputChanged(input)) } } + + @Suppress("MaxLineLength") + @Test + fun `unlock with biometrics click should send BiometricsUnlockClick on biometrics authentication success`() { + composeTestRule + .onNodeWithText("Use biometrics to unlock") + .performScrollTo() + .performClick() + captureBiometricsSuccess.captured() + verify(exactly = 1) { + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + } + } + + @Suppress("MaxLineLength") + @Test + fun `unlock with biometrics click should send BiometricsLockOut on biometrics authentication lock out`() { + composeTestRule + .onNodeWithText("Use biometrics to unlock") + .performScrollTo() + .performClick() + captureBiometricsLockOut.captured() + verify(exactly = 1) { + viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) + } + } } private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 3125af2182..56f408f3e6 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.auth.feature.vaultunlock import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.repository.AuthRepository @@ -527,6 +528,154 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { } } + @Test + fun `on BiometricsLockOut should emit ShowToast`() = runTest { + val viewModel = createViewModel() + + viewModel.eventFlow.test { + viewModel.trySendAction(VaultUnlockAction.BiometricsLockOut) + assertEquals( + VaultUnlockEvent.ShowToast("Lock out not yet implemented".asText()), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics AuthenticationError`() { + val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } returns VaultUnlockResult.AuthenticationError + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithBiometrics() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics GenericError`() { + val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } returns VaultUnlockResult.GenericError + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithBiometrics() + } + } + + @Suppress("MaxLineLength") + @Test + fun `on BiometricsUnlockClick should display error dialog on unlockVaultWithBiometrics InvalidStateError`() { + val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } returns VaultUnlockResult.InvalidStateError + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + + assertEquals( + initialState.copy( + dialog = VaultUnlockState.VaultUnlockDialog.Error( + R.string.generic_error_message.asText(), + ), + ), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithBiometrics() + } + } + + @Test + fun `on BiometricsUnlockClick should clear dialog on unlockVaultWithBiometrics success`() { + val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } returns VaultUnlockResult.Success + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + + assertEquals( + initialState.copy(dialog = null), + viewModel.stateFlow.value, + ) + coVerify { + vaultRepository.unlockVaultWithBiometrics() + } + } + + @Test + fun `on BiometricsUnlockClick should clear dialog when user has changed`() { + val initialState = DEFAULT_STATE.copy(isBiometricEnabled = true) + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isBiometricsEnabled = true)), + ) + val resultFlow = bufferedMutableSharedFlow() + val viewModel = createViewModel(state = initialState) + coEvery { + vaultRepository.unlockVaultWithBiometrics() + } coAnswers { resultFlow.first() } + + viewModel.trySendAction(VaultUnlockAction.BiometricsUnlockClick) + + assertEquals( + initialState.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading), + viewModel.stateFlow.value, + ) + val updatedUserId = "updatedUserId" + mutableUserStateFlow.update { + it?.copy( + activeUserId = updatedUserId, + accounts = listOf(DEFAULT_ACCOUNT.copy(userId = updatedUserId)), + ) + } + resultFlow.tryEmit(VaultUnlockResult.GenericError) + assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value) + coVerify { + vaultRepository.unlockVaultWithBiometrics() + } + } + private fun createViewModel( state: VaultUnlockState? = DEFAULT_STATE, environmentRepo: EnvironmentRepository = environmentRepository,