mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1630: Add unlock with biometrics flow (#827)
This commit is contained in:
parent
31d54b3dc2
commit
b199a67b7d
4 changed files with 263 additions and 1 deletions
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
||||
/**
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue