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

@@ -20,7 +20,7 @@ actual fun rememberShareFilePicker(
onError: (String) -> Unit, onError: (String) -> Unit,
): ShareFilePicker { ): ShareFilePicker {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isEmpty()) return@rememberLauncherForActivityResult if (uris.isEmpty()) return@rememberLauncherForActivityResult
runCatching { runCatching {
uris.map { uri -> context.pickedShareFile(uri) } uris.map { uri -> context.pickedShareFile(uri) }
@@ -29,10 +29,41 @@ actual fun rememberShareFilePicker(
onFailure = { onError(it.message ?: "Could not open the selected files") }, onFailure = { onError(it.message ?: "Could not open the selected files") },
) )
} }
return remember(launcher) { val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
// Read permission only — we expand the tree into file FDs at share time.
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
val displayName = uri.lastPathSegment
?.substringAfterLast(':')
?.substringAfterLast('/')
?.ifBlank { null }
?: "Folder"
listOf(
PickedShareFile(
value = uri.toString(),
displayName = displayName,
sizeBytes = null,
thumbnailBytes = null,
isDirectory = true,
),
)
}.fold(
onSuccess = onFilesPicked,
onFailure = { onError(it.message ?: "Could not open the selected folder") },
)
}
return remember(filesLauncher, folderLauncher) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFiles() { override fun pickFiles() {
launcher.launch(arrayOf("*/*")) filesLauncher.launch(arrayOf("*/*"))
}
override fun pickFolder() {
folderLauncher.launch(null)
} }
} }
} }

View File

@@ -71,12 +71,18 @@ private class AndroidFileSystemService(
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = runCatching { ): Result<Share> = runCatching {
require(files.isNotEmpty()) { "Select at least one file to share" } require(files.isNotEmpty()) { "Select at least one file to share" }
val descriptors = files.map { file -> // Android cannot pass a directory as a single FD. Expand SAF trees into
// individual document files with relative collection paths, then open FDs.
val expanded = files.flatMap { file ->
if (file.isDirectory) context.expandShareDirectory(file) else listOf(file)
}
require(expanded.isNotEmpty()) { "No files found in the selected folder" }
val descriptors = expanded.map { file ->
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r") context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
?: error("Could not open selected file descriptor for ${file.displayName}") ?: error("Could not open selected file descriptor for ${file.displayName}")
} }
try { try {
val sources = files.zip(descriptors) { file, descriptor -> val sources = expanded.zip(descriptors) { file, descriptor ->
uniffi.vnidrop.ShareSource( uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR, kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(), value = descriptor.fd.toString(),
@@ -140,6 +146,63 @@ private class AndroidFileSystemService(
} }
} }
/**
* Expand a SAF document tree into individual file documents.
*
* Rust cannot accept a directory FD. Collection paths preserve the folder
* root name so receivers see `Folder/nested/file.txt`.
*/
private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedShareFile> {
val treeUri = Uri.parse(folder.value)
val rootId = DocumentsContract.getTreeDocumentId(treeUri)
val out = mutableListOf<PickedShareFile>()
fun walk(documentId: String, relativePath: String) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
contentResolver.query(
childrenUri,
arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
),
null,
null,
null,
)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
while (cursor.moveToNext()) {
val id = cursor.getString(idIndex) ?: continue
val name = cursor.getString(nameIndex) ?: continue
val mime = cursor.getString(mimeIndex)
val childRelative = if (relativePath.isEmpty()) name else "$relativePath/$name"
if (mime == DocumentsContract.Document.MIME_TYPE_DIR) {
walk(id, childRelative)
} else {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
val size = if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
cursor.getLong(sizeIndex).takeIf { it >= 0L }?.toULong()
} else {
null
}
out += PickedShareFile(
value = documentUri.toString(),
displayName = childRelative,
sizeBytes = size,
isDirectory = false,
)
}
}
}
}
// Prefix paths with the folder display name so nested structure is preserved.
walk(rootId, folder.displayName)
return out
}
/** /**
* Writes into the shared system Downloads collection via MediaStore. * Writes into the shared system Downloads collection via MediaStore.
* *

View File

@@ -10,8 +10,9 @@
<string name="send_transfers_title">Your transfers</string> <string name="send_transfers_title">Your transfers</string>
<string name="send_new_transfer_title">New transfer</string> <string name="send_new_transfer_title">New transfer</string>
<string name="send_choose_file_title">Choose what to share</string> <string name="send_choose_file_title">Choose what to share</string>
<string name="send_choose_file_body">Select one or more files from this device. You can review them before creating the transfer.</string> <string name="send_choose_file_body">Select files or a folder from this device. You can review the selection before creating the transfer.</string>
<string name="send_selected_files_count">%1$d files selected</string> <string name="send_selected_files_count">%1$d files selected</string>
<string name="send_folder_label">Folder</string>
<string name="button_remove_file">Remove file</string> <string name="button_remove_file">Remove file</string>
<string name="button_choose_files">Choose files</string> <string name="button_choose_files">Choose files</string>
<string name="button_change_files">Change files</string> <string name="button_change_files">Change files</string>

View File

@@ -7,11 +7,20 @@ data class PickedShareFile(
val displayName: String, val displayName: String,
val sizeBytes: ULong? = null, val sizeBytes: ULong? = null,
val thumbnailBytes: ByteArray? = 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 { interface ShareFilePicker {
/** Opens a platform picker that may return one or more files. */ /** Opens a platform picker that may return one or more files. */
fun pickFiles() fun pickFiles()
/** Opens a platform folder picker for sharing a directory as one transfer. */
fun pickFolder()
} }
interface ReceiveFolderPicker { interface ReceiveFolderPicker {

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ enum class TransferDetailPanel { Activity, Receivers, Share }
sealed interface SendEffect { sealed interface SendEffect {
data object OpenFilePicker : SendEffect data object OpenFilePicker : SendEffect
data object OpenFolderPicker : SendEffect
data class CopyTicket(val ticket: String) : SendEffect data class CopyTicket(val ticket: String) : SendEffect
} }
@@ -135,6 +136,7 @@ class SendViewModel(
} }
fun selectFile() = sendEffect(SendEffect.OpenFilePicker) fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun selectFolder() = sendEffect(SendEffect.OpenFolderPicker)
fun onFilesPicked(files: List<PickedShareFile>) { fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return if (files.isEmpty()) return
@@ -271,9 +273,11 @@ class SendViewModel(
} }
} }
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) { private fun defaultTransferName(files: List<PickedShareFile>): String = when {
0 -> "" files.isEmpty() -> ""
1 -> files.first().displayName 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" 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.Res
import vnidrop.shared.generated.resources.button_change_files import vnidrop.shared.generated.resources.button_change_files
import vnidrop.shared.generated.resources.button_choose_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_clear
import vnidrop.shared.generated.resources.button_remove_file import vnidrop.shared.generated.resources.button_remove_file
import vnidrop.shared.generated.resources.button_share_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_body
import vnidrop.shared.generated.resources.send_choose_file_title import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown 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_review_title
import vnidrop.shared.generated.resources.send_selected_files_count import vnidrop.shared.generated.resources.send_selected_files_count
@@ -65,6 +67,7 @@ internal fun TransferComposer(
state: SendState, state: SendState,
windowClass: WindowClass, windowClass: WindowClass,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit, onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
@@ -77,12 +80,13 @@ internal fun TransferComposer(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (state.selectedFiles.isEmpty()) { if (state.selectedFiles.isEmpty()) {
ChooseFileStep(onSelectFile) ChooseFileStep(onSelectFile, onSelectFolder)
} else { } else {
ReviewFileStep( ReviewFileStep(
state = state, state = state,
windowClass = windowClass, windowClass = windowClass,
onSelectFile = onSelectFile, onSelectFile = onSelectFile,
onSelectFolder = onSelectFolder,
onClearFile = onClearFile, onClearFile = onClearFile,
onRemoveFile = onRemoveFile, onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged, onTransferNameChanged = onTransferNameChanged,
@@ -96,7 +100,7 @@ internal fun TransferComposer(
} }
@Composable @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_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text( Text(
stringResource(Res.string.send_choose_file_body), 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)) Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile) 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, state: SendState,
windowClass: WindowClass, windowClass: WindowClass,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onSelectFolder: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit, onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
@@ -164,11 +170,13 @@ private fun ReviewFileStep(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) 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_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 { } else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare) ShareButton(state, coreInitialized, onCreateShare)
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing) 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) 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)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(file.displayName, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text( 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, color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )

View File

@@ -246,6 +246,24 @@ class ViewModelsTest {
assertTrue(viewModel.state.value.selectedFiles.isEmpty()) assertTrue(viewModel.state.value.selectedFiles.isEmpty())
} }
@Test
fun sendViewModelNamesFolderSelectionAfterFolderDisplayName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile(
value = "/tmp/photos",
displayName = "photos",
isDirectory = true,
),
),
)
assertEquals("photos", viewModel.state.value.transferName)
assertTrue(viewModel.state.value.selectedFiles.single().isDirectory)
}
@Test @Test
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -46,6 +46,32 @@ actual fun rememberShareFilePicker(
picker.modalPresentationStyle = UIModalPresentationFormSheet picker.modalPresentationStyle = UIModalPresentationFormSheet
presenter.presentViewController(picker, animated = true, completion = null) presenter.presentViewController(picker, animated = true, completion = null)
} }
@OptIn(ExperimentalForeignApi::class)
override fun pickFolder() {
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
if (presenter == null) {
onError("Could not find an iOS view controller for the folder picker")
return
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
val delegate = DocumentPickerDelegate(
onFilesPicked = { folders ->
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
onFilesPicked(
listOf(
folder.copy(isDirectory = true),
),
)
},
onError = onError,
forceDirectory = true,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
picker.modalPresentationStyle = UIModalPresentationFormSheet
presenter.presentViewController(picker, animated = true, completion = null)
}
} }
} }
@@ -88,6 +114,7 @@ actual fun rememberReceiveFolderPicker(
private class DocumentPickerDelegate( private class DocumentPickerDelegate(
private val onFilesPicked: (List<PickedShareFile>) -> Unit, private val onFilesPicked: (List<PickedShareFile>) -> Unit,
private val onError: (String) -> Unit, private val onError: (String) -> Unit,
private val forceDirectory: Boolean = false,
) : NSObject(), UIDocumentPickerDelegateProtocol { ) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val files = didPickDocumentsAtURLs.mapNotNull { raw -> val files = didPickDocumentsAtURLs.mapNotNull { raw ->
@@ -95,8 +122,12 @@ private class DocumentPickerDelegate(
val displayName = url.lastPathComponent ?: "transfer" val displayName = url.lastPathComponent ?: "transfer"
val didStartAccess = url.startAccessingSecurityScopedResource() val didStartAccess = url.startAccessingSecurityScopedResource()
val sizeBytes = try { val sizeBytes = try {
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } if (forceDirectory) {
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue null
} else {
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
}
} finally { } finally {
if (didStartAccess) url.stopAccessingSecurityScopedResource() if (didStartAccess) url.stopAccessingSecurityScopedResource()
} }
@@ -105,6 +136,7 @@ private class DocumentPickerDelegate(
displayName, displayName,
sizeBytes, sizeBytes,
nativeFileIcon(url), nativeFileIcon(url),
isDirectory = forceDirectory,
) )
} }
if (files.isEmpty()) { if (files.isEmpty()) {

View File

@@ -56,7 +56,7 @@ private class IosFileSystemService : FileSystemService {
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL, kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
value = file.value, value = file.value,
displayName = file.displayName, displayName = file.displayName,
isDirectory = false, isDirectory = file.isDirectory,
) )
} }
return repository.shareSources(sources, transferName, senderName, accessPolicy) return repository.shareSources(sources, transferName, senderName, accessPolicy)

View File

@@ -25,6 +25,23 @@ actual fun rememberShareFilePicker(
if (selected.isNotEmpty()) onFilesPicked(selected) if (selected.isNotEmpty()) onFilesPicked(selected)
} }
} }
override fun pickFolder() {
openPicker(onError) {
val selected = pickDirectory(title = "Select folder to share") ?: return@openPicker
onFilesPicked(
listOf(
PickedShareFile(
value = selected.absolutePath,
displayName = selected.name.ifBlank { selected.absolutePath },
sizeBytes = null,
thumbnailBytes = selected.systemIconPng(),
isDirectory = true,
),
),
)
}
}
} }
} }
@@ -36,7 +53,7 @@ actual fun rememberReceiveFolderPicker(
object : ReceiveFolderPicker { object : ReceiveFolderPicker {
override fun pickFolder() { override fun pickFolder() {
openPicker(onError) { openPicker(onError) {
val selected = pickDirectory() ?: return@openPicker val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
onFolderPicked( onFolderPicked(
ReceiveFolder( ReceiveFolder(
kind = ReceiveFolderKind.FileSystemPath, kind = ReceiveFolderKind.FileSystemPath,
@@ -111,10 +128,10 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
} }
}.getOrNull() }.getOrNull()
private fun pickDirectory(): File? = private fun pickDirectory(title: String): File? =
if (isMacOs()) { if (isMacOs()) {
val dialog = withMacDirectoryDialog { val dialog = withMacDirectoryDialog {
nativeFileDialog("Select receive folder").apply { isVisible = true } nativeFileDialog(title).apply { isVisible = true }
} }
try { try {
val directory = dialog.directory ?: return null val directory = dialog.directory ?: return null
@@ -126,7 +143,7 @@ private fun pickDirectory(): File? =
} }
} else { } else {
val chooser = JFileChooser().apply { val chooser = JFileChooser().apply {
dialogTitle = "Select receive folder" dialogTitle = title
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false isAcceptAllFileFilterUsed = false
} }

View File

@@ -41,7 +41,7 @@ private class JvmFileSystemService : FileSystemService {
kind = uniffi.vnidrop.SourceKind.PATH, kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value, value = file.value,
displayName = file.displayName, displayName = file.displayName,
isDirectory = false, isDirectory = file.isDirectory || File(file.value).isDirectory,
) )
} }
return repository.shareSources(sources, transferName, senderName, accessPolicy) return repository.shareSources(sources, transferName, senderName, accessPolicy)