BIT-1115 Add nested folder support (#1072)

This commit is contained in:
Oleg Semenenko
2024-03-05 11:00:45 -06:00
committed by Álison Fernandes
parent 5c4a7310d4
commit c3b422e46f
15 changed files with 705 additions and 98 deletions

View File

@@ -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,
)

View File

@@ -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 {

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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<DisplayItem>,
val displayFolderList: List<FolderDisplayItem>,
) : 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.

View File

@@ -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(

View File

@@ -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<CipherView>.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<CipherView>.toViewState(
shouldShowAddButton = shouldShowAddButton,
)
}
}
/**
* Transforms a list of [CipherView] into [VaultItemListingState.ViewState].
@@ -142,6 +176,7 @@ fun List<SendView>.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(),
)

View File

@@ -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<FolderView>.getFolders(folderId: String): List<FolderView> {
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<FolderView>.getFilteredFolders(folderName: String? = null): List<FolderView> =
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<FolderView>): 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
}

View File

@@ -45,6 +45,9 @@ fun NavGraphBuilder.vaultGraph(
onNavigateToVaultAddItemScreen = onNavigateToVaultAddItemScreen,
onNavigateToSearchVault = onNavigateToSearchVault,
onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen,
onNavigateToVaultItemListing = {
navController.navigateToVaultItemListing(it)
},
)
vaultVerificationCodeDestination(

View File

@@ -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() }