PM-12733: Add error dialog to be displayed if TOTP code is blank (#4345)

This commit is contained in:
David Perez 2024-11-20 16:00:08 -06:00 committed by GitHub
parent ec8e934bf4
commit 96bd25eae5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 237 additions and 49 deletions

View file

@ -20,43 +20,43 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.parcelize.Parcelize 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 title The optional title to be displayed by the dialog.
* @param onDismissRequest called when the user has requested to dismiss the dialog, whether by * @param message The message to be displayed under the [title] by the dialog.
* tapping "OK", tapping outside the dialog, or pressing the back button. * @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) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun BitwardenBasicDialog( fun BitwardenBasicDialog(
visibilityState: BasicDialogState, title: String?,
message: String,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
): Unit = when (visibilityState) { ) {
BasicDialogState.Hidden -> Unit
is BasicDialogState.Shown -> {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
confirmButton = { confirmButton = {
BitwardenTextButton( BitwardenTextButton(
label = stringResource(id = R.string.ok), label = stringResource(id = R.string.ok),
onClick = onDismissRequest, onClick = onDismissRequest,
modifier = Modifier.testTag("AcceptAlertButton"), modifier = Modifier.testTag(tag = "AcceptAlertButton"),
) )
}, },
title = visibilityState.title?.let { title = title?.let {
{ {
Text( Text(
text = it(), text = it,
style = BitwardenTheme.typography.headlineSmall, style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.testTag("AlertTitleText"), modifier = Modifier.testTag(tag = "AlertTitleText"),
) )
} }
}, },
text = { text = {
Text( Text(
text = visibilityState.message(), text = message,
style = BitwardenTheme.typography.bodyMedium, style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.testTag("AlertContentText"), modifier = Modifier.testTag(tag = "AlertContentText"),
) )
}, },
shape = BitwardenTheme.shapes.dialog, shape = BitwardenTheme.shapes.dialog,
@ -69,6 +69,27 @@ fun BitwardenBasicDialog(
testTag = "AlertPopup" 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,
)
} }
} }

View file

@ -32,6 +32,7 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.BitwardenOutlinedButton 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.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold 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( BitwardenScaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { 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
}
}

View file

@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
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.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -23,13 +25,17 @@ class ManualCodeEntryViewModel @Inject constructor(
private val vaultRepository: VaultRepository, private val vaultRepository: VaultRepository,
) : BaseViewModel<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>( ) : BaseViewModel<ManualCodeEntryState, ManualCodeEntryEvent, ManualCodeEntryAction>(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: ManualCodeEntryState(code = ""), ?: ManualCodeEntryState(
code = "",
dialog = null,
),
) { ) {
override fun handleAction(action: ManualCodeEntryAction) { override fun handleAction(action: ManualCodeEntryAction) {
when (action) { when (action) {
is ManualCodeEntryAction.CloseClick -> handleCloseClick() is ManualCodeEntryAction.CloseClick -> handleCloseClick()
is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action) is ManualCodeEntryAction.CodeTextChange -> handleCodeTextChange(action)
is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit() is ManualCodeEntryAction.CodeSubmit -> handleCodeSubmit()
ManualCodeEntryAction.DialogDismiss -> handleDialogDismiss()
is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick() is ManualCodeEntryAction.ScanQrCodeTextClick -> handleScanQrCodeTextClick()
is ManualCodeEntryAction.SettingsClick -> handleSettingsClick() is ManualCodeEntryAction.SettingsClick -> handleSettingsClick()
} }
@ -46,10 +52,26 @@ class ManualCodeEntryViewModel @Inject constructor(
} }
private fun handleCodeSubmit() { 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) sendEvent(ManualCodeEntryEvent.NavigateBack)
} }
private fun handleDialogDismiss() {
mutableStateFlow.update { it.copy(dialog = null) }
}
private fun handleScanQrCodeTextClick() { private fun handleScanQrCodeTextClick() {
sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen) sendEvent(ManualCodeEntryEvent.NavigateToQrCodeScreen)
} }
@ -65,7 +87,22 @@ class ManualCodeEntryViewModel @Inject constructor(
@Parcelize @Parcelize
data class ManualCodeEntryState( data class ManualCodeEntryState(
val code: String, 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]. * Models events for the [ManualCodeEntryScreen].
@ -113,6 +150,11 @@ sealed class ManualCodeEntryAction {
*/ */
data class CodeTextChange(val code: String) : 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. * The text to switch to QR code scanning is clicked.
*/ */

View file

@ -15,10 +15,13 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.x8bit.bitwarden.R
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.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
@ -36,8 +39,7 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
private var onNavigateToScanQrCodeCalled = false private var onNavigateToScanQrCodeCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<ManualCodeEntryEvent>() private val mutableEventFlow = bufferedMutableSharedFlow<ManualCodeEntryEvent>()
private val mutableStateFlow = private val mutableStateFlow = MutableStateFlow<ManualCodeEntryState>(DEFAULT_STATE)
MutableStateFlow(ManualCodeEntryState(""))
private val fakePermissionManager: FakePermissionManager = FakePermissionManager() private val fakePermissionManager: FakePermissionManager = FakePermissionManager()
private val intentManager = mockk<IntentManager>(relaxed = true) private val intentManager = mockk<IntentManager>(relaxed = true)
@ -131,6 +133,59 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
.assertIsNotDisplayed() .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 @Test
fun `settings dialog should call SettingsClick action on confirm click`() { fun `settings dialog should call SettingsClick action on confirm click`() {
fakePermissionManager.checkPermissionResult = false fakePermissionManager.checkPermissionResult = false
@ -202,3 +257,8 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
} }
} }
} }
private val DEFAULT_STATE: ManualCodeEntryState = ManualCodeEntryState(
code = "",
dialog = null,
)

View file

@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.vault.feature.manualcodeentry
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.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
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.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -26,7 +28,7 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
@Test @Test
fun `CloseClick should emit NavigateBack`() = runTest { fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState("")) val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(ManualCodeEntryAction.CloseClick) 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 @Test
fun `CodeSubmit should emit new code and NavigateBack`() = runTest { 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.eventFlow.test {
viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit) viewModel.trySendAction(ManualCodeEntryAction.CodeSubmit)
@ -50,20 +73,29 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
@Test @Test
fun `CodeTextChange should update state with new value`() = runTest { fun `CodeTextChange should update state with new value`() = runTest {
val viewModel = val viewModel = createViewModel(initialState = DEFAULT_STATE.copy(code = "TestCode"))
createViewModel(initialState = ManualCodeEntryState("TestCode"))
val expectedState = ManualCodeEntryState("NewCode") val expectedState = DEFAULT_STATE.copy(code = "NewCode")
viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange("NewCode")) viewModel.trySendAction(ManualCodeEntryAction.CodeTextChange("NewCode"))
assertEquals(expectedState, viewModel.stateFlow.value) assertEquals(expectedState, viewModel.stateFlow.value)
} }
@Test @Test
fun `SettingsClick should emit NavigateToAppSettings and update state`() = runTest { fun `DialogDismiss should clear the dialog state`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState("")) 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.eventFlow.test {
viewModel.trySendAction(ManualCodeEntryAction.SettingsClick) viewModel.trySendAction(ManualCodeEntryAction.SettingsClick)
@ -75,7 +107,7 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
@Test @Test
fun `ScanQrTextCodeClick should emit NavigateToQrCodeScreen`() = runTest { fun `ScanQrTextCodeClick should emit NavigateToQrCodeScreen`() = runTest {
val viewModel = createViewModel(initialState = ManualCodeEntryState("")) val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(ManualCodeEntryAction.ScanQrCodeTextClick) 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( ManualCodeEntryViewModel(
vaultRepository = vaultRepository, vaultRepository = vaultRepository,
savedStateHandle = SavedStateHandle( savedStateHandle = SavedStateHandle(
@ -92,3 +126,8 @@ class ManualCodeEntryViewModelTests : BaseViewModelTest() {
), ),
) )
} }
private val DEFAULT_STATE: ManualCodeEntryState = ManualCodeEntryState(
code = "",
dialog = null,
)