diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 5592df5..70668c1 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -43,6 +43,8 @@ LSSupportsOpeningDocumentsInPlace + UIFileSharingEnabled + NSCameraUsageDescription VniDrop uses the camera to scan transfer QR codes. NFCReaderUsageDescription diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 325aef4..e866f8b 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -96,6 +96,8 @@ Review transfer VniDrop transfer Transfer received. + Show in Files + Couldn’t open VniDrop in Files. Receiver name Receive Retry 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 7b29acb..badadfe 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -7,6 +7,8 @@ data class PickedShareFile( val displayName: String, val sizeBytes: ULong? = null, val thumbnailBytes: ByteArray? = null, + /** App-owned picker copy that may be deleted after import or when selection is abandoned. */ + val isTemporaryCopy: Boolean = false, /** * When true, [value] is a directory (filesystem path, iOS security-scoped * folder URL, or Android document tree URI). Platform share code expands or 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 16f10fd..8616582 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -27,9 +27,18 @@ enum class FolderAccessStatus { } interface FileSystemService { + val supportsCustomReceiveFolders: Boolean get() = true + fun defaultReceiveFolder(): ReceiveFolder + fun effectiveReceiveFolder(configuredFolder: ReceiveFolder): ReceiveFolder = + if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? + fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false + suspend fun revealReceiveFolder(folder: ReceiveFolder): Result = + Result.failure(UnsupportedOperationException("Revealing the receive folder is not supported")) + /** Releases only app-owned picker copies; implementations must never delete original user sources. */ + suspend fun discardPickedFiles(files: List) = Unit suspend fun sharePickedFile( repository: CoreGateway, file: PickedShareFile, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index 006026a..c2223b9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_retry +import vnidrop.shared.generated.resources.button_show_in_files import vnidrop.shared.generated.resources.error_invitation_empty +import vnidrop.shared.generated.resources.receive_open_files_failed import vnidrop.shared.generated.resources.receive_completed import vnidrop.shared.generated.resources.receive_history_cleared import vnidrop.shared.generated.resources.transfer_deleted @@ -69,11 +71,12 @@ class ReceiveViewModel( init { viewModelScope.launch { preferencesRepository.preferences.collect { preferences -> - val status = fileSystemService.validateReceiveFolder(preferences.receiveFolder) + val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder) + val status = fileSystemService.validateReceiveFolder(receiveFolder) _state.update { current -> current.copy( receiverName = current.receiverName.ifBlank { preferences.username }, - receiveFolder = preferences.receiveFolder, + receiveFolder = receiveFolder, folderAccessStatus = status, ) } @@ -170,7 +173,19 @@ class ReceiveViewModel( result.fold( onSuccess = { resetAcquisition() - messages.tryShow(UiMessage(UiText.Resource(Res.string.receive_completed), UiMessageTone.Success)) + val canRevealFolder = fileSystemService.canRevealReceiveFolder(folder) + messages.tryShow( + UiMessage( + text = UiText.Resource(Res.string.receive_completed), + tone = UiMessageTone.Success, + actionLabel = if (canRevealFolder) UiText.Resource(Res.string.button_show_in_files) else null, + onAction = if (canRevealFolder) { + { revealReceiveFolder(folder) } + } else { + null + }, + ), + ) }, onFailure = { error -> if (error.isUserCancellation()) { @@ -226,6 +241,16 @@ class ReceiveViewModel( } } + private fun revealReceiveFolder(folder: ReceiveFolder) { + viewModelScope.launch { + fileSystemService.revealReceiveFolder(folder).onFailure { + messages.show( + UiMessage(UiText.Resource(Res.string.receive_open_files_failed), UiMessageTone.Error), + ) + } + } + } + private fun inspectInvitation(method: ReceiveMethod, raw: String) { val ticket = raw.trim() if (ticket.isBlank()) return messages.error(UiText.Resource(Res.string.error_invitation_empty)) 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 63f1a09..eaa1017 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 @@ -114,6 +114,7 @@ class SendViewModel( fun openComposer() { if (_state.value.isSharing) return + val discardedFiles = _state.value.selectedFiles _state.update { it.copy( isComposerOpen = true, @@ -122,10 +123,12 @@ class SendViewModel( accessPolicy = ShareAccessPolicy.RequireApproval, ) } + discardPickedFiles(discardedFiles) } fun dismissComposer() { if (_state.value.isSharing) return + val discardedFiles = _state.value.selectedFiles _state.update { it.copy( isComposerOpen = false, @@ -134,6 +137,7 @@ class SendViewModel( accessPolicy = ShareAccessPolicy.RequireApproval, ) } + discardPickedFiles(discardedFiles) } fun selectFile() = sendEffect(SendEffect.OpenFilePicker) @@ -141,6 +145,8 @@ class SendViewModel( fun onFilesPicked(files: List) { if (files.isEmpty()) return + val selectedValues = files.mapTo(mutableSetOf(), PickedShareFile::value) + val discardedFiles = _state.value.selectedFiles.filterNot { it.value in selectedValues } _state.update { it.copy( isComposerOpen = true, @@ -148,15 +154,19 @@ class SendViewModel( transferName = defaultTransferName(files), ) } + discardPickedFiles(discardedFiles) } fun onFilePickFailed(reason: String) = messages.error(IllegalStateException(reason.takeIf(String::isNotBlank) ?: "selection failed")) fun clearSelectedSource() { + val discardedFiles = _state.value.selectedFiles _state.update { it.copy(selectedFiles = emptyList(), transferName = "") } + discardPickedFiles(discardedFiles) } fun removeSelectedFile(value: String) { + val discardedFiles = _state.value.selectedFiles.filter { it.value == value } _state.update { current -> val remaining = current.selectedFiles.filterNot { it.value == value } current.copy( @@ -168,6 +178,7 @@ class SendViewModel( }, ) } + discardPickedFiles(discardedFiles) } fun setTransferName(value: String) = _state.update { it.copy(transferName = value) } @@ -252,6 +263,7 @@ class SendViewModel( senderName = current.senderName.trim(), accessPolicy = current.accessPolicy, ) + if (result.isSuccess) fileSystemService.discardPickedFiles(current.selectedFiles) result.fold( onSuccess = { share -> current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes } @@ -287,6 +299,11 @@ class SendViewModel( viewModelScope.launch { effects.send(effect) } } + private fun discardPickedFiles(files: List) { + if (files.isEmpty()) return + viewModelScope.launch { fileSystemService.discardPickedFiles(files) } + } + private fun refreshReceivers(transferId: ULong) { viewModelScope.launch { _state.update { it.copy(isLoadingReceivers = true) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt index c7f720a..315d527 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/PreferencesSettings.kt @@ -47,9 +47,11 @@ internal fun PreferencesSettings( iconTone = SettingsIconTone.Neutral, ) } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder) - SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder) + if (state.supportsCustomReceiveFolders) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PrimaryButton(stringResource(Res.string.button_choose_folder), onClick = onChooseFolder) + SecondaryButton(stringResource(Res.string.button_reset_default), onClick = onResetFolder) + } } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 8de22bd..a3b6a4f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -58,6 +58,7 @@ data class SettingsState( val receiveFolder: ReceiveFolder? = null, val folderAccessStatus: FolderAccessStatus = FolderAccessStatus.Unavailable, val isValidatingFolder: Boolean = false, + val supportsCustomReceiveFolders: Boolean = true, val themeMode: ThemeMode = ThemeMode.System, val notificationsEnabled: Boolean = false, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, @@ -88,7 +89,12 @@ class SettingsViewModel( private val bugReports: BugReportService, private val diagnostics: DiagnosticsCoordinator? = null, ) : ViewModel() { - private val _state = MutableStateFlow(SettingsState(appVersion = environment.appVersion)) + private val _state = MutableStateFlow( + SettingsState( + appVersion = environment.appVersion, + supportsCustomReceiveFolders = fileSystemService.supportsCustomReceiveFolders, + ), + ) val state: StateFlow = _state.asStateFlow() private val effects = Channel(Channel.BUFFERED) @@ -100,20 +106,21 @@ class SettingsViewModel( viewModelScope.launch { preferencesRepository.preferences.collect { preferences -> val previousFolder = _state.value.receiveFolder + val receiveFolder = fileSystemService.effectiveReceiveFolder(preferences.receiveFolder) // While the user is typing, keep the in-progress value. DataStore // echoes can race keystrokes and trim trailing spaces mid-edit. val editingUsername = usernamePersistJob?.isActive == true _state.update { current -> current.copy( username = if (editingUsername) current.username else preferences.username, - receiveFolder = preferences.receiveFolder, + receiveFolder = receiveFolder, themeMode = preferences.themeMode, notificationsEnabled = preferences.notificationsEnabled, diagnosticsEnabled = preferences.diagnosticsEnabled, ) } - if (preferences.receiveFolder != previousFolder) { - validateFolder(preferences.receiveFolder) + if (receiveFolder != previousFolder) { + validateFolder(receiveFolder) } } } @@ -146,6 +153,7 @@ class SettingsViewModel( } fun chooseReceiveFolder() { + if (!fileSystemService.supportsCustomReceiveFolders) return viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) } } 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 6db3377..661b136 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -3,6 +3,7 @@ package com.vnidrop.app.feature import com.vnidrop.app.DeviceInfo import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.Share @@ -35,6 +36,8 @@ import com.vnidrop.app.ui.navigation.AppDestination import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -45,9 +48,12 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertContentEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_show_in_files import vnidrop.shared.generated.resources.error_permission +import vnidrop.shared.generated.resources.receive_open_files_failed @OptIn(ExperimentalCoroutinesApi::class) class ViewModelsTest { @@ -189,16 +195,40 @@ class ViewModelsTest { assertFalse(viewModel.state.value.isSubmittingBugReport) } + @Test + fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents") + val externalFolder = ReceiveFolder(ReceiveFolderKind.IosSecurityScopedUrl, "file:///external", "External") + val preferences = preferences().apply { + mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder) + } + val fileSystem = FakeFileSystemService(appDocuments).apply { supportsCustomFolders = false } + val viewModel = settingsViewModel(preferences = preferences, fileSystem = fileSystem) + + advanceUntilIdle() + + assertFalse(viewModel.state.value.supportsCustomReceiveFolders) + assertEquals(appDocuments, viewModel.state.value.receiveFolder) + assertEquals(com.vnidrop.app.core.FolderAccessStatus.Writable, viewModel.state.value.folderAccessStatus) + viewModel.chooseReceiveFolder() + assertEquals(null, withTimeoutOrNull(1) { viewModel.effectFlow.first() }) + } + @Test fun sendViewModelOwnsSelectedFileState() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), FakeFilePreviewRepository(), UiMessageController()) + val fileSystem = FakeFileSystemService(folder) + val viewModel = SendViewModel(FakeCoreGateway(), fileSystem, preferences(), FakeFilePreviewRepository(), UiMessageController()) viewModel.openComposer() - viewModel.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL))) + val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, isTemporaryCopy = true) + viewModel.onFilesPicked(listOf(selected)) assertEquals("photo.jpg", viewModel.state.value.transferName) assertEquals(42UL, viewModel.state.value.selectedFile?.sizeBytes) viewModel.clearSelectedSource() + advanceUntilIdle() assertEquals(null, viewModel.state.value.selectedFile) + assertEquals(listOf(selected), fileSystem.discardedPickedFiles) } @Test @@ -209,13 +239,15 @@ class ViewModelsTest { shareResult = Result.success(Share(7UL, "ticket", "photo.jpg", "hash", 1UL, 42UL)) } val previews = FakeFilePreviewRepository() - val viewModel = SendViewModel(core, FakeFileSystemService(folder), preferences(), previews, UiMessageController()) + val fileSystem = FakeFileSystemService(folder) + val viewModel = SendViewModel(core, fileSystem, preferences(), previews, UiMessageController()) advanceUntilIdle() viewModel.openComposer() 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.onFilesPicked(listOf(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail))) + val selected = PickedShareFile("/tmp/photo.jpg", "photo.jpg", 42UL, thumbnail, isTemporaryCopy = true) + viewModel.onFilesPicked(listOf(selected)) viewModel.setAccessPolicy(ShareAccessPolicy.AnyoneWithTransfer) viewModel.createShare() advanceUntilIdle() @@ -225,6 +257,7 @@ class ViewModelsTest { assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(7UL, core.state.value.transfers.first().transferId) assertContentEquals(thumbnail, previews.previews.value.getValue(7UL)) + assertEquals(listOf(selected), fileSystem.discardedPickedFiles) } @Test @@ -397,7 +430,8 @@ class ViewModelsTest { mutableState.value = mutableState.value.copy(isInitialized = true) inspectionResult = Result.success(sampleTicketInspection()) } - val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController()) + val messages = UiMessageController() + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), messages) advanceUntilIdle() viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket-abc")) advanceUntilIdle() @@ -411,6 +445,74 @@ class ViewModelsTest { assertFalse(viewModel.state.value.isAcquisitionOpen) assertEquals("", viewModel.state.value.ticket) assertFalse(viewModel.state.value.isReceiving) + val completed = messages.messages.first() + assertEquals(null, completed.actionLabel) + assertEquals(null, completed.onAction) + } + + @Test + fun receiveViewModelOffersCompletedFolderInFilesWhenPlatformSupportsIt() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success(sampleTicketInspection()) + } + val fileSystem = FakeFileSystemService(folder).apply { canRevealFolder = true } + val messages = UiMessageController() + val viewModel = ReceiveViewModel(core, fileSystem, preferences(), messages) + advanceUntilIdle() + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket")) + advanceUntilIdle() + + viewModel.receive() + advanceUntilIdle() + val completed = messages.messages.first() + + assertEquals(UiText.Resource(Res.string.button_show_in_files), completed.actionLabel) + assertNotNull(completed.onAction).invoke() + advanceUntilIdle() + assertEquals(listOf(folder), fileSystem.revealedFolders) + } + + @Test + fun receiveViewModelUsesPlatformEffectiveFolderInsteadOfPersistedExternalFolder() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val internalFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents") + val fileSystem = FakeFileSystemService(folder).apply { effectiveFolder = internalFolder } + val viewModel = ReceiveViewModel(FakeCoreGateway(), fileSystem, preferences(), UiMessageController()) + + advanceUntilIdle() + + assertEquals(internalFolder, viewModel.state.value.receiveFolder) + } + + @Test + fun receiveViewModelReportsWhenCompletedFolderCannotOpen() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success(sampleTicketInspection()) + } + val fileSystem = FakeFileSystemService(folder).apply { + canRevealFolder = true + revealFolderResult = Result.failure(IllegalStateException("Files unavailable")) + } + val messages = UiMessageController() + val viewModel = ReceiveViewModel(core, fileSystem, preferences(), messages) + advanceUntilIdle() + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.InvitationFile, Result.success("ticket")) + advanceUntilIdle() + viewModel.receive() + advanceUntilIdle() + + val completed = messages.messages.first() + assertNotNull(completed.onAction).invoke() + advanceUntilIdle() + + assertEquals( + UiText.Resource(Res.string.receive_open_files_failed), + messages.messages.first().text, + ) } @Test @@ -540,10 +642,11 @@ class ViewModelsTest { preferences: PreferencesRepository = preferences(), notifications: FakeNotificationService = FakeNotificationService(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), + fileSystem: FakeFileSystemService = FakeFileSystemService(folder), ) = SettingsViewModel( environment(), { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, - FakeFileSystemService(folder), + fileSystem, preferences, notifications, UiMessageController(), 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 5f61ab5..bb4fafc 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -230,9 +230,26 @@ class FakeNotificationService( class FakeFileSystemService( private val folder: ReceiveFolder, ) : FileSystemService { + var supportsCustomFolders = true + var effectiveFolder: ReceiveFolder? = null + var canRevealFolder = false + var revealFolderResult: Result = Result.success(Unit) + val revealedFolders = mutableListOf() + val discardedPickedFiles = mutableListOf() + override val supportsCustomReceiveFolders: Boolean get() = supportsCustomFolders override fun defaultReceiveFolder() = folder + override fun effectiveReceiveFolder(configuredFolder: ReceiveFolder) = + effectiveFolder ?: super.effectiveReceiveFolder(configuredFolder) override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null + override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder + override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { + revealedFolders += folder + return revealFolderResult + } + override suspend fun discardPickedFiles(files: List) { + discardedPickedFiles += files + } override suspend fun sharePickedFiles( repository: CoreGateway, files: List, 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 2efb573..eb38a85 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 @@ -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) -> 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, ) } 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 02f7ee8..f350fb3 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 @@ -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) { + 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 { + 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()) { 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, @@ -51,15 +89,7 @@ private class IosFileSystemService : FileSystemService { accessPolicy: ShareAccessPolicy, ): 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 = 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, +) diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt new file mode 100644 index 0000000..ecfc490 --- /dev/null +++ b/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt @@ -0,0 +1,25 @@ +package com.vnidrop.app.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import uniffi.vnidrop.SourceKind + +class FileSystemServiceIosTest { + @Test + fun sandboxPickerCopyMapsToPathSource() { + val picked = PickedShareFile( + value = "/tmp/VniDrop/photos", + displayName = "photos", + isTemporaryCopy = true, + isDirectory = true, + ) + + val source = picked.toIosShareSource() + + assertEquals(SourceKind.PATH, source.kind) + assertEquals(picked.value, source.value) + assertEquals(picked.displayName, source.displayName) + assertTrue(source.isDirectory) + } +}