mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
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:
@@ -20,7 +20,7 @@ actual fun rememberShareFilePicker(
|
||||
onError: (String) -> Unit,
|
||||
): ShareFilePicker {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
val filesLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
if (uris.isEmpty()) return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
uris.map { uri -> context.pickedShareFile(uri) }
|
||||
@@ -29,10 +29,41 @@ actual fun rememberShareFilePicker(
|
||||
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 {
|
||||
override fun pickFiles() {
|
||||
launcher.launch(arrayOf("*/*"))
|
||||
filesLauncher.launch(arrayOf("*/*"))
|
||||
}
|
||||
|
||||
override fun pickFolder() {
|
||||
folderLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,18 @@ private class AndroidFileSystemService(
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = runCatching {
|
||||
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")
|
||||
?: error("Could not open selected file descriptor for ${file.displayName}")
|
||||
}
|
||||
try {
|
||||
val sources = files.zip(descriptors) { file, descriptor ->
|
||||
val sources = expanded.zip(descriptors) { file, descriptor ->
|
||||
uniffi.vnidrop.ShareSource(
|
||||
kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
<string name="send_transfers_title">Your transfers</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_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_folder_label">Folder</string>
|
||||
<string name="button_remove_file">Remove file</string>
|
||||
<string name="button_choose_files">Choose files</string>
|
||||
<string name="button_change_files">Change files</string>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -246,6 +246,24 @@ class ViewModelsTest {
|
||||
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
|
||||
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
|
||||
@@ -46,6 +46,32 @@ actual fun rememberShareFilePicker(
|
||||
picker.modalPresentationStyle = UIModalPresentationFormSheet
|
||||
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 val onFilesPicked: (List<PickedShareFile>) -> Unit,
|
||||
private val onError: (String) -> Unit,
|
||||
private val forceDirectory: Boolean = false,
|
||||
) : NSObject(), UIDocumentPickerDelegateProtocol {
|
||||
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
|
||||
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
|
||||
@@ -95,8 +122,12 @@ private class DocumentPickerDelegate(
|
||||
val displayName = url.lastPathComponent ?: "transfer"
|
||||
val didStartAccess = url.startAccessingSecurityScopedResource()
|
||||
val sizeBytes = try {
|
||||
if (forceDirectory) {
|
||||
null
|
||||
} else {
|
||||
val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) }
|
||||
(attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue
|
||||
}
|
||||
} finally {
|
||||
if (didStartAccess) url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
@@ -105,6 +136,7 @@ private class DocumentPickerDelegate(
|
||||
displayName,
|
||||
sizeBytes,
|
||||
nativeFileIcon(url),
|
||||
isDirectory = forceDirectory,
|
||||
)
|
||||
}
|
||||
if (files.isEmpty()) {
|
||||
|
||||
@@ -56,7 +56,7 @@ private class IosFileSystemService : FileSystemService {
|
||||
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
|
||||
value = file.value,
|
||||
displayName = file.displayName,
|
||||
isDirectory = false,
|
||||
isDirectory = file.isDirectory,
|
||||
)
|
||||
}
|
||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||
|
||||
@@ -25,6 +25,23 @@ actual fun rememberShareFilePicker(
|
||||
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 {
|
||||
override fun pickFolder() {
|
||||
openPicker(onError) {
|
||||
val selected = pickDirectory() ?: return@openPicker
|
||||
val selected = pickDirectory(title = "Select receive folder") ?: return@openPicker
|
||||
onFolderPicked(
|
||||
ReceiveFolder(
|
||||
kind = ReceiveFolderKind.FileSystemPath,
|
||||
@@ -111,10 +128,10 @@ private fun File.systemIconPng(): ByteArray? = runCatching {
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun pickDirectory(): File? =
|
||||
private fun pickDirectory(title: String): File? =
|
||||
if (isMacOs()) {
|
||||
val dialog = withMacDirectoryDialog {
|
||||
nativeFileDialog("Select receive folder").apply { isVisible = true }
|
||||
nativeFileDialog(title).apply { isVisible = true }
|
||||
}
|
||||
try {
|
||||
val directory = dialog.directory ?: return null
|
||||
@@ -126,7 +143,7 @@ private fun pickDirectory(): File? =
|
||||
}
|
||||
} else {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = "Select receive folder"
|
||||
dialogTitle = title
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ private class JvmFileSystemService : FileSystemService {
|
||||
kind = uniffi.vnidrop.SourceKind.PATH,
|
||||
value = file.value,
|
||||
displayName = file.displayName,
|
||||
isDirectory = false,
|
||||
isDirectory = file.isDirectory || File(file.value).isDirectory,
|
||||
)
|
||||
}
|
||||
return repository.shareSources(sources, transferName, senderName, accessPolicy)
|
||||
|
||||
Reference in New Issue
Block a user