diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt index 5b5cf8c..927219b 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FilePicker.android.kt @@ -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) } } } diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 2873a05..c986f21 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -71,12 +71,18 @@ private class AndroidFileSystemService( accessPolicy: ShareAccessPolicy, ): Result = 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 { + val treeUri = Uri.parse(folder.value) + val rootId = DocumentsContract.getTreeDocumentId(treeUri) + val out = mutableListOf() + 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. * diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 044cebc..1295340 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -10,8 +10,9 @@ Your transfers New transfer Choose what to share - Select one or more files from this device. You can review them before creating the transfer. + Select files or a folder from this device. You can review the selection before creating the transfer. %1$d files selected + Folder Remove file Choose files Change files diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index b1f66bb..7b29acb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -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 { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt index 5e9375e..f8d2cab 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index 81aa8f2..caea4fd 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -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, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index c608975..2918f59 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -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) { if (files.isEmpty()) return @@ -271,9 +273,11 @@ class SendViewModel( } } - private fun defaultTransferName(files: List): String = when (files.size) { - 0 -> "" - 1 -> files.first().displayName + private fun defaultTransferName(files: List): 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" } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt index fa8cee5..c2af833 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -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, ) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 786abe4..ea6d87e 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -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)) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt index 17dca62..2efb573 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt @@ -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) -> 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 { - val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } - (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue + 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()) { diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt index af6e478..02f7ee8 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt @@ -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) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 81232b5..052d82f 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -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 } diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index 7a97307..ef26c9f 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -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)