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

View file

@ -165,6 +165,8 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick -> { is VaultItemAction.Common.ConfirmCloneWithoutFido2CredentialClick -> {
handleConfirmCloneClick() handleConfirmCloneClick()
} }
is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked()
} }
} }
@ -173,42 +175,36 @@ class VaultItemViewModel @Inject constructor(
} }
private fun handleDismissDialogClick() { private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialog = null) } dismissDialog()
} }
private fun handleDeleteClick() { private fun handleDeleteClick() {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.DeleteClick, action = PasswordRepromptAction.DeleteClick,
), ),
) )
}
return@onContent return@onContent
} else { } else {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState
dialog = VaultItemState
.DialogState .DialogState
.DeleteConfirmationPrompt(state.deletionConfirmationText), .DeleteConfirmationPrompt(state.deletionConfirmationText),
) )
} }
} }
} }
}
private fun handleEditClick() { private fun handleEditClick() {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.EditClick, action = PasswordRepromptAction.EditClick,
), ),
) )
}
return@onContent return@onContent
} }
sendEvent( sendEvent(
@ -221,9 +217,7 @@ class VaultItemViewModel @Inject constructor(
} }
private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) { private fun handleMasterPasswordSubmit(action: VaultItemAction.Common.MasterPasswordSubmit) {
mutableStateFlow.update { updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText()))
it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText()))
}
viewModelScope.launch { viewModelScope.launch {
val result = authRepository.validatePassword(action.masterPassword) val result = authRepository.validatePassword(action.masterPassword)
sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action)) sendAction(VaultItemAction.Internal.ValidatePasswordReceive(result, action.action))
@ -240,13 +234,11 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(action.field), action = PasswordRepromptAction.CopyClick(action.field),
), ),
) )
}
return@onContent return@onContent
} }
clipboardManager.setText(text = action.field) clipboardManager.setText(text = action.field)
@ -269,16 +261,14 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewHiddenFieldClicked( action = PasswordRepromptAction.ViewHiddenFieldClicked(
field = action.field, field = action.field,
isVisible = action.isVisible, isVisible = action.isVisible,
), ),
), ),
) )
}
return@onContent return@onContent
} }
mutableStateFlow.update { currentState -> mutableStateFlow.update { currentState ->
@ -311,23 +301,17 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentDownloadClick( action = PasswordRepromptAction.AttachmentDownloadClick(
attachment = action.attachment, attachment = action.attachment,
), ),
), ),
) )
}
return@onContent return@onContent
} }
mutableStateFlow.update { updateDialogState(VaultItemState.DialogState.Loading(R.string.downloading.asText()))
it.copy(
dialog = VaultItemState.DialogState.Loading(R.string.downloading.asText()),
)
}
viewModelScope.launch { viewModelScope.launch {
val result = vaultRepository val result = vaultRepository
@ -349,9 +333,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleAttachmentFileLocationReceive( private fun handleAttachmentFileLocationReceive(
action: VaultItemAction.Common.AttachmentFileLocationReceive, action: VaultItemAction.Common.AttachmentFileLocationReceive,
) { ) {
mutableStateFlow.update { dismissDialog()
it.copy(dialog = null)
}
val file = temporaryAttachmentData ?: return val file = temporaryAttachmentData ?: return
viewModelScope.launch { viewModelScope.launch {
@ -374,25 +356,21 @@ class VaultItemViewModel @Inject constructor(
temporaryAttachmentData?.let { fileManager.delete(it) } temporaryAttachmentData?.let { fileManager.delete(it) }
} }
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(), R.string.unable_to_save_attachment.asText(),
), ),
) )
} }
}
private fun handleAttachmentsClick() { private fun handleAttachmentsClick() {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.AttachmentsClick, action = PasswordRepromptAction.AttachmentsClick,
), ),
) )
}
return@onContent return@onContent
} }
sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId)) sendEvent(VaultItemEvent.NavigateToAttachments(itemId = state.vaultItemId))
@ -403,22 +381,18 @@ class VaultItemViewModel @Inject constructor(
private fun handleCloneClick() { private fun handleCloneClick() {
onContent { content -> onContent { content ->
if (content.common.requiresCloneConfirmation) { if (content.common.requiresCloneConfirmation) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Fido2CredentialCannotBeCopiedConfirmationPrompt(
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(), message = R.string.the_passkey_will_not_be_copied_to_the_cloned_item_do_you_want_to_continue_cloning_this_item.asText(),
), ),
) )
}
return@onContent return@onContent
} else if (content.common.requiresReprompt) { } else if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CloneClick, action = PasswordRepromptAction.CloneClick,
), ),
) )
}
return@onContent return@onContent
} }
sendEvent(VaultItemEvent.NavigateToAddEdit(itemId = state.vaultItemId, isClone = true)) sendEvent(VaultItemEvent.NavigateToAddEdit(itemId = state.vaultItemId, isClone = true))
@ -445,13 +419,11 @@ class VaultItemViewModel @Inject constructor(
private fun handleMoveToOrganizationClick() { private fun handleMoveToOrganizationClick() {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.MoveToOrganizationClick, action = PasswordRepromptAction.MoveToOrganizationClick,
), ),
) )
}
return@onContent return@onContent
} }
sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId)) sendEvent(VaultItemEvent.NavigateToMoveToOrganization(itemId = state.vaultItemId))
@ -464,9 +436,8 @@ class VaultItemViewModel @Inject constructor(
private fun handleConfirmDeleteClick() { private fun handleConfirmDeleteClick() {
onContent { content -> onContent { content ->
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Loading(
dialog = VaultItemState.DialogState.Loading(
if (state.isCipherDeleted) { if (state.isCipherDeleted) {
R.string.deleting.asText() R.string.deleting.asText()
} else { } else {
@ -474,7 +445,6 @@ class VaultItemViewModel @Inject constructor(
}, },
), ),
) )
}
content.common.currentCipher?.let { cipher -> content.common.currentCipher?.let { cipher ->
viewModelScope.launch { viewModelScope.launch {
trySendAction( trySendAction(
@ -497,13 +467,11 @@ class VaultItemViewModel @Inject constructor(
} }
private fun handleConfirmRestoreClick() { private fun handleConfirmRestoreClick() {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Loading(
dialog = VaultItemState.DialogState.Loading(
R.string.restoring.asText(), R.string.restoring.asText(),
), ),
) )
}
onContent { content -> onContent { content ->
content content
.common .common
@ -566,9 +534,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleCheckForBreachClick() { private fun handleCheckForBreachClick() {
onLoginContent { _, login -> onLoginContent { _, login ->
val password = requireNotNull(login.passwordData?.password) val password = requireNotNull(login.passwordData?.password)
mutableStateFlow.update { updateDialogState(VaultItemState.DialogState.Loading(R.string.loading.asText()))
it.copy(dialog = VaultItemState.DialogState.Loading(R.string.loading.asText()))
}
viewModelScope.launch { viewModelScope.launch {
val result = authRepository.getPasswordBreachCount(password = password) val result = authRepository.getPasswordBreachCount(password = password)
sendAction(VaultItemAction.Internal.PasswordBreachReceive(result)) sendAction(VaultItemAction.Internal.PasswordBreachReceive(result))
@ -580,13 +546,11 @@ class VaultItemViewModel @Inject constructor(
onLoginContent { content, login -> onLoginContent { content, login ->
val password = requireNotNull(login.passwordData?.password) val password = requireNotNull(login.passwordData?.password)
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = password), action = PasswordRepromptAction.CopyClick(value = password),
), ),
) )
}
return@onLoginContent return@onLoginContent
} }
clipboardManager.setText(text = password) clipboardManager.setText(text = password)
@ -623,13 +587,11 @@ class VaultItemViewModel @Inject constructor(
private fun handlePasswordHistoryClick() { private fun handlePasswordHistoryClick() {
onContent { content -> onContent { content ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.PasswordHistoryClick, action = PasswordRepromptAction.PasswordHistoryClick,
), ),
) )
}
return@onContent return@onContent
} }
sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId)) sendEvent(VaultItemEvent.NavigateToPasswordHistory(state.vaultItemId))
@ -641,15 +603,13 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onLoginContent { content, login -> onLoginContent { content, login ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewPasswordClick( action = PasswordRepromptAction.ViewPasswordClick(
isVisible = action.isVisible, isVisible = action.isVisible,
), ),
), ),
) )
}
return@onLoginContent return@onLoginContent
} }
mutableStateFlow.update { currentState -> mutableStateFlow.update { currentState ->
@ -696,15 +656,13 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onCardContent { content, card -> onCardContent { content, card ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewCodeClick( action = PasswordRepromptAction.ViewCodeClick(
isVisible = action.isVisible, isVisible = action.isVisible,
), ),
), ),
) )
}
return@onCardContent return@onCardContent
} }
mutableStateFlow.update { currentState -> mutableStateFlow.update { currentState ->
@ -732,13 +690,11 @@ class VaultItemViewModel @Inject constructor(
onCardContent { content, card -> onCardContent { content, card ->
val number = requireNotNull(card.number).number val number = requireNotNull(card.number).number
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = number), action = PasswordRepromptAction.CopyClick(value = number),
), ),
) )
}
return@onCardContent return@onCardContent
} }
clipboardManager.setText(text = number) clipboardManager.setText(text = number)
@ -749,13 +705,11 @@ class VaultItemViewModel @Inject constructor(
onCardContent { content, card -> onCardContent { content, card ->
val securityCode = requireNotNull(card.securityCode).code val securityCode = requireNotNull(card.securityCode).code
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.CopyClick(value = securityCode), action = PasswordRepromptAction.CopyClick(value = securityCode),
), ),
) )
}
return@onCardContent return@onCardContent
} }
clipboardManager.setText(text = securityCode) clipboardManager.setText(text = securityCode)
@ -767,15 +721,13 @@ class VaultItemViewModel @Inject constructor(
) { ) {
onCardContent { content, card -> onCardContent { content, card ->
if (content.common.requiresReprompt) { if (content.common.requiresReprompt) {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.MasterPasswordDialog(
dialog = VaultItemState.DialogState.MasterPasswordDialog(
action = PasswordRepromptAction.ViewNumberClick( action = PasswordRepromptAction.ViewNumberClick(
isVisible = action.isVisible, isVisible = action.isVisible,
), ),
), ),
) )
}
return@onCardContent return@onCardContent
} }
mutableStateFlow.update { currentState -> mutableStateFlow.update { currentState ->
@ -842,9 +794,7 @@ class VaultItemViewModel @Inject constructor(
} }
} }
} }
mutableStateFlow.update { updateDialogState(VaultItemState.DialogState.Generic(message = message))
it.copy(dialog = VaultItemState.DialogState.Generic(message = message))
}
} }
@Suppress("LongMethod") @Suppress("LongMethod")
@ -918,7 +868,7 @@ class VaultItemViewModel @Inject constructor(
.data .data
?.cipher ?.cipher
?.toViewState( ?.toViewState(
previousState = state.viewState as? VaultItemState.ViewState.Content, previousState = state.viewState.asContentOrNull(),
isPremiumUser = account.isPremium, isPremiumUser = account.isPremium,
hasMasterPassword = account.hasMasterPassword, hasMasterPassword = account.hasMasterPassword,
totpCodeItemData = this.data?.totpCodeItemData, totpCodeItemData = this.data?.totpCodeItemData,
@ -930,14 +880,12 @@ class VaultItemViewModel @Inject constructor(
) { ) {
when (val result = action.result) { when (val result = action.result) {
ValidatePasswordResult.Error -> { ValidatePasswordResult.Error -> {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(), message = R.string.generic_error_message.asText(),
), ),
) )
} }
}
is ValidatePasswordResult.Success -> { is ValidatePasswordResult.Success -> {
if (result.isValid) { if (result.isValid) {
@ -953,9 +901,8 @@ class VaultItemViewModel @Inject constructor(
trySendAction(action.repromptAction.vaultItemAction) trySendAction(action.repromptAction.vaultItemAction)
} }
} else { } else {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
message = R.string.invalid_master_password.asText(), message = R.string.invalid_master_password.asText(),
), ),
) )
@ -963,22 +910,19 @@ class VaultItemViewModel @Inject constructor(
} }
} }
} }
}
private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) { private fun handleDeleteCipherReceive(action: VaultItemAction.Internal.DeleteCipherReceive) {
when (action.result) { when (action.result) {
DeleteCipherResult.Error -> { DeleteCipherResult.Error -> {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(), message = R.string.generic_error_message.asText(),
), ),
) )
} }
}
DeleteCipherResult.Success -> { DeleteCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) } dismissDialog()
sendEvent( sendEvent(
VaultItemEvent.ShowToast( VaultItemEvent.ShowToast(
message = if (state.isCipherDeleted) { message = if (state.isCipherDeleted) {
@ -996,17 +940,15 @@ class VaultItemViewModel @Inject constructor(
private fun handleRestoreCipherReceive(action: VaultItemAction.Internal.RestoreCipherReceive) { private fun handleRestoreCipherReceive(action: VaultItemAction.Internal.RestoreCipherReceive) {
when (action.result) { when (action.result) {
RestoreCipherResult.Error -> { RestoreCipherResult.Error -> {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
message = R.string.generic_error_message.asText(), message = R.string.generic_error_message.asText(),
), ),
) )
} }
}
RestoreCipherResult.Success -> { RestoreCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) } dismissDialog()
sendEvent(VaultItemEvent.ShowToast(message = R.string.item_restored.asText())) sendEvent(VaultItemEvent.ShowToast(message = R.string.item_restored.asText()))
sendEvent(VaultItemEvent.NavigateBack) sendEvent(VaultItemEvent.NavigateBack)
} }
@ -1018,14 +960,12 @@ class VaultItemViewModel @Inject constructor(
) { ) {
when (val result = action.result) { when (val result = action.result) {
DownloadAttachmentResult.Failure -> { DownloadAttachmentResult.Failure -> {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
message = R.string.unable_to_download_file.asText(), message = R.string.unable_to_download_file.asText(),
), ),
) )
} }
}
is DownloadAttachmentResult.Success -> { is DownloadAttachmentResult.Success -> {
temporaryAttachmentData = result.file temporaryAttachmentData = result.file
@ -1048,22 +988,44 @@ class VaultItemViewModel @Inject constructor(
if (action.isSaved) { if (action.isSaved) {
sendEvent(VaultItemEvent.ShowToast(R.string.save_attachment_success.asText())) sendEvent(VaultItemEvent.ShowToast(R.string.save_attachment_success.asText()))
} else { } else {
mutableStateFlow.update { updateDialogState(
it.copy( VaultItemState.DialogState.Generic(
dialog = VaultItemState.DialogState.Generic(
R.string.unable_to_save_attachment.asText(), 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 //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( private inline fun onContent(
crossinline block: (VaultItemState.ViewState.Content) -> Unit, crossinline block: (VaultItemState.ViewState.Content) -> Unit,
) { ) {
(state.viewState as? VaultItemState.ViewState.Content)?.let(block) state.viewState.asContentOrNull()?.let(block)
} }
private inline fun onLoginContent( private inline fun onLoginContent(
@ -1072,7 +1034,7 @@ class VaultItemViewModel @Inject constructor(
VaultItemState.ViewState.Content.ItemType.Login, VaultItemState.ViewState.Content.ItemType.Login,
) -> Unit, ) -> Unit,
) { ) {
(state.viewState as? VaultItemState.ViewState.Content) state.viewState.asContentOrNull()
?.let { content -> ?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Login) (content.type as? VaultItemState.ViewState.Content.ItemType.Login)
?.let { loginContent -> ?.let { loginContent ->
@ -1087,7 +1049,7 @@ class VaultItemViewModel @Inject constructor(
VaultItemState.ViewState.Content.ItemType.Card, VaultItemState.ViewState.Content.ItemType.Card,
) -> Unit, ) -> Unit,
) { ) {
(state.viewState as? VaultItemState.ViewState.Content) state.viewState.asContentOrNull()
?.let { content -> ?.let { content ->
(content.type as? VaultItemState.ViewState.Content.ItemType.Card) (content.type as? VaultItemState.ViewState.Content.ItemType.Card)
?.let { loginContent -> ?.let { loginContent ->
@ -1111,7 +1073,7 @@ data class VaultItemState(
* Whether or not the cipher has been deleted. * Whether or not the cipher has been deleted.
*/ */
val isCipherDeleted: Boolean val isCipherDeleted: Boolean
get() = (viewState as? ViewState.Content) get() = viewState.asContentOrNull()
?.common ?.common
?.currentCipher ?.currentCipher
?.deletedDate != null ?.deletedDate != null
@ -1126,7 +1088,7 @@ data class VaultItemState(
* Whether or not the cipher is in a collection. * Whether or not the cipher is in a collection.
*/ */
val isCipherInCollection: Boolean val isCipherInCollection: Boolean
get() = (viewState as? ViewState.Content) get() = viewState.asContentOrNull()
?.common ?.common
?.currentCipher ?.currentCipher
?.collectionIds ?.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( data class Fido2CredentialCannotBeCopiedConfirmationPrompt(
val message: Text, val message: Text,
) : DialogState() ) : 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() 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. * The user has confirmed to restore the cipher.
*/ */
@ -1934,4 +1914,13 @@ sealed class PasswordRepromptAction : Parcelable {
isVisible = this.isVisible, 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 @Test
fun `Restore click should send show restore confirmation dialog`() { fun `Clicking Restore should send RestoreVaultItemClick ViewModel action`() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -867,6 +867,35 @@ class VaultItemScreenTest : BaseComposeTest() {
.onNodeWithText("Restore") .onNodeWithText("Restore")
.performClick() .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 composeTestRule
.onAllNodesWithText("Do you really want to restore this item?") .onAllNodesWithText("Do you really want to restore this item?")
.filterToOne(hasAnyAncestor(isDialog())) .filterToOne(hasAnyAncestor(isDialog()))
@ -889,7 +918,7 @@ class VaultItemScreenTest : BaseComposeTest() {
} }
@Test @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 { mutableStateFlow.update {
it.copy( it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -906,9 +935,9 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
composeTestRule mutableStateFlow.update {
.onNodeWithText("Restore") it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
.performClick() }
composeTestRule composeTestRule
.onAllNodesWithText("Do you really want to restore this item?") .onAllNodesWithText("Do you really want to restore this item?")
@ -929,13 +958,16 @@ class VaultItemScreenTest : BaseComposeTest() {
.onAllNodesWithText("Cancel") .onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog())) .filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed() .assertIsDisplayed()
.performClick()
mutableStateFlow.update {
it.copy(dialog = null)
}
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
} }
@Test @Test
fun `Restore dialog ok click should close the dialog and send ConfirmRestoreClick`() { fun `Restore dialog ok click should send ConfirmRestoreClick`() {
mutableStateFlow.update { mutableStateFlow.update {
it.copy( it.copy(
viewState = DEFAULT_IDENTITY_VIEW_STATE viewState = DEFAULT_IDENTITY_VIEW_STATE
@ -952,24 +984,9 @@ class VaultItemScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
composeTestRule mutableStateFlow.update {
.onNodeWithText("Restore") it.copy(dialog = VaultItemState.DialogState.RestoreItemDialog)
.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()
composeTestRule composeTestRule
.onAllNodesWithText("Ok") .onAllNodesWithText("Ok")
@ -977,13 +994,44 @@ class VaultItemScreenTest : BaseComposeTest() {
.assertIsDisplayed() .assertIsDisplayed()
.performClick() .performClick()
composeTestRule.assertNoDialogExists()
verify { verify {
viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) 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 @Test
fun `Attachments option menu click should send AttachmentsClick action`() { fun `Attachments option menu click should send AttachmentsClick action`() {
// Confirm dropdown version of item is absent // Confirm dropdown version of item is absent

View file

@ -396,6 +396,73 @@ class VaultItemViewModelTest : BaseViewModelTest() {
coVerify { vaultRepo.hardDeleteCipher(cipherId = VAULT_ITEM_ID) } 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 @Test
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() = fun `ConfirmRestoreClick with RestoreCipherResult Success should should ShowToast and NavigateBack`() =