BIT-1630: Add unlock with biometrics flow (#827)

This commit is contained in:
David Perez 2024-01-28 11:43:46 -06:00 committed by Álison Fernandes
parent 31d54b3dc2
commit b199a67b7d
4 changed files with 263 additions and 1 deletions

View file

@ -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) {

View file

@ -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()
/**

View file

@ -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"

View file

@ -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<VaultUnlockResult>()
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,