fix(ios): support sandboxed file transfers

This commit is contained in:
2026-07-15 19:40:20 +02:00
parent 43db35e9a2
commit c3d54e9142
13 changed files with 282 additions and 28 deletions

View File

@@ -35,11 +35,13 @@ actual fun rememberShareFilePicker(
return
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false)
// The composer outlives this callback, so Rust imports a sandbox copy instead of a short-lived provider URL.
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true)
picker.allowsMultipleSelection = true
val delegate = DocumentPickerDelegate(
onFilesPicked = onFilesPicked,
onError = onError,
useFileSystemPaths = true,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
@@ -54,7 +56,7 @@ actual fun rememberShareFilePicker(
onError("Could not find an iOS view controller for the folder picker")
return
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true)
val delegate = DocumentPickerDelegate(
onFilesPicked = { folders ->
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
@@ -66,6 +68,7 @@ actual fun rememberShareFilePicker(
},
onError = onError,
forceDirectory = true,
useFileSystemPaths = true,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
@@ -115,6 +118,7 @@ private class DocumentPickerDelegate(
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
private val onError: (String) -> Unit,
private val forceDirectory: Boolean = false,
private val useFileSystemPaths: Boolean = false,
) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
@@ -132,10 +136,11 @@ private class DocumentPickerDelegate(
if (didStartAccess) url.stopAccessingSecurityScopedResource()
}
PickedShareFile(
url.absoluteString ?: url.path.orEmpty(),
if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(),
displayName,
sizeBytes,
nativeFileIcon(url),
isTemporaryCopy = useFileSystemPaths,
isDirectory = forceDirectory,
)
}

View File

@@ -2,18 +2,25 @@ package com.vnidrop.app.core
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.Foundation.NSFileManager
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask
import platform.UIKit.UIApplication
import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.SourceKind
import kotlin.coroutines.resume
@Composable
actual fun rememberFileSystemService(): FileSystemService =
remember { IosFileSystemService() }
private class IosFileSystemService : FileSystemService {
// App-owned Documents remains durable across launches; raw external picker URLs do not.
override val supportsCustomReceiveFolders: Boolean = false
override fun defaultReceiveFolder(): ReceiveFolder {
val path = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory,
@@ -43,6 +50,37 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun discardPickedFiles(files: List<PickedShareFile>) {
files.asSequence()
.filter(PickedShareFile::isTemporaryCopy)
.map(PickedShareFile::value)
.distinct()
.forEach { path -> NSFileManager.defaultManager.removeItemAtPath(path, null) }
}
override fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean =
folder.kind == ReceiveFolderKind.FileSystemPath &&
folder.value.trimEnd('/') == defaultReceiveFolder().value.trimEnd('/')
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {
if (!canRevealReceiveFolder(folder)) {
return Result.failure(IllegalArgumentException("The receive folder is not VniDrop Documents"))
}
// Files can reveal app-owned Documents after the sharing keys in Info.plist are enabled.
val url = NSURL.URLWithString("shareddocuments://${folder.value}")
?: return Result.failure(IllegalStateException("The Files location URL is unavailable"))
val opened = suspendCancellableCoroutine { continuation ->
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { success ->
if (continuation.isActive) continuation.resume(success)
}
}
return if (opened) {
Result.success(Unit)
} else {
Result.failure(IllegalStateException("Could not open VniDrop Documents in Files"))
}
}
override suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
@@ -51,15 +89,7 @@ private class IosFileSystemService : FileSystemService {
accessPolicy: ShareAccessPolicy,
): Result<Share> {
require(files.isNotEmpty()) { "Select at least one file to share" }
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
value = file.value,
displayName = file.displayName,
isDirectory = file.isDirectory,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy)
}
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
@@ -77,3 +107,10 @@ private class IosFileSystemService : FileSystemService {
}
}
}
internal fun PickedShareFile.toIosShareSource() = uniffi.vnidrop.ShareSource(
kind = SourceKind.PATH,
value = value,
displayName = displayName,
isDirectory = isDirectory,
)