mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 18:58:59 +03:00
BIT-1244: Implement dynamic vault filtering (#451)
This commit is contained in:
parent
9e7cd65fe1
commit
12000b2746
4 changed files with 202 additions and 49 deletions
|
@ -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,
|
||||
|
|
|
@ -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].
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue