PM-29697: Finish View and Edit Cipher UI for archive (#6377)

This commit is contained in:
David Perez
2026-01-20 18:43:14 +00:00
committed by GitHub
parent 8d33e6660a
commit 49b208f013
4 changed files with 594 additions and 6 deletions
@@ -134,6 +134,9 @@ fun VaultItemScreen(
onConfirmRestoreAction = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
},
onUpgradeToPremiumClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -212,6 +215,24 @@ fun VaultItemScreen(
state.isCipherInCollection &&
state.canAssignToCollections
},
OverflowMenuItemData(
text = stringResource(id = BitwardenString.archive_verb),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ArchiveClick) }
},
)
.takeIf { state.displayArchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.unarchive),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.UnarchiveClick,
)
}
},
)
.takeIf { state.displayUnarchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.delete),
onClick = remember(viewModel) {
@@ -280,8 +301,21 @@ private fun VaultItemDialogs(
onConfirmDeleteClick: () -> Unit,
onConfirmCloneWithoutFido2Credential: () -> Unit,
onConfirmRestoreAction: () -> Unit,
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
title = null,
message = dialog.message(),
@@ -4,12 +4,14 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.repository.util.mapNullable
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
@@ -25,15 +27,18 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
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.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
@@ -76,6 +81,7 @@ class VaultItemViewModel @Inject constructor(
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@@ -87,6 +93,8 @@ class VaultItemViewModel @Inject constructor(
dialog = null,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true,
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
)
},
) {
@@ -223,6 +231,12 @@ class VaultItemViewModel @Inject constructor(
.map { VaultItemAction.Internal.SnackbarDataReceived(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.ArchiveItems)
.map { VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemAction) {
@@ -283,6 +297,9 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked()
is VaultItemAction.Common.CopyNotesClick -> handleCopyNotesClick()
is VaultItemAction.Common.PasswordHistoryClick -> handlePasswordHistoryClick()
VaultItemAction.Common.ArchiveClick -> handleArchiveClick()
VaultItemAction.Common.UnarchiveClick -> handleUnarchiveClick()
VaultItemAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
}
}
@@ -320,7 +337,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyCustomHiddenFieldClick(
action: VaultItemAction.Common.CopyCustomHiddenFieldClick,
) {
onContent { content ->
onContent { _ ->
clipboardManager.setText(text = action.field)
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientCopiedHiddenField(
@@ -600,7 +617,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyPasswordClick() {
onLoginContent { content, login ->
onLoginContent { _, login ->
clipboardManager.setText(
text = requireNotNull(login.passwordData).password,
toastDescriptorOverride = BitwardenString.password.asText(),
@@ -648,6 +665,67 @@ class VaultItemViewModel @Inject constructor(
sendEvent(VaultItemEvent.NavigateToPasswordHistory(itemId = state.vaultItemId))
}
private fun handleArchiveClick() {
if (!state.hasPremium) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
}
return
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
message = BitwardenString.archiving.asText(),
),
)
}
onContent { content ->
content.common.currentCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.ArchiveCipherReceive(
result = vaultRepository.archiveCipher(
cipherId = it,
cipherView = content.common.currentCipher,
),
),
)
}
}
}
}
private fun handleUnarchiveClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
onContent { content ->
content.common.currentCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.UnarchiveCipherReceive(
result = vaultRepository.unarchiveCipher(
cipherId = it,
cipherView = content.common.currentCipher,
),
),
)
}
}
}
}
private fun handleUpgradeToPremiumClick() {
updateDialogState(dialog = null)
val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault
val uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium"
sendEvent(VaultItemEvent.NavigateToUri(uri = uri))
}
private fun handlePasswordVisibilityClicked(
action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked,
) {
@@ -717,7 +795,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyNumberClick() {
onCardContent { content, card ->
onCardContent { _, card ->
clipboardManager.setText(
text = requireNotNull(card.number).number,
toastDescriptorOverride = BitwardenString.number.asText(),
@@ -726,7 +804,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopySecurityCodeClick() {
onCardContent { content, card ->
onCardContent { _, card ->
clipboardManager.setText(
text = requireNotNull(card.securityCode).code,
toastDescriptorOverride = BitwardenString.security_code.asText(),
@@ -801,7 +879,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyPrivateKeyClick() {
onSshKeyContent { content, sshKey ->
onSshKeyContent { _, sshKey ->
clipboardManager.setText(
text = sshKey.privateKey,
toastDescriptorOverride = BitwardenString.private_key.asText(),
@@ -961,6 +1039,15 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive -> {
handleIsIconLoadingDisabledUpdateReceive(action)
}
is VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive -> {
handleArchiveItemsFlagUpdateReceive(action)
}
is VaultItemAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
is VaultItemAction.Internal.UnarchiveCipherReceive -> {
handleUnarchiveCipherReceive(action)
}
}
}
@@ -1009,6 +1096,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@@ -1020,6 +1108,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@@ -1030,13 +1119,17 @@ class VaultItemViewModel @Inject constructor(
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Loading)
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = VaultItemState.ViewState.Loading,
)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.internet_connection_required_title
@@ -1053,6 +1146,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@@ -1186,6 +1280,58 @@ class VaultItemViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isIconLoadingDisabled = action.isDisabled) }
}
private fun handleArchiveItemsFlagUpdateReceive(
action: VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive,
) {
mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) }
}
private fun handleArchiveCipherReceive(action: VaultItemAction.Internal.ArchiveCipherReceive) {
when (val result = action.result) {
is ArchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
}
ArchiveCipherResult.Success -> {
updateDialogState(dialog = null)
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_archived.asText()),
relay = SnackbarRelay.CIPHER_ARCHIVED,
)
sendEvent(VaultItemEvent.NavigateBack)
}
}
}
private fun handleUnarchiveCipherReceive(
action: VaultItemAction.Internal.UnarchiveCipherReceive,
) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
}
UnarchiveCipherResult.Success -> {
updateDialogState(dialog = null)
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_unarchived.asText()),
relay = SnackbarRelay.CIPHER_UNARCHIVED,
)
sendEvent(VaultItemEvent.NavigateBack)
}
}
}
//endregion Internal Type Handlers
private fun updateDialogState(dialog: VaultItemState.DialogState?) {
@@ -1276,6 +1422,8 @@ data class VaultItemState(
val dialog: DialogState?,
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
val isArchiveEnabled: Boolean,
val hasPremium: Boolean,
) : Parcelable {
/**
@@ -1337,6 +1485,26 @@ data class VaultItemState(
?.common
?.canRestore == true
/**
* Helper to determine if the UI should display the archive button.
*/
val displayArchiveButton: Boolean
get() = isArchiveEnabled &&
viewState.asContentOrNull()
?.common
?.currentCipher
?.let { it.archivedDate == null && it.deletedDate == null } == true
/**
* Helper to determine if the UI should display the unarchive button.
*/
val displayUnarchiveButton: Boolean
get() = isArchiveEnabled &&
viewState.asContentOrNull()
?.common
?.currentCipher
?.let { it.archivedDate != null && it.deletedDate == null } == true
val canAssignToCollections: Boolean
get() = viewState.asContentOrNull()
?.common
@@ -1705,6 +1873,12 @@ data class VaultItemState(
*/
sealed class DialogState : Parcelable {
/**
* Displays a dialog to the user indicating they archiving requires a premium account.
*/
@Parcelize
data object ArchiveRequiresPremium : DialogState()
/**
* Displays a generic dialog to the user.
*/
@@ -1839,6 +2013,21 @@ sealed class VaultItemAction {
*/
sealed class Common : VaultItemAction() {
/**
* The user has clicked the archive button.
*/
data object ArchiveClick : Common()
/**
* The user has clicked the unarchive button.
*/
data object UnarchiveClick : Common()
/**
* The user has clicked the upgrade to premium button.
*/
data object UpgradeToPremiumClick : Common()
/**
* The user has clicked the close button.
*/
@@ -2141,6 +2330,27 @@ sealed class VaultItemAction {
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the Archive Items flag has been updated.
*/
data class ArchiveItemsFlagUpdateReceive(
val isEnabled: Boolean,
) : Internal()
/**
* Indicates that the archive cipher result has been received.
*/
data class ArchiveCipherReceive(
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates that the vault item data has been received.
*/
@@ -231,6 +231,111 @@ class VaultItemScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
}
@Test
fun `ArchiveRequiresPremium dialog should display based on state`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
}
composeTestRule
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveRequiresPremium dialog on upgrade to premium click should emit UpgradeToPremiumClick`() {
composeTestRule.assertNoDialogExists()
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
}
composeTestRule
.onNodeWithText(text = "Upgrade to premium")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick)
}
}
@Test
fun `Archive option menu should be visible if cipher is un-archived`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = false,
currentCipher = createMockCipherView(
number = 1,
isArchived = false,
),
),
),
)
}
// Confirm dropdown version of item is absent
composeTestRule
.onAllNodesWithText(text = "Archive")
.filter(hasAnyAncestor(isPopup()))
.assertCountEquals(0)
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onNodeWithText(text = "Archive")
.assert(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.Common.ArchiveClick)
}
}
@Test
fun `Unarchive option menu should be visible if cipher is archived`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_LOGIN_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = createMockCipherView(
number = 1,
isArchived = true,
),
),
),
)
}
// Confirm dropdown version of item is absent
composeTestRule
.onAllNodesWithText(text = "Unarchive")
.filter(hasAnyAncestor(isPopup()))
.assertCountEquals(0)
composeTestRule
.onNodeWithContentDescription(label = "More")
.performClick()
composeTestRule
.onNodeWithText(text = "Unarchive")
.assert(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(VaultItemAction.Common.UnarchiveClick)
}
}
@Test
fun `name should be displayed according to state`() {
EMPTY_VIEW_STATES
@@ -3207,6 +3312,8 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState(
dialog = null,
baseIconUrl = "https://example.com/",
isIconLoadingDisabled = true,
hasPremium = false,
isArchiveEnabled = true,
)
private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common =
@@ -4,6 +4,7 @@ import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.manager.file.FileManager
@@ -28,6 +29,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@@ -41,9 +43,11 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipherPermissions
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
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.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
@@ -66,6 +70,7 @@ import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -127,6 +132,11 @@ class VaultItemViewModelTest : BaseViewModelTest() {
} returns mutableSnackbarDataFlow
every { sendSnackbarData(data = any(), relay = any()) } just runs
}
private val mutableArchiveItemsFlow = MutableStateFlow(true)
private val featureFlagManager: FeatureFlagManager = mockk {
every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value }
every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow
}
@BeforeEach
fun setup() {
@@ -221,6 +231,230 @@ class VaultItemViewModelTest : BaseViewModelTest() {
assertEquals(initialState.copy(dialog = null), viewModel.stateFlow.value)
}
@Test
fun `UpgradeToPremiumClick should emit NavigateToPremium`() = runTest {
val viewModel = createViewModel(state = null)
viewModel.eventFlow.test {
viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick)
assertEquals(
VaultItemEvent.NavigateToUri(
uri = "https://vault.bitwarden.com/#/" +
"settings/subscription/premium" +
"?callToAction=upgradeToPremium",
),
awaitItem(),
)
}
}
@Test
fun `ArchiveClick without premium should show ArchiveRequiresPremium dialog`() = runTest {
mutableUserStateFlow.update {
it?.copy(accounts = listOf(DEFAULT_USER_ACCOUNT.copy(isPremium = false)))
}
val viewModel = createViewModel(state = null)
viewModel.trySendAction(VaultItemAction.Common.ArchiveClick)
assertEquals(
DEFAULT_STATE.copy(
hasPremium = false,
dialog = VaultItemState.DialogState.ArchiveRequiresPremium,
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `ArchiveClick with ArchiveCipherResult Success should emit send snackbar event and NavigateBack`() =
runTest {
val cipherView = createMockCipherView(number = 1, isArchived = false)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = null)
coEvery {
vaultRepo.archiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns ArchiveCipherResult.Success
viewModel.trySendAction(VaultItemAction.Common.ArchiveClick)
viewModel.eventFlow.test {
assertEquals(VaultItemEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_archived.asText()),
relay = SnackbarRelay.CIPHER_ARCHIVED,
)
}
}
@Test
fun `ArchiveClick with ArchiveCipherResult Failure should show generic error`() = runTest {
val cipherView = createMockCipherView(number = 1, isArchived = false)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = null)
val error = Throwable("Oh dang.")
coEvery {
vaultRepo.archiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns ArchiveCipherResult.Error(error = error)
viewModel.trySendAction(VaultItemAction.Common.ArchiveClick)
assertEquals(
DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE,
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
error = error,
),
),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `UnarchiveClick with UnarchiveCipherResult Success should emit send snackbar event and NavigateBack`() =
runTest {
val cipherView = createMockCipherView(number = 1, isArchived = true)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = cipherView,
),
)
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
coEvery {
vaultRepo.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Success
viewModel.trySendAction(VaultItemAction.Common.UnarchiveClick)
viewModel.eventFlow.test {
assertEquals(VaultItemEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) {
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_unarchived.asText()),
relay = SnackbarRelay.CIPHER_UNARCHIVED,
)
}
}
@Test
fun `UnarchiveClick with UnarchiveCipherResult Failure should show generic error`() =
runTest {
val cipherView = createMockCipherView(number = 1, isArchived = true)
every {
cipherView.toViewState(
previousState = null,
isPremiumUser = true,
totpCodeItemData = null,
canDelete = true,
canRestore = false,
canAssignToCollections = true,
canEdit = true,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
relatedLocations = persistentListOf(),
hasOrganizations = true,
)
} returns DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = cipherView,
),
)
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
mutableCollectionsStateFlow.value = DataState.Loaded(emptyList())
mutableFoldersStateFlow.value = DataState.Loaded(emptyList())
val viewModel = createViewModel(state = null)
val error = Throwable("Oh dang.")
coEvery {
vaultRepo.unarchiveCipher(cipherId = "mockId-1", cipherView = cipherView)
} returns UnarchiveCipherResult.Error(error = error)
viewModel.trySendAction(VaultItemAction.Common.UnarchiveClick)
assertEquals(
DEFAULT_STATE.copy(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = error,
),
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON.copy(
archived = true,
currentCipher = cipherView,
),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `DeleteClick should update state`() =
runTest {
@@ -2513,6 +2747,7 @@ class VaultItemViewModelTest : BaseViewModelTest() {
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
snackbarRelayManager = snackbarRelayManager,
featureFlagManager = featureFlagManager,
)
private fun createViewState(
@@ -2550,6 +2785,8 @@ class VaultItemViewModelTest : BaseViewModelTest() {
dialog = null,
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = false,
hasPremium = true,
isArchiveEnabled = true,
)
private val DEFAULT_USER_ACCOUNT = UserState.Account(