diff --git a/docs/dialogs/file-picker.mdx b/docs/dialogs/file-picker.mdx index 92f3d869..fa4a325b 100644 --- a/docs/dialogs/file-picker.mdx +++ b/docs/dialogs/file-picker.mdx @@ -79,6 +79,7 @@ stateFlow.collect { state -> is FileKitPickerState.Started -> println("Selection started with ${state.total} files") is FileKitPickerState.Progress -> println("Processing: ${state.processed.size} / ${state.total}") is FileKitPickerState.Completed -> println("Completed: ${state.result.size} files selected") + is FileKitPickerState.Failed -> println("Selection failed: ${state.cause.message}") is FileKitPickerState.Cancelled -> println("Selection cancelled") } } @@ -102,6 +103,10 @@ val stateLauncher = rememberFilePickerLauncher( // Handle selected file: state.result println("Completed: ${state.result.size} files selected") } + is FileKitPickerState.Failed -> { + // Handle picker failure + println("Selection failed: ${state.cause.message}") + } is FileKitPickerState.Cancelled -> { // Handle cancellation println("Selection cancelled") diff --git a/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt b/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt index c1d7671a..4e62147d 100644 --- a/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt +++ b/filekit-dialogs-compose/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.android.kt @@ -612,6 +612,10 @@ internal fun dispatchPickerConsumedResult( onConsumed(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + onConsumed(FileKitPickerState.Failed(cause = state.cause)) + } + is FileKitPickerState.Started -> { onConsumed(FileKitPickerState.Started(total = state.total)) } @@ -647,6 +651,10 @@ internal fun dispatchPickerConsumedResult( onConsumed(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + onConsumed(FileKitPickerState.Failed(cause = state.cause)) + } + is FileKitPickerState.Started -> { onConsumed( FileKitPickerState.Started( diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt index c0349ef6..5ff74393 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. * @return The result of the picker, depending on the [mode]. + * @throws FileKitPickerException When the user selected files but FileKit could not resolve them. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), @@ -35,6 +36,7 @@ public suspend fun FileKit.openFilePicker( * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. * @return The picked [PlatformFile], or null if cancelled. + * @throws FileKitPickerException When the user selected a file but FileKit could not resolve it. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt index 4a7e4875..acb4e0ce 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitMode.kt @@ -30,6 +30,10 @@ public sealed class FileKitMode { override suspend fun parseResult(flow: Flow>>): PlatformFile? = flow.last().let { when (it) { + is FileKitPickerState.Failed -> { + throw it.cause + } + is FileKitPickerState.Completed -> { it.result.firstOrNull() } @@ -62,10 +66,15 @@ public sealed class FileKitMode { override suspend fun parseResult(flow: Flow>>): List? = flow.last().let { when (it) { + is FileKitPickerState.Failed -> { + throw it.cause + } + is FileKitPickerState.Completed -> { - maxItems + val files = maxItems ?.let { max -> it.result.take(max) } ?: it.result + files.takeIfNotEmpty() } else -> { @@ -96,6 +105,10 @@ public sealed class FileKitMode { emit(FileKitPickerState.Cancelled) } + is FileKitPickerState.Failed -> { + emit(FileKitPickerState.Failed(cause = pickerState.cause)) + } + is FileKitPickerState.Started -> { emit(FileKitPickerState.Started(total = pickerState.total)) } @@ -145,29 +158,39 @@ public sealed class FileKitMode { override suspend fun parseResult( flow: Flow>>, ): Flow>> = flow - .mapNotNull { - when (it) { + .mapNotNull { pickerState -> + when (pickerState) { is FileKitPickerState.Cancelled -> { FileKitPickerState.Cancelled } + is FileKitPickerState.Failed -> { + FileKitPickerState.Failed(cause = pickerState.cause) + } + is FileKitPickerState.Started -> { FileKitPickerState.Started( - total = maxItems?.let { max -> minOf(it.total, max) } ?: it.total, + total = maxItems?.let { max -> minOf(pickerState.total, max) } ?: pickerState.total, ) } is FileKitPickerState.Progress -> { FileKitPickerState.Progress( - processed = maxItems?.let { max -> it.processed.take(max) } ?: it.processed, - total = maxItems?.let { max -> minOf(it.total, max) } ?: it.total, + processed = maxItems?.let { max -> pickerState.processed.take(max) } ?: pickerState.processed, + total = maxItems?.let { max -> minOf(pickerState.total, max) } ?: pickerState.total, ) } is FileKitPickerState.Completed -> { - FileKitPickerState.Completed( - result = maxItems?.let { max -> it.result.take(max) } ?: it.result, - ) + val files = ( + maxItems?.let { max -> pickerState.result.take(max) } + ?: pickerState.result + ).takeIfNotEmpty() + + when { + files != null -> FileKitPickerState.Completed(result = files) + else -> FileKitPickerState.Cancelled + } } } } @@ -181,6 +204,8 @@ public sealed class FileKitMode { } } +private fun List.takeIfNotEmpty(): List? = takeIf { it.isNotEmpty() } + internal sealed class PickerMode { data object Single : PickerMode() diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt new file mode 100644 index 00000000..3f44d476 --- /dev/null +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt @@ -0,0 +1,9 @@ +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.exceptions.FileKitException + +public class FileKitPickerException : FileKitException { + public constructor(message: String) : super(message) + + public constructor(message: String, cause: Throwable) : super(message, cause) +} diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt index 1e35b393..a8a39e2b 100644 --- a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerState.kt @@ -11,6 +11,15 @@ public sealed class FileKitPickerState { */ public data object Cancelled : FileKitPickerState() + /** + * The picker failed after the user made a selection. + * + * @property cause The underlying picker failure. + */ + public data class Failed( + val cause: FileKitPickerException, + ) : FileKitPickerState() + /** * The picker has started and is processing the files. * diff --git a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt index 1f7f0a22..aba2612e 100644 --- a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt +++ b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitModeMaxItemsTest.kt @@ -9,8 +9,59 @@ import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class FileKitModeMaxItemsTest { + @Test + fun Single_parseResult_throwsPickerException_whenFailed() = runTest { + val failure = FileKitPickerException("Failed to load the selected file.") + + val thrown = assertFailsWith { + FileKitMode.Single.parseResult( + flow = flowOf(FileKitPickerState.Failed(failure)), + ) + } + + assertEquals(expected = failure.message, actual = thrown.message) + } + + @Test + fun Single_parseResult_returnsNull_whenCompletedResultEmpty() = runTest { + val result = FileKitMode.Single.parseResult( + flow = flowOf(FileKitPickerState.Completed(emptyList())), + ) + + assertEquals(expected = null, actual = result) + } + + @Test + fun SingleWithState_parseResult_treatsEmptyCompletedResultAsCancelled() = runTest { + val states = FileKitMode + .SingleWithState + .parseResult(flow = flowOf(FileKitPickerState.Completed(emptyList()))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Cancelled), + actual = states, + ) + } + + @Test + fun SingleWithState_parseResult_keepsFailedUnchanged() = runTest { + val failure = FileKitPickerException("Failed to load the selected file.") + + val states = FileKitMode + .SingleWithState + .parseResult(flow = flowOf(FileKitPickerState.Failed(failure))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Failed(failure)), + actual = states, + ) + } + @Test fun Multiple_parseResult_truncatesCompletedResult_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -31,6 +82,28 @@ class FileKitModeMaxItemsTest { assertEquals(expected = files, actual = result) } + @Test + fun Multiple_parseResult_returnsNull_whenCompletedResultEmpty() = runTest { + val result = FileKitMode.Multiple(maxItems = null).parseResult( + flow = flowOf(FileKitPickerState.Completed(emptyList())), + ) + + assertEquals(expected = null, actual = result) + } + + @Test + fun Multiple_parseResult_throwsPickerException_whenFailed() = runTest { + val failure = FileKitPickerException("Failed to load one of the selected files.") + + val thrown = assertFailsWith { + FileKitMode.Multiple(maxItems = null).parseResult( + flow = flowOf(FileKitPickerState.Failed(failure)), + ) + } + + assertEquals(expected = failure.message, actual = thrown.message) + } + @Test fun MultipleWithState_parseResult_capsStartedProgressCompleted_whenMaxItemsSet() = runTest { val files = createFiles(count = 4) @@ -68,6 +141,34 @@ class FileKitModeMaxItemsTest { ) } + @Test + fun MultipleWithState_parseResult_treatsEmptyCompletedResultAsCancelled() = runTest { + val states = FileKitMode + .MultipleWithState(maxItems = null) + .parseResult(flow = flowOf(FileKitPickerState.Completed(emptyList()))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Cancelled), + actual = states, + ) + } + + @Test + fun MultipleWithState_parseResult_keepsFailedUnchanged() = runTest { + val failure = FileKitPickerException("Failed to load one of the selected files.") + + val states = FileKitMode + .MultipleWithState(maxItems = null) + .parseResult(flow = flowOf(FileKitPickerState.Failed(failure))) + .toList() + + assertEquals( + expected = listOf(FileKitPickerState.Failed(failure)), + actual = states, + ) + } + @Test fun MultipleWithState_parseResult_returnsUnchangedFlow_whenMaxItemsNull() = runTest { val files = createFiles(count = 4) diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt index 5ca5c746..c42fe6a2 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.ios.kt @@ -481,45 +481,78 @@ private fun callPhPicker( // Pre-allocated array to preserve selection order val orderedFiles = arrayOfNulls(pickerResults.size) val lock = Mutex() + var failure: FileKitPickerException? = null // Launch a child coroutine for every copy, preserving the index pickerResults .mapIndexed { index, result -> launch(Dispatchers.IO) { - val src = suspendCancellableCoroutine { cont -> - result.itemProvider.loadFileRepresentationForTypeIdentifier( - when (type) { - is FileKitType.Image -> UTTypeImage.identifier - is FileKitType.Video -> UTTypeMovie.identifier - is FileKitType.ImageAndVideo -> UTTypeContent.identifier - else -> error("Unsupported type $type") - }, - ) { url, error -> - when { - error != null -> { - cont.resumeWithException(IllegalStateException(error.localizedFailureReason())) - } + try { + val src = suspendCancellableCoroutine { cont -> + result.itemProvider.loadFileRepresentationForTypeIdentifier( + when (type) { + is FileKitType.Image -> UTTypeImage.identifier + is FileKitType.Video -> UTTypeMovie.identifier + is FileKitType.ImageAndVideo -> UTTypeContent.identifier + else -> error("Unsupported type $type") + }, + ) { url, error -> + when { + error != null -> { + cont.resumeWithException( + FileKitPickerException( + message = error.localizedDescription, + ), + ) + } + + url == null -> { + cont.resumeWithException( + FileKitPickerException("The selected file could not be resolved."), + ) + } - else -> { - // Must copy the URL here because it becomes invalid outside the loadFileRepresentationForTypeIdentifier callback scope - val tempUrl = url?.let { - copyToTempFile(fileManager, it, tempRoot.lastPathComponent!!) + else -> { + // Must copy the URL here because it becomes invalid outside the loadFileRepresentationForTypeIdentifier callback scope + runCatching { + copyToTempFile(fileManager, url, tempRoot.lastPathComponent!!) + }.onSuccess(cont::resume) + .onFailure { cont.resumeWithException(it) } } - cont.resume(tempUrl) } } } - } ?: return@launch // skip nulls - lock.withLock { - // Insert at the original index to preserve selection order - orderedFiles[index] = PlatformFile(src) - send(FileKitPickerState.Progress(orderedFiles.filterNotNull(), pickerResults.size)) + lock.withLock { + if (failure != null) return@launch + + // Insert at the original index to preserve selection order + orderedFiles[index] = PlatformFile(src) + send(FileKitPickerState.Progress(orderedFiles.filterNotNull(), pickerResults.size)) + } + } catch (cause: Throwable) { + val pickerFailure = cause as? FileKitPickerException + ?: FileKitPickerException("Failed to load the selected file.", cause) + + lock.withLock { + if (failure == null) { + failure = pickerFailure + } + } } } }.joinAll() - send(FileKitPickerState.Completed(orderedFiles.filterNotNull())) + failure?.let { + send(FileKitPickerState.Failed(it)) + return@channelFlow + } + + val files = orderedFiles.filterNotNull() + when { + files.isEmpty() -> send(FileKitPickerState.Cancelled) + else -> send(FileKitPickerState.Completed(files)) + } } private val FileKitType.contentTypes: List @@ -563,11 +596,14 @@ private fun copyToTempFile( ?: throw IllegalStateException("Failed to create file URL") // Write the data to the file URL - fileManager.copyItemAtURL( + val didCopy = fileManager.copyItemAtURL( srcURL = url, toURL = fileUrl, error = null, ) + if (!didCopy) { + throw FileKitPickerException("Failed to copy the selected file to a temporary location.") + } return fileUrl } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt index d72ddcd5..f4da0899 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt @@ -117,6 +117,7 @@ private fun FilePickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed -> listOf(state.result) is FileKitPickerState.Progress -> listOf(state.processed) is FileKitPickerState.Started -> emptyList() @@ -132,6 +133,7 @@ private fun FilePickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed> -> state.result is FileKitPickerState.Progress> -> state.processed is FileKitPickerState.Started -> emptyList() diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt index 61fdee4a..6ef3a654 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/GalleryPickerScreen.kt @@ -103,6 +103,7 @@ private fun GalleryPickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed -> listOf(state.result) is FileKitPickerState.Progress -> listOf(state.processed) is FileKitPickerState.Started -> emptyList() @@ -117,6 +118,7 @@ private fun GalleryPickerScreen( buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() is FileKitPickerState.Completed> -> state.result is FileKitPickerState.Progress> -> state.processed is FileKitPickerState.Started -> emptyList()