mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-29697: Finish View and Edit Cipher UI for archive (#6377)
This commit is contained in:
@@ -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(),
|
||||
|
||||
+216
-6
@@ -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 =
|
||||
|
||||
+237
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user