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

@@ -16,18 +16,22 @@ import androidx.compose.ui.platform.LocalContext
@Composable @Composable
actual fun rememberShareFilePicker( actual fun rememberShareFilePicker(
onFilePicked: (PickedShareFile) -> Unit, onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
): ShareFilePicker { ): ShareFilePicker {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uri != null) { if (uris.isEmpty()) return@rememberLauncherForActivityResult
onFilePicked(context.pickedShareFile(uri)) runCatching {
} uris.map { uri -> context.pickedShareFile(uri) }
}.fold(
onSuccess = onFilesPicked,
onFailure = { onError(it.message ?: "Could not open the selected files") },
)
} }
return remember(launcher) { return remember(launcher) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFile() { override fun pickFiles() {
launcher.launch(arrayOf("*/*")) launcher.launch(arrayOf("*/*"))
} }
} }

View File

@@ -63,22 +63,30 @@ private class AndroidFileSystemService(
ReceiveFolderKind.IosSecurityScopedUrl -> null ReceiveFolderKind.IosSecurityScopedUrl -> null
} }
override suspend fun sharePickedFile( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
file: PickedShareFile, files: List<PickedShareFile>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = runCatching { ): Result<Share> = runCatching {
context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor -> require(files.isNotEmpty()) { "Select at least one file to share" }
checkNotNull(descriptor) { "Could not open selected file descriptor" } val descriptors = files.map { file ->
repository.shareFileDescriptor( context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r")
fd = descriptor.fd, ?: error("Could not open selected file descriptor for ${file.displayName}")
displayName = file.displayName, }
transferName = transferName, try {
senderName = senderName, val sources = files.zip(descriptors) { file, descriptor ->
accessPolicy = accessPolicy, uniffi.vnidrop.ShareSource(
).getOrThrow() kind = uniffi.vnidrop.SourceKind.FILE_DESCRIPTOR,
value = descriptor.fd.toString(),
displayName = file.displayName,
isDirectory = false,
)
}
repository.shareSources(sources, transferName, senderName, accessPolicy).getOrThrow()
} finally {
descriptors.forEach { it.close() }
} }
} }

View File

@@ -10,7 +10,11 @@
<string name="send_transfers_title">Your transfers</string> <string name="send_transfers_title">Your transfers</string>
<string name="send_new_transfer_title">New transfer</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_title">Choose what to share</string>
<string name="send_choose_file_body">Select one file from this device. You can review its details before creating the transfer.</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_selected_files_count">%1$d files selected</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>
<string name="send_review_title">Review transfer</string> <string name="send_review_title">Review transfer</string>
<string name="send_access_title">Who can receive it?</string> <string name="send_access_title">Who can receive it?</string>
<string name="send_access_approval">Ask before each download</string> <string name="send_access_approval">Ask before each download</string>
@@ -58,7 +62,7 @@
<string name="transfer_event_updated">Transfer updated</string> <string name="transfer_event_updated">Transfer updated</string>
<string name="button_choose_file">Choose file</string> <string name="button_choose_file">Choose file</string>
<string name="button_change_file">Change file</string> <string name="button_change_file">Change file</string>
<string name="button_share_file">Share file</string> <string name="button_share_file">Share files</string>
<string name="button_sharing_file">Preparing transfer…</string> <string name="button_sharing_file">Preparing transfer…</string>
<string name="button_copy_ticket">Copy transfer link</string> <string name="button_copy_ticket">Copy transfer link</string>
<string name="send_new_transfer_description">Create a new transfer</string> <string name="send_new_transfer_description">Create a new transfer</string>

View File

@@ -142,6 +142,13 @@ interface CoreGateway {
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> ): Result<Share>
/** Multi-source share used by multi-file pickers. */
suspend fun shareSources(
sources: List<uniffi.vnidrop.ShareSource>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit> suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>

View File

@@ -197,12 +197,13 @@ class CoreRepository(
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() } override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() }
private suspend fun shareSources( override suspend fun shareSources(
sources: List<ShareSource>, sources: List<ShareSource>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = runCore { ): Result<Share> = runCore {
require(sources.isNotEmpty()) { "Select at least one file to share" }
withPlatformPathAccess(sources) { withPlatformPathAccess(sources) {
requireCore().shareFiles( requireCore().shareFiles(
sources = sources, sources = sources,

View File

@@ -10,7 +10,8 @@ data class PickedShareFile(
) )
interface ShareFilePicker { interface ShareFilePicker {
fun pickFile() /** Opens a platform picker that may return one or more files. */
fun pickFiles()
} }
interface ReceiveFolderPicker { interface ReceiveFolderPicker {
@@ -19,7 +20,7 @@ interface ReceiveFolderPicker {
@Composable @Composable
expect fun rememberShareFilePicker( expect fun rememberShareFilePicker(
onFilePicked: (PickedShareFile) -> Unit, onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
): ShareFilePicker ): ShareFilePicker

View File

@@ -36,6 +36,14 @@ interface FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy)
suspend fun sharePickedFiles(
repository: CoreGateway,
files: List<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> ): Result<Share>
} }

View File

@@ -17,13 +17,13 @@ fun SendRoute(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val coreState by viewModel.coreState.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed) val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed)
val shareActions = rememberTransferShareActions() val shareActions = rememberTransferShareActions()
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {
viewModel.effectFlow.collect { effect -> viewModel.effectFlow.collect { effect ->
when (effect) { when (effect) {
SendEffect.OpenFilePicker -> picker.pickFile() SendEffect.OpenFilePicker -> picker.pickFiles()
is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket))
} }
} }
@@ -38,6 +38,7 @@ fun SendRoute(
onDismissComposer = viewModel::dismissComposer, onDismissComposer = viewModel::dismissComposer,
onSelectFile = viewModel::selectFile, onSelectFile = viewModel::selectFile,
onClearFile = viewModel::clearSelectedSource, onClearFile = viewModel::clearSelectedSource,
onRemoveFile = viewModel::removeSelectedFile,
onTransferNameChanged = viewModel::setTransferName, onTransferNameChanged = viewModel::setTransferName,
onSenderNameChanged = viewModel::setSenderName, onSenderNameChanged = viewModel::setSenderName,
onAccessPolicyChanged = viewModel::setAccessPolicy, onAccessPolicyChanged = viewModel::setAccessPolicy,

View File

@@ -25,6 +25,7 @@ fun SendScreen(
onDismissComposer: () -> Unit, onDismissComposer: () -> Unit,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit = {},
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
@@ -83,6 +84,7 @@ fun SendScreen(
windowClass = windowClass, windowClass = windowClass,
onSelectFile = onSelectFile, onSelectFile = onSelectFile,
onClearFile = onClearFile, onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged, onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged, onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged, onAccessPolicyChanged = onAccessPolicyChanged,

View File

@@ -29,7 +29,7 @@ import vnidrop.shared.generated.resources.transfer_nfc_written
data class SendState( data class SendState(
val isComposerOpen: Boolean = false, val isComposerOpen: Boolean = false,
val selectedFile: PickedShareFile? = null, val selectedFiles: List<PickedShareFile> = emptyList(),
val transferName: String = "", val transferName: String = "",
val senderName: String = "", val senderName: String = "",
val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval, val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval,
@@ -42,8 +42,12 @@ data class SendState(
val isDeleteConfirmationOpen: Boolean = false, val isDeleteConfirmationOpen: Boolean = false,
val isDeleting: Boolean = false, val isDeleting: Boolean = false,
) { ) {
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
val totalSelectedBytes: ULong
get() = selectedFiles.fold(0UL) { acc, file -> acc + (file.sizeBytes ?: 0UL) }
fun canCreateShare(coreInitialized: Boolean): Boolean = fun canCreateShare(coreInitialized: Boolean): Boolean =
coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing
} }
enum class TransferDetailPanel { Activity, Receivers, Share } enum class TransferDetailPanel { Activity, Receivers, Share }
@@ -111,7 +115,7 @@ class SendViewModel(
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = true, isComposerOpen = true,
selectedFile = null, selectedFiles = emptyList(),
transferName = "", transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
) )
@@ -123,7 +127,7 @@ class SendViewModel(
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = false, isComposerOpen = false,
selectedFile = null, selectedFiles = emptyList(),
transferName = "", transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
) )
@@ -132,12 +136,13 @@ class SendViewModel(
fun selectFile() = sendEffect(SendEffect.OpenFilePicker) fun selectFile() = sendEffect(SendEffect.OpenFilePicker)
fun onFilePicked(file: PickedShareFile) { fun onFilesPicked(files: List<PickedShareFile>) {
if (files.isEmpty()) return
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = true, isComposerOpen = true,
selectedFile = file, selectedFiles = files,
transferName = file.displayName, transferName = defaultTransferName(files),
) )
} }
} }
@@ -145,7 +150,21 @@ class SendViewModel(
fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason))
fun clearSelectedSource() { fun clearSelectedSource() {
_state.update { it.copy(selectedFile = null, transferName = "") } _state.update { it.copy(selectedFiles = emptyList(), transferName = "") }
}
fun removeSelectedFile(value: String) {
_state.update { current ->
val remaining = current.selectedFiles.filterNot { it.value == value }
current.copy(
selectedFiles = remaining,
transferName = when {
remaining.isEmpty() -> ""
current.transferName == defaultTransferName(current.selectedFiles) -> defaultTransferName(remaining)
else -> current.transferName
},
)
}
} }
fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } fun setTransferName(value: String) = _state.update { it.copy(transferName = value) }
@@ -218,24 +237,25 @@ class SendViewModel(
fun createShare() { fun createShare() {
val current = state.value val current = state.value
val file = current.selectedFile ?: return if (current.selectedFiles.isEmpty()) return
if (!current.canCreateShare(coreState.value.isInitialized)) return if (!current.canCreateShare(coreState.value.isInitialized)) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isSharing = true) } _state.update { it.copy(isSharing = true) }
val result = fileSystemService.sharePickedFile( val result = fileSystemService.sharePickedFiles(
repository = repository, repository = repository,
file = file, files = current.selectedFiles,
transferName = current.transferName.trim(), transferName = current.transferName.trim(),
senderName = current.senderName.trim(), senderName = current.senderName.trim(),
accessPolicy = current.accessPolicy, accessPolicy = current.accessPolicy,
) )
result.fold( result.fold(
onSuccess = { share -> onSuccess = { share ->
file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) } current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
?.let { filePreviewRepository.save(share.transferId, it) }
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = false, isComposerOpen = false,
selectedFile = null, selectedFiles = emptyList(),
transferName = "", transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false, isSharing = false,
@@ -251,6 +271,12 @@ class SendViewModel(
} }
} }
private fun defaultTransferName(files: List<PickedShareFile>): String = when (files.size) {
0 -> ""
1 -> files.first().displayName
else -> "${files.size} files"
}
private fun sendEffect(effect: SendEffect) { private fun sendEffect(effect: SendEffect) {
viewModelScope.launch { effects.send(effect) } viewModelScope.launch { effects.send(effect) }
} }

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -39,9 +40,10 @@ import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_change_file import vnidrop.shared.generated.resources.button_change_files
import vnidrop.shared.generated.resources.button_choose_file import vnidrop.shared.generated.resources.button_choose_files
import vnidrop.shared.generated.resources.button_clear import vnidrop.shared.generated.resources.button_clear
import vnidrop.shared.generated.resources.button_remove_file
import vnidrop.shared.generated.resources.button_share_file import vnidrop.shared.generated.resources.button_share_file
import vnidrop.shared.generated.resources.button_sharing_file import vnidrop.shared.generated.resources.button_sharing_file
import vnidrop.shared.generated.resources.field_sender_name import vnidrop.shared.generated.resources.field_sender_name
@@ -55,6 +57,7 @@ import vnidrop.shared.generated.resources.send_choose_file_body
import vnidrop.shared.generated.resources.send_choose_file_title import vnidrop.shared.generated.resources.send_choose_file_title
import vnidrop.shared.generated.resources.send_file_size_unknown import vnidrop.shared.generated.resources.send_file_size_unknown
import vnidrop.shared.generated.resources.send_review_title import vnidrop.shared.generated.resources.send_review_title
import vnidrop.shared.generated.resources.send_selected_files_count
@Composable @Composable
internal fun TransferComposer( internal fun TransferComposer(
@@ -63,6 +66,7 @@ internal fun TransferComposer(
windowClass: WindowClass, windowClass: WindowClass,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
@@ -72,16 +76,15 @@ internal fun TransferComposer(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp), modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
val file = state.selectedFile if (state.selectedFiles.isEmpty()) {
if (file == null) {
ChooseFileStep(onSelectFile) ChooseFileStep(onSelectFile)
} else { } else {
ReviewFileStep( ReviewFileStep(
file = file,
state = state, state = state,
windowClass = windowClass, windowClass = windowClass,
onSelectFile = onSelectFile, onSelectFile = onSelectFile,
onClearFile = onClearFile, onClearFile = onClearFile,
onRemoveFile = onRemoveFile,
onTransferNameChanged = onTransferNameChanged, onTransferNameChanged = onTransferNameChanged,
onSenderNameChanged = onSenderNameChanged, onSenderNameChanged = onSenderNameChanged,
onAccessPolicyChanged = onAccessPolicyChanged, onAccessPolicyChanged = onAccessPolicyChanged,
@@ -107,18 +110,18 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
) { ) {
Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp)) Icon(SendIcons.File, contentDescription = null, tint = LocalVniDropColors.current.brandLink, modifier = Modifier.size(32.dp))
PrimaryButton(stringResource(Res.string.button_choose_file), onClick = onSelectFile) PrimaryButton(stringResource(Res.string.button_choose_files), onClick = onSelectFile)
} }
} }
} }
@Composable @Composable
private fun ReviewFileStep( private fun ReviewFileStep(
file: PickedShareFile,
state: SendState, state: SendState,
windowClass: WindowClass, windowClass: WindowClass,
onSelectFile: () -> Unit, onSelectFile: () -> Unit,
onClearFile: () -> Unit, onClearFile: () -> Unit,
onRemoveFile: (String) -> Unit,
onTransferNameChanged: (String) -> Unit, onTransferNameChanged: (String) -> Unit,
onSenderNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit,
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
@@ -126,7 +129,20 @@ private fun ReviewFileStep(
coreInitialized: Boolean, coreInitialized: Boolean,
) { ) {
Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(stringResource(Res.string.send_review_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
SelectedFileCard(file) if (state.selectedFiles.size > 1) {
Text(
stringResource(Res.string.send_selected_files_count, state.selectedFiles.size),
color = LocalVniDropColors.current.foregroundLighter,
style = MaterialTheme.typography.bodyMedium,
)
}
state.selectedFiles.forEach { file ->
SelectedFileCard(
file = file,
canRemove = state.selectedFiles.size > 1 && !state.isSharing,
onRemove = { onRemoveFile(file.value) },
)
}
Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name)) Field(state.transferName, onTransferNameChanged, stringResource(Res.string.field_transfer_name))
Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name)) Field(state.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name))
Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text(stringResource(Res.string.send_access_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
@@ -147,12 +163,12 @@ private fun ReviewFileStep(
if (windowClass == WindowClass.Phone) { if (windowClass == WindowClass.Phone) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
} }
} else { } else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare) ShareButton(state, coreInitialized, onCreateShare)
QuietButton(stringResource(Res.string.button_change_file), onClick = onSelectFile, enabled = !state.isSharing) QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing) QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
} }
} }
@@ -169,7 +185,11 @@ private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShar
} }
@Composable @Composable
private fun SelectedFileCard(file: PickedShareFile) { private fun SelectedFileCard(
file: PickedShareFile,
canRemove: Boolean,
onRemove: () -> Unit,
) {
Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) {
Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) { Box(Modifier.size(44.dp).background(LocalVniDropColors.current.backgroundSurface300, RoundedCornerShape(11.dp))) {
@@ -184,6 +204,11 @@ private fun SelectedFileCard(file: PickedShareFile) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
if (canRemove) {
IconButton(onClick = onRemove) {
Icon(SendIcons.Delete, stringResource(Res.string.button_remove_file), tint = LocalVniDropColors.current.destructiveDefault)
}
}
} }
} }
} }

View File

@@ -181,6 +181,9 @@ private fun ReceiverRow(receiver: ReceiverRequestModel) {
Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall) Text(it, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
} }
Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) Text(receiverStatusText(receiver.status), color = receiverStatusColor(receiver.status), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
receiver.reason?.takeIf { it.isNotBlank() }?.let { reason ->
Text(reason, color = LocalVniDropColors.current.foregroundLighter, style = MaterialTheme.typography.bodySmall)
}
} }
} }

View File

@@ -174,7 +174,7 @@ class ViewModelsTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
viewModel.openComposer() viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
assertEquals("photo.jpg", viewModel.state.value.transferName) assertEquals("photo.jpg", viewModel.state.value.transferName)
assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes) assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes)
viewModel.clearSelectedSource() viewModel.clearSelectedSource()
@@ -195,7 +195,7 @@ class ViewModelsTest {
val thumbnail = ByteArray(12).also { val thumbnail = ByteArray(12).also {
it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte() it[0] = 0x89.toByte(); it[1] = 'P'.code.toByte(); it[2] = 'N'.code.toByte(); it[3] = 'G'.code.toByte()
} }
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail)) viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail)))
viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer)
viewModel.createShare() viewModel.createShare()
advanceUntilIdle() advanceUntilIdle()
@@ -214,7 +214,7 @@ class ViewModelsTest {
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle() advanceUntilIdle()
viewModel.openComposer() viewModel.openComposer()
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)) viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL)))
viewModel.createShare() viewModel.createShare()
advanceUntilIdle() advanceUntilIdle()
@@ -223,6 +223,29 @@ class ViewModelsTest {
assertFalse(viewModel.state.value.isSharing) assertFalse(viewModel.state.value.isSharing)
} }
@Test
fun sendViewModelSupportsMultipleFilesAndDefaultName() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val core = FakeCoreGateway().apply {
mutableState.value = CoreState(isInitialized = true)
shareResult = Result.success(Share(9UL, "ticket", "2 files", "hash", 2UL, 84UL))
}
val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController())
advanceUntilIdle()
viewModel.onFilesPicked(
listOf(
com.vnidrop.app.core.PickedShareFile("/tmp/a.jpg", "a.jpg", 40UL),
com.vnidrop.app.core.PickedShareFile("/tmp/b.jpg", "b.jpg", 44UL),
),
)
assertEquals("2 files", viewModel.state.value.transferName)
assertEquals(2, viewModel.state.value.selectedFiles.size)
viewModel.createShare()
advanceUntilIdle()
assertEquals(2, core.lastShareSourceCount)
assertTrue(viewModel.state.value.selectedFiles.isEmpty())
}
@Test @Test
fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -67,8 +67,43 @@ class FakeCoreGateway : CoreGateway {
return Result.success(Unit) return Result.success(Unit)
} }
override fun shutdown() = Unit override fun shutdown() = Unit
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> { var lastShareSourceCount: Int = 0
override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> =
shareSources(
sources = listOf(
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = path,
displayName = path.substringAfterLast('/'),
isDirectory = false,
),
),
transferName = transferName,
senderName = senderName,
accessPolicy = accessPolicy,
)
override suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSources(
sources: List<uniffi.vnidrop.ShareSource>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
lastShareAccessPolicy = accessPolicy lastShareAccessPolicy = accessPolicy
lastShareSourceCount = sources.size
shareResult.onSuccess { share -> shareResult.onSuccess { share ->
mutableState.value = mutableState.value.copy( mutableState.value = mutableState.value.copy(
transfers = listOf( transfers = listOf(
@@ -92,20 +127,6 @@ class FakeCoreGateway : CoreGateway {
} }
return shareResult return shareResult
} }
override suspend fun shareFileDescriptor(
fd: Int,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun inspectTicket(ticket: String) = inspectionResult override suspend fun inspectTicket(ticket: String) = inspectionResult
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> { override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> {
receiveCount += 1 receiveCount += 1
@@ -202,13 +223,23 @@ class FakeFileSystemService(
override fun defaultReceiveFolder() = folder override fun defaultReceiveFolder() = folder
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun sharePickedFile( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
file: PickedShareFile, files: List<PickedShareFile>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
) = repository.sharePath(file.value, transferName, senderName, accessPolicy) ): Result<Share> {
val sources = files.map { file ->
uniffi.vnidrop.ShareSource(
kind = uniffi.vnidrop.SourceKind.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
}
} }
class FakeFilePreviewRepository : FilePreviewRepository { class FakeFilePreviewRepository : FilePreviewRepository {

View File

@@ -47,7 +47,7 @@ class AppUiModelsTest {
@Test @Test
fun sendStateExposesShareEligibility() { fun sendStateExposesShareEligibility() {
val ready = SendState( val ready = SendState(
selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL), selectedFiles = listOf(PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL)),
transferName = "payload.txt", transferName = "payload.txt",
) )

View File

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

View File

@@ -43,19 +43,24 @@ private class IosFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun sharePickedFile( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
file: PickedShareFile, files: List<PickedShareFile>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = repository.shareSecurityScopedFileUrl( ): Result<Share> {
file.value, require(files.isNotEmpty()) { "Select at least one file to share" }
file.displayName, val sources = files.map { file ->
transferName, uniffi.vnidrop.ShareSource(
senderName, kind = uniffi.vnidrop.SourceKind.IOS_SECURITY_SCOPED_URL,
accessPolicy, value = file.value,
) displayName = file.displayName,
isDirectory = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
}
private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { private fun validateSecurityScopedUrl(value: String): FolderAccessStatus {
val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value)

View File

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

View File

@@ -15,13 +15,14 @@ import javax.swing.filechooser.FileSystemView
@Composable @Composable
actual fun rememberShareFilePicker( actual fun rememberShareFilePicker(
onFilePicked: (PickedShareFile) -> Unit, onFilesPicked: (List<PickedShareFile>) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
): ShareFilePicker = remember(onFilePicked, onError) { ): ShareFilePicker = remember(onFilesPicked, onError) {
object : ShareFilePicker { object : ShareFilePicker {
override fun pickFile() { override fun pickFiles() {
openPicker(onError) { openPicker(onError) {
pickShareFile()?.let(onFilePicked) val selected = pickShareFiles()
if (selected.isNotEmpty()) onFilesPicked(selected)
} }
} }
} }
@@ -71,22 +72,24 @@ private fun activeFrame(): Frame? {
?: Frame.getFrames().firstOrNull { it.isVisible } ?: Frame.getFrames().firstOrNull { it.isVisible }
} }
private fun pickShareFile(): PickedShareFile? { private fun pickShareFiles(): List<PickedShareFile> {
val dialog = nativeFileDialog("Select file to share") val dialog = nativeFileDialog("Select files to share").apply {
isMultipleMode = true
}
return try { return try {
dialog.isVisible = true dialog.isVisible = true
val directory = dialog.directory val directory = dialog.directory ?: return emptyList()
val file = dialog.file val names = dialog.files?.map { it.name }.orEmpty().ifEmpty {
if (directory != null && file != null) { dialog.file?.let { listOf(it) }.orEmpty()
val selected = File(directory, file) }
names.map { name ->
val selected = File(directory, name)
PickedShareFile( PickedShareFile(
selected.absolutePath, selected.absolutePath,
selected.name, selected.name,
selected.length().takeIf { it >= 0L }?.toULong(), selected.length().takeIf { it >= 0L }?.toULong(),
selected.systemIconPng(), selected.systemIconPng(),
) )
} else {
null
} }
} finally { } finally {
dialog.dispose() dialog.dispose()

View File

@@ -28,11 +28,22 @@ private class JvmFileSystemService : FileSystemService {
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
override suspend fun sharePickedFile( override suspend fun sharePickedFiles(
repository: CoreGateway, repository: CoreGateway,
file: PickedShareFile, files: List<PickedShareFile>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = repository.sharePath(file.value, 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.PATH,
value = file.value,
displayName = file.displayName,
isDirectory = false,
)
}
return repository.shareSources(sources, transferName, senderName, accessPolicy)
}
} }

View File

@@ -227,7 +227,7 @@ class FoundationComposeTest {
onNodeWithText("New transfer").performClick() onNodeWithText("New transfer").performClick()
onNodeWithText("Choose what to share").assertIsDisplayed() onNodeWithText("Choose what to share").assertIsDisplayed()
onNodeWithText("Choose file").assertIsDisplayed() onNodeWithText("Choose files").assertIsDisplayed()
} }
@Test @Test
@@ -239,7 +239,7 @@ class FoundationComposeTest {
coreState = CoreState(isInitialized = true), coreState = CoreState(isInitialized = true),
state = SendState( state = SendState(
isComposerOpen = true, isComposerOpen = true,
selectedFile = PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL), selectedFiles = listOf(PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL)),
transferName = "photos.zip", transferName = "photos.zip",
senderName = "Sender", senderName = "Sender",
), ),