Compare commits

...

4 Commits

Author SHA1 Message Date
Maciej Zieniuk
7e91e71d95 extract lib document file version 2026-03-27 22:13:26 +00:00
Maciej Zieniuk
1b69c2a066 empty folder invalid 2026-03-27 22:06:21 +00:00
Maciej Zieniuk
13b41d104a behind feature flag 2026-03-27 21:59:51 +00:00
Maciej Zieniuk
2946186b51 send folder 2026-03-27 21:45:33 +00:00
28 changed files with 645 additions and 22 deletions

View File

@@ -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<File>
/**
* 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<MakeSendFolderFileUniFfiEntry>,
outputZipPath: String,
): Result<MakeSendFolderFileUniFfiResult>
/**
* Decrypts a [Send] for the user with the given [userId], returning a [SendView] wrapped in a
* [Result].

View File

@@ -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<MakeSendFolderFileUniFfiEntry>,
outputZipPath: String,
): Result<MakeSendFolderFileUniFfiResult> =
runCatchingWithLogs {
getClient(userId = userId)
.sends()
.makeSendFolderFile(
folderName = folderName,
files = files,
destination = outputZipPath,
)
}
override suspend fun encryptAttachment(
userId: String,
cipher: Cipher,

View File

@@ -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.
*/

View File

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

View File

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

View File

@@ -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<SendState, SendEvent, SendAction>(
// 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),
),
) {
@@ -304,7 +309,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(
@@ -475,6 +480,7 @@ data class SendState(
val policyDisablesSend: Boolean,
val isRefreshing: Boolean,
val isPremiumUser: Boolean,
val isSendFolderEnabled: Boolean,
) : Parcelable {
/**

View File

@@ -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.
*

View File

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

View File

@@ -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,34 @@ class AddEditSendViewModel @Inject constructor(
sendEvent(AddEditSendEvent.ShowChooserSheet(action.isCameraPermissionGranted))
}
private fun handleChooseFolderClick() {
sendEvent(AddEditSendEvent.ShowFolderChooser)
}
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,
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 +994,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 +1030,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 +1122,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 +1208,11 @@ sealed class AddEditSendEvent {
*/
data class ShowChooserSheet(val withCameraOption: Boolean) : AddEditSendEvent()
/**
* Show folder chooser.
*/
data object ShowFolderChooser : AddEditSendEvent()
/**
* Show share sheet.
*/
@@ -1199,6 +1317,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.
*/

View File

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

View File

@@ -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? =

View File

@@ -8,5 +8,6 @@ import kotlinx.serialization.Serializable
@Serializable
enum class SendItemType {
FILE,
FOLDER,
TEXT,
}

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ sealed class FlagKey<out T : Any> {
ArchiveItems,
SendEmailVerification,
MobilePremiumUpgrade,
SendFolder,
)
}
}
@@ -116,6 +117,14 @@ sealed class FlagKey<out T : Any> {
override val defaultValue: Boolean = false
}
/**
* Data object holding the feature flag key for the Send Folder feature.
*/
data object SendFolder : FlagKey<Boolean>() {
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.

View File

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

View File

@@ -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<File>
/**
* 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<List<FolderDiskEntry>>
}

View File

@@ -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<List<FolderDiskEntry>> =
runCatching {
withContext(dispatcherManager.io) {
val rootDocument = DocumentFile.fromTreeUri(context, folderUri)
?: throw IllegalStateException("Cannot access folder")
val entries = mutableListOf<FolderDiskEntry>()
streamDocumentTreeToCache(
document = rootDocument,
parentPath = "",
entries = entries,
)
entries
}
}
private fun streamDocumentTreeToCache(
document: DocumentFile,
parentPath: String,
entries: MutableList<FolderDiskEntry>,
) {
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,
),
)
}
}
}
}
/**

View File

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

View File

@@ -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,6 +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.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" }

View File

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

View File

@@ -32,6 +32,7 @@ fun <T : Any> FlagKey<T>.ListItemContent(
FlagKey.ArchiveItems,
FlagKey.SendEmailVerification,
FlagKey.MobilePremiumUpgrade,
FlagKey.SendFolder,
-> {
@Suppress("UNCHECKED_CAST")
BooleanFlagItem(
@@ -85,4 +86,5 @@ private fun <T : Any> FlagKey<T>.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)
}

View File

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

View File

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

View File

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

View File

@@ -483,6 +483,12 @@ Scanning will happen automatically.</string>
<string name="edit_file_send">Edit file Send</string>
<string name="edit_text_send">Edit text Send</string>
<string name="add_file_send">New file Send</string>
<string name="add_folder_send">New folder Send</string>
<string name="edit_folder_send">Edit folder Send</string>
<string name="choose_folder">Choose folder</string>
<string name="the_selected_folder_is_empty">The selected folder is empty. Please choose a folder that contains files.</string>
<string name="folder_info">%1$d files, %2$s</string>
<string name="required_max_folder_size">Maximum total size: 100 MB.</string>
<string name="add_text_send">New text Send</string>
<string name="are_you_sure_delete_send">Are you sure you want to delete this Send?</string>
<string name="send_deleted">Send deleted</string>

View File

@@ -46,6 +46,7 @@
<string name="trigger_cookie_acquisition">Trigger cookie acquisition</string>
<string name="clear_sso_cookies">Clear SSO cookies</string>
<string name="mobile_premium_upgrade">Mobile Premium Upgrade</string>
<string name="send_folder">Send Folder</string>
<!-- endregion Debug Menu -->
</resources>