mirror of
https://github.com/bitwarden/android.git
synced 2024-11-21 08:55:48 +03:00
PM-12733: Add error dialog to be displayed if TOTP code is blank (#4345)
This commit is contained in:
parent
ec8e934bf4
commit
96bd25eae5
5 changed files with 237 additions and 49 deletions
|
@ -20,43 +20,43 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
|||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents a Bitwarden-styled dialog that is hidden or shown based on [visibilityState].
|
||||
* Represents a Bitwarden-styled dialog.
|
||||
*
|
||||
* @param visibilityState the [BasicDialogState] used to populate the dialog.
|
||||
* @param onDismissRequest called when the user has requested to dismiss the dialog, whether by
|
||||
* tapping "OK", tapping outside the dialog, or pressing the back button.
|
||||
* @param title The optional title to be displayed by the dialog.
|
||||
* @param message The message to be displayed under the [title] by the dialog.
|
||||
* @param onDismissRequest A lambda that is invoked when the user has requested to dismiss the
|
||||
* dialog, whether by tapping "OK", tapping outside the dialog, or pressing the back button.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun BitwardenBasicDialog(
|
||||
visibilityState: BasicDialogState,
|
||||
title: String?,
|
||||
message: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
): Unit = when (visibilityState) {
|
||||
BasicDialogState.Hidden -> Unit
|
||||
is BasicDialogState.Shown -> {
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = R.string.ok),
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.testTag("AcceptAlertButton"),
|
||||
modifier = Modifier.testTag(tag = "AcceptAlertButton"),
|
||||
)
|
||||
},
|
||||
title = visibilityState.title?.let {
|
||||
title = title?.let {
|
||||
{
|
||||
Text(
|
||||
text = it(),
|
||||
text = it,
|
||||
style = BitwardenTheme.typography.headlineSmall,
|
||||
modifier = Modifier.testTag("AlertTitleText"),
|
||||
modifier = Modifier.testTag(tag = "AlertTitleText"),
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = visibilityState.message(),
|
||||
text = message,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
modifier = Modifier.testTag("AlertContentText"),
|
||||
modifier = Modifier.testTag(tag = "AlertContentText"),
|
||||
)
|
||||
},
|
||||
shape = BitwardenTheme.shapes.dialog,
|
||||
|
@ -69,6 +69,27 @@ fun BitwardenBasicDialog(
|
|||
testTag = "AlertPopup"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Bitwarden-styled dialog that is hidden or shown based on [visibilityState].
|
||||
*
|
||||
* @param visibilityState the [BasicDialogState] used to populate the dialog.
|
||||
* @param onDismissRequest called when the user has requested to dismiss the dialog, whether by
|
||||
* tapping "OK", tapping outside the dialog, or pressing the back button.
|
||||
*/
|
||||
@Composable
|
||||
fun BitwardenBasicDialog(
|
||||
visibilityState: BasicDialogState,
|
||||
onDismissRequest: () -> Unit,
|
||||
): Unit = when (visibilityState) {
|
||||
BasicDialogState.Hidden -> Unit
|
||||
is BasicDialogState.Shown -> {
|
||||
BitwardenBasicDialog(
|
||||
title = visibilityState.title?.invoke(),
|
||||
message = visibilityState.message(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ import com.x8bit.bitwarden.R
|
|||
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.button.BitwardenOutlinedButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
|
@ -108,6 +109,13 @@ fun ManualCodeEntryScreen(
|
|||
)
|
||||
}
|
||||
|
||||
ManualCodeEntryDialogs(
|
||||
state = state.dialog,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ManualCodeEntryAction.DialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
|
@ -200,3 +208,21 @@ fun ManualCodeEntryScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualCodeEntryDialogs(
|
||||
state: ManualCodeEntryState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is ManualCodeEntryState.DialogState.Error -> {
|
||||
BitwardenBasicDialog(
|
||||
title = state.title?.invoke(),
|
||||
message = state.message(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
@ -23,13 +25,17 @@ class ManualCodeEntryViewModel @Inject constructor(
|
|||
private val vaultRepository: VaultRepository,
|
||||
) : BaseViewModel<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: ManualCodeEntryState(code = ""),
|
||||
?: ManualCodeEntryState(
|
||||
code = "",
|
||||
dialog = null,
|
||||
),
|
||||
) {
|
||||
override fun handleAction(action: ManualCodeEntryAction) {
|
||||
when (action) {
|
||||
is ManualCodeEntryAction.CloseClick -> handleCloseClick()
|
||||
is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action)
|
||||
is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit()
|
||||
ManualCodeEntryAction.DialogDismiss -> handleDialogDismiss()
|
||||
is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick()
|
||||
is ManualCodeEntryAction.SettingsClick -> handleSettingsClick()
|
||||
}
|
||||
|
@ -46,10 +52,26 @@ class ManualCodeEntryViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun handleCodeSubmit() {
|
||||
vaultRepository.emitTotpCodeResult(TotpCodeResult.Success(state.code.trim()))
|
||||
val code = state.code.trim()
|
||||
if (code.isEmpty()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = ManualCodeEntryState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.authenticator_key_read_error.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
vaultRepository.emitTotpCodeResult(TotpCodeResult.Success(code))
|
||||
sendEvent(ManualCodeEntryEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleScanQrCodeTextClick() {
|
||||
sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen)
|
||||
}
|
||||
|
@ -65,7 +87,22 @@ class ManualCodeEntryViewModel @Inject constructor(
|
|||
@Parcelize
|
||||
data class ManualCodeEntryState(
|
||||
val code: String,
|
||||
) : Parcelable
|
||||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
/**
|
||||
* Represents the current state of any dialogs on the screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
/**
|
||||
* Represents a dismissible dialog with the given error [message].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text?,
|
||||
val message: Text,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the [ManualCodeEntryScreen].
|
||||
|
@ -113,6 +150,11 @@ sealed class ManualCodeEntryAction {
|
|||
*/
|
||||
data class CodeTextChange(val code: String) : ManualCodeEntryAction()
|
||||
|
||||
/**
|
||||
* User dismissed the dialog.
|
||||
*/
|
||||
data object DialogDismiss : ManualCodeEntryAction()
|
||||
|
||||
/**
|
||||
* The text to switch to QR code scanning is clicked.
|
||||
*/
|
||||
|
|
|
@ -15,10 +15,13 @@ import androidx.compose.ui.test.onNodeWithText
|
|||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
|
@ -36,8 +39,7 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
|
|||
private var onNavigateToScanQrCodeCalled = false
|
||||
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ManualCodeEntryEvent>()
|
||||
private val mutableStateFlow =
|
||||
MutableStateFlow(ManualCodeEntryState(""))
|
||||
private val mutableStateFlow = MutableStateFlow<ManualCodeEntryState>(DEFAULT_STATE)
|
||||
|
||||
private val fakePermissionManager: FakePermissionManager = FakePermissionManager()
|
||||
private val intentManager = mockk<IntentManager>(relaxed = true)
|
||||
|
@ -131,6 +133,59 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
|
|||
.assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog should be updated according to state`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = ManualCodeEntryState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.authenticator_key_read_error.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "An error has occurred.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Cannot read authenticator key.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialog = null)
|
||||
}
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error dialog Ok click should emit DialogDismiss`() {
|
||||
composeTestRule.assertNoDialogExists()
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = ManualCodeEntryState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.authenticator_key_read_error.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText(text = "Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.DialogDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings dialog should call SettingsClick action on confirm click`() {
|
||||
fakePermissionManager.checkPermissionResult = false
|
||||
|
@ -202,3 +257,8 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: ManualCodeEntryState = ManualCodeEntryState(
|
||||
code = "",
|
||||
dialog = null,
|
||||
)
|
||||
|
|
|
@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
|
|||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
|
@ -26,7 +28,7 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.CloseClick)
|
||||
|
@ -34,9 +36,30 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeSubmit wihh blank code should display error dialog`() {
|
||||
val initialState = DEFAULT_STATE.copy(code = " ")
|
||||
val viewModel = createViewModel(initialState = initialState)
|
||||
|
||||
viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit)
|
||||
|
||||
verify(exactly = 0) {
|
||||
vaultRepository.emitTotpCodeResult(TotpCodeResult.Success("TestCode"))
|
||||
}
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = ManualCodeEntryState.DialogState.Error(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = R.string.authenticator_key_read_error.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CodeSubmit should emit new code and NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel(initialState = ManualCodeEntryState(" TestCode "))
|
||||
val viewModel = createViewModel(initialState = DEFAULT_STATE.copy(code = " TestCode "))
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit)
|
||||
|
@ -50,20 +73,29 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `CodeTextChange should update state with new value`() = runTest {
|
||||
val viewModel =
|
||||
createViewModel(initialState = ManualCodeEntryState("TestCode"))
|
||||
val viewModel = createViewModel(initialState = DEFAULT_STATE.copy(code = "TestCode"))
|
||||
|
||||
val expectedState = ManualCodeEntryState("NewCode")
|
||||
val expectedState = DEFAULT_STATE.copy(code = "NewCode")
|
||||
|
||||
viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange("NewCode"))
|
||||
assertEquals(expectedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SettingsClick should emit NavigateToAppSettings and update state`() = runTest {
|
||||
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
|
||||
fun `DialogDismiss should clear the dialog state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val expectedState = ManualCodeEntryState("")
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.CloseClick)
|
||||
assertEquals(ManualCodeEntryEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SettingsClick should emit NavigateToAppSettings and update state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val expectedState = DEFAULT_STATE
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.SettingsClick)
|
||||
|
@ -75,7 +107,7 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `ScanQrTextCodeClick should emit NavigateToQrCodeScreen`() = runTest {
|
||||
val viewModel = createViewModel(initialState = ManualCodeEntryState(""))
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick)
|
||||
|
@ -84,7 +116,9 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(initialState: ManualCodeEntryState): ManualCodeEntryViewModel =
|
||||
private fun createViewModel(
|
||||
initialState: ManualCodeEntryState? = null,
|
||||
): ManualCodeEntryViewModel =
|
||||
ManualCodeEntryViewModel(
|
||||
vaultRepository = vaultRepository,
|
||||
savedStateHandle = SavedStateHandle(
|
||||
|
@ -92,3 +126,8 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
|
|||
),
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: ManualCodeEntryState = ManualCodeEntryState(
|
||||
code = "",
|
||||
dialog = null,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue