From 2946186b517bd5b2f37ad1fb93fead7936a60748 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 27 Mar 2026 21:45:33 +0000 Subject: [PATCH 1/4] send folder --- .../vault/datasource/sdk/VaultSdkSource.kt | 17 +++ .../datasource/sdk/VaultSdkSourceImpl.kt | 18 +++ .../data/vault/manager/SendManager.kt | 10 ++ .../data/vault/manager/SendManagerImpl.kt | 120 +++++++++++++++++ .../ui/tools/feature/send/SendViewModel.kt | 2 +- .../send/addedit/AddEditSendContent.kt | 88 ++++++++++++ .../feature/send/addedit/AddEditSendScreen.kt | 11 ++ .../send/addedit/AddEditSendViewModel.kt | 127 +++++++++++++++++- .../addedit/handlers/AddEditSendHandlers.kt | 9 ++ .../util/AddEditSendStateExtensions.kt | 28 +++- .../tools/feature/send/model/SendItemType.kt | 1 + .../send/util/SentItemTypeExtensions.kt | 1 + .../send/viewsend/ViewSendViewModel.kt | 4 +- .../itemlisting/VaultItemListingViewModel.kt | 4 +- data/build.gradle.kts | 1 + .../data/manager/file/FileManager.kt | 14 ++ .../data/manager/file/FileManagerImpl.kt | 74 ++++++++++ .../data/manager/model/FolderFileEntry.kt | 10 ++ gradle/libs.versions.toml | 1 + ui/build.gradle.kts | 1 + .../ui/platform/manager/IntentManager.kt | 11 ++ .../ui/platform/manager/IntentManagerImpl.kt | 45 +++++++ .../bitwarden/ui/platform/model/FolderData.kt | 16 +++ ui/src/main/res/values/strings.xml | 5 + 24 files changed, 602 insertions(+), 16 deletions(-) create mode 100644 data/src/main/kotlin/com/bitwarden/data/manager/model/FolderFileEntry.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/model/FolderData.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index cf585c82f08..6a05ad36997 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -17,6 +17,8 @@ import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse import com.bitwarden.sdk.Fido2CredentialStore +import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry +import com.bitwarden.sdk.MakeSendFolderFileUniFfiResult import com.bitwarden.send.Send import com.bitwarden.send.SendView import com.bitwarden.vault.Attachment @@ -278,6 +280,21 @@ interface VaultSdkSource { destinationFilePath: String, ): Result + /** + * Creates a zip archive from the given folder [files] on disk for the user with the given + * [userId]. The SDK reads files directly from disk and writes the resulting zip to + * [outputZipPath], returning a [MakeSendFolderFileUniFfiResult] wrapped in a [Result]. + * + * This should only be called after a successful call to [initializeCrypto] for the associated + * user. + */ + suspend fun makeSendFolderFile( + userId: String, + folderName: String, + files: List, + outputZipPath: String, + ): Result + /** * Decrypts a [Send] for the user with the given [userId], returning a [SendView] wrapped in a * [Result]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 0072b84d3a8..9f9d17322d4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -21,6 +21,8 @@ import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse import com.bitwarden.sdk.BitwardenException import com.bitwarden.sdk.Fido2CredentialStore import com.bitwarden.sdk.VaultClient +import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry +import com.bitwarden.sdk.MakeSendFolderFileUniFfiResult import com.bitwarden.send.Send import com.bitwarden.send.SendView import com.bitwarden.vault.Attachment @@ -261,6 +263,22 @@ class VaultSdkSourceImpl( File(destinationFilePath) } + override suspend fun makeSendFolderFile( + userId: String, + folderName: String, + files: List, + outputZipPath: String, + ): Result = + runCatchingWithLogs { + getClient(userId = userId) + .sends() + .makeSendFolderFile( + folderName = folderName, + files = files, + destination = outputZipPath, + ) + } + override suspend fun encryptAttachment( userId: String, cipher: Cipher, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManager.kt index 9df7939e70b..a60b6e871eb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManager.kt @@ -18,6 +18,16 @@ interface SendManager { */ suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult + /** + * Attempt to create a folder send. The folder at [folderUri] will be zipped via the SDK + * and uploaded as a file send. [folderName] is the display name of the selected folder. + */ + suspend fun createFolderSend( + sendView: SendView, + folderUri: Uri, + folderName: String, + ): CreateSendResult + /** * Attempt to delete a send. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManagerImpl.kt index 91b09c3b3d8..5ed98a233f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/SendManagerImpl.kt @@ -5,6 +5,7 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import com.bitwarden.core.data.util.flatMap +import com.bitwarden.sdk.MakeSendFolderFileUniFfiEntry import com.bitwarden.data.manager.file.FileManager import com.bitwarden.network.model.CreateFileSendResponse import com.bitwarden.network.model.CreateSendJsonResponse @@ -110,6 +111,125 @@ class SendManagerImpl( ) } + @Suppress("LongMethod") + override suspend fun createFolderSend( + sendView: SendView, + folderUri: Uri, + folderName: String, + ): CreateSendResult { + val userId = activeUserId + ?: return CreateSendResult.Error(message = null, error = NoActiveUserException()) + + val zipFile = fileManager.createTempFileInCache( + prefix = "send_folder_", + suffix = ".zip", + ) + return fileManager + .writeFolderFilesToCache(folderUri) + .flatMap { diskEntries -> + val sdkEntries = diskEntries.map { entry -> + MakeSendFolderFileUniFfiEntry( + path = entry.relativePath, + source = entry.diskPath, + ) + } + vaultSdkSource + .makeSendFolderFile( + userId = userId, + folderName = folderName, + files = sdkEntries, + outputZipPath = zipFile.absolutePath, + ) + .also { + // Clean up individual temp files now that the zip is written. + fileManager.delete( + *diskEntries + .map { java.io.File(it.diskPath) } + .toTypedArray(), + ) + } + } + .flatMap { folderResult -> + val folderSendView = sendView.copy( + file = folderResult.file, + ) + + vaultSdkSource + .encryptSend(userId = userId, sendView = folderSendView) + .flatMap { send -> + vaultSdkSource + .encryptFile( + userId = userId, + send = send, + path = zipFile.absolutePath, + destinationFilePath = zipFile.absolutePath, + ) + .flatMap { encryptedFile -> + sendsService + .createFileSend( + body = send.toEncryptedNetworkSend( + fileLength = encryptedFile.length(), + ), + ) + .flatMap { sendFileResponse -> + when (sendFileResponse) { + is CreateFileSendResponse.Invalid -> { + CreateSendJsonResponse + .Invalid( + message = sendFileResponse.message, + validationErrors = sendFileResponse + .validationErrors, + ) + .asSuccess() + } + + is CreateFileSendResponse.Success -> { + sendsService + .uploadFile( + sendFileResponse = sendFileResponse + .createFileJsonResponse, + encryptedFile = encryptedFile, + ) + .map { CreateSendJsonResponse.Success(it) } + } + } + } + } + } + } + .also { + fileManager.delete(zipFile) + } + .map { createSendResponse -> + when (createSendResponse) { + is CreateSendJsonResponse.Invalid -> { + return CreateSendResult.Error( + message = createSendResponse.firstValidationErrorMessage, + error = null, + ) + } + + is CreateSendJsonResponse.Success -> { + vaultDiskSource.saveSend(userId = userId, send = createSendResponse.send) + createSendResponse + } + } + } + .flatMap { createSendSuccessResponse -> + vaultSdkSource.decryptSend( + userId = userId, + send = createSendSuccessResponse.send.toEncryptedSdkSend(), + ) + } + .fold( + onFailure = { CreateSendResult.Error(message = null, error = it) }, + onSuccess = { + reviewPromptManager.registerCreateSendAction() + CreateSendResult.Success(sendView = it) + }, + ) + } + override suspend fun deleteSend(sendId: String): DeleteSendResult { val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException()) return sendsService diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 76befe5ffac..ecd920976dd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -304,7 +304,7 @@ class SendViewModel @Inject constructor( } private fun handleAddSendSelected(action: SendAction.AddSendSelected) { - if (action.sendType == SendItemType.FILE) { + if (action.sendType == SendItemType.FILE || action.sendType == SendItemType.FOLDER) { if (state.policyDisablesSend) { mutableStateFlow.update { it.copy( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt index cbd9a4ccc92..42c5439d452 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendContent.kt @@ -129,6 +129,14 @@ fun AddEditSendContent( ) } + is AddEditSendState.ViewState.Content.SendType.Folder -> { + FolderTypeContent( + folderType = type, + addSendHandlers = addSendHandlers, + isAddMode = isAddMode, + ) + } + is AddEditSendState.ViewState.Content.SendType.Text -> { TextTypeContent( textType = type, @@ -372,6 +380,86 @@ private fun ColumnScope.FileTypeContent( } } +@Composable +private fun ColumnScope.FolderTypeContent( + folderType: AddEditSendState.ViewState.Content.SendType.Folder, + addSendHandlers: AddEditSendHandlers, + isAddMode: Boolean, +) { + Spacer(modifier = Modifier.height(height = 8.dp)) + if (isAddMode) { + folderType.name?.let { folderName -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .defaultMinSize(minHeight = 60.dp) + .cardStyle(cardStyle = CardStyle.Full, paddingHorizontal = 16.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = folderName, + color = BitwardenTheme.colorScheme.text.primary, + style = BitwardenTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier + .fillMaxWidth() + .testTag(tag = "SendCurrentFolderNameLabel"), + ) + if (folderType.fileCount != null && folderType.totalSizeBytes != null) { + Spacer(modifier = Modifier.height(height = 4.dp)) + Text( + text = stringResource( + id = BitwardenString.folder_info, + folderType.fileCount, + formatFileSize(folderType.totalSizeBytes), + ), + color = BitwardenTheme.colorScheme.text.secondary, + style = BitwardenTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + Spacer(modifier = Modifier.height(height = 8.dp)) + } + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.choose_folder), + onClick = addSendHandlers.onChooseFolderClick, + isExternalLink = true, + modifier = Modifier + .testTag(tag = "SendChooseFolderButton") + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + Text( + text = stringResource(id = BitwardenString.required_max_folder_size), + color = BitwardenTheme.colorScheme.text.secondary, + style = BitwardenTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + .padding(horizontal = 16.dp), + ) + } +} + +/** + * Formats a file size in bytes to a human-readable string. + */ +private fun formatFileSize(bytes: Long): String { + val kb = 1024.0 + val mb = kb * 1024 + return when { + bytes >= mb -> "%.1f MB".format(bytes / mb) + bytes >= kb -> "%.1f KB".format(bytes / kb) + else -> "$bytes B" + } +} + /** * Displays a collapsable set of new send options. * diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt index 8b895631848..11342b853b1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt @@ -66,6 +66,11 @@ fun AddEditSendScreen( addSendHandlers.onFileChoose(it) } } + val folderChooserLauncher = intentManager.getActivityResultLauncher { activityResult -> + intentManager.getFolderDataFromActivityResult(activityResult)?.let { + addSendHandlers.onFolderChoose(it) + } + } val snackbarHostState = rememberBitwardenSnackbarHostState() BackHandler( @@ -85,6 +90,12 @@ fun AddEditSendScreen( ) } + is AddEditSendEvent.ShowFolderChooser -> { + folderChooserLauncher.launch( + intentManager.createFolderChooserIntent(), + ) + } + is AddEditSendEvent.ShowShareSheet -> { intentManager.shareText(event.message) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index d6e6fc527e3..571ab8e6da0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -17,6 +17,7 @@ import com.bitwarden.ui.platform.base.util.isValidEmail import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.bitwarden.ui.platform.model.FileData +import com.bitwarden.ui.platform.model.FolderData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -138,6 +139,15 @@ class AddEditSendViewModel @Inject constructor( ) } + SendItemType.FOLDER -> { + AddEditSendState.ViewState.Content.SendType.Folder( + uri = null, + name = null, + fileCount = null, + totalSizeBytes = null, + ) + } + SendItemType.TEXT -> { AddEditSendState.ViewState.Content.SendType.Text( input = "", @@ -197,6 +207,8 @@ class AddEditSendViewModel @Inject constructor( AddEditSendAction.DismissDialogClick -> handleDismissDialogClick() is AddEditSendAction.SaveClick -> handleSaveClick() is AddEditSendAction.ChooseFileClick -> handleChooseFileClick(action) + AddEditSendAction.ChooseFolderClick -> handleChooseFolderClick() + is AddEditSendAction.FolderChoose -> handleFolderChoose(action) is AddEditSendAction.NameChange -> handleNameChange(action) is AddEditSendAction.MaxAccessCountChange -> handleMaxAccessCountChange(action) is AddEditSendAction.TextChange -> handleTextChange(action) @@ -750,6 +762,44 @@ class AddEditSendViewModel @Inject constructor( return@onContent } } + + (content.selectedType as? AddEditSendState.ViewState.Content.SendType.Folder) + ?.let { folderType -> + if (!state.isPremium) { + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = BitwardenString.send.asText(), + message = BitwardenString.send_file_premium_required.asText(), + ), + ) + } + return@onContent + } + if (folderType.uri == null) { + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.choose_folder.asText(), + ), + ) + } + return@onContent + } + if ((folderType.totalSizeBytes ?: 0) > MAX_FILE_SIZE_BYTES) { + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString.max_file_size.asText(), + ), + ) + } + return@onContent + } + } + if (!networkConnectionManager.isNetworkConnected) { mutableStateFlow.update { it.copy( @@ -771,12 +821,23 @@ class AddEditSendViewModel @Inject constructor( viewModelScope.launch { when (val addSendType = state.addEditSendType) { AddEditSendType.AddItem -> { - val fileType = content - .selectedType as? AddEditSendState.ViewState.Content.SendType.File - val result = vaultRepo.createSend( - sendView = content.toSendView(clock), - fileUri = fileType?.uri, - ) + val folderType = content + .selectedType as? AddEditSendState.ViewState.Content.SendType.Folder + val result = if (folderType != null) { + vaultRepo.createFolderSend( + sendView = content.toSendView(clock), + folderUri = requireNotNull(folderType.uri), + folderName = requireNotNull(folderType.name), + ) + } else { + val fileType = content + .selectedType + as? AddEditSendState.ViewState.Content.SendType.File + vaultRepo.createSend( + sendView = content.toSendView(clock), + fileUri = fileType?.uri, + ) + } sendAction(AddEditSendAction.Internal.CreateSendResultReceive(result)) } @@ -818,6 +879,21 @@ class AddEditSendViewModel @Inject constructor( sendEvent(AddEditSendEvent.ShowChooserSheet(action.isCameraPermissionGranted)) } + private fun handleChooseFolderClick() { + sendEvent(AddEditSendEvent.ShowFolderChooser) + } + + private fun handleFolderChoose(action: AddEditSendAction.FolderChoose) { + updateFolderContent { + it.copy( + uri = action.folderData.uri, + name = action.folderData.folderName, + fileCount = action.folderData.fileCount, + totalSizeBytes = action.folderData.totalSizeBytes, + ) + } + } + private fun handleMaxAccessCountChange(action: AddEditSendAction.MaxAccessCountChange) { updateCommonContent { common -> common.copy(maxAccessCount = action.value.takeUnless { it == 0 }) @@ -905,6 +981,17 @@ class AddEditSendViewModel @Inject constructor( ?.let { currentContent.copy(selectedType = block(it)) } } } + + private inline fun updateFolderContent( + crossinline block: ( + AddEditSendState.ViewState.Content.SendType.Folder, + ) -> AddEditSendState.ViewState.Content.SendType.Folder, + ) { + updateContent { currentContent -> + (currentContent.selectedType as? AddEditSendState.ViewState.Content.SendType.Folder) + ?.let { currentContent.copy(selectedType = block(it)) } + } + } } /** @@ -930,11 +1017,13 @@ data class AddEditSendState( get() = when (addEditSendType) { AddEditSendType.AddItem -> when (sendType) { SendItemType.FILE -> BitwardenString.add_file_send.asText() + SendItemType.FOLDER -> BitwardenString.add_folder_send.asText() SendItemType.TEXT -> BitwardenString.add_text_send.asText() } is AddEditSendType.EditItem -> when (sendType) { SendItemType.FILE -> BitwardenString.edit_file_send.asText() + SendItemType.FOLDER -> BitwardenString.edit_folder_send.asText() SendItemType.TEXT -> BitwardenString.edit_text_send.asText() } } @@ -1020,6 +1109,17 @@ data class AddEditSendState( val sizeBytes: Long?, ) : SendType() + /** + * Sending a folder (zipped as a file). + */ + @Parcelize + data class Folder( + val uri: Uri?, + val name: String?, + val fileCount: Int?, + val totalSizeBytes: Long?, + ) : SendType() + /** * Sending text. */ @@ -1095,6 +1195,11 @@ sealed class AddEditSendEvent { */ data class ShowChooserSheet(val withCameraOption: Boolean) : AddEditSendEvent() + /** + * Show folder chooser. + */ + data object ShowFolderChooser : AddEditSendEvent() + /** * Show share sheet. */ @@ -1199,6 +1304,16 @@ sealed class AddEditSendAction { val isCameraPermissionGranted: Boolean, ) : AddEditSendAction() + /** + * User clicked the choose folder button. + */ + data object ChooseFolderClick : AddEditSendAction() + + /** + * User has chosen a folder. + */ + data class FolderChoose(val folderData: FolderData) : AddEditSendAction() + /** * User toggled the "hide text by default" toggle. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt index 0d0e5fbd0ef..1f435455738 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/handlers/AddEditSendHandlers.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit.handlers import com.bitwarden.ui.platform.model.FileData +import com.bitwarden.ui.platform.model.FolderData import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendAction import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendViewModel import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AuthEmail @@ -15,6 +16,8 @@ data class AddEditSendHandlers( val onNameChange: (String) -> Unit, val onChooseFileClick: (hasPermission: Boolean) -> Unit, val onFileChoose: (FileData) -> Unit, + val onChooseFolderClick: () -> Unit, + val onFolderChoose: (FolderData) -> Unit, val onTextChange: (String) -> Unit, val onIsHideByDefaultToggle: (Boolean) -> Unit, val onMaxAccessCountChange: (Int) -> Unit, @@ -47,6 +50,12 @@ data class AddEditSendHandlers( viewModel.trySendAction(AddEditSendAction.ChooseFileClick(it)) }, onFileChoose = { viewModel.trySendAction(AddEditSendAction.FileChoose(it)) }, + onChooseFolderClick = { + viewModel.trySendAction(AddEditSendAction.ChooseFolderClick) + }, + onFolderChoose = { + viewModel.trySendAction(AddEditSendAction.FolderChoose(it)) + }, onTextChange = { viewModel.trySendAction(AddEditSendAction.TextChange(it)) }, onIsHideByDefaultToggle = { viewModel.trySendAction(AddEditSendAction.HideByDefaultToggle(it)) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt index 162b35f54f7..1d2d47f611e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/AddEditSendStateExtensions.kt @@ -59,17 +59,31 @@ fun AddEditSendState.ViewState.Content.toSendView( private fun AddEditSendState.ViewState.Content.SendType.toSendType(): SendType = when (this) { is AddEditSendState.ViewState.Content.SendType.File -> SendType.FILE + is AddEditSendState.ViewState.Content.SendType.Folder -> SendType.FILE is AddEditSendState.ViewState.Content.SendType.Text -> SendType.TEXT } private fun AddEditSendState.ViewState.Content.toSendFileView(): SendFileView? = - (this.selectedType as? AddEditSendState.ViewState.Content.SendType.File)?.let { - SendFileView( - id = null, - fileName = it.name.orEmpty(), - size = null, - sizeName = null, - ) + when (val type = this.selectedType) { + is AddEditSendState.ViewState.Content.SendType.File -> { + SendFileView( + id = null, + fileName = type.name.orEmpty(), + size = null, + sizeName = null, + ) + } + + is AddEditSendState.ViewState.Content.SendType.Folder -> { + SendFileView( + id = null, + fileName = "${type.name.orEmpty()}.zip", + size = null, + sizeName = null, + ) + } + + else -> null } private fun AddEditSendState.ViewState.Content.toSendTextView(): SendTextView? = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt index 86dc009c157..0c6e8012628 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/model/SendItemType.kt @@ -8,5 +8,6 @@ import kotlinx.serialization.Serializable @Serializable enum class SendItemType { FILE, + FOLDER, TEXT, } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/util/SentItemTypeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/util/SentItemTypeExtensions.kt index 0923adbaf93..346c201471b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/util/SentItemTypeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/util/SentItemTypeExtensions.kt @@ -11,5 +11,6 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType val SendItemType.selectionText: Text get() = when (this) { SendItemType.FILE -> BitwardenString.file.asText() + SendItemType.FOLDER -> BitwardenString.folder.asText() SendItemType.TEXT -> BitwardenString.text.asText() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt index 108b10c07af..81a80c30856 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt @@ -253,7 +253,9 @@ data class ViewSendState( */ val screenDisplayName: Text get() = when (sendType) { - SendItemType.FILE -> BitwardenString.view_file_send.asText() + SendItemType.FILE, + SendItemType.FOLDER, + -> BitwardenString.view_file_send.asText() SendItemType.TEXT -> BitwardenString.view_text_send.asText() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 5e8fd3e99ea..a1fcdc8503b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -889,7 +889,9 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingState.ItemListingType.Send -> { when (val sendType = itemListingType.toSendItemType()) { - SendItemType.FILE -> { + SendItemType.FILE, + SendItemType.FOLDER, + -> { if (state.isPremium) { sendEvent(VaultItemListingEvent.NavigateToAddSendItem(sendType)) } else { diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 91e07345e60..6282d929996 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(project(":network")) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.documentfile) implementation(libs.androidx.security.crypto) implementation(libs.androidx.lifecycle.process) implementation(libs.google.hilt.android) diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManager.kt b/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManager.kt index 0b3e5cecd47..a481360bc22 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManager.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManager.kt @@ -3,6 +3,7 @@ package com.bitwarden.data.manager.file import android.net.Uri import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.data.manager.model.DownloadResult +import com.bitwarden.data.manager.model.FolderDiskEntry import com.bitwarden.data.manager.model.ZipFileResult import java.io.File @@ -27,6 +28,12 @@ interface FileManager { */ suspend fun delete(vararg files: File) + /** + * Creates a temporary file in the private cache directory with the given [prefix] and + * [suffix]. Returns the created [File]. + */ + suspend fun createTempFileInCache(prefix: String, suffix: String): File + /** * Downloads a file temporarily to cache from [url]. A successful [DownloadResult] will contain * the final file path. @@ -61,4 +68,11 @@ interface FileManager { * reference. */ suspend fun writeUriToCache(fileUri: Uri): Result + + /** + * Streams all files from a folder tree identified by [folderUri] (a tree URI from + * `ACTION_OPEN_DOCUMENT_TREE`) to temporary files in the cache directory. Returns a flat + * list of [FolderDiskEntry] mapping relative paths to their on-disk locations. + */ + suspend fun writeFolderFilesToCache(folderUri: Uri): Result> } diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManagerImpl.kt b/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManagerImpl.kt index 1673fe32206..097eb36bb38 100644 --- a/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManagerImpl.kt +++ b/data/src/main/kotlin/com/bitwarden/data/manager/file/FileManagerImpl.kt @@ -7,7 +7,9 @@ import android.net.Uri import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.util.sdkAgnosticTransferTo +import androidx.documentfile.provider.DocumentFile import com.bitwarden.data.manager.model.DownloadResult +import com.bitwarden.data.manager.model.FolderDiskEntry import com.bitwarden.data.manager.model.ZipFileResult import com.bitwarden.network.service.DownloadService import kotlinx.coroutines.withContext @@ -48,6 +50,13 @@ internal class FileManagerImpl( } } + override suspend fun createTempFileInCache( + prefix: String, + suffix: String, + ): File = withContext(dispatcherManager.io) { + File.createTempFile(prefix, suffix, context.cacheDir) + } + @Suppress("NestedBlockDepth") override suspend fun downloadFileToCache(url: String): DownloadResult { val response = downloadService @@ -194,6 +203,71 @@ internal class FileManagerImpl( File(context.filesDir, tempFileName) } } + + override suspend fun writeFolderFilesToCache( + folderUri: Uri, + ): Result> = + runCatching { + withContext(dispatcherManager.io) { + val rootDocument = DocumentFile.fromTreeUri(context, folderUri) + ?: throw IllegalStateException("Cannot access folder") + val entries = mutableListOf() + streamDocumentTreeToCache( + document = rootDocument, + parentPath = "", + entries = entries, + ) + entries + } + } + + private fun streamDocumentTreeToCache( + document: DocumentFile, + parentPath: String, + entries: MutableList, + ) { + document.listFiles().forEach { child -> + val childPath = if (parentPath.isEmpty()) { + child.name.orEmpty() + } else { + "$parentPath/${child.name.orEmpty()}" + } + if (child.isDirectory) { + streamDocumentTreeToCache( + document = child, + parentPath = childPath, + entries = entries, + ) + } else { + val tempFile = File.createTempFile( + "send_folder_file_", + null, + context.cacheDir, + ) + context + .contentResolver + .openInputStream(child.uri) + ?.use { inputStream -> + FileOutputStream(tempFile).use { outputStream -> + val buffer = ByteArray(BUFFER_SIZE) + var length: Int + while ( + inputStream.read(buffer).also { length = it } != -1 + ) { + outputStream.write(buffer, 0, length) + } + } + } + ?: throw IllegalStateException("Cannot read file: $childPath") + entries.add( + FolderDiskEntry( + relativePath = childPath, + diskPath = tempFile.absolutePath, + ), + ) + } + } + } } /** diff --git a/data/src/main/kotlin/com/bitwarden/data/manager/model/FolderFileEntry.kt b/data/src/main/kotlin/com/bitwarden/data/manager/model/FolderFileEntry.kt new file mode 100644 index 00000000000..fcfe6be35aa --- /dev/null +++ b/data/src/main/kotlin/com/bitwarden/data/manager/model/FolderFileEntry.kt @@ -0,0 +1,10 @@ +package com.bitwarden.data.manager.model + +/** + * Represents a single file entry within a folder, with its [relativePath] from the folder root + * and its absolute [diskPath] on the local file system. + */ +data class FolderDiskEntry( + val relativePath: String, + val diskPath: String, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09bc669cc96..45ac5491801 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version = "1.0.1" } #noinspection CredentialDependency - Used for Passkey support, which is not available below Android 14 androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" } androidx-credentials-providerevents = { module = "androidx.credentials.providerevents:providerevents", version.ref = "androidxCredentialsProviderEvents" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 312268ae336..eb30284b624 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.credentials) + implementation(libs.androidx.documentfile) implementation(libs.androidx.navigation.compose) implementation(libs.bumptech.glide) implementation(libs.kotlinx.serialization) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt index d2bea223005..30c29e43df9 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManager.kt @@ -12,6 +12,7 @@ import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.model.FileData +import com.bitwarden.ui.platform.model.FolderData import java.time.Clock /** @@ -99,6 +100,16 @@ interface IntentManager { */ fun createDocumentIntent(fileName: String): Intent + /** + * Creates an intent for choosing a folder from the device. + */ + fun createFolderChooserIntent(): Intent + + /** + * Processes the [activityResult] and attempts to get the relevant folder data from it. + */ + fun getFolderDataFromActivityResult(activityResult: ActivityResult): FolderData? + @Suppress("UndocumentedPublicClass") @OmitFromCoverage companion object { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt index 1d0f3dd409e..f032e655c3b 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/IntentManagerImpl.kt @@ -30,7 +30,9 @@ import com.bitwarden.core.data.manager.util.deviceData import com.bitwarden.core.data.manager.util.fileProviderAuthority import com.bitwarden.core.util.isBuildVersionAtLeast import com.bitwarden.ui.platform.manager.intent.model.AuthTabData +import androidx.documentfile.provider.DocumentFile import com.bitwarden.ui.platform.model.FileData +import com.bitwarden.ui.platform.model.FolderData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.util.getLocalFileData import timber.log.Timber @@ -230,6 +232,49 @@ internal class IntentManagerImpl( putExtra(Intent.EXTRA_TITLE, fileName) } + override fun createFolderChooserIntent(): Intent = + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + + override fun getFolderDataFromActivityResult( + activityResult: ActivityResult, + ): FolderData? { + if (activityResult.resultCode != Activity.RESULT_OK) return null + val treeUri = activityResult.data?.data ?: return null + val rootDocument = DocumentFile.fromTreeUri(activity, treeUri) ?: return null + var fileCount = 0 + var totalSize = 0L + countFilesRecursively(rootDocument) { count, size -> + fileCount = count + totalSize = size + } + return FolderData( + folderName = rootDocument.name ?: "folder", + uri = treeUri, + fileCount = fileCount, + totalSizeBytes = totalSize, + ) + } + + private fun countFilesRecursively( + document: DocumentFile, + onResult: (fileCount: Int, totalSize: Long) -> Unit, + ) { + var count = 0 + var size = 0L + fun traverse(doc: DocumentFile) { + doc.listFiles().forEach { child -> + if (child.isDirectory) { + traverse(child) + } else { + count++ + size += child.length() + } + } + } + traverse(document) + onResult(count, size) + } + private fun createPlayStoreIntent(packageName: String): Intent { val playStoreUri = "https://play.google.com/store/apps/details" .toUri() diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/model/FolderData.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/model/FolderData.kt new file mode 100644 index 00000000000..7d4adc556ac --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/model/FolderData.kt @@ -0,0 +1,16 @@ +package com.bitwarden.ui.platform.model + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents metadata about a selected folder. + */ +@Parcelize +data class FolderData( + val folderName: String, + val uri: Uri, + val fileCount: Int, + val totalSizeBytes: Long, +) : Parcelable diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 2494cbcd3de..b60b969c582 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -483,6 +483,11 @@ Scanning will happen automatically. Edit file Send Edit text Send New file Send + New folder Send + Edit folder Send + Choose folder + %1$d files, %2$s + Maximum total size: 100 MB. New text Send Are you sure you want to delete this Send? Send deleted From 13b41d104a84aebb0213cb90edeeef8112300552 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 27 Mar 2026 21:59:51 +0000 Subject: [PATCH 2/4] behind feature flag --- .../ui/tools/feature/send/SendScreen.kt | 16 ++++++++++------ .../ui/tools/feature/send/SendViewModel.kt | 6 ++++++ .../bitwarden/core/data/manager/model/FlagKey.kt | 9 +++++++++ .../components/debug/FeatureFlagListItems.kt | 2 ++ ui/src/main/res/values/strings_non_localized.xml | 1 + 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index 2c99ddc38cf..f03ae40d92a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -122,6 +122,7 @@ fun SendScreen( SendDialogs( dialogState = state.dialogState, + isSendFolderEnabled = state.isSendFolderEnabled, onAddSendSelected = { viewModel.trySendAction(SendAction.AddSendSelected(it)) }, onDismissRequest = { viewModel.trySendAction(SendAction.DismissDialog) }, ) @@ -211,6 +212,7 @@ fun SendScreen( @Composable private fun SendDialogs( dialogState: SendState.DialogState?, + isSendFolderEnabled: Boolean, onAddSendSelected: (SendItemType) -> Unit, onDismissRequest: () -> Unit, ) { @@ -230,12 +232,14 @@ private fun SendDialogs( title = stringResource(id = BitwardenString.type), onDismissRequest = onDismissRequest, ) { - SendItemType.entries.forEach { - BitwardenBasicDialogRow( - text = it.selectionText(), - onClick = { onAddSendSelected(it) }, - ) - } + SendItemType.entries + .filter { it != SendItemType.FOLDER || isSendFolderEnabled } + .forEach { + BitwardenBasicDialogRow( + text = it.selectionText(), + onClick = { onAddSendSelected(it) }, + ) + } } null -> Unit diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index ecd920976dd..e5b60d1224b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -16,8 +16,10 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText +import com.bitwarden.core.data.manager.model.FlagKey import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager @@ -59,6 +61,7 @@ class SendViewModel @Inject constructor( private val environmentRepo: EnvironmentRepository, private val vaultRepo: VaultRepository, private val networkConnectionManager: NetworkConnectionManager, + featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] @@ -71,6 +74,8 @@ class SendViewModel @Inject constructor( .any(), isRefreshing = false, isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true, + isSendFolderEnabled = featureFlagManager + .getFeatureFlag(key = FlagKey.SendFolder), ), ) { @@ -475,6 +480,7 @@ data class SendState( val policyDisablesSend: Boolean, val isRefreshing: Boolean, val isPremiumUser: Boolean, + val isSendFolderEnabled: Boolean, ) : Parcelable { /** diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index bed22817e55..0791e3771d7 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -38,6 +38,7 @@ sealed class FlagKey { ArchiveItems, SendEmailVerification, MobilePremiumUpgrade, + SendFolder, ) } } @@ -116,6 +117,14 @@ sealed class FlagKey { override val defaultValue: Boolean = false } + /** + * Data object holding the feature flag key for the Send Folder feature. + */ + data object SendFolder : FlagKey() { + override val keyName: String = "innovation-sprint-2026-send-folder" + override val defaultValue: Boolean = false + } + //region Dummy keys for testing /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 84193580aab..c81e0785b7f 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -32,6 +32,7 @@ fun FlagKey.ListItemContent( FlagKey.ArchiveItems, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, + FlagKey.SendFolder, -> { @Suppress("UNCHECKED_CAST") BooleanFlagItem( @@ -85,4 +86,5 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { FlagKey.ArchiveItems -> stringResource(BitwardenString.archive_items) FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification) FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade) + FlagKey.SendFolder -> stringResource(BitwardenString.send_folder) } diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 206100c289f..ea3a7786bfc 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -46,6 +46,7 @@ Trigger cookie acquisition Clear SSO cookies Mobile Premium Upgrade + Send Folder From 1b69c2a066f88cb156c6b56cbe5a680e1f39aad2 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 27 Mar 2026 22:06:21 +0000 Subject: [PATCH 3/4] empty folder invalid --- .../feature/send/addedit/AddEditSendViewModel.kt | 13 +++++++++++++ ui/src/main/res/values/strings.xml | 1 + 2 files changed, 14 insertions(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index 571ab8e6da0..e2151880258 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -884,6 +884,19 @@ class AddEditSendViewModel @Inject constructor( } private fun handleFolderChoose(action: AddEditSendAction.FolderChoose) { + if (action.folderData.fileCount <= 0) { + mutableStateFlow.update { + it.copy( + dialogState = AddEditSendState.DialogState.Error( + title = BitwardenString.an_error_has_occurred.asText(), + message = BitwardenString + .the_selected_folder_is_empty + .asText(), + ), + ) + } + return + } updateFolderContent { it.copy( uri = action.folderData.uri, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index b60b969c582..99a54b82db1 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -486,6 +486,7 @@ Scanning will happen automatically. New folder Send Edit folder Send Choose folder + The selected folder is empty. Please choose a folder that contains files. %1$d files, %2$s Maximum total size: 100 MB. New text Send From 7e91e71d950a41796f9da1ec6f763f859b06b891 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk Date: Fri, 27 Mar 2026 22:13:26 +0000 Subject: [PATCH 4/4] extract lib document file version --- gradle/libs.versions.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45ac5491801..6ad60b715ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ androidxBrowser = "1.9.0" androidxCamera = "1.5.3" androidxComposeBom = "2026.03.00" androidxCore = "1.18.0" +androidxDocumentFile = "1.1.0" androidxCredentials = "1.6.0-rc02" androidxCredentialsProviderEvents = "1.0.0-alpha05" androidxHiltNavigationCompose = "1.3.0" @@ -84,7 +85,7 @@ androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-mani androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } -androidx-documentfile = { module = "androidx.documentfile:documentfile", version = "1.0.1" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidxDocumentFile" } #noinspection CredentialDependency - Used for Passkey support, which is not available below Android 14 androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" } androidx-credentials-providerevents = { module = "androidx.credentials.providerevents:providerevents", version.ref = "androidxCredentialsProviderEvents" }