Add logic for biometric unlock to SetupUnlockScreen (#3702)

This commit is contained in:
David Perez 2024-08-09 09:09:41 -05:00 committed by GitHub
parent 145f8adf0c
commit 805fea630c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 359 additions and 4 deletions

View file

@ -42,7 +42,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
@ -63,11 +65,27 @@ fun SetupUnlockScreen(
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) } val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) }
var showBiometricsPrompt by rememberSaveable { mutableStateOf(value = false) }
EventsEffect(viewModel = viewModel) { event -> EventsEffect(viewModel = viewModel) { event ->
when (event) { when (event) {
SetupUnlockEvent.NavigateToSetupAutofill -> onNavigateToSetupAutofill() SetupUnlockEvent.NavigateToSetupAutofill -> onNavigateToSetupAutofill()
is SetupUnlockEvent.ShowBiometricsPrompt -> {
showBiometricsPrompt = true
biometricsManager.promptBiometrics(
onSuccess = {
handler.unlockWithBiometricToggle()
showBiometricsPrompt = false
},
onCancel = { showBiometricsPrompt = false },
onLockOut = { showBiometricsPrompt = false },
onError = { showBiometricsPrompt = false },
cipher = event.cipher,
)
} }
} }
}
SetupUnlockScreenDialogs(dialogState = state.dialogState)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold( BitwardenScaffold(
@ -84,6 +102,7 @@ fun SetupUnlockScreen(
) { innerPadding -> ) { innerPadding ->
SetupUnlockScreenContent( SetupUnlockScreenContent(
state = state, state = state,
showBiometricsPrompt = showBiometricsPrompt,
handler = handler, handler = handler,
biometricsManager = biometricsManager, biometricsManager = biometricsManager,
modifier = Modifier modifier = Modifier
@ -96,6 +115,7 @@ fun SetupUnlockScreen(
@Composable @Composable
private fun SetupUnlockScreenContent( private fun SetupUnlockScreenContent(
state: SetupUnlockState, state: SetupUnlockState,
showBiometricsPrompt: Boolean,
handler: SetupUnlockHandler, handler: SetupUnlockHandler,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
biometricsManager: BiometricsManager, biometricsManager: BiometricsManager,
@ -116,7 +136,7 @@ private fun SetupUnlockScreenContent(
Spacer(modifier = Modifier.height(height = 24.dp)) Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenUnlockWithBiometricsSwitch( BitwardenUnlockWithBiometricsSwitch(
isBiometricsSupported = biometricsManager.isBiometricsSupported, isBiometricsSupported = biometricsManager.isBiometricsSupported,
isChecked = state.isUnlockWithBiometricsEnabled, isChecked = state.isUnlockWithBiometricsEnabled || showBiometricsPrompt,
onDisableBiometrics = handler.onDisableBiometrics, onDisableBiometrics = handler.onDisableBiometrics,
onEnableBiometrics = handler.onEnableBiometrics, onEnableBiometrics = handler.onEnableBiometrics,
modifier = Modifier modifier = Modifier
@ -270,3 +290,16 @@ private fun SetupUnlockHeaderLandscape(
} }
} }
} }
@Composable
private fun SetupUnlockScreenDialogs(
dialogState: SetupUnlockState.DialogState?,
) {
when (dialogState) {
is SetupUnlockState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(text = dialogState.title),
)
null -> Unit
}
}

View file

@ -2,13 +2,21 @@ package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.crypto.Cipher
import javax.inject.Inject import javax.inject.Inject
private const val KEY_STATE = "state" private const val KEY_STATE = "state"
@ -30,6 +38,7 @@ class SetupUnlockViewModel @Inject constructor(
cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId), cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId),
) )
SetupUnlockState( SetupUnlockState(
userId = userId,
isUnlockWithPasswordEnabled = authRepository isUnlockWithPasswordEnabled = authRepository
.userStateFlow .userStateFlow
.value .value
@ -38,6 +47,7 @@ class SetupUnlockViewModel @Inject constructor(
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled, isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled && isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
isBiometricsValid, isBiometricsValid,
dialogState = null,
) )
}, },
) { ) {
@ -51,6 +61,7 @@ class SetupUnlockViewModel @Inject constructor(
} }
is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
is SetupUnlockAction.Internal -> handleInternalActions(action)
} }
} }
@ -59,7 +70,12 @@ class SetupUnlockViewModel @Inject constructor(
} }
private fun handleEnableBiometricsClick() { private fun handleEnableBiometricsClick() {
// TODO: Handle biometric unlocking logic PM-10624 sendEvent(
SetupUnlockEvent.ShowBiometricsPrompt(
// Generate a new key in case the previous one was invalidated
cipher = biometricsEncryptionManager.createCipher(userId = state.userId),
),
)
} }
private fun handleSetUpLaterClick() { private fun handleSetUpLaterClick() {
@ -69,12 +85,58 @@ class SetupUnlockViewModel @Inject constructor(
private fun handleUnlockWithBiometricToggle( private fun handleUnlockWithBiometricToggle(
action: SetupUnlockAction.UnlockWithBiometricToggle, action: SetupUnlockAction.UnlockWithBiometricToggle,
) { ) {
// TODO: Handle biometric unlocking logic PM-10624 if (action.isEnabled) {
mutableStateFlow.update {
it.copy(
dialogState = SetupUnlockState.DialogState.Loading(R.string.saving.asText()),
isUnlockWithBiometricsEnabled = true,
)
}
viewModelScope.launch {
val result = settingsRepository.setupBiometricsKey()
sendAction(SetupUnlockAction.Internal.BiometricsKeyResultReceive(result))
}
} else {
settingsRepository.clearBiometricsKey()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
}
} }
private fun handleUnlockWithPinToggle(action: SetupUnlockAction.UnlockWithPinToggle) { private fun handleUnlockWithPinToggle(action: SetupUnlockAction.UnlockWithPinToggle) {
// TODO: Handle pin unlocking logic PM-10628 // TODO: Handle pin unlocking logic PM-10628
} }
private fun handleInternalActions(action: SetupUnlockAction.Internal) {
when (action) {
is SetupUnlockAction.Internal.BiometricsKeyResultReceive -> {
handleBiometricsKeyResultReceive(action)
}
}
}
private fun handleBiometricsKeyResultReceive(
action: SetupUnlockAction.Internal.BiometricsKeyResultReceive,
) {
when (action.result) {
BiometricsKeyResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = null,
isUnlockWithBiometricsEnabled = false,
)
}
}
BiometricsKeyResult.Success -> {
mutableStateFlow.update {
it.copy(
dialogState = null,
isUnlockWithBiometricsEnabled = true,
)
}
}
}
}
} }
/** /**
@ -82,15 +144,30 @@ class SetupUnlockViewModel @Inject constructor(
*/ */
@Parcelize @Parcelize
data class SetupUnlockState( data class SetupUnlockState(
val userId: String,
val isUnlockWithPasswordEnabled: Boolean, val isUnlockWithPasswordEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean, val isUnlockWithPinEnabled: Boolean,
val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean,
val dialogState: DialogState?,
) : Parcelable { ) : Parcelable {
/** /**
* Indicates whether the continue button should be enabled or disabled. * Indicates whether the continue button should be enabled or disabled.
*/ */
val isContinueButtonEnabled: Boolean val isContinueButtonEnabled: Boolean
get() = isUnlockWithBiometricsEnabled || isUnlockWithPinEnabled get() = isUnlockWithBiometricsEnabled || isUnlockWithPinEnabled
/**
* Represents the dialog UI state for the setup unlock screen.
*/
sealed class DialogState : Parcelable {
/**
* Displays a loading dialog with a title.
*/
@Parcelize
data class Loading(
val title: Text,
) : DialogState()
}
} }
/** /**
@ -101,6 +178,13 @@ sealed class SetupUnlockEvent {
* Navigate to autofill setup. * Navigate to autofill setup.
*/ */
data object NavigateToSetupAutofill : SetupUnlockEvent() data object NavigateToSetupAutofill : SetupUnlockEvent()
/**
* Shows the prompt for biometrics using with the given [cipher].
*/
data class ShowBiometricsPrompt(
val cipher: Cipher,
) : SetupUnlockEvent()
} }
/** /**
@ -135,4 +219,16 @@ sealed class SetupUnlockAction {
* The user clicked the set up later button. * The user clicked the set up later button.
*/ */
data object SetUpLaterClick : SetupUnlockAction() data object SetUpLaterClick : SetupUnlockAction()
/**
* Models actions that can be sent by the view model itself.
*/
sealed class Internal : SetupUnlockAction() {
/**
* A biometrics key result has been received.
*/
data class BiometricsKeyResultReceive(
val result: BiometricsKeyResult,
) : Internal()
}
} }

View file

@ -14,6 +14,7 @@ data class SetupUnlockHandler(
val onUnlockWithPinToggle: (UnlockWithPinState) -> Unit, val onUnlockWithPinToggle: (UnlockWithPinState) -> Unit,
val onContinueClick: () -> Unit, val onContinueClick: () -> Unit,
val onSetUpLaterClick: () -> Unit, val onSetUpLaterClick: () -> Unit,
val unlockWithBiometricToggle: () -> Unit,
) { ) {
companion object { companion object {
/** /**
@ -35,6 +36,11 @@ data class SetupUnlockHandler(
}, },
onContinueClick = { viewModel.trySendAction(SetupUnlockAction.ContinueClick) }, onContinueClick = { viewModel.trySendAction(SetupUnlockAction.ContinueClick) },
onSetUpLaterClick = { viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) }, onSetUpLaterClick = { viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) },
unlockWithBiometricToggle = {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true),
)
},
) )
} }
} }

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.assertNoDialogExists
@ -21,6 +22,7 @@ import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -28,13 +30,27 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import javax.crypto.Cipher
class SetupUnlockScreenTest : BaseComposeTest() { class SetupUnlockScreenTest : BaseComposeTest() {
private var onNavigateToSetupAutofillCalled = false private var onNavigateToSetupAutofillCalled = false
private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>()
private val captureBiometricsCancel = slot<() -> Unit>()
private val captureBiometricsLockOut = slot<() -> Unit>()
private val captureBiometricsError = slot<() -> Unit>()
private val biometricsManager: BiometricsManager = mockk { private val biometricsManager: BiometricsManager = mockk {
every { isBiometricsSupported } returns true every { isBiometricsSupported } returns true
every {
promptBiometrics(
onSuccess = capture(captureBiometricsSuccess),
onCancel = capture(captureBiometricsCancel),
onLockOut = capture(captureBiometricsLockOut),
onError = capture(captureBiometricsError),
cipher = CIPHER,
)
} just runs
} }
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -114,6 +130,90 @@ class SetupUnlockScreenTest : BaseComposeTest() {
} }
} }
@Test
fun `on unlock with biometrics toggle should un-toggle on cancel`() {
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
mutableEventFlow.tryEmit(SetupUnlockEvent.ShowBiometricsPrompt(cipher = CIPHER))
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOn()
captureBiometricsCancel.captured()
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
verify(exactly = 0) {
viewModel.trySendAction(any())
}
}
@Test
fun `on unlock with biometrics toggle should un-toggle on error`() {
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
mutableEventFlow.tryEmit(SetupUnlockEvent.ShowBiometricsPrompt(cipher = CIPHER))
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOn()
captureBiometricsError.captured()
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
verify(exactly = 0) {
viewModel.trySendAction(any())
}
}
@Test
fun `on unlock with biometrics toggle should un-toggle on lock out`() {
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
mutableEventFlow.tryEmit(SetupUnlockEvent.ShowBiometricsPrompt(cipher = CIPHER))
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOn()
captureBiometricsLockOut.captured()
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
verify(exactly = 0) {
viewModel.trySendAction(any())
}
}
@Test
fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle on success`() {
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
mutableEventFlow.tryEmit(SetupUnlockEvent.ShowBiometricsPrompt(cipher = CIPHER))
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOn()
captureBiometricsSuccess.captured(CIPHER)
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
verify(exactly = 1) {
viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true))
}
}
@Test @Test
fun `on unlock with pin code should be toggled on or off according to state`() { fun `on unlock with pin code should be toggled on or off according to state`() {
composeTestRule.onNodeWithText(text = "Unlock with PIN code").assertIsOff() composeTestRule.onNodeWithText(text = "Unlock with PIN code").assertIsOff()
@ -469,10 +569,32 @@ class SetupUnlockScreenTest : BaseComposeTest() {
viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick)
} }
} }
@Test
fun `Loading Dialog should be displayed according to state`() {
val title = "title"
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialogState = SetupUnlockState.DialogState.Loading(title = title.asText()))
}
composeTestRule
.onAllNodesWithText(text = title)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
} }
private const val DEFAULT_USER_ID: String = "user_id"
private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState( private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
userId = DEFAULT_USER_ID,
isUnlockWithPinEnabled = false, isUnlockWithPinEnabled = false,
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
dialogState = null,
) )
private val CIPHER = mockk<Cipher>()

View file

@ -2,14 +2,22 @@ package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.BiometricsKeyResult
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@ -57,6 +65,94 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() =
runTest {
val initialState = DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true)
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { settingsRepository.clearBiometricsKey() } just runs
val viewModel = createViewModel(initialState)
assertEquals(initialState, viewModel.stateFlow.value)
viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false))
assertEquals(
initialState.copy(isUnlockWithBiometricsEnabled = false),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
settingsRepository.clearBiometricsKey()
}
}
@Suppress("MaxLineLength")
@Test
fun `on UnlockWithBiometricToggle true and setupBiometricsKey error should update the state accordingly`() =
runTest {
coEvery { settingsRepository.setupBiometricsKey() } returns BiometricsKeyResult.Error
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetupUnlockState.DialogState.Loading(
title = R.string.saving.asText(),
),
isUnlockWithBiometricsEnabled = true,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = null,
isUnlockWithBiometricsEnabled = false,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
settingsRepository.setupBiometricsKey()
}
}
@Suppress("MaxLineLength")
@Test
fun `on UnlockWithBiometricToggle true and setupBiometricsKey success should call update the state accordingly`() =
runTest {
coEvery { settingsRepository.setupBiometricsKey() } returns BiometricsKeyResult.Success
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = true),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SetupUnlockState.DialogState.Loading(
title = R.string.saving.asText(),
),
isUnlockWithBiometricsEnabled = true,
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = null,
isUnlockWithBiometricsEnabled = true,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
settingsRepository.setupBiometricsKey()
}
}
private fun createViewModel( private fun createViewModel(
state: SetupUnlockState? = null, state: SetupUnlockState? = null,
): SetupUnlockViewModel = ): SetupUnlockViewModel =
@ -68,14 +164,16 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
) )
} }
private const val DEFAULT_USER_ID: String = "activeUserId"
private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState( private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
userId = DEFAULT_USER_ID,
isUnlockWithPinEnabled = false, isUnlockWithPinEnabled = false,
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
dialogState = null,
) )
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()
private const val DEFAULT_USER_ID: String = "activeUserId"
private val DEFAULT_USER_STATE: UserState = UserState( private val DEFAULT_USER_STATE: UserState = UserState(
activeUserId = DEFAULT_USER_ID, activeUserId = DEFAULT_USER_ID,
accounts = listOf( accounts = listOf(