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 f8116b9..5b5cf8c 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 @@ -16,18 +16,22 @@ import androidx.compose.ui.platform.LocalContext @Composable actual fun rememberShareFilePicker( - onFilePicked: (PickedShareFile) -> Unit, + onFilesPicked: (List) -> Unit, onError: (String) -> Unit, ): ShareFilePicker { val context = LocalContext.current - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - onFilePicked(context.pickedShareFile(uri)) - } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isEmpty()) return@rememberLauncherForActivityResult + runCatching { + uris.map { uri -> context.pickedShareFile(uri) } + }.fold( + onSuccess = onFilesPicked, + onFailure = { onError(it.message ?: "Could not open the selected files") }, + ) } return remember(launcher) { object : ShareFilePicker { - override fun pickFile() { + override fun pickFiles() { launcher.launch(arrayOf("*/*")) } } 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 49cea2c..2873a05 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 @@ -63,22 +63,30 @@ private class AndroidFileSystemService( ReceiveFolderKind.IosSecurityScopedUrl -> null } - override suspend fun sharePickedFile( + override suspend fun sharePickedFiles( repository: CoreGateway, - file: PickedShareFile, + files: List, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, ): Result = runCatching { - context.contentResolver.openFileDescriptor(Uri.parse(file.value), "r").use { descriptor -> - checkNotNull(descriptor) { "Could not open selected file descriptor" } - repository.shareFileDescriptor( - fd = descriptor.fd, - displayName = file.displayName, - transferName = transferName, - senderName = senderName, - accessPolicy = accessPolicy, - ).getOrThrow() + require(files.isNotEmpty()) { "Select at least one file to share" } + val descriptors = files.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 -> + uniffi.vnidrop.ShareSource( + 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() } } } diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 0a72f0f..044cebc 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -10,7 +10,11 @@ Your transfers New transfer Choose what to share - Select one file from this device. You can review its details before creating the transfer. + Select one or more files from this device. You can review them before creating the transfer. + %1$d files selected + Remove file + Choose files + Change files Review transfer Who can receive it? Ask before each download @@ -58,7 +62,7 @@ Transfer updated Choose file Change file - Share file + Share files Preparing transfer… Copy transfer link Create a new transfer diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index 5368031..6ccc20c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -142,6 +142,13 @@ interface CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ): Result + /** Multi-source share used by multi-file pickers. */ + suspend fun shareSources( + sources: List, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result suspend fun inspectTicket(ticket: String): Result suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 7a249a7..8eab4f1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -197,12 +197,13 @@ class CoreRepository( override suspend fun refresh(): Result = runCore { refreshSnapshot() } - private suspend fun shareSources( + override suspend fun shareSources( sources: List, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, ): Result = runCore { + require(sources.isNotEmpty()) { "Select at least one file to share" } withPlatformPathAccess(sources) { requireCore().shareFiles( sources = sources, 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 4d0b710..b1f66bb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -10,7 +10,8 @@ data class PickedShareFile( ) interface ShareFilePicker { - fun pickFile() + /** Opens a platform picker that may return one or more files. */ + fun pickFiles() } interface ReceiveFolderPicker { @@ -19,7 +20,7 @@ interface ReceiveFolderPicker { @Composable expect fun rememberShareFilePicker( - onFilePicked: (PickedShareFile) -> Unit, + onFilesPicked: (List) -> Unit, onError: (String) -> Unit, ): ShareFilePicker diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 1423a90..16f10fd 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -36,6 +36,14 @@ interface FileSystemService { transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, + ): Result = sharePickedFiles(repository, listOf(file), transferName, senderName, accessPolicy) + + suspend fun sharePickedFiles( + repository: CoreGateway, + files: List, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, ): Result } 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 2312276..5e9375e 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 @@ -17,13 +17,13 @@ fun SendRoute( val state by viewModel.state.collectAsStateWithLifecycle() val coreState by viewModel.coreState.collectAsStateWithLifecycle() val clipboard = LocalClipboardManager.current - val picker = rememberShareFilePicker(viewModel::onFilePicked, viewModel::onFilePickFailed) + val picker = rememberShareFilePicker(viewModel::onFilesPicked, viewModel::onFilePickFailed) val shareActions = rememberTransferShareActions() LaunchedEffect(viewModel) { viewModel.effectFlow.collect { effect -> when (effect) { - SendEffect.OpenFilePicker -> picker.pickFile() + SendEffect.OpenFilePicker -> picker.pickFiles() is SendEffect.CopyTicket -> clipboard.setText(AnnotatedString(effect.ticket)) } } @@ -38,6 +38,7 @@ fun SendRoute( onDismissComposer = viewModel::dismissComposer, onSelectFile = viewModel::selectFile, onClearFile = viewModel::clearSelectedSource, + onRemoveFile = viewModel::removeSelectedFile, onTransferNameChanged = viewModel::setTransferName, onSenderNameChanged = viewModel::setSenderName, onAccessPolicyChanged = viewModel::setAccessPolicy, 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 5b9d9d8..81aa8f2 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 @@ -25,6 +25,7 @@ fun SendScreen( onDismissComposer: () -> Unit, onSelectFile: () -> Unit, onClearFile: () -> Unit, + onRemoveFile: (String) -> Unit = {}, onTransferNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, @@ -83,6 +84,7 @@ fun SendScreen( windowClass = windowClass, onSelectFile = onSelectFile, onClearFile = onClearFile, + onRemoveFile = onRemoveFile, onTransferNameChanged = onTransferNameChanged, onSenderNameChanged = onSenderNameChanged, onAccessPolicyChanged = onAccessPolicyChanged, 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 ce274e1..c608975 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 @@ -29,7 +29,7 @@ import vnidrop.shared.generated.resources.transfer_nfc_written data class SendState( val isComposerOpen: Boolean = false, - val selectedFile: PickedShareFile? = null, + val selectedFiles: List = emptyList(), val transferName: String = "", val senderName: String = "", val accessPolicy: ShareAccessPolicy = ShareAccessPolicy.RequireApproval, @@ -42,8 +42,12 @@ data class SendState( val isDeleteConfirmationOpen: 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 = - coreInitialized && selectedFile != null && transferName.isNotBlank() && !isSharing + coreInitialized && selectedFiles.isNotEmpty() && transferName.isNotBlank() && !isSharing } enum class TransferDetailPanel { Activity, Receivers, Share } @@ -111,7 +115,7 @@ class SendViewModel( _state.update { it.copy( isComposerOpen = true, - selectedFile = null, + selectedFiles = emptyList(), transferName = "", accessPolicy = ShareAccessPolicy.RequireApproval, ) @@ -123,7 +127,7 @@ class SendViewModel( _state.update { it.copy( isComposerOpen = false, - selectedFile = null, + selectedFiles = emptyList(), transferName = "", accessPolicy = ShareAccessPolicy.RequireApproval, ) @@ -132,12 +136,13 @@ class SendViewModel( fun selectFile() = sendEffect(SendEffect.OpenFilePicker) - fun onFilePicked(file: PickedShareFile) { + fun onFilesPicked(files: List) { + if (files.isEmpty()) return _state.update { it.copy( isComposerOpen = true, - selectedFile = file, - transferName = file.displayName, + selectedFiles = files, + transferName = defaultTransferName(files), ) } } @@ -145,7 +150,21 @@ class SendViewModel( fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason)) 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) } @@ -218,24 +237,25 @@ class SendViewModel( fun createShare() { val current = state.value - val file = current.selectedFile ?: return + if (current.selectedFiles.isEmpty()) return if (!current.canCreateShare(coreState.value.isInitialized)) return viewModelScope.launch { _state.update { it.copy(isSharing = true) } - val result = fileSystemService.sharePickedFile( + val result = fileSystemService.sharePickedFiles( repository = repository, - file = file, + files = current.selectedFiles, transferName = current.transferName.trim(), senderName = current.senderName.trim(), accessPolicy = current.accessPolicy, ) result.fold( onSuccess = { share -> - file.thumbnailBytes?.let { filePreviewRepository.save(share.transferId, it) } + current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes } + ?.let { filePreviewRepository.save(share.transferId, it) } _state.update { it.copy( isComposerOpen = false, - selectedFile = null, + selectedFiles = emptyList(), transferName = "", accessPolicy = ShareAccessPolicy.RequireApproval, isSharing = false, @@ -251,6 +271,12 @@ class SendViewModel( } } + private fun defaultTransferName(files: List): String = when (files.size) { + 0 -> "" + 1 -> files.first().displayName + else -> "${files.size} files" + } + private fun sendEffect(effect: SendEffect) { viewModelScope.launch { effects.send(effect) } } 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 3639126..fa8cee5 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface @@ -39,9 +40,10 @@ import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res -import vnidrop.shared.generated.resources.button_change_file -import vnidrop.shared.generated.resources.button_choose_file +import vnidrop.shared.generated.resources.button_change_files +import vnidrop.shared.generated.resources.button_choose_files 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_sharing_file 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_file_size_unknown import vnidrop.shared.generated.resources.send_review_title +import vnidrop.shared.generated.resources.send_selected_files_count @Composable internal fun TransferComposer( @@ -63,6 +66,7 @@ internal fun TransferComposer( windowClass: WindowClass, onSelectFile: () -> Unit, onClearFile: () -> Unit, + onRemoveFile: (String) -> Unit, onTransferNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, @@ -72,16 +76,15 @@ internal fun TransferComposer( modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val file = state.selectedFile - if (file == null) { + if (state.selectedFiles.isEmpty()) { ChooseFileStep(onSelectFile) } else { ReviewFileStep( - file = file, state = state, windowClass = windowClass, onSelectFile = onSelectFile, onClearFile = onClearFile, + onRemoveFile = onRemoveFile, onTransferNameChanged = onTransferNameChanged, onSenderNameChanged = onSenderNameChanged, onAccessPolicyChanged = onAccessPolicyChanged, @@ -107,18 +110,18 @@ private fun ChooseFileStep(onSelectFile: () -> Unit) { verticalArrangement = Arrangement.spacedBy(14.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 private fun ReviewFileStep( - file: PickedShareFile, state: SendState, windowClass: WindowClass, onSelectFile: () -> Unit, onClearFile: () -> Unit, + onRemoveFile: (String) -> Unit, onTransferNameChanged: (String) -> Unit, onSenderNameChanged: (String) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, @@ -126,7 +129,20 @@ private fun ReviewFileStep( coreInitialized: Boolean, ) { 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.senderName, onSenderNameChanged, stringResource(Res.string.field_sender_name)) 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) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { 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) } } @@ -169,7 +185,11 @@ private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShar } @Composable -private fun SelectedFileCard(file: PickedShareFile) { +private fun SelectedFileCard( + file: PickedShareFile, + canRemove: Boolean, + onRemove: () -> Unit, +) { Surface(shape = RoundedCornerShape(14.dp), color = LocalVniDropColors.current.backgroundSurface200) { Row(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { 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, ) } + if (canRemove) { + IconButton(onClick = onRemove) { + Icon(SendIcons.Delete, stringResource(Res.string.button_remove_file), tint = LocalVniDropColors.current.destructiveDefault) + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index db61e0e..8027b23 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -181,6 +181,9 @@ private fun ReceiverRow(receiver: ReceiverRequestModel) { 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) + receiver.reason?.takeIf { it.isNotBlank() }?.let { reason -> + Text(reason, 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 0705a26..786abe4 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -174,7 +174,7 @@ class ViewModelsTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) 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(42UL, viewModel.state.value.selectedFile?.sizeBytes) viewModel.clearSelectedSource() @@ -195,7 +195,7 @@ class ViewModelsTest { val thumbnail = ByteArray(12).also { 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.createShare() advanceUntilIdle() @@ -214,7 +214,7 @@ class ViewModelsTest { val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) advanceUntilIdle() 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() advanceUntilIdle() @@ -223,6 +223,29 @@ class ViewModelsTest { 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 fun sendDeletionRemovesCoreTransferAndOwnedPreview() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index edbbea1..b6b3b16 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -67,8 +67,43 @@ class FakeCoreGateway : CoreGateway { return Result.success(Unit) } override fun shutdown() = Unit - override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result { + var lastShareSourceCount: Int = 0 + override suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result = + 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(UnsupportedOperationException()) + override suspend fun shareSecurityScopedFileUrl( + fileUrl: String, + displayName: String, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ) = Result.failure(UnsupportedOperationException()) + override suspend fun shareSources( + sources: List, + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy, + ): Result { lastShareAccessPolicy = accessPolicy + lastShareSourceCount = sources.size shareResult.onSuccess { share -> mutableState.value = mutableState.value.copy( transfers = listOf( @@ -92,20 +127,6 @@ class FakeCoreGateway : CoreGateway { } return shareResult } - override suspend fun shareFileDescriptor( - fd: Int, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ) = Result.failure(UnsupportedOperationException()) - override suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ) = Result.failure(UnsupportedOperationException()) override suspend fun inspectTicket(ticket: String) = inspectionResult override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result { receiveCount += 1 @@ -202,13 +223,23 @@ class FakeFileSystemService( override fun defaultReceiveFolder() = folder override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null - override suspend fun sharePickedFile( + override suspend fun sharePickedFiles( repository: CoreGateway, - file: PickedShareFile, + files: List, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, - ) = repository.sharePath(file.value, transferName, senderName, accessPolicy) + ): Result { + 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 { diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index 0aba42c..5765f96 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -47,7 +47,7 @@ class AppUiModelsTest { @Test fun sendStateExposesShareEligibility() { val ready = SendState( - selectedFile = PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL), + selectedFiles = listOf(PickedShareFile("/tmp/payload.txt", "payload.txt", 128UL)), transferName = "payload.txt", ) 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 52499cb..17dca62 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 @@ -23,12 +23,12 @@ private var retainedPickerDelegate: DocumentPickerDelegate? = null @Composable actual fun rememberShareFilePicker( - onFilePicked: (PickedShareFile) -> Unit, + onFilesPicked: (List) -> 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) -> 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 } 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 82c956f..af6e478 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 @@ -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, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, - ): Result = repository.shareSecurityScopedFileUrl( - file.value, - file.displayName, - transferName, - senderName, - accessPolicy, - ) + ): Result { + 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) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt index 31fcf75..bf6f66f 100644 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt @@ -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) -> Unit) { 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 c7b5198..81232b5 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 @@ -15,13 +15,14 @@ import javax.swing.filechooser.FileSystemView @Composable actual fun rememberShareFilePicker( - onFilePicked: (PickedShareFile) -> Unit, + onFilesPicked: (List) -> Unit, onError: (String) -> Unit, -): ShareFilePicker = remember(onFilePicked, onError) { +): ShareFilePicker = remember(onFilesPicked, onError) { object : ShareFilePicker { - override fun pickFile() { + override fun pickFiles() { 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 } } -private fun pickShareFile(): PickedShareFile? { - val dialog = nativeFileDialog("Select file to share") +private fun pickShareFiles(): List { + val dialog = nativeFileDialog("Select files to share").apply { + isMultipleMode = true + } return try { dialog.isVisible = true - val directory = dialog.directory - val file = dialog.file - if (directory != null && file != null) { - val selected = File(directory, file) + val directory = dialog.directory ?: return emptyList() + val names = dialog.files?.map { it.name }.orEmpty().ifEmpty { + dialog.file?.let { listOf(it) }.orEmpty() + } + names.map { name -> + val selected = File(directory, name) PickedShareFile( selected.absolutePath, selected.name, selected.length().takeIf { it >= 0L }?.toULong(), selected.systemIconPng(), ) - } else { - null } } finally { dialog.dispose() 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 b882401..7a97307 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 @@ -28,11 +28,22 @@ private class JvmFileSystemService : FileSystemService { override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null - override suspend fun sharePickedFile( + override suspend fun sharePickedFiles( repository: CoreGateway, - file: PickedShareFile, + files: List, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, - ): Result = repository.sharePath(file.value, transferName, senderName, accessPolicy) + ): Result { + 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) + } } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 0377e9f..54f9fd7 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -227,7 +227,7 @@ class FoundationComposeTest { onNodeWithText("New transfer").performClick() onNodeWithText("Choose what to share").assertIsDisplayed() - onNodeWithText("Choose file").assertIsDisplayed() + onNodeWithText("Choose files").assertIsDisplayed() } @Test @@ -239,7 +239,7 @@ class FoundationComposeTest { coreState = CoreState(isInitialized = true), state = SendState( isComposerOpen = true, - selectedFile = PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL), + selectedFiles = listOf(PickedShareFile("/tmp/photos.zip", "photos.zip", 1536UL)), transferName = "photos.zip", senderName = "Sender", ),