Skip to content

Commit

Permalink
PM-12733: Add error dialog to be displayed if TOTP code is blank (#4345)
Browse files Browse the repository at this point in the history
  • Loading branch information
david-livefront authored Nov 20, 2024
1 parent ec8e934 commit 96bd25e
Show file tree
Hide file tree
Showing 5 changed files with 236 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,55 +19,76 @@ import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.parcelize.Parcelize

/**
* Represents a Bitwarden-styled dialog.
*
* @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(
title: String?,
message: String,
onDismissRequest: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
BitwardenTextButton(
label = stringResource(id = R.string.ok),
onClick = onDismissRequest,
modifier = Modifier.testTag(tag = "AcceptAlertButton"),
)
},
title = title?.let {
{
Text(
text = it,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.testTag(tag = "AlertTitleText"),
)
}
},
text = {
Text(
text = message,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.testTag(tag = "AlertContentText"),
)
},
shape = BitwardenTheme.shapes.dialog,
containerColor = BitwardenTheme.colorScheme.background.primary,
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
titleContentColor = BitwardenTheme.colorScheme.text.primary,
textContentColor = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.semantics {
testTagsAsResourceId = true
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.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun BitwardenBasicDialog(
visibilityState: BasicDialogState,
onDismissRequest: () -> Unit,
): Unit = when (visibilityState) {
BasicDialogState.Hidden -> Unit
is BasicDialogState.Shown -> {
AlertDialog(
BitwardenBasicDialog(
title = visibilityState.title?.invoke(),
message = visibilityState.message(),
onDismissRequest = onDismissRequest,
confirmButton = {
BitwardenTextButton(
label = stringResource(id = R.string.ok),
onClick = onDismissRequest,
modifier = Modifier.testTag("AcceptAlertButton"),
)
},
title = visibilityState.title?.let {
{
Text(
text = it(),
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.testTag("AlertTitleText"),
)
}
},
text = {
Text(
text = visibilityState.message(),
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.testTag("AlertContentText"),
)
},
shape = BitwardenTheme.shapes.dialog,
containerColor = BitwardenTheme.colorScheme.background.primary,
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
titleContentColor = BitwardenTheme.colorScheme.text.primary,
textContentColor = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.semantics {
testTagsAsResourceId = true
testTag = "AlertPopup"
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,6 +109,13 @@ fun ManualCodeEntryScreen(
)
}

ManualCodeEntryDialogs(
state = state.dialog,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(ManualCodeEntryAction.DialogDismiss) }
},
)

BitwardenScaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
Expand All @@ -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)
}
Expand All @@ -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].
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -202,3 +257,8 @@ class ManualCodeEntryScreenTests : BaseComposeTest() {
}
}
}

private val DEFAULT_STATE: ManualCodeEntryState = ManualCodeEntryState(
code = "",
dialog = null,
)
Loading

0 comments on commit 96bd25e

Please sign in to comment.