diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 348a410fa..70c4e7c0c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -61,6 +61,11 @@ class VaultViewModel @Inject constructor( ) }, ) { + /** + * Helper for retrieving the selected vault filter type from the state (or a default). + */ + private val vaultFilterTypeOrDefault: VaultFilterType + get() = state.vaultFilterData?.selectedVaultFilterType ?: VaultFilterType.AllVaults init { vaultRepository @@ -193,6 +198,7 @@ class VaultViewModel @Inject constructor( } private fun handleVaultFilterTypeSelect(action: VaultAction.VaultFilterTypeSelect) { + // Update the current filter mutableStateFlow.update { it.copy( vaultFilterData = it.vaultFilterData?.copy( @@ -200,6 +206,9 @@ class VaultViewModel @Inject constructor( ), ) } + + // Re-process the current vault data with the new filter + updateViewState(vaultData = vaultRepository.vaultDataStateFlow.value) } private fun handleTrashClick() { @@ -253,7 +262,11 @@ class VaultViewModel @Inject constructor( // navigating. if (state.isSwitchingAccounts) return - when (val vaultData = action.vaultData) { + updateViewState(vaultData = action.vaultData) + } + + private fun updateViewState(vaultData: DataState<VaultData>) { + when (vaultData) { is DataState.Error -> vaultErrorReceive(vaultData = vaultData) is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData) is DataState.Loading -> vaultLoadingReceive() @@ -265,13 +278,16 @@ class VaultViewModel @Inject constructor( private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) { mutableStateFlow.updateToErrorStateOrDialog( vaultData = vaultData.data, + vaultFilterType = vaultFilterTypeOrDefault, errorTitle = R.string.an_error_has_occurred.asText(), errorMessage = R.string.generic_error_message.asText(), ) } private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) { - mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) } + mutableStateFlow.update { + it.copy(viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault)) + } } private fun vaultLoadingReceive() { @@ -281,6 +297,7 @@ class VaultViewModel @Inject constructor( private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) { mutableStateFlow.updateToErrorStateOrDialog( vaultData = vaultData.data, + vaultFilterType = vaultFilterTypeOrDefault, errorTitle = R.string.internet_connection_required_title.asText(), errorMessage = R.string.internet_connection_required_message.asText(), ) @@ -288,7 +305,9 @@ class VaultViewModel @Inject constructor( private fun vaultPendingReceive(vaultData: DataState.Pending<VaultData>) { // TODO update state to refresh state BIT-505 - mutableStateFlow.update { it.copy(viewState = vaultData.data.toViewState()) } + mutableStateFlow.update { + it.copy(viewState = vaultData.data.toViewState(vaultFilterTypeOrDefault)) + } sendEvent(VaultEvent.ShowToast(message = "Refreshing")) } @@ -741,13 +760,14 @@ sealed class VaultAction { private fun MutableStateFlow<VaultState>.updateToErrorStateOrDialog( vaultData: VaultData?, + vaultFilterType: VaultFilterType, errorTitle: Text, errorMessage: Text, ) { this.update { if (vaultData != null) { it.copy( - viewState = vaultData.toViewState(), + viewState = vaultData.toViewState(vaultFilterType = vaultFilterType), dialog = VaultState.DialogState.Error( title = errorTitle, message = errorMessage, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 88a0fdc55..2d4948cf0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -1,11 +1,15 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.ui.vault.feature.vault.util import com.bitwarden.core.CardView import com.bitwarden.core.CipherRepromptType import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView +import com.bitwarden.core.CollectionView import com.bitwarden.core.FieldType import com.bitwarden.core.FieldView +import com.bitwarden.core.FolderView import com.bitwarden.core.IdentityView import com.bitwarden.core.LoginUriView import com.bitwarden.core.LoginView @@ -17,6 +21,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import java.time.Instant /** @@ -55,41 +60,45 @@ private fun CipherView.toVaultItemOrNull(): VaultState.ViewState.VaultItem? { } /** - * Transforms [VaultData] into [VaultState.ViewState]. + * Transforms [VaultData] into [VaultState.ViewState] using the given [vaultFilterType]. */ -fun VaultData.toViewState(): VaultState.ViewState = - if (cipherViewList.isEmpty() && folderViewList.isEmpty()) { +fun VaultData.toViewState( + vaultFilterType: VaultFilterType, +): VaultState.ViewState { + val filteredCipherViewList = cipherViewList.toFilteredList(vaultFilterType) + val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType) + val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType) + + return if (filteredCipherViewList.isEmpty()) { VaultState.ViewState.NoItems } else { - // Filter out any items with invalid IDs in the unlikely case they exist - val filteredCipherViewList = cipherViewList.filterNot { it.id.isNullOrBlank() } VaultState.ViewState.Content( loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN }, cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD }, identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY }, secureNoteItemsCount = filteredCipherViewList .count { it.type == CipherType.SECURE_NOTE }, - favoriteItems = cipherViewList + favoriteItems = filteredCipherViewList .filter { it.favorite } .mapNotNull { it.toVaultItemOrNull() }, - folderItems = folderViewList.map { folderView -> + folderItems = filteredFolderViewList.map { folderView -> VaultState.ViewState.FolderItem( id = folderView.id, name = folderView.name.asText(), - itemCount = cipherViewList + itemCount = filteredCipherViewList .count { !it.id.isNullOrBlank() && folderView.id == it.folderId }, ) }, - noFolderItems = cipherViewList + noFolderItems = filteredCipherViewList .filter { it.folderId.isNullOrBlank() } .mapNotNull { it.toVaultItemOrNull() }, - collectionItems = collectionViewList + collectionItems = filteredCollectionViewList .filter { it.id != null } .map { collectionView -> VaultState.ViewState.CollectionItem( id = requireNotNull(collectionView.id), name = collectionView.name, - itemCount = cipherViewList + itemCount = filteredCipherViewList .count { !it.id.isNullOrBlank() && collectionView.id in it.collectionIds @@ -100,6 +109,55 @@ fun VaultData.toViewState(): VaultState.ViewState = trashItemsCount = 0, ) } +} + +@JvmName("toFilteredCipherList") +private fun List<CipherView>.toFilteredList( + vaultFilterType: VaultFilterType, +): List<CipherView> = + this + // Filter out any items with invalid IDs in the unlikely case they exist + .filterNot { it.id.isNullOrBlank() } + .filter { + when (vaultFilterType) { + VaultFilterType.AllVaults -> true + VaultFilterType.MyVault -> it.organizationId == null + is VaultFilterType.OrganizationVault -> { + it.organizationId == vaultFilterType.organizationId + } + } + } + +@JvmName("toFilteredFolderList") +private fun List<FolderView>.toFilteredList( + vaultFilterType: VaultFilterType, +): List<FolderView> = + this + .filter { + when (vaultFilterType) { + // Folders are only included when including the user's personal data. + VaultFilterType.AllVaults, + VaultFilterType.MyVault, + -> true + + is VaultFilterType.OrganizationVault -> false + } + } + +@JvmName("toFilteredCollectionList") +private fun List<CollectionView>.toFilteredList( + vaultFilterType: VaultFilterType, +): List<CollectionView> = + this + .filter { + when (vaultFilterType) { + VaultFilterType.AllVaults -> true + VaultFilterType.MyVault -> false + is VaultFilterType.OrganizationVault -> { + it.organizationId == vaultFilterType.organizationId + } + } + } /** * Transforms a [VaultAddItemState.ViewState.ItemType] into [CipherView]. diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 1463b7982..e58cbe1fd 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import io.mockk.every import io.mockk.just @@ -306,14 +307,19 @@ class VaultViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `on VaultFilterTypeSelect should update the selected filter type`() { - val viewModel = createViewModel() - - // Update to state with filters - val initialState = DEFAULT_STATE.copy( - appBarTitle = R.string.vaults.asText(), - vaultFilterData = VAULT_FILTER_DATA, + fun `on VaultFilterTypeSelect should update the selected filter type and re-filter any existing data`() { + // Update to state with filters and content + val vaultData = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + folderViewList = listOf(createMockFolderView(number = 1)), + ) + mutableVaultDataStateFlow.tryEmit( + value = DataState.Loaded( + data = vaultData, + ), ) mutableUserStateFlow.value = DEFAULT_USER_STATE .copy( @@ -329,6 +335,14 @@ class VaultViewModelTest : BaseViewModelTest() { DEFAULT_USER_STATE.accounts[1], ), ) + val viewModel = createViewModel() + val initialState = createMockVaultState( + viewState = vaultData.toViewState(VaultFilterType.AllVaults), + ) + .copy( + appBarTitle = R.string.vaults.asText(), + vaultFilterData = VAULT_FILTER_DATA, + ) assertEquals( initialState, viewModel.stateFlow.value, @@ -341,6 +355,7 @@ class VaultViewModelTest : BaseViewModelTest() { vaultFilterData = VAULT_FILTER_DATA.copy( selectedVaultFilterType = VaultFilterType.MyVault, ), + viewState = vaultData.toViewState(VaultFilterType.MyVault), ), viewModel.stateFlow.value, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index e1a1b771a..a56b5f869 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import io.mockk.every import io.mockk.mockkStatic @@ -36,15 +37,16 @@ class VaultDataExtensionsTest { unmockkStatic(Instant::class) } + @Suppress("MaxLineLength") @Test - fun `toViewState should transform full VaultData into ViewState Content`() { + fun `toViewState for AllVaults should transform full VaultData into ViewState Content without filtering`() { val vaultData = VaultData( cipherViewList = listOf(createMockCipherView(number = 1)), collectionViewList = listOf(createMockCollectionView(number = 1)), folderViewList = listOf(createMockFolderView(number = 1)), ) - val actual = vaultData.toViewState() + val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults) assertEquals( VaultState.ViewState.Content( @@ -74,6 +76,86 @@ class VaultDataExtensionsTest { ) } + @Suppress("MaxLineLength") + @Test + fun `toViewState for MyVault should transform full VaultData into ViewState Content with filtering of non-user data`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1).copy(organizationId = null), + createMockCipherView(number = 2), + ), + collectionViewList = listOf(createMockCollectionView(number = 1)), + folderViewList = listOf(createMockFolderView(number = 1)), + ) + + val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.MyVault) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf( + VaultState.ViewState.FolderItem( + id = "mockId-1", + name = "mockName-1".asText(), + itemCount = 1, + ), + ), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + ), + actual, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toViewState for OrganizationVault should transform full VaultData into ViewState Content with filtering of non-organization data`() { + val vaultData = VaultData( + cipherViewList = listOf( + createMockCipherView(number = 1), + createMockCipherView(number = 2), + ), + collectionViewList = listOf( + createMockCollectionView(number = 1), + createMockCollectionView(number = 2), + ), + folderViewList = listOf(createMockFolderView(number = 1)), + ) + + val actual = vaultData.toViewState( + vaultFilterType = VaultFilterType.OrganizationVault( + organizationId = "mockOrganizationId-1", + organizationName = "Mock Organization 1", + ), + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf(), + collectionItems = listOf( + VaultState.ViewState.CollectionItem( + id = "mockId-1", + name = "mockName-1", + itemCount = 1, + ), + ), + noFolderItems = listOf(), + trashItemsCount = 0, + ), + actual, + ) + } + @Test fun `toViewState should transform empty VaultData into ViewState NoItems`() { val vaultData = VaultData( @@ -82,7 +164,7 @@ class VaultDataExtensionsTest { folderViewList = emptyList(), ) - val actual = vaultData.toViewState() + val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults) assertEquals( VaultState.ViewState.NoItems, @@ -98,32 +180,10 @@ class VaultDataExtensionsTest { folderViewList = listOf(createMockFolderView(number = 1)), ) - val actual = vaultData.toViewState() + val actual = vaultData.toViewState(vaultFilterType = VaultFilterType.AllVaults) assertEquals( - VaultState.ViewState.Content( - loginItemsCount = 0, - cardItemsCount = 0, - identityItemsCount = 0, - secureNoteItemsCount = 0, - favoriteItems = emptyList(), - folderItems = listOf( - VaultState.ViewState.FolderItem( - id = "mockId-1", - name = "mockName-1".asText(), - itemCount = 0, - ), - ), - collectionItems = listOf( - VaultState.ViewState.CollectionItem( - id = "mockId-1", - name = "mockName-1", - itemCount = 0, - ), - ), - noFolderItems = emptyList(), - trashItemsCount = 0, - ), + VaultState.ViewState.NoItems, actual, ) }