PM-31043: Add unarchive button to overflow menus (#6387)

This commit is contained in:
David Perez 2026-01-21 10:50:30 -06:00 committed by GitHub
parent afc1ff4d7a
commit c52910e74a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 397 additions and 1 deletions

View File

@ -75,6 +75,7 @@ fun SearchContent(
is ListingItemOverflowAction.VaultAction.LaunchClick,
is ListingItemOverflowAction.VaultAction.ViewClick,
is ListingItemOverflowAction.VaultAction.ArchiveClick,
is ListingItemOverflowAction.VaultAction.UnarchiveClick,
null,
-> Unit
}

View File

@ -47,6 +47,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
@ -379,6 +380,10 @@ class SearchViewModel @Inject constructor(
is ListingItemOverflowAction.VaultAction.ArchiveClick -> {
handleArchiveClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.UnarchiveClick -> {
handleUnarchiveClick(overflowAction)
}
}
}
@ -456,6 +461,28 @@ class SearchViewModel @Inject constructor(
}
}
private fun handleUnarchiveClick(action: ListingItemOverflowAction.VaultAction.UnarchiveClick) {
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
viewModelScope.launch {
decryptCipherViewOrNull(cipherId = action.cipherId)?.let {
sendAction(
SearchAction.Internal.UnarchiveCipherReceive(
result = vaultRepo.unarchiveCipher(
cipherId = action.cipherId,
cipherView = it,
),
),
)
}
}
}
private fun handleRemovePasswordClick(
action: ListingItemOverflowAction.SendAction.RemovePasswordClick,
) {
@ -616,6 +643,7 @@ class SearchViewModel @Inject constructor(
}
is SearchAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
is SearchAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action)
}
}
@ -660,6 +688,27 @@ class SearchViewModel @Inject constructor(
}
}
private fun handleUnarchiveCipherReceive(action: SearchAction.Internal.UnarchiveCipherReceive) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = result.error,
),
)
}
}
UnarchiveCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SearchEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()))
}
}
}
private fun handleIconLoadingSettingReceive(
action: SearchAction.Internal.IconLoadingSettingReceive,
) {
@ -1426,6 +1475,13 @@ sealed class SearchAction {
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates a result for removing the password protection from a send has been received.
*/

View File

@ -78,6 +78,7 @@ fun VaultItemListingContent(
is ListingItemOverflowAction.VaultAction.ViewClick,
is ListingItemOverflowAction.VaultAction.CopyTotpClick,
is ListingItemOverflowAction.VaultAction.ArchiveClick,
is ListingItemOverflowAction.VaultAction.UnarchiveClick,
null,
-> Unit
}

View File

@ -81,6 +81,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult
import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult
@ -1391,6 +1392,30 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handleUnarchiveClick(
action: ListingItemOverflowAction.VaultAction.UnarchiveClick,
) {
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
viewModelScope.launch {
getCipherViewOrNull(cipherId = action.cipherId)?.let {
sendAction(
VaultItemListingsAction.Internal.UnarchiveCipherReceive(
result = vaultRepository.unarchiveCipher(
cipherId = action.cipherId,
cipherView = it,
),
),
)
}
}
}
private fun handleDismissDialogClick() {
clearDialogState()
}
@ -1566,6 +1591,10 @@ class VaultItemListingViewModel @Inject constructor(
is ListingItemOverflowAction.VaultAction.ArchiveClick -> {
handleArchiveClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.UnarchiveClick -> {
handleUnarchiveClick(overflowAction)
}
}
}
@ -1660,6 +1689,10 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.Internal.ArchiveCipherReceive -> {
handleArchiveCipherReceive(action)
}
is VaultItemListingsAction.Internal.UnarchiveCipherReceive -> {
handleUnarchiveCipherReceive(action)
}
}
}
@ -1704,6 +1737,31 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handleUnarchiveCipherReceive(
action: VaultItemListingsAction.Internal.UnarchiveCipherReceive,
) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = result.error,
),
)
}
}
UnarchiveCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(
VaultItemListingEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()),
)
}
}
}
private fun handleDecryptCipherErrorReceive(
action: VaultItemListingsAction.Internal.DecryptCipherErrorReceive,
) {
@ -3698,6 +3756,13 @@ sealed class VaultItemListingsAction {
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates a result for generating a verification code has been received.
*/

View File

@ -192,5 +192,14 @@ sealed class ListingItemOverflowAction : Parcelable {
override val title: Text get() = BitwardenString.archive_verb.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
/**
* Click on the unarchive overflow option.
*/
@Parcelize
data class UnarchiveClick(val cipherId: String) : VaultAction() {
override val title: Text get() = BitwardenString.unarchive.asText()
override val requiresPasswordReprompt: Boolean get() = false
}
}
}

View File

@ -15,7 +15,7 @@ import kotlinx.collections.immutable.toImmutableList
/**
* Creates the list of overflow actions to be displayed for a [CipherView].
*/
@Suppress("LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
fun CipherListView.toOverflowActions(
hasMasterPassword: Boolean,
isPremiumUser: Boolean,
@ -91,6 +91,10 @@ fun CipherListView.toOverflowActions(
.takeIf {
this.archivedDate == null && deletedDate == null && isArchiveEnabled
},
ListingItemOverflowAction.VaultAction.UnarchiveClick(cipherId = cipherId)
.takeIf {
this.archivedDate != null && deletedDate == null && isArchiveEnabled
},
)
}
.orEmpty()

View File

@ -50,6 +50,7 @@ import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
@ -645,6 +646,10 @@ class VaultViewModel @Inject constructor(
is ListingItemOverflowAction.VaultAction.ArchiveClick -> {
handleArchiveClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.UnarchiveClick -> {
handleUnarchiveClick(overflowAction)
}
}
}
@ -804,6 +809,28 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleUnarchiveClick(action: ListingItemOverflowAction.VaultAction.UnarchiveClick) {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
viewModelScope.launch {
getCipherForCopyOrNull(cipherId = action.cipherId)?.let {
sendAction(
VaultAction.Internal.UnarchiveCipherReceive(
result = vaultRepository.unarchiveCipher(
cipherId = action.cipherId,
cipherView = it,
),
),
)
}
}
}
private fun showCipherDecryptionErrorItemClick(itemId: String) {
mutableStateFlow.update {
it.copy(
@ -872,6 +899,7 @@ class VaultViewModel @Inject constructor(
}
is VaultAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
is VaultAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action)
}
}
@ -951,6 +979,27 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleUnarchiveCipherReceive(action: VaultAction.Internal.UnarchiveCipherReceive) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
}
}
UnarchiveCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(VaultEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()))
}
}
}
private fun handleDecryptionErrorReceive(action: VaultAction.Internal.DecryptionErrorReceive) {
mutableStateFlow.update {
it.copy(
@ -2198,6 +2247,13 @@ sealed class VaultAction {
data class ArchiveCipherReceive(
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
}
}

View File

@ -60,6 +60,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
@ -397,6 +398,64 @@ class SearchViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel(initialState = null)
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Success
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
viewModel.eventFlow.test {
assertEquals(
SearchEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()),
awaitItem(),
)
}
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Failure should show generic error`() = runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel(initialState = null)
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Error(error = error)
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `AutofillItemClick should call emitAccessibilitySelection`() = runTest {
val cipherView = setupForAutofill(

View File

@ -101,6 +101,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.credentials.manager.model.AssertFido2CredentialResult
import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult
@ -1152,6 +1153,64 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createVaultItemListingViewModel()
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Success
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
action = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
viewModel.eventFlow.test {
assertEquals(
VaultItemListingEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()),
awaitItem(),
)
}
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Failure should show generic error`() = runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createVaultItemListingViewModel()
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Error(error = error)
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
action = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
createVaultItemListingState(
dialogState = VaultItemListingState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `MasterPasswordRepromptSubmit for a request Error should show a generic error dialog`() =
runTest {

View File

@ -425,6 +425,33 @@ class CipherListViewExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toOverflowActions should return Unarchive action when cipher is archived and not deleted`() {
val loginListView = createMockLoginListView(
number = 1,
username = "",
uris = emptyList(),
totp = null,
)
val cipher = createMockCipherListView(
number = 1,
id = id,
isArchived = true,
isDeleted = false,
edit = true,
type = CipherListViewType.Login(loginListView),
)
val result = cipher.toOverflowActions(
hasMasterPassword = true,
isPremiumUser = false,
isArchiveEnabled = true,
)
assertTrue(result.contains(ListingItemOverflowAction.VaultAction.UnarchiveClick(id)))
}
@Test
fun `toOverflowActions should not return Archive action when Archive is disabled`() {
val loginListView = createMockLoginListView(

View File

@ -60,6 +60,7 @@ import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
@ -681,6 +682,64 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit a ShowSnackbar event`() =
runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel()
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Success
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
viewModel.eventFlow.test {
assertEquals(
VaultEvent.ShowSnackbar(BitwardenString.item_unarchived.asText()),
awaitItem(),
)
}
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Failure should show generic error`() = runTest {
val cipherView = createMockCipherView(number = 1, clock = clock)
val viewModel = createViewModel()
val error = Throwable("Oh dang.")
coEvery {
vaultRepository.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Error(error = error)
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
overflowAction = ListingItemOverflowAction.VaultAction.UnarchiveClick(
cipherId = "mockId-1",
),
),
)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on LockAccountClick should call lockVault for the given account`() {
val accountUserId = "userId"