From c3b422e46fed3e9c62f206779953db11610f719d Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Tue, 5 Mar 2024 11:00:45 -0600 Subject: [PATCH] BIT-1115 Add nested folder support (#1072) --- .../base/util/NavGraphBuilderExtensions.kt | 4 +- .../itemlisting/VaultItemListingContent.kt | 176 +++++++++++------- .../itemlisting/VaultItemListingNavigation.kt | 7 + .../itemlisting/VaultItemListingScreen.kt | 8 + .../itemlisting/VaultItemListingViewModel.kt | 50 +++-- .../handlers/VaultItemListingHandlers.kt | 2 + .../util/VaultItemListingDataExtensions.kt | 44 ++++- .../feature/util/FolderViewExtensions.kt | 86 +++++++++ .../feature/vault/VaultGraphNavigation.kt | 3 + .../feature/vault/util/VaultDataExtensions.kt | 5 +- .../itemlisting/VaultItemListingScreenTest.kt | 140 ++++++++++++++ .../VaultItemListingViewModelTest.kt | 18 ++ .../VaultItemListingDataExtensionsTest.kt | 90 ++++++++- .../feature/util/FolderViewExtensionsTest.kt | 78 ++++++++ .../vault/util/VaultDataExtensionsTest.kt | 92 ++++++++- 15 files changed, 705 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/NavGraphBuilderExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/NavGraphBuilderExtensions.kt index 61513df9e0..c45f34c6c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/NavGraphBuilderExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/NavGraphBuilderExtensions.kt @@ -72,8 +72,8 @@ fun NavGraphBuilder.composableWithPushTransitions( arguments = arguments, deepLinks = deepLinks, enterTransition = TransitionProviders.Enter.pushLeft, - exitTransition = TransitionProviders.Exit.pushLeft, - popEnterTransition = TransitionProviders.Enter.pushLeft, + exitTransition = TransitionProviders.Exit.stay, + popEnterTransition = TransitionProviders.Enter.stay, popExitTransition = TransitionProviders.Exit.pushRight, content = content, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index 32e0708e64..90044f098d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -14,9 +16,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.BitwardenGroupItem import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem import com.x8bit.bitwarden.ui.platform.components.BitwardenPolicyWarningText @@ -35,6 +39,7 @@ import kotlinx.collections.immutable.toPersistentList fun VaultItemListingContent( state: VaultItemListingState.ViewState.Content, policyDisablesSend: Boolean, + folderClick: (id: String) -> Unit, vaultItemClick: (id: String) -> Unit, masterPasswordRepromptSubmit: (password: String, data: MasterPasswordRepromptData) -> Unit, onOverflowItemClick: (action: ListingItemOverflowAction) -> Unit, @@ -106,75 +111,116 @@ fun VaultItemListingContent( } } - item { - BitwardenListHeaderTextWithSupportLabel( - label = stringResource(id = R.string.items), - supportingLabel = state.displayItemList.size.toString(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) + if (state.displayFolderList.isNotEmpty()) { + item { + BitwardenListHeaderTextWithSupportLabel( + label = stringResource(id = R.string.folders), + supportingLabel = state.displayFolderList.count().toString(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + item { + Spacer(modifier = Modifier.height(4.dp)) + } + + items(state.displayFolderList) { folder -> + BitwardenGroupItem( + startIcon = painterResource(id = R.drawable.ic_folder), + label = folder.name, + supportingLabel = folder.count.toString(), + onClick = { folderClick(folder.id) }, + showDivider = false, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } } - items(state.displayItemList) { - BitwardenListItem( - startIcon = it.iconData, - label = it.title, - supportingLabel = it.subtitle, - onClick = { - if (it.isAutofill && it.shouldShowMasterPasswordReprompt) { - masterPasswordRepromptData = - MasterPasswordRepromptData.Autofill( - cipherId = it.id, - ) - } else { - vaultItemClick(it.id) - } - }, - trailingLabelIcons = it - .extraIconList - .toIconResources() - .toPersistentList(), - selectionDataList = it - .overflowOptions - .map { option -> - SelectionItemData( - text = option.title(), - onClick = { - when (option) { - is ListingItemOverflowAction.SendAction.DeleteClick -> { - showConfirmationDialog = option - } - is ListingItemOverflowAction.VaultAction -> { - if (option.requiresPasswordReprompt && - it.shouldShowMasterPasswordReprompt - ) { - masterPasswordRepromptData = - MasterPasswordRepromptData.OverflowItem( - action = option, - ) - } else { - onOverflowItemClick(option) + if (state.displayItemList.isNotEmpty()) { + item { + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp), + ) + } + + item { + BitwardenListHeaderTextWithSupportLabel( + label = stringResource(id = R.string.items), + supportingLabel = state.displayItemList.size.toString(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + items(state.displayItemList) { + BitwardenListItem( + startIcon = it.iconData, + label = it.title, + supportingLabel = it.subtitle, + onClick = { + if (it.isAutofill && it.shouldShowMasterPasswordReprompt) { + masterPasswordRepromptData = + MasterPasswordRepromptData.Autofill( + cipherId = it.id, + ) + } else { + vaultItemClick(it.id) + } + }, + trailingLabelIcons = it + .extraIconList + .toIconResources() + .toPersistentList(), + selectionDataList = it + .overflowOptions + .map { option -> + SelectionItemData( + text = option.title(), + onClick = { + when (option) { + is ListingItemOverflowAction.SendAction.DeleteClick -> { + showConfirmationDialog = option } - } - else -> onOverflowItemClick(option) - } - }, - ) - } - // Only show options if allowed - .filter { !policyDisablesSend } - .toPersistentList(), - modifier = Modifier - .fillMaxWidth() - .padding( - start = 16.dp, - // There is some built-in padding to the menu button that makes up - // the visual difference here. - end = 12.dp, - ), - ) + is ListingItemOverflowAction.VaultAction -> { + if (option.requiresPasswordReprompt && + it.shouldShowMasterPasswordReprompt + ) { + masterPasswordRepromptData = + MasterPasswordRepromptData.OverflowItem( + action = option, + ) + } else { + onOverflowItemClick(option) + } + } + + else -> onOverflowItemClick(option) + } + }, + ) + } + // Only show options if allowed + .filter { !policyDisablesSend } + .toPersistentList(), + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + // There is some built-in padding to the menu button that makes up + // the visual difference here. + end = 12.dp, + ), + ) + } } item { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt index 76ec73ff0f..5fffa7661c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt @@ -56,10 +56,12 @@ data class VaultItemListingArgs( /** * Add the [VaultItemListingScreen] to the nav graph. */ +@Suppress("LongParameterList") fun NavGraphBuilder.vaultItemListingDestination( onNavigateBack: () -> Unit, onNavigateToVaultItemScreen: (id: String) -> Unit, onNavigateToVaultEditItemScreen: (cipherId: String) -> Unit, + onNavigateToVaultItemListing: (vaultItemListingType: VaultItemListingType) -> Unit, onNavigateToVaultAddItemScreen: () -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, ) { @@ -69,6 +71,7 @@ fun NavGraphBuilder.vaultItemListingDestination( onNavigateToAddSendItem = { }, onNavigateToEditSendItem = { }, onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen, + onNavigateToVaultItemListing = onNavigateToVaultItemListing, onNavigateToVaultItemScreen = onNavigateToVaultItemScreen, onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen, onNavigateToSearch = { onNavigateToSearchVault(it as SearchType.Vault) }, @@ -102,6 +105,7 @@ fun NavGraphBuilder.vaultItemListingDestinationAsRoot( onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen, onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen, onNavigateToSearch = { onNavigateToSearchVault(it as SearchType.Vault) }, + onNavigateToVaultItemListing = {}, onNavigateToAddSendItem = {}, onNavigateToEditSendItem = {}, ) @@ -125,6 +129,7 @@ fun NavGraphBuilder.sendItemListingDestination( onNavigateToVaultAddItemScreen = { }, onNavigateToVaultItemScreen = { }, onNavigateToVaultEditItemScreen = { }, + onNavigateToVaultItemListing = { }, onNavigateToSearch = { onNavigateToSearchSend(it as SearchType.Sends) }, ) } @@ -138,6 +143,7 @@ private fun NavGraphBuilder.internalVaultItemListingDestination( onNavigateBack: () -> Unit, onNavigateToVaultItemScreen: (id: String) -> Unit, onNavigateToVaultEditItemScreen: (cipherId: String) -> Unit, + onNavigateToVaultItemListing: (vaultItemListingType: VaultItemListingType) -> Unit, onNavigateToVaultAddItemScreen: () -> Unit, onNavigateToAddSendItem: () -> Unit, onNavigateToEditSendItem: (sendId: String) -> Unit, @@ -168,6 +174,7 @@ private fun NavGraphBuilder.internalVaultItemListingDestination( onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen, onNavigateToAddSendItem = onNavigateToAddSendItem, onNavigateToEditSendItem = onNavigateToEditSendItem, + onNavigateToVaultItemListing = onNavigateToVaultItemListing, onNavigateToSearch = onNavigateToSearch, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index ee0171783a..d6addbfc2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -47,6 +47,7 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.vault.feature.itemlisting.handlers.VaultItemListingHandlers import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -55,10 +56,12 @@ import kotlinx.collections.immutable.toImmutableList */ @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("LongMethod") fun VaultItemListingScreen( onNavigateBack: () -> Unit, onNavigateToVaultItem: (id: String) -> Unit, onNavigateToVaultEditItemScreen: (cipherVaultId: String) -> Unit, + onNavigateToVaultItemListing: (vaultItemListingType: VaultItemListingType) -> Unit, onNavigateToVaultAddItemScreen: () -> Unit, onNavigateToAddSendItem: () -> Unit, onNavigateToEditSendItem: (sendId: String) -> Unit, @@ -117,6 +120,10 @@ fun VaultItemListingScreen( is VaultItemListingEvent.NavigateToSearchScreen -> { onNavigateToSearch(event.searchType) } + + is VaultItemListingEvent.NavigateToFolderItem -> { + onNavigateToVaultItemListing(VaultItemListingType.Folder(event.folderId)) + } } } @@ -237,6 +244,7 @@ private fun VaultItemListingScaffold( policyDisablesSend = state.policyDisablesSend && state.itemListingType is VaultItemListingState.ItemListingType.Send, vaultItemClick = vaultItemListingHandlers.itemClick, + folderClick = vaultItemListingHandlers.folderClick, masterPasswordRepromptSubmit = vaultItemListingHandlers.masterPasswordRepromptSubmit, onOverflowItemClick = vaultItemListingHandlers.overflowItemClick, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 2538877e42..01f6f0154c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -138,6 +138,7 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action) is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick() is VaultItemListingsAction.BackClick -> handleBackClick() + is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.LockClick -> handleLockClick() is VaultItemListingsAction.SyncClick -> handleSyncClick() is VaultItemListingsAction.SearchIconClick -> handleSearchIconClick() @@ -167,6 +168,10 @@ class VaultItemListingViewModel @Inject constructor( authRepository.switchAccount(userId = action.accountSummary.userId) } + private fun handleFolderClick(action: VaultItemListingsAction.FolderClick) { + sendEvent(VaultItemListingEvent.NavigateToFolderItem(action.id)) + } + private fun handleRefreshClick() { vaultRepository.sync() } @@ -671,18 +676,13 @@ class VaultItemListingViewModel @Inject constructor( ), viewState = when (val listingType = currentState.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { - vaultData - .cipherViewList - .filter { cipherView -> - cipherView.determineListingPredicate(listingType) - } - .toFilteredList(state.vaultFilterType) - .toViewState( - itemListingType = listingType, - baseIconUrl = state.baseIconUrl, - isIconLoadingDisabled = state.isIconLoadingDisabled, - autofillSelectionData = state.autofillSelectionData, - ) + vaultData.toViewState( + vaultFilterType = state.vaultFilterType, + itemListingType = listingType, + baseIconUrl = state.baseIconUrl, + isIconLoadingDisabled = state.isIconLoadingDisabled, + autofillSelectionData = state.autofillSelectionData, + ) } is VaultItemListingState.ItemListingType.Send -> { @@ -842,6 +842,7 @@ data class VaultItemListingState( */ data class Content( val displayItemList: List, + val displayFolderList: List, ) : ViewState() { override val isPullToRefreshEnabled: Boolean get() = true } @@ -881,6 +882,19 @@ data class VaultItemListingState( val shouldShowMasterPasswordReprompt: Boolean, ) + /** + * The folder that is displayed to the user on the ItemListingScreen. + * + * @property id the id of the folder. + * @property name the name of the folder. + * @property count the amount of ciphers in the folder. + */ + data class FolderDisplayItem( + val id: String, + val name: String, + val count: Int, + ) + /** * Represents different types of item listing. */ @@ -1017,6 +1031,11 @@ sealed class VaultItemListingEvent { */ data object NavigateToAddVaultItem : VaultItemListingEvent() + /** + * Navigates to the folder. + */ + data class NavigateToFolderItem(val folderId: String) : VaultItemListingEvent() + /** * Navigates to the AddSendItemScreen. */ @@ -1146,6 +1165,13 @@ sealed class VaultItemListingsAction { */ data class ItemClick(val id: String) : VaultItemListingsAction() + /** + * Click on the folder. + * + * @property id the id of the folder that has been clicked + */ + data class FolderClick(val id: String) : VaultItemListingsAction() + /** * A master password prompt was encountered when trying to perform a senstive action described * by the given [masterPasswordRepromptData] and the given [password] was submitted. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt index df0ecbf807..0c54ffe1ab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/handlers/VaultItemListingHandlers.kt @@ -18,6 +18,7 @@ data class VaultItemListingHandlers( val searchIconClick: () -> Unit, val addVaultItemClick: () -> Unit, val itemClick: (id: String) -> Unit, + val folderClick: (id: String) -> Unit, val masterPasswordRepromptSubmit: (password: String, MasterPasswordRepromptData) -> Unit, val refreshClick: () -> Unit, val syncClick: () -> Unit, @@ -50,6 +51,7 @@ data class VaultItemListingHandlers( viewModel.trySendAction(VaultItemListingsAction.AddVaultItemClick) }, itemClick = { viewModel.trySendAction(VaultItemListingsAction.ItemClick(it)) }, + folderClick = { viewModel.trySendAction(VaultItemListingsAction.FolderClick(it)) }, masterPasswordRepromptSubmit = { password, data -> viewModel.trySendAction( VaultItemListingsAction.MasterPasswordRepromptSubmit( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 1f8bb004f4..a7f2e39525 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -11,6 +11,7 @@ import com.bitwarden.core.SendView import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.util.subtitle +import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.IconData @@ -18,8 +19,12 @@ import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern import com.x8bit.bitwarden.ui.tools.feature.send.util.toLabelIcons import com.x8bit.bitwarden.ui.tools.feature.send.util.toOverflowActions import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState +import com.x8bit.bitwarden.ui.vault.feature.util.getFolders +import com.x8bit.bitwarden.ui.vault.feature.util.toFolderDisplayName import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import java.time.Clock @@ -82,19 +87,47 @@ fun SendView.determineListingPredicate( /** * Transforms a list of [CipherView] into [VaultItemListingState.ViewState]. */ -fun List.toViewState( +@Suppress("CyclomaticComplexMethod", "LongMethod") +fun VaultData.toViewState( itemListingType: VaultItemListingState.ItemListingType.Vault, + vaultFilterType: VaultFilterType, baseIconUrl: String, isIconLoadingDisabled: Boolean, autofillSelectionData: AutofillSelectionData?, -): VaultItemListingState.ViewState = - if (isNotEmpty()) { +): VaultItemListingState.ViewState { + val filteredCipherViewList = cipherViewList + .filter { cipherView -> + cipherView.determineListingPredicate(itemListingType) + } + .toFilteredList(vaultFilterType) + + val folderList = if (itemListingType is VaultItemListingState.ItemListingType.Vault.Folder && + !itemListingType.folderId.isNullOrBlank() + ) { + folderViewList.getFolders(itemListingType.folderId) + } else { + emptyList() + } + + return if (folderList.isNotEmpty() || filteredCipherViewList.isNotEmpty()) { VaultItemListingState.ViewState.Content( - displayItemList = toDisplayItemList( + displayItemList = filteredCipherViewList.toDisplayItemList( baseIconUrl = baseIconUrl, isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = autofillSelectionData != null, ), + displayFolderList = folderList.map { folderView -> + VaultItemListingState.FolderDisplayItem( + id = requireNotNull(folderView.id), + name = folderView.name, + count = this.cipherViewList + .count { + it.deletedDate == null && + !it.id.isNullOrBlank() && + folderView.id == it.folderId + }, + ) + }, ) } else { // Use the autofill empty message if necessary, otherwise use normal type-specific message @@ -128,6 +161,7 @@ fun List.toViewState( shouldShowAddButton = shouldShowAddButton, ) } +} /** * Transforms a list of [CipherView] into [VaultItemListingState.ViewState]. @@ -142,6 +176,7 @@ fun List.toViewState( baseWebSendUrl = baseWebSendUrl, clock = clock, ), + displayFolderList = emptyList(), ) } else { VaultItemListingState.ViewState.NoItems( @@ -168,6 +203,7 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary( folderName = folderList .find { it.id == folderId } ?.name + ?.toFolderDisplayName(folderList) .orEmpty(), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensions.kt new file mode 100644 index 0000000000..9d12cb0087 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensions.kt @@ -0,0 +1,86 @@ +package com.x8bit.bitwarden.ui.vault.feature.util + +import com.bitwarden.core.FolderView + +private const val FOLDER_DIVIDER: String = "/" + +/** + * Retrieves the subfolders of a given [folderId] and updates their names to proper display names. + * This function is necessary if we want to show the subfolders for a specific folder. + */ +@Suppress("ReturnCount") +fun List.getFolders(folderId: String): List { + val currentFolder = this.find { it.id == folderId } ?: return emptyList() + + // If two folders have the same name the second folder should have no nested folders + val firstFolderWithName = this.first { it.name == currentFolder.name } + if (firstFolderWithName.id != folderId) return emptyList() + + val folderList = this + .getFilteredFolders(currentFolder.name) + .map { + it.copy(name = it.name.substringAfter(currentFolder.name + FOLDER_DIVIDER)) + } + + return folderList +} + +/** + * Filters out subfolders of subfolders from the given list. If a [folderName] is provided, + * folders that are not subfolders of the specified [folderName] will be filtered out. + */ +fun List.getFilteredFolders(folderName: String? = null): List = + this.filter { folderView -> + // If the folder name is not null we filter out folders that are not subfolders. + if (folderName != null && + !folderView.name.startsWith(folderName + FOLDER_DIVIDER) + ) { + return@filter false + } + + this.forEach { + val firstFolder = folderName + ?.let { name -> folderView.name.substringAfter(name + FOLDER_DIVIDER) } + ?: folderView.name + + val secondFolder = folderName + ?.let { name -> it.name.substringAfter(name + FOLDER_DIVIDER) } + ?: it.name + + // We don't want to compare the folder to itself or itself plus a slash. + if (firstFolder == secondFolder || firstFolder == secondFolder + FOLDER_DIVIDER) { + return@forEach + } + + // If the first folder name is blank or the first folder is a subfolder of the second + // folder, we want to filter it out. + if (firstFolder.isEmpty() || + firstFolder.startsWith(secondFolder + FOLDER_DIVIDER) + ) { + return@filter false + } + } + + true + } + +/** + * Converts a folder name to a user-friendly display name. This function is necessary because the + * folder name we receive is often nested, and we want to extract just the relevant name for + * display to the user. + */ +fun String.toFolderDisplayName(list: List): String { + var folderName = this + + // cycle through the list and determine the correct display name of the folder. + list.forEach { folderView -> + if (this.startsWith(folderView.name + FOLDER_DIVIDER)) { + val newName = this.substringAfter(folderView.name + FOLDER_DIVIDER) + if (newName.isNotBlank() && newName.length < folderName.length) { + folderName = newName + } + } + } + + return folderName +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index ca5610227c..7904af5850 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -45,6 +45,9 @@ fun NavGraphBuilder.vaultGraph( onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen, onNavigateToSearchVault = onNavigateToSearchVault, onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen, + onNavigateToVaultItemListing = { + navController.navigateToVaultItemListing(it) + }, ) vaultVerificationCodeDestination( 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 1824b420c9..c694cf7092 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 @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.vault.feature.util.getFilteredFolders import com.x8bit.bitwarden.ui.vault.feature.util.toLabelIcons import com.x8bit.bitwarden.ui.vault.feature.util.toOverflowActions import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState @@ -43,7 +44,9 @@ fun VaultData.toViewState( val filteredCipherViewList = filteredCipherViewListWithDeletedItems .filter { it.deletedDate == null } - val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType) + + val filteredFolderViewList = folderViewList.toFilteredList(vaultFilterType).getFilteredFolders() + val filteredCollectionViewList = collectionViewList.toFilteredList(vaultFilterType) val noFolderItems = filteredCipherViewList .filter { it.folderId.isNullOrBlank() } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 565a164b6c..14ea6f3479 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -47,6 +47,7 @@ import com.x8bit.bitwarden.ui.util.performLogoutAccountClick import com.x8bit.bitwarden.ui.util.performLogoutAccountConfirmationClick import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -72,6 +73,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { private var onNavigateToVaultItemId: String? = null private var onNavigateToVaultEditItemScreenId: String? = null private var onNavigateToSearchType: SearchType? = null + private var onNavigateToVaultItemListingScreenType: VaultItemListingType? = null private val intentManager: IntentManager = mockk { every { shareText(any()) } just runs @@ -99,6 +101,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { onNavigateToEditSendItem = { onNavigateToEditSendItemId = it }, onNavigateToSearch = { onNavigateToSearchType = it }, onNavigateToVaultEditItemScreen = { onNavigateToVaultEditItemScreenId = it }, + onNavigateToVaultItemListing = { this.onNavigateToVaultItemListingScreenType = it }, ) } } @@ -403,6 +406,13 @@ class VaultItemListingScreenTest : BaseComposeTest() { assertEquals(id, onNavigateToVaultItemId) } + @Test + fun `NavigateToFolderItem should call onNavigateToVaultItemListing`() { + val itemListingType = VaultItemListingType.Folder("testId") + mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToFolderItem("testId")) + assertEquals(itemListingType, onNavigateToVaultItemListingScreenType) + } + @Test fun `NavigateToUrl should call launchUri on the IntentManager`() { val url = "www.test.com" @@ -539,6 +549,122 @@ class VaultItemListingScreenTest : BaseComposeTest() { .assertDoesNotExist() } + @Test + fun `Folders text should be displayed according to state`() { + val folders = "Folders" + mutableStateFlow.update { DEFAULT_STATE } + composeTestRule + .onNodeWithText(text = folders) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = emptyList(), + displayFolderList = listOf( + VaultItemListingState.FolderDisplayItem( + name = "test", id = "1", count = 0, + ), + ), + ), + ) + } + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasText(folders)) + composeTestRule + .onNodeWithText(text = folders) + .assertIsDisplayed() + } + + @Test + fun `Folders text count should be displayed according to state`() { + val folders = "Folders" + mutableStateFlow.update { DEFAULT_STATE } + composeTestRule + .onNodeWithText(text = folders) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = emptyList(), + displayFolderList = listOf( + VaultItemListingState.FolderDisplayItem(name = "test", id = "1", count = 0), + ), + ), + ) + } + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasText(folders)) + composeTestRule + .onNodeWithText(text = folders) + .assertIsDisplayed() + .assertTextEquals(folders, 1.toString()) + + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = emptyList(), + displayFolderList = listOf( + VaultItemListingState.FolderDisplayItem( + name = "test", + id = "1", + count = 0, + ), + VaultItemListingState.FolderDisplayItem( + name = "test1", + id = "2", + count = 0, + ), + VaultItemListingState.FolderDisplayItem( + name = "test2", + id = "3", + count = 0, + ), + ), + ), + ) + } + + composeTestRule + .onNode(hasScrollToNodeAction()) + .performScrollToNode(hasText(folders)) + composeTestRule + .onNodeWithText(text = folders) + .assertIsDisplayed() + .assertTextEquals(folders, 3.toString()) + } + + @Test + fun `folderDisplayItems should be displayed according to state`() { + val folderName = "TestFolder" + mutableStateFlow.update { DEFAULT_STATE } + composeTestRule + .onNodeWithText(text = folderName) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = VaultItemListingState.ViewState.Content( + displayItemList = emptyList(), + displayFolderList = listOf( + VaultItemListingState.FolderDisplayItem( + name = folderName, + id = "1", + count = 0, + ), + ), + ), + ) + } + + composeTestRule + .onNodeWithText(text = folderName) + .assertIsDisplayed() + } + @Test fun `Items text should be displayed according to state`() { val items = "Items" @@ -553,6 +679,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { displayItemList = listOf( createDisplayItem(number = 1), ), + displayFolderList = emptyList(), ), ) } @@ -578,6 +705,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { displayItemList = listOf( createDisplayItem(number = 1), ), + displayFolderList = emptyList(), ), ) } @@ -598,6 +726,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { createDisplayItem(number = 3), createDisplayItem(number = 4), ), + displayFolderList = emptyList(), ), ) } @@ -619,6 +748,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { displayItemList = listOf( createDisplayItem(number = 1), ), + displayFolderList = emptyList(), ), ) } @@ -644,6 +774,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { shouldShowMasterPasswordReprompt = false, ), ), + displayFolderList = emptyList(), ), ) } @@ -670,6 +801,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { shouldShowMasterPasswordReprompt = true, ), ), + displayFolderList = emptyList(), ), ) } @@ -720,6 +852,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { shouldShowMasterPasswordReprompt = true, ), ), + displayFolderList = emptyList(), ), ) } @@ -749,6 +882,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { shouldShowMasterPasswordReprompt = true, ), ), + displayFolderList = emptyList(), ), ) } @@ -882,6 +1016,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { itemListingType = VaultItemListingState.ItemListingType.Vault.Login, viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf(createCipherDisplayItem(number = 1)), + displayFolderList = emptyList(), ), ) } @@ -921,6 +1056,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { createCipherDisplayItem(number = 1) .copy(shouldShowMasterPasswordReprompt = true), ), + displayFolderList = emptyList(), ), ) } @@ -948,6 +1084,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { itemListingType = VaultItemListingState.ItemListingType.Send.SendFile, viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf(createDisplayItem(number = 1)), + displayFolderList = emptyList(), ), ) } @@ -973,6 +1110,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf(createDisplayItem(number = number)), + displayFolderList = emptyList(), ), ) } @@ -996,6 +1134,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf(createDisplayItem(number = number)), + displayFolderList = emptyList(), ), ) } @@ -1084,6 +1223,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { it.copy( viewState = VaultItemListingState.ViewState.Content( displayItemList = listOf(createDisplayItem(number = 1)), + displayFolderList = emptyList(), ), ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 4a59ace32d..a7e72c7fe5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -308,6 +308,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ) assertEquals( @@ -356,6 +357,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ) val viewModel = createVaultItemListingViewModel() @@ -472,6 +474,17 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Test + fun `FolderClick for vault item should emit NavigateToFolderItem`() = runTest { + val viewModel = createVaultItemListingViewModel() + val testId = "1" + + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VaultItemListingsAction.FolderClick(testId)) + assertEquals(VaultItemListingEvent.NavigateToFolderItem(testId), awaitItem()) + } + } + @Test fun `RefreshClick should sync`() = runTest { val viewModel = createVaultItemListingViewModel() @@ -832,6 +845,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ), viewModel.stateFlow.value, @@ -878,6 +892,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1).copy(isAutofill = true), ), + displayFolderList = emptyList(), ), ) .copy( @@ -978,6 +993,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ), viewModel.stateFlow.value, @@ -1084,6 +1100,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ), viewModel.stateFlow.value, @@ -1197,6 +1214,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { displayItemList = listOf( createMockDisplayItemForCipher(number = 1), ), + displayFolderList = emptyList(), ), ), viewModel.stateFlow.value, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 1904212689..131aee40ad 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -4,6 +4,7 @@ import android.net.Uri import com.bitwarden.core.CipherRepromptType import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView +import com.bitwarden.core.FolderView import com.bitwarden.core.SendType import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData @@ -15,8 +16,10 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +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.itemlisting.VaultItemListingState +import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -28,6 +31,7 @@ import java.time.Clock import java.time.Instant import java.time.ZoneOffset +@Suppress("LargeClass") class VaultItemListingDataExtensionsTest { private val clock: Clock = Clock.fixed( @@ -355,27 +359,37 @@ class VaultItemListingDataExtensionsTest { number = 1, isDeleted = false, cipherType = CipherType.LOGIN, + folderId = "mockId-1", ) .copy(reprompt = CipherRepromptType.PASSWORD), createMockCipherView( number = 2, isDeleted = false, cipherType = CipherType.CARD, + folderId = "mockId-1", ), createMockCipherView( number = 3, isDeleted = false, cipherType = CipherType.SECURE_NOTE, + folderId = "mockId-1", ), createMockCipherView( number = 4, isDeleted = false, cipherType = CipherType.IDENTITY, + folderId = "mockId-1", ), ) - val result = cipherViewList.toViewState( - itemListingType = VaultItemListingState.ItemListingType.Vault.Login, + val result = VaultData( + cipherViewList = cipherViewList, + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ).toViewState( + vaultFilterType = VaultFilterType.AllVaults, + itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("mockId-1"), isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = null, @@ -406,6 +420,7 @@ class VaultItemListingDataExtensionsTest { subtitle = null, ), ), + displayFolderList = emptyList(), ), result, ) @@ -425,17 +440,25 @@ class VaultItemListingDataExtensionsTest { number = 1, isDeleted = false, cipherType = CipherType.LOGIN, + folderId = "mockId-1", ) .copy(reprompt = CipherRepromptType.PASSWORD), createMockCipherView( number = 2, isDeleted = false, cipherType = CipherType.CARD, + folderId = "mockId-1", ), ) - val result = cipherViewList.toViewState( - itemListingType = VaultItemListingState.ItemListingType.Vault.Login, + val result = VaultData( + cipherViewList = cipherViewList, + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ).toViewState( + vaultFilterType = VaultFilterType.AllVaults, + itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("mockId-1"), isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = AutofillSelectionData( @@ -463,6 +486,7 @@ class VaultItemListingDataExtensionsTest { ) .copy(isAutofill = true), ), + displayFolderList = emptyList(), ), result, ) @@ -471,7 +495,12 @@ class VaultItemListingDataExtensionsTest { @Suppress("MaxLineLength") @Test fun `toViewState should transform an empty list of CipherViews into a NoItems ViewState with the appropriate data`() { - val cipherViewList = emptyList() + val vaultData = VaultData( + cipherViewList = listOf(), + collectionViewList = listOf(), + folderViewList = listOf(), + sendViewList = listOf(), + ) // Trash assertEquals( @@ -479,7 +508,8 @@ class VaultItemListingDataExtensionsTest { message = R.string.no_items_trash.asText(), shouldShowAddButton = false, ), - cipherViewList.toViewState( + vaultData.toViewState( + vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Trash, isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, @@ -493,7 +523,8 @@ class VaultItemListingDataExtensionsTest { message = R.string.no_items_folder.asText(), shouldShowAddButton = false, ), - cipherViewList.toViewState( + vaultData.toViewState( + vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder( folderId = "folderId", ), @@ -509,7 +540,8 @@ class VaultItemListingDataExtensionsTest { message = R.string.no_items.asText(), shouldShowAddButton = true, ), - cipherViewList.toViewState( + vaultData.toViewState( + vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Login, isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, @@ -523,7 +555,8 @@ class VaultItemListingDataExtensionsTest { message = R.string.no_items_for_uri.asText("www.test.com"), shouldShowAddButton = true, ), - cipherViewList.toViewState( + vaultData.toViewState( + vaultFilterType = VaultFilterType.AllVaults, itemListingType = VaultItemListingState.ItemListingType.Vault.Login, isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, @@ -553,6 +586,7 @@ class VaultItemListingDataExtensionsTest { createMockDisplayItemForSend(number = 1, sendType = SendType.FILE), createMockDisplayItemForSend(number = 2, sendType = SendType.TEXT), ), + displayFolderList = emptyList(), ), result, ) @@ -645,4 +679,42 @@ class VaultItemListingDataExtensionsTest { result, ) } + + @Test + fun `toViewState should properly filter and return the correct folders`() { + val vaultData = VaultData( + listOf(createMockCipherView(number = 1)), + collectionViewList = emptyList(), + folderViewList = listOf( + FolderView("1", "test", clock.instant()), + FolderView("2", "test/test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("4", "test/test/test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ), + sendViewList = emptyList(), + ) + + val actual = vaultData.toViewState( + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + autofillSelectionData = null, + itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("1"), + vaultFilterType = VaultFilterType.AllVaults, + ) + + assertEquals( + VaultItemListingState.ViewState.Content( + displayItemList = listOf(), + displayFolderList = listOf( + VaultItemListingState.FolderDisplayItem( + name = "test", + id = "2", + count = 0, + ), + ), + ), + actual, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensionsTest.kt new file mode 100644 index 0000000000..cb9827d987 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/util/FolderViewExtensionsTest.kt @@ -0,0 +1,78 @@ +package com.x8bit.bitwarden.ui.vault.feature.util + +import com.bitwarden.core.FolderView +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class FolderViewExtensionsTest { + + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + + @Test + fun `getFolders should get the folders for a folderId with the correct names`() { + val folderList: List = listOf( + FolderView("1", "test", clock.instant()), + FolderView("2", "test/test", clock.instant()), + FolderView("2", "test/Folder", clock.instant()), + FolderView("3", "test//", clock.instant()), + FolderView("4", "test/test/test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ) + + val expected = listOf( + FolderView("2", "test", clock.instant()), + FolderView("2", "Folder", clock.instant()), + FolderView("3", "/", clock.instant()), + ) + + assertEquals( + expected, + folderList.getFolders(1.toString()), + ) + } + + @Test + fun `getFilteredFolders should properly filter out sub folders in a list`() { + val folderList: List = listOf( + FolderView("1", "test", clock.instant()), + FolderView("2", "test/test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("4", "test/test/test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ) + + val expected = listOf( + FolderView("1", "test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ) + + assertEquals( + expected, + folderList.getFilteredFolders(), + ) + } + + @Test + fun `toFolderDisplayName should return the correct name`() { + val folderName = "Folder/test/2" + + val folderList: List = listOf( + FolderView("2", "Folder/test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("4", folderName, clock.instant()), + FolderView("5", "Folder", clock.instant()), + ) + + assertEquals( + "2", + folderName.toFolderDisplayName(folderList), + ) + } +} 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 25df223f15..c8a04197e8 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 @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault.util import android.net.Uri import com.bitwarden.core.CipherType +import com.bitwarden.core.FolderView import com.bitwarden.core.LoginUriView import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.R @@ -22,16 +23,30 @@ import io.mockk.mockkStatic import io.mockk.unmockkStatic import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset class VaultDataExtensionsTest { + private val clock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + @Suppress("MaxLineLength") @Test 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)), + folderViewList = listOf( + FolderView("1", "test", clock.instant()), + FolderView("2", "test/test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("4", "test/test/test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ), sendViewList = listOf(createMockSendView(number = 1)), ) @@ -51,11 +66,22 @@ class VaultDataExtensionsTest { favoriteItems = listOf(), folderItems = listOf( VaultState.ViewState.FolderItem( - id = "mockId-1", - name = "mockName-1".asText(), - itemCount = 1, + id = "1", + name = "test".asText(), + itemCount = 0, + ), + VaultState.ViewState.FolderItem( + id = "3", + name = "test/".asText(), + itemCount = 0, + ), + VaultState.ViewState.FolderItem( + id = "5", + name = "Folder".asText(), + itemCount = 0, + ), + ), - ), collectionItems = listOf( VaultState.ViewState.CollectionItem( id = "mockId-1", @@ -466,4 +492,60 @@ class VaultDataExtensionsTest { ) unmockkStatic(Uri::class) } + + @Test + fun `toViewState should properly filter nested folders out`() { + val vaultData = VaultData( + listOf(createMockCipherView(number = 1)), + collectionViewList = emptyList(), + folderViewList = listOf( + FolderView("1", "test", clock.instant()), + FolderView("2", "test/test", clock.instant()), + FolderView("3", "test/", clock.instant()), + FolderView("4", "test/test/test/", clock.instant()), + FolderView("5", "Folder", clock.instant()), + ), + sendViewList = emptyList(), + ) + + val actual = vaultData.toViewState( + isPremium = true, + isIconLoadingDisabled = false, + baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, + vaultFilterType = VaultFilterType.AllVaults, + ) + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 1, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = listOf(), + folderItems = listOf( + VaultState.ViewState.FolderItem( + id = "1", + name = "test".asText(), + itemCount = 0, + ), + VaultState.ViewState.FolderItem( + id = "3", + name = "test/".asText(), + itemCount = 0, + ), + VaultState.ViewState.FolderItem( + id = "5", + name = "Folder".asText(), + itemCount = 0, + ), + + ), + collectionItems = listOf(), + noFolderItems = listOf(), + trashItemsCount = 0, + totpItemsCount = 1, + ), + actual, + ) + } }