PM-8202 move dialog status to VM for restore item, add check for MP p… (#3436)

This commit is contained in:
Dave Severns 2024-07-12 09:56:40 -04:00 committed by GitHub
parent dbf1d423e8
commit 27747b6cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 369 additions and 269 deletions

View file

@ -15,9 +15,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@ -69,11 +67,6 @@ fun VaultItemScreen(
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
val resources = context.resources
val confirmRestoreAction = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
}
var pendingRestoreCipher by rememberSaveable { mutableStateOf(false) }
val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult ->
intentManager.getFileDataFromActivityResult(activityResult)
@ -150,26 +143,12 @@ fun VaultItemScreen(
)
}
},
)
if (pendingRestoreCipher) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.restore),
message = stringResource(id = R.string.do_you_really_want_to_restore_cipher),
confirmButtonText = stringResource(id = R.string.ok),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
pendingRestoreCipher = false
confirmRestoreAction()
},
onDismissClick = {
pendingRestoreCipher = false
},
onDismissRequest = {
pendingRestoreCipher = false
},
)
onConfirmRestoreAction = remember(viewModel) {
{
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
}
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
@ -189,7 +168,13 @@ fun VaultItemScreen(
if (state.isCipherDeleted) {
BitwardenTextButton(
label = stringResource(id = R.string.restore),
onClick = { pendingRestoreCipher = true },
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.RestoreVaultItemClick,
)
}
},
modifier = Modifier.testTag("RestoreButton"),
)
}
@ -298,6 +283,7 @@ private fun VaultItemDialogs(
onConfirmDeleteClick: () -> Unit,
onSubmitMasterPassword: (masterPassword: String, action: PasswordRepromptAction) -> Unit,
onConfirmCloneWithoutFido2Credential: () -> Unit,
onConfirmRestoreAction: () -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
@ -343,6 +329,16 @@ private fun VaultItemDialogs(
)
}
VaultItemState.DialogState.RestoreItemDialog -> BitwardenTwoButtonDialog(
title = stringResource(id = R.string.restore),
message = stringResource(id = R.string.do_you_really_want_to_restore_cipher),
confirmButtonText = stringResource(id = R.string.ok),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = onConfirmRestoreAction,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
null -> Unit
}
}

View file

@ -165,6 +165,8 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick -> {
handleConfirmCloneClick()
}
is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked()
}
}
@ -173,42 +175,36 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialog = null) }
dismissDialog()
}
private fun handleDeleteClick() {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.DeleteClick,
),
)
}
return@onContent
} else {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState
updateDialogState(
VaultItemState
.DialogState
.DeleteConfirmationPrompt(state.deletionConfirmationText),
)
}
}
}
}
private fun handleEditClick() {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.EditClick,
),
)
}
return@onContent
}
sendEvent(
@ -221,9 +217,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText()))
}
updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText()))
viewModelScope.launch {
val result = authRepository.validatePassword(action.masterPassword)
sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action))
@ -240,13 +234,11 @@ class VaultItemViewModel @Inject constructor(
) {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(action.field),
),
)
}
return@onContent
}
clipboardManager.setText(text = action.field)
@ -269,16 +261,14 @@ class VaultItemViewModel @Inject constructor(
) {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewHiddenFieldClicked(
field = action.field,
isVisible = action.isVisible,
),
),
)
}
return@onContent
}
mutableStateFlow.update { currentState ->
@ -311,23 +301,17 @@ class VaultItemViewModel @Inject constructor(
) {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentDownloadClick(
attachment = action.attachment,
),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText()),
)
}
updateDialogState(VaultItemState.DialogState.Loading(R.string.downloading.asText()))
viewModelScope.launch {
val result = vaultRepository
@ -349,9 +333,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleAttachmentFileLocationReceive(
action: VaultItemAction.Common.AttachmentFileLocationReceive,
) {
mutableStateFlow.update {
it.copy(dialog = null)
}
dismissDialog()
val file = temporaryAttachmentData ?: return
viewModelScope.launch {
@ -374,25 +356,21 @@ class VaultItemViewModel @Inject constructor(
temporaryAttachmentData?.let { fileManager.delete(it) }
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
)
}
}
private fun handleAttachmentsClick() {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentsClick,
),
)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId))
@ -403,22 +381,18 @@ class VaultItemViewModel @Inject constructor(
private fun handleCloneClick() {
onContent { content ->
if (content.common.requiresCloneConfirmation) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
updateDialogState(
VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
),
)
}
return@onContent
} else if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CloneClick,
),
)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToAddEdit(itemId = state.vaultItemId, isClone = true))
@ -445,13 +419,11 @@ class VaultItemViewModel @Inject constructor(
private fun handleMoveToOrganizationClick() {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.MoveToOrganizationClick,
),
)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId))
@ -464,9 +436,8 @@ class VaultItemViewModel @Inject constructor(
private fun handleConfirmDeleteClick() {
onContent { content ->
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
updateDialogState(
VaultItemState.DialogState.Loading(
if (state.isCipherDeleted) {
R.string.deleting.asText()
} else {
@ -474,7 +445,6 @@ class VaultItemViewModel @Inject constructor(
},
),
)
}
content.common.currentCipher?.let { cipher ->
viewModelScope.launch {
trySendAction(
@ -497,13 +467,11 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleConfirmRestoreClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
updateDialogState(
VaultItemState.DialogState.Loading(
R.string.restoring.asText(),
),
)
}
onContent { content ->
content
.common
@ -566,9 +534,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleCheckForBreachClick() {
onLoginContent { _, login ->
val password = requireNotNull(login.passwordData?.password)
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText()))
}
updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText()))
viewModelScope.launch {
val result = authRepository.getPasswordBreachCount(password = password)
sendAction(VaultItemAction.Internal.PasswordBreachReceive(result))
@ -580,13 +546,11 @@ class VaultItemViewModel @Inject constructor(
onLoginContent { content, login ->
val password = requireNotNull(login.passwordData?.password)
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = password),
),
)
}
return@onLoginContent
}
clipboardManager.setText(text = password)
@ -623,13 +587,11 @@ class VaultItemViewModel @Inject constructor(
private fun handlePasswordHistoryClick() {
onContent { content ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.PasswordHistoryClick,
),
)
}
return@onContent
}
sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId))
@ -641,15 +603,13 @@ class VaultItemViewModel @Inject constructor(
) {
onLoginContent { content, login ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewPasswordClick(
isVisible = action.isVisible,
),
),
)
}
return@onLoginContent
}
mutableStateFlow.update { currentState ->
@ -696,15 +656,13 @@ class VaultItemViewModel @Inject constructor(
) {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewCodeClick(
isVisible = action.isVisible,
),
),
)
}
return@onCardContent
}
mutableStateFlow.update { currentState ->
@ -732,13 +690,11 @@ class VaultItemViewModel @Inject constructor(
onCardContent { content, card ->
val number = requireNotNull(card.number).number
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = number),
),
)
}
return@onCardContent
}
clipboardManager.setText(text = number)
@ -749,13 +705,11 @@ class VaultItemViewModel @Inject constructor(
onCardContent { content, card ->
val securityCode = requireNotNull(card.securityCode).code
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = securityCode),
),
)
}
return@onCardContent
}
clipboardManager.setText(text = securityCode)
@ -767,15 +721,13 @@ class VaultItemViewModel @Inject constructor(
) {
onCardContent { content, card ->
if (content.common.requiresReprompt) {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewNumberClick(
isVisible = action.isVisible,
),
),
)
}
return@onCardContent
}
mutableStateFlow.update { currentState ->
@ -842,9 +794,7 @@ class VaultItemViewModel @Inject constructor(
}
}
}
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.Generic(message = message))
}
updateDialogState(VaultItemState.DialogState.Generic(message = message))
}
@Suppress("LongMethod")
@ -918,7 +868,7 @@ class VaultItemViewModel @Inject constructor(
.data
?.cipher
?.toViewState(
previousState = state.viewState as? VaultItemState.ViewState.Content,
previousState = state.viewState.asContentOrNull(),
isPremiumUser = account.isPremium,
hasMasterPassword = account.hasMasterPassword,
totpCodeItemData = this.data?.totpCodeItemData,
@ -930,14 +880,12 @@ class VaultItemViewModel @Inject constructor(
) {
when (val result = action.result) {
ValidatePasswordResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
)
}
}
is ValidatePasswordResult.Success -> {
if (result.isValid) {
@ -953,9 +901,8 @@ class VaultItemViewModel @Inject constructor(
trySendAction(action.repromptAction.vaultItemAction)
}
} else {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.invalid_master_password.asText(),
),
)
@ -963,22 +910,19 @@ class VaultItemViewModel @Inject constructor(
}
}
}
}
private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) {
when (action.result) {
DeleteCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
)
}
}
DeleteCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
dismissDialog()
sendEvent(
VaultItemEvent.ShowToast(
message = if (state.isCipherDeleted) {
@ -996,17 +940,15 @@ class VaultItemViewModel @Inject constructor(
private fun handleRestoreCipherReceive(action: VaultItemAction.Internal.RestoreCipherReceive) {
when (action.result) {
RestoreCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
)
}
}
RestoreCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
dismissDialog()
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_restored.asText()))
sendEvent(VaultItemEvent.NavigateBack)
}
@ -1018,14 +960,12 @@ class VaultItemViewModel @Inject constructor(
) {
when (val result = action.result) {
DownloadAttachmentResult.Failure -> {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
message = R.string.unable_to_download_file.asText(),
),
)
}
}
is DownloadAttachmentResult.Success -> {
temporaryAttachmentData = result.file
@ -1048,22 +988,44 @@ class VaultItemViewModel @Inject constructor(
if (action.isSaved) {
sendEvent(VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()))
} else {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Generic(
updateDialogState(
VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(),
),
)
}
}
private fun handleRestoreItemClicked() {
onContent { content ->
if (content.common.requiresReprompt) {
updateDialogState(
VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.RestoreItemClick,
),
)
} else {
updateDialogState(VaultItemState.DialogState.RestoreItemDialog)
}
}
}
//endregion Internal Type Handlers
private fun updateDialogState(dialog: VaultItemState.DialogState?) {
mutableStateFlow.update {
it.copy(dialog = dialog)
}
}
private fun dismissDialog() {
updateDialogState(null)
}
private inline fun onContent(
crossinline block: (VaultItemState.ViewState.Content) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content)?.let(block)
state.viewState.asContentOrNull()?.let(block)
}
private inline fun onLoginContent(
@ -1072,7 +1034,7 @@ class VaultItemViewModel @Inject constructor(
VaultItemState.ViewState.Content.ItemType.Login,
) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content)
state.viewState.asContentOrNull()
?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Login)
?.let { loginContent ->
@ -1087,7 +1049,7 @@ class VaultItemViewModel @Inject constructor(
VaultItemState.ViewState.Content.ItemType.Card,
) -> Unit,
) {
(state.viewState as? VaultItemState.ViewState.Content)
state.viewState.asContentOrNull()
?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Card)
?.let { loginContent ->
@ -1111,7 +1073,7 @@ data class VaultItemState(
* Whether or not the cipher has been deleted.
*/
val isCipherDeleted: Boolean
get() = (viewState as? ViewState.Content)
get() = viewState.asContentOrNull()
?.common
?.currentCipher
?.deletedDate != null
@ -1126,7 +1088,7 @@ data class VaultItemState(
* Whether or not the cipher is in a collection.
*/
val isCipherInCollection: Boolean
get() = (viewState as? ViewState.Content)
get() = viewState.asContentOrNull()
?.common
?.currentCipher
?.collectionIds
@ -1399,6 +1361,12 @@ data class VaultItemState(
}
}
}
/**
* Convenience function to keep the syntax a little cleaner when safe casting specifically
* for [Content]
*/
fun asContentOrNull(): Content? = this as? Content
}
/**
@ -1445,6 +1413,12 @@ data class VaultItemState(
data class Fido2CredentialCannotBeCopiedConfirmationPrompt(
val message: Text,
) : DialogState()
/**
* Displays the dialog to prompt the user to confirm restoring a deleted item.
*/
@Parcelize
data object RestoreItemDialog : DialogState()
}
}
@ -1541,6 +1515,12 @@ sealed class VaultItemAction {
*/
data object ConfirmDeleteClick : Common()
/**
* The user has clicked to restore a deleted item.
*/
data object RestoreVaultItemClick : Common()
/**
* The user has confirmed to restore the cipher.
*/
@ -1934,4 +1914,13 @@ sealed class PasswordRepromptAction : Parcelable {
isVisible = this.isVisible,
)
}
/**
* Indicates that we should show the confirm restore
*/
@Parcelize
data object RestoreItemClick : PasswordRepromptAction() {
override val vaultItemAction: VaultItemAction
get() = VaultItemAction.Common.RestoreVaultItemClick
}
}

View file

@ -846,7 +846,7 @@ class VaultItemScreenTest : BaseComposeTest() {
}
@Test
fun `Restore click should send show restore confirmation dialog`() {
fun `Clicking Restore should send RestoreVaultItemClick ViewModel action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -867,6 +867,35 @@ class VaultItemScreenTest : BaseComposeTest() {
.onNodeWithText("Restore")
.performClick()
verify {
viewModel.trySendAction(
VaultItemAction.Common.RestoreVaultItemClick
)
}
}
@Test
fun `Restore dialog should display correctly when dialog state changes`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
.copy(
common = DEFAULT_COMMON
.copy(
currentCipher = createMockCipherView(1).copy(
deletedDate = Instant.MIN,
),
),
),
)
}
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
}
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog()))
@ -889,7 +918,7 @@ class VaultItemScreenTest : BaseComposeTest() {
}
@Test
fun `Restore dialog cancel click should hide restore confirmation menu`() {
fun `Restore dialog should hide restore confirmation menu if dialog state changes`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -906,9 +935,9 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText("Restore")
.performClick()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
}
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
@ -929,13 +958,16 @@ class VaultItemScreenTest : BaseComposeTest() {
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
mutableStateFlow.update {
it.copy(dialog = null)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `Restore dialog ok click should close the dialog and send ConfirmRestoreClick`() {
fun `Restore dialog ok click should send ConfirmRestoreClick`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -952,24 +984,9 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText("Restore")
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Restore")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
}
composeTestRule
.onAllNodesWithText("Ok")
@ -977,13 +994,44 @@ class VaultItemScreenTest : BaseComposeTest() {
.assertIsDisplayed()
.performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick)
}
}
@Test
fun `Restore dialog cancel click should send DismissDialogClick`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE
.copy(
common = DEFAULT_COMMON
.copy(
currentCipher = createMockCipherView(1).copy(
deletedDate = Instant.MIN,
),
),
),
)
}
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
}
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
.performClick()
verify {
viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick)
}
}
@Test
fun `Attachments option menu click should send AttachmentsClick action`() {
// Confirm dropdown version of item is absent

View file

@ -396,6 +396,73 @@ class VaultItemViewModelTest : BaseViewModelTest() {
coVerify { vaultRepo.hardDeleteCipher(cipherId = VAULT_ITEM_ID) }
}
@Test
fun `on RestoreItemClick should prompt for master password when required`() = runTest {
val mockCipherView = mockk<CipherView> {
every {
toViewState(
previousState = any(),
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = createTotpCodeData(),
)
} returns DEFAULT_VIEW_STATE
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = createVerificationCodeItem())
val loginState = DEFAULT_STATE.copy(viewState = DEFAULT_VIEW_STATE)
val viewModel = createViewModel(state = loginState)
assertEquals(loginState, viewModel.stateFlow.value)
viewModel.trySendAction(VaultItemAction.Common.RestoreVaultItemClick)
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.RestoreItemClick,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on RestoreItemClick when no need to prompt for master password updates pendingCipher state correctly`() =
runTest {
val viewState =
DEFAULT_VIEW_STATE.copy(common = DEFAULT_COMMON.copy(requiresReprompt = false))
val mockCipherView = mockk<CipherView> {
every {
toViewState(
previousState = any(),
isPremiumUser = true,
hasMasterPassword = true,
totpCodeItemData = createTotpCodeData(),
)
} returns viewState
}
mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView)
mutableAuthCodeItemFlow.value =
DataState.Loaded(data = createVerificationCodeItem())
val loginState = DEFAULT_STATE.copy(viewState = viewState)
val viewModel = createViewModel(state = loginState)
assertEquals(loginState, viewModel.stateFlow.value)
// show dialog
viewModel.trySendAction(VaultItemAction.Common.RestoreVaultItemClick)
assertEquals(
loginState.copy(dialog = VaultItemState.DialogState.RestoreItemDialog),
viewModel.stateFlow.value
)
// dismiss dialog
viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick)
assertEquals(
// setting this to be explicit.
loginState.copy(dialog = null),
viewModel.stateFlow.value
)
}
@Test
@Suppress("MaxLineLength")
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =