feat(send): multi-file shares and receivers polish

Allow selecting multiple files on Android, desktop, and iOS and share them
as one transfer through the core multi-source path. Update the composer UI
for multi-file review, hide unfinished iOS receive methods, and surface
refusal reasons on the receivers list.
This commit is contained in:
2026-07-12 17:03:53 +02:00
parent ceec383473
commit d3a5f19ecb
21 changed files with 285 additions and 115 deletions

View File

@@ -23,12 +23,12 @@ private var retainedPickerDelegate: DocumentPickerDelegate? = null
@Composable
actual fun rememberShareFilePicker(
onFilePicked: (PickedShareFile) -> Unit,
onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit,
): ShareFilePicker = remember(onFilePicked, onError) {
): ShareFilePicker = remember(onFilesPicked, onError) {
object : ShareFilePicker {
@OptIn(ExperimentalForeignApi::class)
override fun pickFile() {
override fun pickFiles() {
val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController
if (presenter == null) {
onError("Could not find an iOS view controller for the document picker")
@@ -36,7 +36,11 @@ actual fun rememberShareFilePicker(
}
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = false)
val delegate = DocumentPickerDelegate(onFilePicked, onError)
picker.allowsMultipleSelection = true
val delegate = DocumentPickerDelegate(
onFilesPicked = onFilesPicked,
onError = onError,
)
retainedPickerDelegate = delegate
picker.delegate = delegate
picker.modalPresentationStyle = UIModalPresentationFormSheet
@@ -61,7 +65,8 @@ actual fun rememberReceiveFolderPicker(
val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false)
val delegate = DocumentPickerDelegate(
onFilePicked = { folder ->
onFilesPicked = { folders ->
val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate
onFolderPicked(
ReceiveFolder(
kind = ReceiveFolderKind.IosSecurityScopedUrl,
@@ -81,14 +86,12 @@ actual fun rememberReceiveFolderPicker(
}
private class DocumentPickerDelegate(
private val onFilePicked: (PickedShareFile) -> Unit,
private val onFilesPicked: (List<PickedShareFile>) -> Unit,
private val onError: (String) -> Unit,
) : NSObject(), UIDocumentPickerDelegateProtocol {
override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) {
val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL
if (url == null) {
onError("The selected iOS document URL was invalid")
} else {
val files = didPickDocumentsAtURLs.mapNotNull { raw ->
val url = raw as? NSURL ?: return@mapNotNull null
val displayName = url.lastPathComponent ?: "transfer"
val didStartAccess = url.startAccessingSecurityScopedResource()
val sizeBytes = try {
@@ -97,15 +100,18 @@ private class DocumentPickerDelegate(
} finally {
if (didStartAccess) url.stopAccessingSecurityScopedResource()
}
onFilePicked(
PickedShareFile(
url.absoluteString ?: url.path.orEmpty(),
displayName,
sizeBytes,
nativeFileIcon(url),
),
PickedShareFile(
url.absoluteString ?: url.path.orEmpty(),
displayName,
sizeBytes,
nativeFileIcon(url),
)
}
if (files.isEmpty()) {
onError("The selected iOS document URL was invalid")
} else {
onFilesPicked(files)
}
retainedPickerDelegate = null
}

View File

@@ -43,19 +43,24 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun sharePickedFile(
override suspend fun sharePickedFiles(
repository: CoreGateway,
file: PickedShareFile,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> = repository.shareSecurityScopedFileUrl(
file.value,
file.displayName,
transferName,
senderName,
accessPolicy,
)
): 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 = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
}
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)

View File

@@ -19,8 +19,9 @@ private var retainedInvitationDelegate: InvitationDocumentDelegate? = null
actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember {
object : ReceiveInvitationActions {
override val fileAvailability = ReceiveMethodAvailability.Available
override val qrAvailability = ReceiveMethodAvailability.Unavailable
override val nfcAvailability = ReceiveMethodAvailability.Unavailable
// Hide unfinished iOS methods so the method list only shows what works.
override val qrAvailability = ReceiveMethodAvailability.Hidden
override val nfcAvailability = ReceiveMethodAvailability.Hidden
@OptIn(ExperimentalForeignApi::class)
override fun pickInvitation(onResult: (Result<String>) -> Unit) {