feat(send): share folders as multi-file collections

Add folder pickers on Android, iOS, and desktop. Desktop and iOS walk
directory paths in Rust; Android expands SAF trees to per-file FDs
with relative collection paths because directory FDs are unsupported.
This commit is contained in:
2026-07-12 17:57:00 +02:00
parent d2929fbfbb
commit 50d43a3867
13 changed files with 211 additions and 20 deletions

View File

@@ -7,11 +7,20 @@ data class PickedShareFile(
val displayName: String,
val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = null,
/**
* When true, [value] is a directory (filesystem path, iOS security-scoped
* folder URL, or Android document tree URI). Platform share code expands or
* walks it; Rust cannot treat an Android FD as a directory.
*/
val isDirectory: Boolean = false,
)
interface ShareFilePicker {
/** Opens a platform picker that may return one or more files. */
fun pickFiles()
/** Opens a platform folder picker for sharing a directory as one transfer. */
fun pickFolder()
}
interface ReceiveFolderPicker {

View File

@@ -24,6 +24,7 @@ fun SendRoute(
viewModel.effectFlow.collect { effect ->
when (effect) {
SendEffect.OpenFilePicker -> picker.pickFiles()
SendEffect.OpenFolderPicker -> picker.pickFolder()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
}
}
@@ -37,6 +38,7 @@ fun SendRoute(
onOpenComposer = viewModel::openComposer,
onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile,
onSelectFolder = viewModel::selectFolder,
onClearFile = viewModel::clearSelectedSource,
onRemoveFile = viewModel::removeSelectedFile,
onTransferNameChanged = viewModel::setTransferName,

View File

@@ -24,6 +24,7 @@ fun SendScreen(
onOpenComposer: () -> Unit,
onDismissComposer: () -> Unit,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit = {},
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit = {},
onTransferNameChanged: (String) -> Unit,
@@ -83,6 +84,7 @@ fun SendScreen(
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onSelectFolder = onSelectFolder,
onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged,

View File

@@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect {
data object OpenFilePicker : SendEffect
data object OpenFolderPicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect
}
@@ -135,6 +136,7 @@ class SendViewModel(
}
fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return
@@ -271,9 +273,11 @@ class SendViewModel(
}
}
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
0 -> ""
1 -> files.first().displayName
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
files.isEmpty() -> ""
files.size == 1 && files.first().isDirectory -> files.first().displayName
files.size == 1 -> files.first().displayName
files.all { it.isDirectory } -> "${files.size} folders"
else -> "${files.size} files"
}

View File

@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_change_files
import vnidrop.shared.generated.resources.button_choose_files
import vnidrop.shared.generated.resources.button_choose_folder
import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_remove_file
import vnidrop.shared.generated.resources.button_share_file
@@ -56,6 +57,7 @@ import vnidrop.shared.generated.resources.send_access_title
import vnidrop.shared.generated.resources.send_choose_file_body
import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_folder_label
import vnidrop.shared.generated.resources.send_review_title
import vnidrop.shared.generated.resources.send_selected_files_count
@@ -65,6 +67,7 @@ internal fun TransferComposer(
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit,
@@ -77,12 +80,13 @@ internal fun TransferComposer(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.selectedFiles.isEmpty()) {
ChooseFileStep(onSelectFile)
ChooseFileStep(onSelectFile, onSelectFolder)
} else {
ReviewFileStep(
state = state,
windowClass = windowClass,
onSelectFile = onSelectFile,
onSelectFolder = onSelectFolder,
onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged,
@@ -96,7 +100,7 @@ internal fun TransferComposer(
}
@Composable
private fun ChooseFileStep(onSelectFile: () -> Unit) {
private fun ChooseFileStep(onSelectFile: () -> Unit, onSelectFolder: () -> Unit) {
Text(stringResource(Res.string.send_choose_file_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(
stringResource(Res.string.send_choose_file_body),
@@ -111,6 +115,7 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
) {
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder)
}
}
}
@@ -120,6 +125,7 @@ private fun ReviewFileStep(
state: SendState,
windowClass: WindowClass,
onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit,
@@ -164,11 +170,13 @@ private fun ReviewFileStep(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare)
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
}
}
@@ -199,7 +207,11 @@ private fun SelectedFileCard(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
file.sizeBytes?.let(::formatBytes) ?: stringResource(Res.string.send_file_size_unknown),
when {
file.isDirectory -> stringResource(Res.string.send_folder_label)
file.sizeBytes != null -> formatBytes(file.sizeBytes)
else -> stringResource(Res.string.send_file_size_unknown)
},
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall,
)