mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 18:39: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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user