From 27747b6cb98ab5fc5689a9d08ebc451eb22578bc Mon Sep 17 00:00:00 2001 From: Dave Severns <149429124+dseverns-livefront@users.noreply.github.com> Date: Fri, 12 Jul 2024 09:56:40 -0400 Subject: [PATCH] =?UTF-8?q?PM-8202=20move=20dialog=20status=20to=20VM=20fo?= =?UTF-8?q?r=20restore=20item,=20add=20check=20for=20MP=20p=E2=80=A6=20(#3?= =?UTF-8?q?436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/vault/feature/item/VaultItemScreen.kt | 50 +-- .../vault/feature/item/VaultItemViewModel.kt | 419 +++++++++--------- .../vault/feature/item/VaultItemScreenTest.kt | 102 +++-- .../feature/item/VaultItemViewModelTest.kt | 67 +++ 4 files changed, 369 insertions(+), 269 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 3ab130d633..3de7096fae 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -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,27 +143,13 @@ fun VaultItemScreen( ) } }, + onConfirmRestoreAction = remember(viewModel) { + { + viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) + } + }, ) - 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 - }, - ) - } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -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 } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 243dbe08dc..df774ff667 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -165,6 +165,8 @@ class VaultItemViewModel @Inject constructor( is VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick -> { handleConfirmCloneClick() } + + is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked() } } @@ -173,28 +175,24 @@ 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( - action = PasswordRepromptAction.DeleteClick, - ), - ) - } + updateDialogState( + VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.DeleteClick, + ), + ) return@onContent } else { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState - .DialogState - .DeleteConfirmationPrompt(state.deletionConfirmationText), - ) - } + updateDialogState( + VaultItemState + .DialogState + .DeleteConfirmationPrompt(state.deletionConfirmationText), + ) } } } @@ -202,13 +200,11 @@ class VaultItemViewModel @Inject constructor( private fun handleEditClick() { onContent { content -> if (content.common.requiresReprompt) { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.MasterPasswordDialog( - action = PasswordRepromptAction.EditClick, - ), - ) - } + 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( - action = PasswordRepromptAction.CopyClick(action.field), - ), - ) - } + 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( - action = PasswordRepromptAction.ViewHiddenFieldClicked( - field = action.field, - isVisible = action.isVisible, - ), + 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( - action = PasswordRepromptAction.AttachmentDownloadClick( - attachment = action.attachment, - ), + 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( - R.string.unable_to_save_attachment.asText(), - ), - ) - } + 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( - action = PasswordRepromptAction.AttachmentsClick, - ), - ) - } + 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( - message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(), - ), - ) - } + 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( - action = PasswordRepromptAction.CloneClick, - ), - ) - } + 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( - action = PasswordRepromptAction.MoveToOrganizationClick, - ), - ) - } + updateDialogState( + VaultItemState.DialogState.MasterPasswordDialog( + action = PasswordRepromptAction.MoveToOrganizationClick, + ), + ) return@onContent } sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) @@ -464,17 +436,15 @@ class VaultItemViewModel @Inject constructor( private fun handleConfirmDeleteClick() { onContent { content -> - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Loading( - if (state.isCipherDeleted) { - R.string.deleting.asText() - } else { - R.string.soft_deleting.asText() - }, - ), - ) - } + updateDialogState( + VaultItemState.DialogState.Loading( + if (state.isCipherDeleted) { + R.string.deleting.asText() + } else { + R.string.soft_deleting.asText() + }, + ), + ) 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( - R.string.restoring.asText(), - ), - ) - } + 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( - action = PasswordRepromptAction.CopyClick(value = password), - ), - ) - } + 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( - action = PasswordRepromptAction.PasswordHistoryClick, - ), - ) - } + 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( - action = PasswordRepromptAction.ViewPasswordClick( - isVisible = action.isVisible, - ), + 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( - action = PasswordRepromptAction.ViewCodeClick( - isVisible = action.isVisible, - ), + 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( - action = PasswordRepromptAction.CopyClick(value = number), - ), - ) - } + 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( - action = PasswordRepromptAction.CopyClick(value = securityCode), - ), - ) - } + 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( - action = PasswordRepromptAction.ViewNumberClick( - isVisible = action.isVisible, - ), + 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,13 +880,11 @@ class VaultItemViewModel @Inject constructor( ) { when (val result = action.result) { ValidatePasswordResult.Error -> { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.generic_error_message.asText(), - ), - ) - } + updateDialogState( + VaultItemState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ) } is ValidatePasswordResult.Success -> { @@ -953,13 +901,11 @@ class VaultItemViewModel @Inject constructor( trySendAction(action.repromptAction.vaultItemAction) } } else { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.invalid_master_password.asText(), - ), - ) - } + updateDialogState( + VaultItemState.DialogState.Generic( + message = R.string.invalid_master_password.asText(), + ), + ) } } } @@ -968,17 +914,15 @@ class VaultItemViewModel @Inject constructor( private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) { when (action.result) { DeleteCipherResult.Error -> { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.generic_error_message.asText(), - ), - ) - } + 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( - message = R.string.generic_error_message.asText(), - ), - ) - } + 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,13 +960,11 @@ class VaultItemViewModel @Inject constructor( ) { when (val result = action.result) { DownloadAttachmentResult.Failure -> { - mutableStateFlow.update { - it.copy( - dialog = VaultItemState.DialogState.Generic( - message = R.string.unable_to_download_file.asText(), - ), - ) - } + updateDialogState( + VaultItemState.DialogState.Generic( + message = R.string.unable_to_download_file.asText(), + ), + ) } is DownloadAttachmentResult.Success -> { @@ -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( - R.string.unable_to_save_attachment.asText(), + 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 + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 479655a83a..6fe41f2d00 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index c3db45c61e..de744e901f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -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 { + 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 { + 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`() =