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()