From 49b208f013751af34d8522e4a33fbffd45801582 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 20 Jan 2026 12:43:14 -0600 Subject: [PATCH] PM-29697: Finish View and Edit Cipher UI for archive (#6377) --- .../ui/vault/feature/item/VaultItemScreen.kt | 34 +++ .../vault/feature/item/VaultItemViewModel.kt | 222 +++++++++++++++- .../vault/feature/item/VaultItemScreenTest.kt | 107 ++++++++ .../feature/item/VaultItemViewModelTest.kt | 237 ++++++++++++++++++ 4 files changed, 594 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 47db4f9c87..4157ecb427 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -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(), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 3017e2b746..66e80abf06 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -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, + featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // 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. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 95b1c2d131..fa1491392a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -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 = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 9a19611b75..4720300494 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -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(