From f4f67966afdbceff4a9f8c140af1290191ec0233 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 6 Aug 2026 19:26:43 +0200 Subject: [PATCH 01/40] =?UTF-8?q?=F0=9F=93=9D=20Add=20initial=20specificat?= =?UTF-8?q?ions=20for=20FileKit=20dialog=20interactions=20and=20operationa?= =?UTF-8?q?l=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/CONTEXT.md | 17 +++++++++++++++++ .../adr/0001-own-dialog-operational-failures.md | 13 +++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 specs/CONTEXT.md create mode 100644 specs/adr/0001-own-dialog-operational-failures.md diff --git a/specs/CONTEXT.md b/specs/CONTEXT.md new file mode 100644 index 00000000..0025790c --- /dev/null +++ b/specs/CONTEXT.md @@ -0,0 +1,17 @@ +# FileKit + +FileKit provides multiplatform file access and system-mediated file interactions through a consistent interface. + +## Dialog interactions + +**Dialog operation**: +A FileKit-mediated system interaction for picking, selecting a directory, choosing a save destination, capturing media, or sharing files. +_Avoid_: Launcher operation, picker operation when referring to all dialog kinds + +**Operational failure**: +An expected inability to complete a valid dialog operation, represented to callers as a FileKit-owned dialog failure rather than an incidental platform failure. +_Avoid_: Platform exception, unexpected defect + +**Invalid invocation**: +A dialog request that violates a caller-controlled precondition, such as required initialization, valid arguments, or a documented argument combination. It is a caller-contract violation rather than an operational failure. +_Avoid_: Operational failure, platform failure diff --git a/specs/adr/0001-own-dialog-operational-failures.md b/specs/adr/0001-own-dialog-operational-failures.md new file mode 100644 index 00000000..b6a9c45a --- /dev/null +++ b/specs/adr/0001-own-dialog-operational-failures.md @@ -0,0 +1,13 @@ +--- +status: accepted +--- + +# Own dialog operational failures + +FileKit normalizes expected platform failures at each suspending dialog-operation seam and exposes them through a small FileKit-owned hierarchy rooted at `FileKitDialogException`. The existing `FileKitPickerException` remains as a subtype, while new operation-specific subtypes are added only when they enable distinct caller recovery; this prevents platform exception leakage without making the broad `FileKitException` hierarchy or incidental platform types part of every Compose launcher's error interface. Caller-controlled contract violations remain fail-fast and outside this hierarchy, while valid operations blocked by platform or environmental conditions are operational failures. + +Compose launchers catch only `FileKitDialogException` during operation execution. Cancellation and consumer callback exceptions therefore continue to propagate, while compatibility overloads without `onError` retain their interface and deliberately ignore normalized operational failures without implicit logging. + +Picker, directory, saver, camera, and sharing launchers adopt this explicit error interface together so callers do not need operation-specific knowledge of which callback launchers report failures. + +State-tracking picker modes retain their existing failure-as-data interface: `FileKitPickerState.Failed` remains a terminal value delivered through `onResult`. Their `onError` callback is reserved for thrown operational failures that the state stream does not represent. From 86310830b24fceddeb16248304b73426cea8d6a4 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 6 Aug 2026 20:09:16 +0200 Subject: [PATCH 02/40] =?UTF-8?q?=E2=9C=A8=20Establish=20shared=20dialog?= =?UTF-8?q?=20failure=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/file-picker.mdx | 94 +++++--- docs/dialogs/gallery-picker.mdx | 8 +- .../AndroidComposePickerReliabilityTest.kt | 35 +-- .../dialogs/compose/FileKitCompose.android.kt | 191 +++++++++-------- .../compose/DialogOperationDispatcher.kt | 18 ++ .../filekit/dialogs/compose/FileKitCompose.kt | 59 ++++- .../compose/FileKitComposeFailureTest.kt | 202 +++++++++++++++++- .../dialogs/compose/FileKitPickerCallShape.kt | 28 +++ .../LegacyPickerLauncherConsumer.java | 54 +++++ .../compose/FileKitPickerJvmCallShape.kt | 17 ++ ...cyPickerLauncherBinaryCompatibilityTest.kt | 16 ++ ...cyPickerLauncherConsumer$LinkageCall.class | Bin 0 -> 384 bytes .../LegacyPickerLauncherConsumer.class | Bin 0 -> 2643 bytes .../AndroidPickerLaunchFallbackTest.kt | 49 +++-- .../filekit/dialogs/FileKit.android.kt | 14 +- .../vinceglb/filekit/dialogs/FileKit.kt | 13 +- .../filekit/dialogs/FileKitDialogException.kt | 12 ++ .../filekit/dialogs/FileKitPickerException.kt | 7 +- .../dialogs/FileKitDialogExceptionTest.kt | 21 ++ .../vinceglb/filekit/dialogs/FileKit.ios.kt | 85 +++++--- .../filekit/dialogs/ApplePickerFailureTest.kt | 34 +++ .../ui/screens/filepicker/FilePickerScreen.kt | 78 ++++--- .../gallerypicker/GalleryPickerScreen.kt | 48 +++-- 23 files changed, 827 insertions(+), 256 deletions(-) create mode 100644 filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt create mode 100644 filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt create mode 100644 filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java create mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt create mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt create mode 100644 filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class create mode 100644 filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class create mode 100644 filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogException.kt create mode 100644 filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogExceptionTest.kt create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerFailureTest.kt diff --git a/docs/dialogs/file-picker.mdx b/docs/dialogs/file-picker.mdx index 76de29bd..5374030c 100644 --- a/docs/dialogs/file-picker.mdx +++ b/docs/dialogs/file-picker.mdx @@ -26,6 +26,21 @@ Button(onClick = { launcher.launch() }) { ``` +The short Compose form above is kept for source and binary compatibility. It ignores operational picker failures without logging. +For new integrations, provide `onError` explicitly: + +```kotlin +val launcher = rememberFilePickerLauncher( + onError = { failure -> + // The valid picker operation could not be completed. + println("Picker failed: ${failure.message}") + }, + onResult = { file -> + // A file was selected, or the user cancelled when file is null. + }, +) +``` + On iOS, remember FileKit Compose launchers from a stable/root Compose scope, not inside transient surfaces such as `ModalBottomSheet`, dialogs, popups, or @@ -55,17 +70,21 @@ val files = FileKit.openFilePicker(mode = FileKitMode.Multiple(maxItems = 5)) ```kotlin filekit-dialogs-compose // Single file selection val singleLauncher = rememberFilePickerLauncher( - mode = FileKitMode.Single -) { file -> - // Handle single file: PlatformFile? -} + mode = FileKitMode.Single, + onError = { failure -> println("Picker failed: ${failure.message}") }, + onResult = { file -> + // Handle single file: PlatformFile? (null means user cancellation) + }, +) // Multiple file selection val multipleLauncher = rememberFilePickerLauncher( - mode = FileKitMode.Multiple(maxItems = 10) -) { files -> - // Handle multiple files: List? -} + mode = FileKitMode.Multiple(maxItems = 10), + onError = { failure -> println("Picker failed: ${failure.message}") }, + onResult = { files -> + // Handle multiple files: List? (null means user cancellation) + }, +) ``` @@ -94,34 +113,45 @@ stateFlow.collect { state -> ```kotlin filekit-dialogs-compose // Single file with state tracking val stateLauncher = rememberFilePickerLauncher( - mode = FileKitMode.MultipleWithState() -) { state -> - when (state) { - is FileKitPickerState.Started -> { - // Show loading indicator - println("Selection started with ${state.total} files") + mode = FileKitMode.MultipleWithState(), + onError = { failure -> + // A thrown operational failure not represented by the state stream. + println("Picker failed: ${failure.message}") + }, + onResult = { state -> + when (state) { + is FileKitPickerState.Started -> { + // Show loading indicator + println("Selection started with ${state.total} files") + } + is FileKitPickerState.Progress -> { + // Update progress for: state.processed + println("Processing: ${state.processed.size} / ${state.total}") + } + is FileKitPickerState.Completed -> { + // Handle selected file: state.result + println("Completed: ${state.result.size} files selected") + } + is FileKitPickerState.Failed -> { + // A failure represented as a terminal state value. + println("Selection failed: ${state.cause.message}") + } + is FileKitPickerState.Cancelled -> { + // The user dismissed the picker. + println("Selection cancelled") + } } - is FileKitPickerState.Progress -> { - // Update progress for: state.processed - println("Processing: ${state.processed.size} / ${state.total}") - } - is FileKitPickerState.Completed -> { - // 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") - } - } -} + }, +) ``` + +User dismissal is a normal result (`null` or `FileKitPickerState.Cancelled`). A represented state-processing failure is +`FileKitPickerState.Failed` through `onResult`. A thrown operational failure reaches `onError`. Coroutine cancellation, +invalid invocations, unexpected defects, and exceptions thrown by your callbacks propagate and are not redelivered. + + The `Multiple` and `MultipleWithState` modes support a `maxItems` parameter (1-50 files). If not specified, there's no limit. diff --git a/docs/dialogs/gallery-picker.mdx b/docs/dialogs/gallery-picker.mdx index b7705d82..686b15b1 100644 --- a/docs/dialogs/gallery-picker.mdx +++ b/docs/dialogs/gallery-picker.mdx @@ -32,9 +32,11 @@ val image = FileKit.openFilePicker(type = FileKitType.Image) ```kotlin filekit-dialogs-compose val launcher = rememberFilePickerLauncher( type = FileKitType.Image, -) { image -> - // Handle the image -} + onError = { failure -> println("Picker failed: ${failure.message}") }, + onResult = { image -> + // Handle the image, or user cancellation when image is null. + }, +) ``` diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index fda5a4a8..7e12158c 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -7,6 +7,7 @@ import android.content.ActivityNotFoundException import android.net.Uri import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitAndroidDialogsInternal +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.path import org.junit.runner.RunWith @@ -17,6 +18,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) @@ -118,23 +120,27 @@ class AndroidComposePickerReliabilityTest { } @Test - fun PickerLaunchSafely_whenActivityNotFound_returnsFalse() { - val launched = launchPickerSafely { - throw ActivityNotFoundException("No activity found") + fun PickerLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No activity found") + + val result = launchFilePickerSafely { + throw launchFailure } - assertFalse(launched) + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) } @Test - fun PickerLaunchSafely_whenNoError_returnsTrue() { + fun PickerLaunchSafely_whenNoError_returnsLaunched() { var launched = false - val wasLaunched = launchPickerSafely { + val result = launchFilePickerSafely { launched = true } - assertTrue(wasLaunched) + assertIs(result) assertTrue(launched) } @@ -143,10 +149,10 @@ class AndroidComposePickerReliabilityTest { var fallbackCalls = 0 val outcome = resolvePickerLaunchOutcome( - launchPrimary = { false }, + launchPrimary = { PickerLaunchResult.Failed(FileKitPickerException("Primary failed")) }, launchFallback = { fallbackCalls++ - true + PickerLaunchResult.Launched }, ) @@ -155,13 +161,16 @@ class AndroidComposePickerReliabilityTest { } @Test - fun PickerLaunchOutcome_primaryAndFallbackFail_returnsCancelled() { + fun PickerLaunchOutcome_primaryAndFallbackFail_returnsFallbackOperationalFailure() { + val fallbackFailure = FileKitPickerException("Fallback failed") + val outcome = resolvePickerLaunchOutcome( - launchPrimary = { false }, - launchFallback = { false }, + launchPrimary = { PickerLaunchResult.Failed(FileKitPickerException("Primary failed")) }, + launchFallback = { PickerLaunchResult.Failed(fallbackFailure) }, ) - assertEquals(PickerLaunchOutcome.Cancelled, outcome) + val failure = assertIs(outcome) + assertSame(fallbackFailure, failure.failure) } @Test 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 61e28562..30295306 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 @@ -82,12 +82,19 @@ internal actual fun rememberPlatformFilePickerLau val currentType by rememberUpdatedState(type) val currentMode by rememberUpdatedState(mode) + val currentOnError by rememberUpdatedState(onError) val currentOnConsumed by rememberUpdatedState(onResult) var pendingModeId by rememberSaveable { mutableStateOf(null) } var pendingMaxItems by rememberSaveable { mutableStateOf(null) } var pendingLauncherId by rememberSaveable { mutableStateOf(null) } + fun clearPendingState() { + pendingModeId = null + pendingMaxItems = null + pendingLauncherId = null + } + fun dispatchPendingResult(launcherId: String, files: List?) { dispatchPendingPickerResult( expectedLauncherId = launcherId, @@ -95,11 +102,7 @@ internal actual fun rememberPlatformFilePickerLau pendingModeId = pendingModeId, pendingMaxItems = pendingMaxItems, files = files, - clearPendingState = { - pendingModeId = null - pendingMaxItems = null - pendingLauncherId = null - }, + clearPendingState = ::clearPendingState, onConsumed = { consumed -> @Suppress("UNCHECKED_CAST") currentOnConsumed(consumed as ConsumedResult) @@ -107,12 +110,9 @@ internal actual fun rememberPlatformFilePickerLau ) } - fun dispatchCancelledResult(launcherId: String) { - pendingLauncherId = launcherId - dispatchPendingResult( - launcherId = launcherId, - files = null, - ) + fun dispatchLaunchFailure(failure: FileKitPickerException) { + clearPendingState() + currentOnError(failure) } val visualSingleLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> @@ -171,65 +171,47 @@ internal actual fun rememberPlatformFilePickerLau modeId = modeSnapshot.modeId, maxItems = modeSnapshot.maxItems, ) -> { - when ( - resolvePickerLaunchOutcome( - launchPrimary = { - pendingLauncherId = LAUNCHER_VISUAL_SINGLE - launchPickerSafely { - visualSingleLauncher.launch(request) - } - }, - launchFallback = { - pendingLauncherId = LAUNCHER_FILE_SINGLE - launchPickerSafely { - fileSingleLauncher.launch(fallbackMimeTypes) - } - }, - ) - ) { - PickerLaunchOutcome.PrimaryLaunched, - PickerLaunchOutcome.FallbackLaunched, - -> { - Unit - } - - PickerLaunchOutcome.Cancelled -> { - dispatchCancelledResult(LAUNCHER_FILE_SINGLE) - } + val outcome = resolvePickerLaunchOutcome( + launchPrimary = { + pendingLauncherId = LAUNCHER_VISUAL_SINGLE + launchFilePickerSafely { + visualSingleLauncher.launch(request) + } + }, + launchFallback = { + pendingLauncherId = LAUNCHER_FILE_SINGLE + launchFilePickerSafely { + fileSingleLauncher.launch(fallbackMimeTypes) + } + }, + ) + if (outcome is PickerLaunchOutcome.Failed) { + dispatchLaunchFailure(outcome.failure) } } else -> { - when ( - resolvePickerLaunchOutcome( - launchPrimary = { - pendingLauncherId = LAUNCHER_VISUAL_MULTIPLE - launchPickerSafely { - visualMultipleLauncher.launch( - DynamicPickMultipleVisualMediaInput( - request = request, - maxItems = modeSnapshot.maxItems, - ), - ) - } - }, - launchFallback = { - pendingLauncherId = LAUNCHER_FILE_MULTIPLE - launchPickerSafely { - fileMultipleLauncher.launch(fallbackMimeTypes) - } - }, - ) - ) { - PickerLaunchOutcome.PrimaryLaunched, - PickerLaunchOutcome.FallbackLaunched, - -> { - Unit - } - - PickerLaunchOutcome.Cancelled -> { - dispatchCancelledResult(LAUNCHER_FILE_MULTIPLE) - } + val outcome = resolvePickerLaunchOutcome( + launchPrimary = { + pendingLauncherId = LAUNCHER_VISUAL_MULTIPLE + launchFilePickerSafely { + visualMultipleLauncher.launch( + DynamicPickMultipleVisualMediaInput( + request = request, + maxItems = modeSnapshot.maxItems, + ), + ) + } + }, + launchFallback = { + pendingLauncherId = LAUNCHER_FILE_MULTIPLE + launchFilePickerSafely { + fileMultipleLauncher.launch(fallbackMimeTypes) + } + }, + ) + if (outcome is PickerLaunchOutcome.Failed) { + dispatchLaunchFailure(outcome.failure) } } } @@ -240,21 +222,25 @@ internal actual fun rememberPlatformFilePickerLau when { modeSnapshot.isSingleMode() -> { pendingLauncherId = LAUNCHER_FILE_SINGLE - val isLaunched = launchPickerSafely { - fileSingleLauncher.launch(mimeTypes) - } - if (!isLaunched) { - dispatchCancelledResult(LAUNCHER_FILE_SINGLE) + when ( + val launchResult = launchFilePickerSafely { + fileSingleLauncher.launch(mimeTypes) + } + ) { + PickerLaunchResult.Launched -> Unit + is PickerLaunchResult.Failed -> dispatchLaunchFailure(launchResult.failure) } } else -> { pendingLauncherId = LAUNCHER_FILE_MULTIPLE - val isLaunched = launchPickerSafely { - fileMultipleLauncher.launch(mimeTypes) - } - if (!isLaunched) { - dispatchCancelledResult(LAUNCHER_FILE_MULTIPLE) + when ( + val launchResult = launchFilePickerSafely { + fileMultipleLauncher.launch(mimeTypes) + } + ) { + PickerLaunchResult.Launched -> Unit + is PickerLaunchResult.Failed -> dispatchLaunchFailure(launchResult.failure) } } } @@ -489,6 +475,20 @@ internal fun launchCameraSafely( false } +internal fun launchFilePickerSafely( + launch: () -> Unit, +): PickerLaunchResult = try { + launch() + PickerLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + PickerLaunchResult.Failed( + FileKitPickerException( + message = "No Android activity is available to open the file picker.", + cause = failure, + ), + ) +} + internal fun launchPickerSafely( launch: () -> Unit, ): Boolean = try { @@ -498,19 +498,38 @@ internal fun launchPickerSafely( false } -internal enum class PickerLaunchOutcome { - PrimaryLaunched, - FallbackLaunched, - Cancelled, +internal sealed interface PickerLaunchResult { + data object Launched : PickerLaunchResult + + data class Failed( + val failure: FileKitPickerException, + ) : PickerLaunchResult +} + +internal sealed interface PickerLaunchOutcome { + data object PrimaryLaunched : PickerLaunchOutcome + + data object FallbackLaunched : PickerLaunchOutcome + + data class Failed( + val failure: FileKitPickerException, + ) : PickerLaunchOutcome } internal fun resolvePickerLaunchOutcome( - launchPrimary: () -> Boolean, - launchFallback: () -> Boolean, -): PickerLaunchOutcome = when { - launchPrimary() -> PickerLaunchOutcome.PrimaryLaunched - launchFallback() -> PickerLaunchOutcome.FallbackLaunched - else -> PickerLaunchOutcome.Cancelled + launchPrimary: () -> PickerLaunchResult, + launchFallback: () -> PickerLaunchResult, +): PickerLaunchOutcome = when (launchPrimary()) { + PickerLaunchResult.Launched -> { + PickerLaunchOutcome.PrimaryLaunched + } + + is PickerLaunchResult.Failed -> { + when (val fallbackResult = launchFallback()) { + PickerLaunchResult.Launched -> PickerLaunchOutcome.FallbackLaunched + is PickerLaunchResult.Failed -> PickerLaunchOutcome.Failed(fallbackResult.failure) + } + } } internal fun resolveCameraResult( diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt new file mode 100644 index 00000000..f6923bfd --- /dev/null +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt @@ -0,0 +1,18 @@ +package io.github.vinceglb.filekit.dialogs.compose + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException + +internal suspend fun runDialogOperation( + operation: suspend () -> OperationResult, + onError: (FileKitDialogException) -> Unit, + onResult: suspend (OperationResult) -> Unit, +) { + val result = try { + operation() + } catch (failure: FileKitDialogException) { + onError(failure) + return + } + + onResult(result) +} diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 229fa247..5e0d9611 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -8,6 +8,8 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch /** * Creates and remembers a [PickerResultLauncher] for picking files. @@ -19,7 +21,8 @@ import io.github.vinceglb.filekit.dialogs.FileKitType * @param onResult Callback invoked with the result. * @return A [PickerResultLauncher] that can be used to launch the picker. * - * Picker failures are ignored by this overload. Use the overload with `onError` to handle them. + * Operational picker failures are ignored without logging by this compatibility overload. + * Use the overload with `onError` to observe them. User cancellation remains an [onResult] value. */ @Composable public fun rememberFilePickerLauncher( @@ -44,7 +47,9 @@ public fun rememberFilePickerLauncher( * @param mode The picking mode (e.g. Single, Multiple). * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. - * @param onError Callback invoked when FileKit cannot resolve the selected files. + * @param onError Callback invoked when a valid picker operation cannot complete. It is not invoked for user cancellation, + * coroutine cancellation, invalid invocations, unexpected defects, or [io.github.vinceglb.filekit.dialogs.FileKitPickerState.Failed] + * values delivered by state-tracking modes. * @param onResult Callback invoked with the result. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @@ -77,7 +82,8 @@ public fun rememberFilePickerLauncher( * @param onResult Callback invoked with the picked file, or null if cancelled. * @return A [PickerResultLauncher] that can be used to launch the picker. * - * Picker failures are ignored by this overload. Use the overload with `onError` to handle them. + * Operational picker failures are ignored without logging by this compatibility overload. + * Use the overload with `onError` to observe them. User cancellation remains an [onResult] value. */ @Composable public fun rememberFilePickerLauncher( @@ -99,7 +105,8 @@ public fun rememberFilePickerLauncher( * @param type The type of files to pick. Defaults to [FileKitType.File]. * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. - * @param onError Callback invoked when FileKit cannot resolve the selected file. + * @param onError Callback invoked when a valid picker operation cannot complete. It is not invoked for user cancellation, + * coroutine cancellation, invalid invocations, or unexpected defects. * @param onResult Callback invoked with the picked file, or null if cancelled. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @@ -135,13 +142,45 @@ internal suspend fun runFilePickerLauncher( onError: (FileKitPickerException) -> Unit, onResult: (ConsumedResult) -> Unit, ) { - val result = try { - openPicker() - } catch (failure: FileKitPickerException) { - onError(failure) - return + runDialogOperation( + operation = openPicker, + onError = { failure -> + when (failure) { + is FileKitPickerException -> onError(failure) + else -> throw failure + } + }, + onResult = { result -> + mode.consumePickerResult(result, onError, onResult) + }, + ) +} + +private suspend fun FileKitMode.consumePickerResult( + result: PickerResult, + onFailure: (FileKitPickerException) -> Unit, + onConsumed: (ConsumedResult) -> Unit, +) { + when (this) { + FileKitMode.Single, + is FileKitMode.Multiple, + -> { + consumeResult(result, onConsumed) + } + + FileKitMode.SingleWithState, + is FileKitMode.MultipleWithState, + -> { + @Suppress("UNCHECKED_CAST") + (result as Flow) + .catch { failure -> + when (failure) { + is FileKitPickerException -> onFailure(failure) + else -> throw failure + } + }.collect(onConsumed) + } } - mode.consumeResult(result, onResult) } /** diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index bf4401ed..e63b29e5 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -2,45 +2,229 @@ package io.github.vinceglb.filekit.dialogs.compose +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.FileKitPickerState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertTrue +import kotlin.test.assertSame class FileKitComposeFailureTest { @Test - fun runFilePickerLauncher_reportsPickerException_withoutInvokingResult() = runTest { + fun runDialogOperation_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { + val failure = FileKitDialogException("The system dialog could not be opened.") + val reportedFailures = mutableListOf() + var resultInvoked = false + + runDialogOperation( + operation = { throw failure }, + onError = reportedFailures::add, + onResult = { resultInvoked = true }, + ) + + assertEquals(listOf(failure), reportedFailures) + assertFalse(resultInvoked) + } + + @Test + fun runDialogOperation_success_invokesResultOnce_withoutInvokingError() = runTest { + val results = mutableListOf() + var errorInvoked = false + + runDialogOperation( + operation = { null }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertEquals(1, results.size) + assertEquals(null, results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runDialogOperation_coroutineCancellation_propagates_withoutInvokingCallbacks() = runTest { + var errorInvoked = false + var resultInvoked = false + + assertFailsWith { + runDialogOperation( + operation = { throw CancellationException("Cancelled by caller") }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + + @Test + fun runDialogOperation_unexpectedFailure_propagates_withoutInvokingCallbacks() = runTest { + val failure = IllegalStateException("Unexpected picker defect") + var errorInvoked = false + var resultInvoked = false + + val thrown = assertFailsWith { + runDialogOperation( + operation = { throw failure }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + assertSame(failure, thrown) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + + @Test + fun runDialogOperation_resultCallbackFailure_propagates_withoutInvokingError() = runTest { + val failure = IllegalStateException("Consumer result callback failed") + var errorInvoked = false + + val thrown = assertFailsWith { + runDialogOperation( + operation = { "selected" }, + onError = { errorInvoked = true }, + onResult = { throw failure }, + ) + } + + assertSame(failure, thrown) + assertFalse(errorInvoked) + } + + @Test + fun runDialogOperation_errorCallbackFailure_propagates_once() = runTest { + val callbackFailure = IllegalStateException("Consumer error callback failed") + var errorInvocations = 0 + + val thrown = assertFailsWith { + runDialogOperation( + operation = { throw FileKitDialogException("Operational failure") }, + onError = { + errorInvocations++ + throw callbackFailure + }, + onResult = {}, + ) + } + + assertSame(callbackFailure, thrown) + assertEquals(1, errorInvocations) + } + + @Test + fun runFilePickerLauncher_pickerFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitPickerException("Failed to load the selected file.") - var reportedFailure: FileKitPickerException? = null + val reportedFailures = mutableListOf() var resultInvoked = false runFilePickerLauncher( mode = FileKitMode.Single, openPicker = { throw failure }, - onError = { reportedFailure = it }, + onError = reportedFailures::add, onResult = { resultInvoked = true }, ) - assertEquals(expected = failure, actual = reportedFailure) + assertEquals(listOf(failure), reportedFailures) assertFalse(resultInvoked) } @Test - fun runFilePickerLauncher_invokesResult_withoutInvokingError() = runTest { + fun runFilePickerLauncher_userCancellation_invokesResultOnce_withoutInvokingError() = runTest { + val results = mutableListOf() var errorInvoked = false - var resultInvoked = false runFilePickerLauncher( mode = FileKitMode.Single, openPicker = { null }, onError = { errorInvoked = true }, - onResult = { resultInvoked = true }, + onResult = results::add, + ) + + assertEquals(1, results.size) + assertEquals(null, results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runFilePickerLauncher_stateValueFailure_invokesResult_withoutInvokingError() = runTest { + val failure = FileKitPickerException("Failed after selection.") + val results = mutableListOf>() + var errorInvoked = false + + runFilePickerLauncher( + mode = FileKitMode.SingleWithState, + openPicker = { flowOf(FileKitPickerState.Failed(failure)) }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertEquals(FileKitPickerState.Failed(failure), results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runFilePickerLauncher_thrownStateStreamFailure_reportsError_afterEarlierState() = runTest { + val failure = FileKitPickerException("Failed while processing the selection.") + val results = mutableListOf>() + val reportedFailures = mutableListOf() + + runFilePickerLauncher( + mode = FileKitMode.SingleWithState, + openPicker = { + flow { + emit(FileKitPickerState.Started(total = 2)) + throw failure + } + }, + onError = reportedFailures::add, + onResult = results::add, ) + assertEquals(FileKitPickerState.Started(total = 2), results.single()) + assertEquals(listOf(failure), reportedFailures) + } + + @Test + fun runFilePickerLauncher_stateCallbackFailure_propagates_withoutInvokingError() = runTest { + val callbackFailure = IllegalStateException("Consumer state callback failed") + var errorInvoked = false + + val thrown = assertFailsWith { + runFilePickerLauncher( + mode = FileKitMode.SingleWithState, + openPicker = { flowOf(FileKitPickerState.Started(total = 1)) }, + onError = { errorInvoked = true }, + onResult = { throw callbackFailure }, + ) + } + + assertSame(callbackFailure, thrown) assertFalse(errorInvoked) - assertTrue(resultInvoked) + } + + @Test + fun runFilePickerLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { + var resultInvoked = false + + runFilePickerLauncher( + mode = FileKitMode.Single, + openPicker = { throw FileKitPickerException("Ignored compatibility failure") }, + onError = {}, + onResult = { resultInvoked = true }, + ) + + assertFalse(resultInvoked) } } diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt new file mode 100644 index 00000000..43d11a63 --- /dev/null +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt @@ -0,0 +1,28 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitMode +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.FileKitPickerState + +@Composable +private fun CompileCommonPickerCallShapes() { + val legacySingle = rememberFilePickerLauncher { _: PlatformFile? -> } + val explicitSingle = rememberFilePickerLauncher( + onError = { _: FileKitPickerException -> }, + onResult = { _: PlatformFile? -> }, + ) + + val legacyState = rememberFilePickerLauncher( + mode = FileKitMode.SingleWithState, + onResult = { _: FileKitPickerState -> }, + ) + val explicitState = rememberFilePickerLauncher( + mode = FileKitMode.SingleWithState, + onError = { _: FileKitPickerException -> }, + onResult = { _: FileKitPickerState -> }, + ) +} diff --git a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java new file mode 100644 index 00000000..96caa2d1 --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java @@ -0,0 +1,54 @@ +package io.github.vinceglb.filekit.dialogs.compose.compatibility; + +import androidx.compose.runtime.Composer; +import io.github.vinceglb.filekit.PlatformFile; +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings; +import io.github.vinceglb.filekit.dialogs.FileKitMode; +import io.github.vinceglb.filekit.dialogs.FileKitType; +import io.github.vinceglb.filekit.dialogs.compose.FileKitComposeKt; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; + +/** + * Source for the class fixture in jvmTest/resources. Compile this source only against the fixed-point + * FileKit artifacts so the runtime test proves that precompiled legacy consumers still link. + */ +public final class LegacyPickerLauncherConsumer { + private LegacyPickerLauncherConsumer() {} + + public static void linkLegacyOverloads() { + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + } + + private static void link(LinkageCall call) { + try { + call.invoke(); + } catch (LinkageError failure) { + throw failure; + } catch (Throwable expectedEntryFailure) { + // Null arguments are intentional: reaching the entry point proves method resolution. + } + } + + private interface LinkageCall { + void invoke(); + } +} diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt new file mode 100644 index 00000000..71e777cd --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt @@ -0,0 +1,17 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.WindowScope +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitPickerException + +@Composable +private fun WindowScope.CompileJvmPickerCallShapes() { + val legacy = rememberFilePickerLauncher { _: PlatformFile? -> } + val explicit = rememberFilePickerLauncher( + onError = { _: FileKitPickerException -> }, + onResult = { _: PlatformFile? -> }, + ) +} diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt new file mode 100644 index 00000000..b64d8904 --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt @@ -0,0 +1,16 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import kotlin.test.Test + +class LegacyPickerLauncherBinaryCompatibilityTest { + @Test + fun LegacyPickerLauncher_precompiledConsumer_linksAgainstCurrentArtifacts() { + val consumer = Class.forName( + "io.github.vinceglb.filekit.dialogs.compose.compatibility.LegacyPickerLauncherConsumer", + ) + + consumer.getMethod("linkLegacyOverloads").invoke(null) + } +} diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class new file mode 100644 index 0000000000000000000000000000000000000000..8b1d18017319ae6583f06426af60f3c5d4e70990 GIT binary patch literal 384 zcmcJL!Ab)`42J)y+O69vzJlOEFU}KqDu}|Og6KUv#%a3K8QGa#=&O0~0emPi_Tzd*>I= z2K8eaddJtg+u-!C;g>(N3}$zn%T3Cq!rb8LUpx#Jw=t?$pqx1y%zueZ0X<%SsZ_-S YXhHKz;{-Dt?pR}?thG}WaWt*J0dsP4z5oCK literal 0 HcmV?d00001 diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class new file mode 100644 index 0000000000000000000000000000000000000000..6ec34c7e70e5213d09a59c73e6ed3a6b212b1eec GIT binary patch literal 2643 zcmds3-B%k$6#osuEG7XWAX;dtt*HcPSz^^z(+?#rHnO2T2KB+GNiqpTHaj`HlPG_Y z&waA;pgw#2n>^mxU7A$dEj`Ej;GQ!(A9wD@{O+B5=a0XRe+6(Gdl^h1WnnUhH;`t? zAMzo$UG8=4hmAwg)C}oc(v$iQL#nv;?HqAU8q1%saO z3y$=*NlS_(dnX>1EWFQf ziF++y$<~ig1JWOOTK0rpi2~Hm;0ivlu$n^=YYgQxm~;}`v1zfSUAjI?zUYZwL->aD zY?w2)e5NRig&IK1Nl`ftX5z*~Aj?iNh-K%c0G6xOSIP(xEMJRY;OdhcP`0q~O7T`x zEnz%&&A4rovE6?a+kZf2lqEv^|MCT;#R{F6VU|*b*c+I5-Zx2sQiaKvQ4)VR6uzr? zD`1$eYu@bEct75C-sQbUix-}=xlm$AB5yHd>T2LOg}DcGf0HW4rb%23**y{HT6pcq znEbXg++_j0(89fr%Jgcwyl z+X)Xu0(qM^wepWhYj1q^XVE#u6zP9J7swSs!BMI^=$FT~)wNH#Y ziIK`=!E0lN#A;39txGM+47sZ3QDt^{5D3CFOM!4wnk{_AaODMNJ$qOS4rS@&t@N^p zkU@!DGC?m6(BovnJW!}w+BCaBJu|E8KZ8A{0mByk4H61etH-c_C&(Vt z2Gft&W{@Jb1x#TDd8{I19vH-CG<{FKv0!HX7tAsI1k2g@&3PQIrAg5u(b8n)yR>3V zSfUxQLH{kHUL{%Ah`NbsGU-F2WRa>`SlqUVBcCzB7Av}L%%g9a{f;GS=c%nwdym@X zRO$%|-;FGe4!S}DD0X83H^_eTT_gr0V4d12Ay-ccS->PdCbkKDf*aINlDK)?q|qX+ geNNDPiqEJ;quVt4l4x^8o2P?4NM}&RE^0XUHvrNaTmS$7 literal 0 HcmV?d00001 diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt index 98c65643..356ce0f1 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt @@ -9,7 +9,9 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertSame class AndroidPickerLaunchFallbackTest { @Test @@ -31,28 +33,41 @@ class AndroidPickerLaunchFallbackTest { } @Test - fun PickerLaunch_primaryAndFallbackThrowActivityNotFound_returnsNull() = runBlocking { - val result = runPickerLaunchWithActivityNotFoundFallback( - primary = { - throw ActivityNotFoundException("No activity for visual picker") - }, - fallback = { - throw ActivityNotFoundException("No activity for document picker") - }, - ) + fun PickerLaunch_primaryAndFallbackThrowActivityNotFound_throwsPickerFailureWithFallbackCause() { + val fallbackFailure = ActivityNotFoundException("No activity for document picker") - assertNull(result) + val failure = assertFailsWith { + runBlocking { + runPickerLaunchWithActivityNotFoundFallback( + primary = { + throw ActivityNotFoundException("No activity for visual picker") + }, + fallback = { + throw fallbackFailure + }, + ) + } + } + + assertSame(fallbackFailure, failure.cause) + assertIs(failure) } @Test - fun PickerLaunch_primaryThrowsActivityNotFoundWithoutFallback_returnsNull() = runBlocking { - val result = runPickerLaunchWithActivityNotFoundFallback( - primary = { - throw ActivityNotFoundException("No activity for document picker") - }, - ) + fun PickerLaunch_primaryThrowsActivityNotFoundWithoutFallback_throwsPickerFailureWithCause() { + val launchFailure = ActivityNotFoundException("No activity for document picker") - assertNull(result) + val failure = assertFailsWith { + runBlocking { + runPickerLaunchWithActivityNotFoundFallback( + primary = { + throw launchFailure + }, + ) + } + } + + assertSame(launchFailure, failure.cause) } @Test diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index f08e4fad..8bf6fb15 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -446,12 +446,18 @@ internal suspend fun runPickerLaunchWithActivityNotFoundFallback( fallback: (suspend () -> O)? = null, ): O? = try { primary() -} catch (_: ActivityNotFoundException) { - val fallbackLaunch = fallback ?: return null +} catch (primaryFailure: ActivityNotFoundException) { + val fallbackLaunch = fallback ?: throw FileKitPickerException( + message = "No Android activity is available to open the file picker.", + cause = primaryFailure, + ) try { fallbackLaunch() - } catch (_: ActivityNotFoundException) { - null + } catch (fallbackFailure: ActivityNotFoundException) { + throw FileKitPickerException( + message = "No Android activity is available to open the file picker.", + cause = fallbackFailure, + ) } } 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 b386c721..29b90289 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 @@ -11,8 +11,11 @@ import kotlinx.coroutines.flow.Flow * @param mode The picking mode (e.g. Single, Multiple). * @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. + * For state-tracking modes, [FileKitPickerState.Failed] remains a result value in the returned flow. + * Coroutine cancellation and invalid invocations propagate separately from picker failures. + * + * @return The result of the picker, depending on the [mode]. Basic modes return `null` when the user cancels. + * @throws FileKitPickerException When a valid picker operation cannot be completed or its selected files cannot be resolved. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), @@ -35,8 +38,10 @@ public suspend fun FileKit.openFilePicker( * @param type The type of files to pick (e.g. Images, Videos, or specific extensions). Defaults to [FileKitType.File]. * @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. + * Coroutine cancellation and invalid invocations propagate separately from picker failures. + * + * @return The picked [PlatformFile], or `null` if the user cancels. + * @throws FileKitPickerException When a valid picker operation cannot be completed or its selected file cannot be resolved. */ public suspend fun FileKit.openFilePicker( type: FileKitType = FileKitType.File(), diff --git a/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogException.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogException.kt new file mode 100644 index 00000000..b6a204f3 --- /dev/null +++ b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogException.kt @@ -0,0 +1,12 @@ +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.exceptions.FileKitException + +/** + * An expected inability to complete a valid dialog operation because of platform or environmental conditions. + */ +public open class FileKitDialogException : 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/FileKitPickerException.kt b/filekit-dialogs/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitPickerException.kt index 3f44d476..69aa796c 100644 --- 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 @@ -1,8 +1,9 @@ package io.github.vinceglb.filekit.dialogs -import io.github.vinceglb.filekit.exceptions.FileKitException - -public class FileKitPickerException : FileKitException { +/** + * An operational failure while opening or resolving a file-picker result. + */ +public class FileKitPickerException : FileKitDialogException { public constructor(message: String) : super(message) public constructor(message: String, cause: Throwable) : super(message, cause) diff --git a/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogExceptionTest.kt b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogExceptionTest.kt new file mode 100644 index 00000000..34ef567f --- /dev/null +++ b/filekit-dialogs/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogExceptionTest.kt @@ -0,0 +1,21 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.exceptions.FileKitException +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertSame + +class FileKitDialogExceptionTest { + @Test + fun FileKitPickerException_isAFileKitDialogException_andPreservesCause() { + val cause = IllegalStateException("Native picker failed") + + val failure = FileKitPickerException("Could not open the picker", cause) + + assertIs(failure) + assertIs(failure) + assertSame(cause, failure.cause) + } +} 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 2fd8a070..32e75cfe 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 @@ -13,8 +13,15 @@ import io.github.vinceglb.filekit.dialogs.util.PhPickerDismissDelegate import io.github.vinceglb.filekit.path import io.github.vinceglb.filekit.startAccessingSecurityScopedResource import io.github.vinceglb.filekit.stopAccessingSecurityScopedResource +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.CPointer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr import kotlinx.cinterop.useContents +import kotlinx.cinterop.value import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -28,6 +35,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import platform.CoreGraphics.CGRectMake import platform.Foundation.NSData +import platform.Foundation.NSError import platform.Foundation.NSFileManager import platform.Foundation.NSURL import platform.Foundation.NSUUID @@ -475,7 +483,7 @@ private suspend fun getPhPickerResults( ) } -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private fun callPhPicker( mode: PickerMode, type: FileKitType, @@ -496,13 +504,17 @@ private fun callPhPicker( val fileManager = NSFileManager.defaultManager val tempRoot = fileManager.temporaryDirectory .URLByAppendingPathComponent(NSUUID().UUIDString) - ?: throw IllegalStateException("Failed to create temporary directory") - fileManager.createDirectoryAtURL( - url = tempRoot, - withIntermediateDirectories = true, - attributes = null, - error = null, - ) + ?: throw FileKitPickerException("Failed to create a temporary directory for the selected files.") + requireApplePickerOperation( + message = "Failed to create a temporary directory for the selected files.", + ) { error -> + fileManager.createDirectoryAtURL( + url = tempRoot, + withIntermediateDirectories = true, + attributes = null, + error = error, + ) + } // Pre-allocated array to preserve selection order val orderedFiles = arrayOfNulls(pickerResults.size) @@ -526,8 +538,9 @@ private fun callPhPicker( when { error != null -> { cont.resumeWithException( - FileKitPickerException( - message = error.localizedDescription, + applePickerFailure( + message = "Failed to load the selected file representation.", + error = error, ), ) } @@ -556,10 +569,7 @@ private fun callPhPicker( 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) - + } catch (pickerFailure: FileKitPickerException) { lock.withLock { if (failure == null) { failure = pickerFailure @@ -605,7 +615,7 @@ private val FileKitType.contentTypes: List private fun List?.ifNullOrEmpty(block: () -> List): List = if (this.isNullOrEmpty()) block() else this -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private fun copyToTempFile( fileManager: NSFileManager, url: NSURL, @@ -615,25 +625,52 @@ private fun copyToTempFile( val fileComponents = fileManager.temporaryDirectory.pathComponents ?.plus(id) ?.plus(url.lastPathComponent) - ?: throw IllegalStateException("Failed to get temporary directory") + ?: throw FileKitPickerException("Failed to resolve the temporary directory for the selected file.") // Create a file URL val fileUrl = NSURL.fileURLWithPathComponents(fileComponents) - ?: throw IllegalStateException("Failed to create file URL") + ?: throw FileKitPickerException("Failed to create a temporary URL for the selected file.") // Write the data to the file URL - val didCopy = fileManager.copyItemAtURL( - srcURL = url, - toURL = fileUrl, - error = null, - ) - if (!didCopy) { - throw FileKitPickerException("Failed to copy the selected file to a temporary location.") + requireApplePickerOperation( + message = "Failed to copy the selected file to a temporary location.", + ) { error -> + fileManager.copyItemAtURL( + srcURL = url, + toURL = fileUrl, + error = error, + ) } return fileUrl } +internal class ApplePickerExceptionCause( + val error: NSError, +) : Exception(error.localizedDescription) + +internal fun applePickerFailure( + message: String, + error: NSError?, +): FileKitPickerException = if (error == null) { + FileKitPickerException(message) +} else { + FileKitPickerException(message, ApplePickerExceptionCause(error)) +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private inline fun requireApplePickerOperation( + message: String, + operation: (CPointer>) -> Boolean, +) { + memScoped { + val error = alloc>() + if (!operation(error.ptr)) { + throw applePickerFailure(message, error.value) + } + } +} + private fun UIApplication.topMostViewController(): UIViewController? { val keyWindow = this.connectedScenes .filterIsInstance() diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerFailureTest.kt new file mode 100644 index 00000000..8000ab93 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerFailureTest.kt @@ -0,0 +1,34 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import platform.Foundation.NSError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ApplePickerFailureTest { + @Test + fun ApplePicker_nativeFailure_preservesNSErrorAsPickerFailureCause() { + val nativeError = NSError.errorWithDomain( + domain = "io.github.vinceglb.filekit.tests", + code = 42, + userInfo = null, + ) + + val failure = applePickerFailure("Failed to load the selected file.", nativeError) + + val cause = assertIs(failure.cause) + assertSame(nativeError, cause.error) + assertEquals(nativeError.localizedDescription, cause.message) + } + + @Test + fun ApplePicker_failureWithoutNSError_hasNoSyntheticCause() { + val failure = applePickerFailure("Failed to resolve the selected file.", null) + + assertNull(failure.cause) + } +} 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 4cc31d2b..4a771b0e 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher @@ -79,6 +80,7 @@ private fun FilePickerScreen( var customExtensions by remember { mutableStateOf("") } var startDirectory by remember { mutableStateOf(null) } var files by remember { mutableStateOf(emptyList()) } + var pickerError by remember { mutableStateOf(null) } val dialogSettingsState = rememberFilePickerDialogSettingsState() val dialogSettings = dialogSettingsTransform(dialogSettingsState.build()) @@ -94,57 +96,75 @@ private fun FilePickerScreen( val resolvedType = resolveFilePickerType(customExtensions) + val onPickerError: (FileKitPickerException) -> Unit = { failure -> + buttonState = AppScreenHeaderButtonState.Enabled + files = emptyList() + pickerError = failure.message + } + val singlePicker = rememberFilePickerLauncher( type = resolvedType, mode = FileKitMode.Single, directory = startDirectory, dialogSettings = dialogSettings, - ) { selectedFile -> - buttonState = AppScreenHeaderButtonState.Enabled - files = selectedFile?.let(::listOf) ?: emptyList() - } + onError = onPickerError, + onResult = { selectedFile -> + buttonState = AppScreenHeaderButtonState.Enabled + files = selectedFile?.let(::listOf) ?: emptyList() + pickerError = null + }, + ) val multiplePicker = rememberFilePickerLauncher( type = resolvedType, mode = FileKitMode.Multiple(maxItems = pickerMaxItems), directory = startDirectory, dialogSettings = dialogSettings, - ) { selectedFiles -> - buttonState = AppScreenHeaderButtonState.Enabled - files = selectedFiles ?: emptyList() - } + onError = onPickerError, + onResult = { selectedFiles -> + buttonState = AppScreenHeaderButtonState.Enabled + files = selectedFiles ?: emptyList() + pickerError = null + }, + ) val singleWithStatePicker = rememberFilePickerLauncher( type = resolvedType, mode = FileKitMode.SingleWithState, directory = startDirectory, dialogSettings = dialogSettings, - ) { state -> - 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() - } - } + onError = onPickerError, + onResult = { state -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = (state as? FileKitPickerState.Failed)?.cause?.message + 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() + } + }, + ) val multipleWithStatePicker = rememberFilePickerLauncher( type = resolvedType, mode = FileKitMode.MultipleWithState(maxItems = pickerMaxItems), directory = startDirectory, dialogSettings = dialogSettings, - ) { state -> - 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() - } - } + onError = onPickerError, + onResult = { state -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = (state as? FileKitPickerState.Failed)?.cause?.message + files = when (state) { + FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() + is FileKitPickerState.Completed> -> state.result + is FileKitPickerState.Progress> -> state.processed + is FileKitPickerState.Started -> emptyList() + } + }, + ) val primaryButtonText = when (pickerMode) { Modes.Single, @@ -213,7 +233,7 @@ private fun FilePickerScreen( item { AppPickerResultsCard( files = files, - emptyText = "No files selected yet", + emptyText = pickerError ?: "No files selected yet", emptyIcon = LucideIcons.File, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), 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 da4ad401..a2d53073 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 @@ -111,33 +111,37 @@ private fun GalleryPickerScreen( type = pickerType, mode = FileKitMode.SingleWithState, directory = pickerDirectory, - ) { state -> - buttonState = AppScreenHeaderButtonState.Enabled - pickerError = (state as? FileKitPickerState.Failed)?.cause?.message - 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() - } - } + onError = onPickerError, + onResult = { state -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = (state as? FileKitPickerState.Failed)?.cause?.message + 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() + } + }, + ) val galleryMultipleWithStatePicker = rememberFilePickerLauncher( type = pickerType, mode = FileKitMode.MultipleWithState(maxItems = pickerMaxItems), directory = pickerDirectory, - ) { state -> - buttonState = AppScreenHeaderButtonState.Enabled - pickerError = (state as? FileKitPickerState.Failed)?.cause?.message - files = when (state) { - FileKitPickerState.Cancelled -> emptyList() - is FileKitPickerState.Failed -> emptyList() - is FileKitPickerState.Completed> -> state.result - is FileKitPickerState.Progress> -> state.processed - is FileKitPickerState.Started -> emptyList() - } - } + onError = onPickerError, + onResult = { state -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = (state as? FileKitPickerState.Failed)?.cause?.message + files = when (state) { + FileKitPickerState.Cancelled -> emptyList() + is FileKitPickerState.Failed -> emptyList() + is FileKitPickerState.Completed> -> state.result + is FileKitPickerState.Progress> -> state.processed + is FileKitPickerState.Started -> emptyList() + } + }, + ) fun openGalleryPicker() { buttonState = AppScreenHeaderButtonState.Loading From 7b65affd12374d7241b4a2a20b1c3580b0c26675 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 6 Aug 2026 22:13:39 +0200 Subject: [PATCH 03/40] =?UTF-8?q?=E2=9C=A8=20Make=20directory=20launcher?= =?UTF-8?q?=20failures=20observable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/directory-picker.mdx | 28 ++- .../AndroidComposePickerReliabilityTest.kt | 37 ++++ .../dialogs/compose/FileKitCompose.android.kt | 66 ++++++- .../filekit/dialogs/compose/FileKitCompose.kt | 34 ++++ .../compose/FileKitComposeFailureTest.kt | 64 +++++++ .../dialogs/compose/FileKitPickerCallShape.kt | 7 + .../dialogs/compose/FileKitCompose.jvm.kt | 14 ++ .../LegacyPickerLauncherConsumer.java | 19 ++ .../compose/DirectoryLauncherJvmTest.kt | 28 +++ .../compose/FileKitPickerJvmCallShape.kt | 7 + .../LegacyPickerLauncherConsumer.class | Bin 2643 -> 3625 bytes .../compose/FileKitCompose.nonAndroid.kt | 37 +++- .../dialogs/platform/awt/AwtFilePicker.kt | 3 +- .../dialogs/platform/swing/SwingFilePicker.kt | 38 +++- .../platform/windows/WindowsDialogExecutor.kt | 6 +- .../platform/windows/WindowsFilePicker.kt | 56 ++++-- .../awt/AwtDirectoryPickerFailureTest.kt | 24 +++ .../swing/SwingDirectoryPickerResultTest.kt | 53 ++++++ .../WindowsDirectoryPickerFailureTest.kt | 36 ++++ .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 174 +++++++++++++----- .../directorypicker/DirectoryPickerScreen.kt | 37 ++-- 21 files changed, 669 insertions(+), 99 deletions(-) create mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDirectoryPickerFailureTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDirectoryPickerFailureTest.kt diff --git a/docs/dialogs/directory-picker.mdx b/docs/dialogs/directory-picker.mdx index 9efc09da..d6e29448 100644 --- a/docs/dialogs/directory-picker.mdx +++ b/docs/dialogs/directory-picker.mdx @@ -16,9 +16,19 @@ val directory = FileKit.openDirectoryPicker() ``` ```kotlin filekit-dialogs-compose -val launcher = rememberDirectoryPickerLauncher { directory -> - // Handle the directory -} +val launcher = rememberDirectoryPickerLauncher( + onError = { failure -> + // A valid directory operation could not be completed + showError(failure.message) + }, + onResult = { directory -> + if (directory == null) { + // The user cancelled the picker + } else { + // Handle the selected directory + } + }, +) Button(onClick = { launcher.launch() }) { Text("Pick a directory") @@ -26,6 +36,10 @@ Button(onClick = { launcher.launch() }) { ``` +`onError` receives a `FileKitDialogException` only when FileKit cannot complete a valid directory operation. User cancellation is not a failure: it invokes `onResult(null)`. Coroutine cancellation, invalid invocation, and unexpected defects continue to propagate normally. + +The compatibility overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. + ## Customizing the dialog You can customize the dialog by setting the initial directory and platform-specific `dialogSettings`, such as a title on supported platforms. @@ -41,9 +55,11 @@ val directory = FileKit.openDirectoryPicker( val launcher = rememberDirectoryPickerLauncher( directory = PlatformFile("/custom/initial/path"), dialogSettings = FileKitDialogSettings.createDefault(), -) { directory -> - // Handle the directory -} + onError = { failure -> showError(failure.message) }, + onResult = { directory -> + // Handle the selected directory, or null when the user cancelled + }, +) ``` diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index 7e12158c..bb28f902 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -7,6 +7,7 @@ import android.content.ActivityNotFoundException import android.net.Uri import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitAndroidDialogsInternal +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.path @@ -144,6 +145,42 @@ class AndroidComposePickerReliabilityTest { assertTrue(launched) } + @Test + fun DirectoryLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No directory picker activity") + + val result = launchDirectoryPickerSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + + @Test + fun DirectoryLaunchSafely_whenUnexpectedFailure_propagates() { + val failure = IllegalStateException("Unexpected launcher defect") + + val thrown = kotlin.test.assertFailsWith { + launchDirectoryPickerSafely { throw failure } + } + + assertSame(failure, thrown) + } + + @Test + fun DirectoryLaunchSafely_whenNoError_returnsLaunched() { + var launched = false + + val result = launchDirectoryPickerSafely { + launched = true + } + + assertIs(result) + assertTrue(launched) + } + @Test fun PickerLaunchOutcome_primaryFailsAndFallbackSucceeds_returnsFallbackLaunched() { var fallbackCalls = 0 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 30295306..fe2e10dd 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 @@ -31,6 +31,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitAndroidCameraPermissionInternal import io.github.vinceglb.filekit.dialogs.FileKitAndroidDialogsInternal import io.github.vinceglb.filekit.dialogs.FileKitCameraFacing +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings @@ -263,9 +264,33 @@ public actual fun rememberDirectoryPickerLauncher( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher = rememberDirectoryPickerLauncher( + directory = directory, + dialogSettings = dialogSettings, + onError = {}, + onResult = onResult, +) + +/** + * Creates and remembers a [PickerResultLauncher] for picking a directory. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @param onError Callback invoked when a valid directory operation cannot complete. + * @param onResult Callback invoked with the picked directory, or null if cancelled. + * @return A [PickerResultLauncher] that can be used to launch the picker. + */ +@Composable +@Suppress("UNUSED_PARAMETER") +public actual fun rememberDirectoryPickerLauncher( + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, ): PickerResultLauncher { InitializeAndroidFileKit() + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) val currentDirectory by rememberUpdatedState(directory) @@ -283,12 +308,19 @@ public actual fun rememberDirectoryPickerLauncher( PickerResultLauncher { val initialUri = currentDirectory?.path?.toUri() hasPendingLaunch = true - val isLaunched = launchPickerSafely { - launcher.launch(initialUri) - } - if (!isLaunched) { - hasPendingLaunch = false - currentOnResult(null) + when ( + val launchResult = launchDirectoryPickerSafely { + launcher.launch(initialUri) + } + ) { + DirectoryLaunchResult.Launched -> { + // Await the Activity Result callback. + } + + is DirectoryLaunchResult.Failed -> { + hasPendingLaunch = false + currentOnError(launchResult.failure) + } } } } @@ -498,6 +530,28 @@ internal fun launchPickerSafely( false } +internal fun launchDirectoryPickerSafely( + launch: () -> Unit, +): DirectoryLaunchResult = try { + launch() + DirectoryLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + DirectoryLaunchResult.Failed( + FileKitDialogException( + message = "No Android activity is available to open the directory picker.", + cause = failure, + ), + ) +} + +internal sealed interface DirectoryLaunchResult { + data object Launched : DirectoryLaunchResult + + data class Failed( + val failure: FileKitDialogException, + ) : DirectoryLaunchResult +} + internal sealed interface PickerLaunchResult { data object Launched : PickerLaunchResult diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 5e0d9611..b72895d5 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -4,6 +4,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException @@ -190,10 +191,43 @@ private suspend fun FileKitMode Unit, +): PickerResultLauncher + +/** + * Creates and remembers a [PickerResultLauncher] for picking a directory. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @param onError Callback invoked when a valid directory operation cannot complete. It is not invoked for user cancellation, + * coroutine cancellation, invalid invocations, or unexpected defects. + * @param onResult Callback invoked with the picked directory, or null if cancelled. + * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable public expect fun rememberDirectoryPickerLauncher( directory: PlatformFile? = null, dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): PickerResultLauncher + +internal suspend fun runDirectoryPickerLauncher( + openDirectoryPicker: suspend () -> PlatformFile?, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + runDialogOperation( + operation = openDirectoryPicker, + onError = onError, + onResult = onResult, + ) +} diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index e63b29e5..f01153a0 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -18,6 +18,70 @@ import kotlin.test.assertFalse import kotlin.test.assertSame class FileKitComposeFailureTest { + @Test + fun runDirectoryPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { + val failure = FileKitDialogException("The directory picker could not be opened.") + val reportedFailures = mutableListOf() + var resultInvoked = false + + runDirectoryPickerLauncher( + openDirectoryPicker = { throw failure }, + onError = reportedFailures::add, + onResult = { resultInvoked = true }, + ) + + assertEquals(listOf(failure), reportedFailures) + assertFalse(resultInvoked) + } + + @Test + fun runDirectoryPickerLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { + val results = mutableListOf() + var errorInvoked = false + + runDirectoryPickerLauncher( + openDirectoryPicker = { null }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertEquals(1, results.size) + assertEquals(null, results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runDirectoryPickerLauncher_invalidInvocation_propagates_withoutInvokingCallbacks() = runTest { + val failure = IllegalArgumentException("Unsupported directory argument") + var errorInvoked = false + var resultInvoked = false + + val thrown = assertFailsWith { + runDirectoryPickerLauncher( + openDirectoryPicker = { throw failure }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + assertSame(failure, thrown) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + + @Test + fun runDirectoryPickerLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { + var resultInvoked = false + + runDirectoryPickerLauncher( + openDirectoryPicker = { throw FileKitDialogException("Ignored compatibility failure") }, + onError = {}, + onResult = { resultInvoked = true }, + ) + + assertFalse(resultInvoked) + } + @Test fun runDialogOperation_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitDialogException("The system dialog could not be opened.") diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt index 43d11a63..700e1429 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt @@ -4,6 +4,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitPickerState @@ -25,4 +26,10 @@ private fun CompileCommonPickerCallShapes() { onError = { _: FileKitPickerException -> }, onResult = { _: FileKitPickerState -> }, ) + + val legacyDirectory = rememberDirectoryPickerLauncher { _: PlatformFile? -> } + val explicitDirectory = rememberDirectoryPickerLauncher( + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) } diff --git a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt index a57f893b..7b851fac 100644 --- a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt +++ b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt @@ -5,6 +5,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import androidx.compose.ui.window.WindowScope import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode @@ -82,6 +83,19 @@ public fun WindowScope.rememberDirectoryPickerLauncher( onResult = onResult, ) +@Composable +public fun WindowScope.rememberDirectoryPickerLauncher( + directory: PlatformFile? = null, + dialogSettings: FileKitDialogSettings? = null, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher = io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher( + directory = directory, + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), + onError = onError, + onResult = onResult, +) + @Composable public fun WindowScope.rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings? = null, diff --git a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java index 96caa2d1..6f0815a5 100644 --- a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java +++ b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java @@ -6,6 +6,8 @@ import io.github.vinceglb.filekit.dialogs.FileKitMode; import io.github.vinceglb.filekit.dialogs.FileKitType; import io.github.vinceglb.filekit.dialogs.compose.FileKitComposeKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_jvmKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_nonAndroidKt; import kotlin.Unit; import kotlin.jvm.functions.Function1; @@ -36,6 +38,23 @@ public static void linkLegacyOverloads() { 0, 0 )); + link(() -> FileKitCompose_nonAndroidKt.rememberDirectoryPickerLauncher( + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberDirectoryPickerLauncher( + null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); } private static void link(LinkageCall call) { diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt new file mode 100644 index 00000000..120dbad0 --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt @@ -0,0 +1,28 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame + +class DirectoryLauncherJvmTest { + @Test + fun runDirectoryPickerLauncher_success_invokesResultOnce_withoutInvokingError() = runTest { + val directory = PlatformFile(File("selected-directory")) + val results = mutableListOf() + var errorInvoked = false + + runDirectoryPickerLauncher( + openDirectoryPicker = { directory }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertSame(directory, results.single()) + assertFalse(errorInvoked) + } +} diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt index 71e777cd..3e7a016b 100644 --- a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt @@ -5,6 +5,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import androidx.compose.ui.window.WindowScope import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitPickerException @Composable @@ -14,4 +15,10 @@ private fun WindowScope.CompileJvmPickerCallShapes() { onError = { _: FileKitPickerException -> }, onResult = { _: PlatformFile? -> }, ) + + val legacyDirectory = rememberDirectoryPickerLauncher { _: PlatformFile? -> } + val explicitDirectory = rememberDirectoryPickerLauncher( + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) } diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class index 6ec34c7e70e5213d09a59c73e6ed3a6b212b1eec..d1dac8566b6fd88df77698d6f5f1c933cfed4f64 100644 GIT binary patch delta 1029 zcmZuwOHUI~6#j0fom+;HM=7O1DPT-21sNU+3My{|DauO`QE_M|Sg@UDScojpxOD3;aP7ulU_3LO!a^rGbI!T*o$q{ePVSH3hluk1&*2e(D@g7s z&skjn9{2;0;4eirLL4GZrpz5}(Q)%hGbesZQEH-!tnXWef>yL~ zoKq1)JHvRfZ{iulfmk5WtwGQT+T@ckiQg0XI4%2C!u?^om36{wg}F#@?< z#+`YZ{Q-sKeH63koywrX-R-`O!|o(O6xa z<}?6m*jGfzqGU~ErzPnC@x_zhvkQP0Dy9v&#ocxjdg`e_Bm0M-vq+F9RDq$17--`z zLobcXei~hd-v6;&DzY4tgy5wCfQB@Naq?cGtHw2o;uPPayE=hMvO<)=M4KDlJU*PG V=pGf=x4W8VdS6!X01vT>+P~Z=r*QxP delta 586 zcmZXQ%We}v5JgXo?U}e|^1z-rBqj!=Afj<%#{>v3^9aun2MH+>i%3XDL?DPTumG|G z34Q?e4`hc}T3!HN04o--V8I9QCx~j>A`4pTzSXztcHQdt`+mjd`=9P-pw5l9c_Tw1 zdE$U^yCV-}!bVHktFv!!&UAhUuf^_UUnHju5oaQf;*a{Ab+>a7i@nF!5$ Ujjn1gr@6dZzvdfOSY?gLzsNR8MF0Q* diff --git a/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt b/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt index 1979f103..b01c15a4 100644 --- a/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt +++ b/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException @@ -28,22 +29,50 @@ public actual fun rememberDirectoryPickerLauncher( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, +): PickerResultLauncher = rememberDirectoryPickerLauncher( + directory = directory, + dialogSettings = dialogSettings, + onError = {}, + onResult = onResult, +) + +/** + * Creates and remembers a [PickerResultLauncher] for picking a directory. + * + * @param directory The initial directory. Supported on desktop platforms. + * @param dialogSettings Platform-specific settings for the dialog. + * @param onError Callback invoked when a valid directory operation cannot complete. + * @param onResult Callback invoked with the picked directory, or null if cancelled. + * @return A [PickerResultLauncher] that can be used to launch the picker. + */ +@Composable +public actual fun rememberDirectoryPickerLauncher( + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, ): PickerResultLauncher { val coroutineScope = rememberCoroutineScope() val stableDialogSettings = rememberStableDialogSettings(dialogSettings) val currentDirectory by rememberUpdatedState(directory) val currentDialogSettings by rememberUpdatedState(stableDialogSettings) + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) return remember { PickerResultLauncher { coroutineScope.launch { - val result = FileKit.openDirectoryPicker( - directory = currentDirectory, - dialogSettings = currentDialogSettings, + runDirectoryPickerLauncher( + openDirectoryPicker = { + FileKit.openDirectoryPicker( + directory = currentDirectory, + dialogSettings = currentDialogSettings, + ) + }, + onError = currentOnError, + onResult = currentOnResult, ) - currentOnResult(result) } } } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt index 9fff706c..a741effb 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt @@ -1,6 +1,7 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.path @@ -43,7 +44,7 @@ internal class AwtFilePicker : PlatformFilePicker { override suspend fun openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = throw UnsupportedOperationException("Directory picker is not supported on Linux yet.") + ): File? = throw FileKitDialogException("AWT does not support directory picker dialogs.") private suspend fun callAwtPicker( title: String?, diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt index b4c2c214..f94e578f 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt @@ -1,11 +1,13 @@ package io.github.vinceglb.filekit.dialogs.platform.swing import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine +import java.awt.HeadlessException import java.io.File import javax.swing.JFileChooser import javax.swing.UIManager @@ -48,7 +50,7 @@ internal class SwingFilePicker : PlatformFilePicker { override suspend fun openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = + ): File? = runSwingDirectoryPicker { callSwingFilePicker( mode = JFileChooser.DIRECTORIES_ONLY, isMultiSelectionEnabled = false, @@ -56,6 +58,7 @@ internal class SwingFilePicker : PlatformFilePicker { fileExtensions = null, dialogSettings = dialogSettings, )?.firstOrNull() + } private suspend fun callSwingFilePicker( mode: Int, @@ -79,12 +82,35 @@ internal class SwingFilePicker : PlatformFilePicker { val parentWindow = dialogSettings.parent.requireAwtWindowOrNull("Swing dialogs") val returnValue = jFileChooser.showOpenDialog(parentWindow) - if (returnValue == JFileChooser.APPROVE_OPTION) { - continuation.resume( - jFileChooser.selectedFiles.toList().takeIf { it.isNotEmpty() } ?: jFileChooser.selectedFile?.let { listOf(it) }, - ) - } + continuation.resume( + resolveSwingPickerResult( + returnValue = returnValue, + selectedFiles = jFileChooser.selectedFiles, + selectedFile = jFileChooser.selectedFile, + ), + ) continuation.invokeOnCancellation { jFileChooser.cancelSelection() } } } + +internal suspend fun runSwingDirectoryPicker( + operation: suspend () -> T, +): T = try { + operation() +} catch (failure: HeadlessException) { + throw FileKitDialogException( + message = "The Swing directory picker is unavailable in a headless environment.", + cause = failure, + ) +} + +internal fun resolveSwingPickerResult( + returnValue: Int, + selectedFiles: Array, + selectedFile: File?, +): List? = if (returnValue == JFileChooser.APPROVE_OPTION) { + selectedFiles.toList().takeIf { it.isNotEmpty() } ?: selectedFile?.let(::listOf) +} else { + null +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt index 373b18d4..db2ef7d6 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt @@ -32,6 +32,10 @@ internal object WindowsDialogThreadFactory : ThreadFactory { private const val THREAD_NAME = "FileKit-Windows-Dialog" } +internal class WindowsDialogOperationalException( + message: String, +) : RuntimeException(message) + internal class WindowsDialogExecutor( private val comRuntime: WindowsComRuntime, threadFactory: ThreadFactory = WindowsDialogThreadFactory, @@ -43,7 +47,7 @@ internal class WindowsDialogExecutor( suspend fun execute(block: () -> T): T = withContext(dispatcher) { val initializationResult = comRuntime.initializeSta() if (initializationResult != S_OK && initializationResult != S_FALSE) { - throw RuntimeException( + throw WindowsDialogOperationalException( "CoInitializeEx failed with HRESULT 0x${initializationResult.toUInt().toString(16)}", ) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 570c2e41..f275cdae 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -16,6 +16,7 @@ import com.sun.jna.platform.win32.WinNT.HRESULT import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.PointerByReference import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileDialog @@ -97,24 +98,30 @@ internal class WindowsFilePicker( override suspend fun openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = useFileDialog(FileDialogType.Open) { fileOpenDialog -> - // Set the initial directory - directory?.let { fileOpenDialog.setDefaultPath(it) } - - // Set title - dialogSettings.title?.let { - fileOpenDialog - .SetTitle(WString(dialogSettings.title)) - .verify("SetTitle failed") - } + ): File? = try { + useFileDialog(FileDialogType.Open) { fileOpenDialog -> + // Set the initial directory + directory?.let { fileOpenDialog.setDefaultPath(it) } + + // Set title + dialogSettings.title?.let { + fileOpenDialog + .SetTitle(WString(dialogSettings.title)) + .verify("SetTitle failed") + } - // Add in FOS_PICKFOLDERS which hides files and only allows selection of folders - fileOpenDialog.setFlag(FOS_PICKFOLDERS) + // Add in FOS_PICKFOLDERS which hides files and only allows selection of folders + fileOpenDialog.setFlag(FOS_PICKFOLDERS) - // Show the dialog to the user - fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { - it.getResult(SIGDN_DESKTOPABSOLUTEPARSING) + // Show the dialog to the user + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { + it.getResult(SIGDN_DESKTOPABSOLUTEPARSING) + } } + } catch (failure: Win32Exception) { + throw failure.toDirectoryPickerFailure() + } catch (failure: WindowsDialogOperationalException) { + throw failure.toDirectoryPickerFailure() } override suspend fun openFileSaver( @@ -222,14 +229,16 @@ internal class WindowsFilePicker( // Invalid error codes: throw exception if (FAILED(resultFolder)) { - throw RuntimeException("SHCreateItemFromParsingName failed") + throw WindowsDialogOperationalException( + "SHCreateItemFromParsingName failed with HRESULT 0x${resultFolder.toInt().toUInt().toString(16)}", + ) } // Create ShellItem from the folder val folder = ShellItem(pbrFolder.value) // Set the initial directory - this.SetFolder(folder.pointer) + this.SetFolder(folder.pointer).verify("SetFolder failed") // Release the folder folder.Release() @@ -275,7 +284,9 @@ internal class WindowsFilePicker( // Invalid error codes: throw exception if (FAILED(openDialogResult)) { - throw RuntimeException("Show failed") + throw WindowsDialogOperationalException( + "Show failed with HRESULT 0x${openDialogResult.toInt().toUInt().toString(16)}", + ) } return block(this) @@ -369,7 +380,9 @@ internal class WindowsFilePicker( private fun HRESULT.verify(exceptionMessage: String): HRESULT { if (FAILED(this)) { - throw RuntimeException(exceptionMessage) + throw WindowsDialogOperationalException( + "$exceptionMessage with HRESULT 0x${toInt().toUInt().toString(16)}", + ) } else { return this } @@ -381,6 +394,11 @@ internal class WindowsFilePicker( } } +private fun Throwable.toDirectoryPickerFailure(): FileKitDialogException = FileKitDialogException( + message = "The Windows directory picker could not complete the operation.", + cause = this, +) + internal fun showWindowsDialog( parentHandle: Long?, show: (WinDef.HWND?) -> T, diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDirectoryPickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDirectoryPickerFailureTest.kt new file mode 100644 index 00000000..f8764a46 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDirectoryPickerFailureTest.kt @@ -0,0 +1,24 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.awt + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AwtDirectoryPickerFailureTest { + @Test + fun AwtDirectoryPicker_unsupportedValidRequest_throwsDialogOperationalFailure() = runTest { + val failure = assertFailsWith { + AwtFilePicker().openDirectoryPicker( + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertEquals("AWT does not support directory picker dialogs.", failure.message) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt new file mode 100644 index 00000000..556f5367 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt @@ -0,0 +1,53 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.swing + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import kotlinx.coroutines.test.runTest +import java.awt.HeadlessException +import java.io.File +import javax.swing.JFileChooser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +class SwingDirectoryPickerResultTest { + @Test + fun SwingDirectoryPicker_approvedSelection_returnsSelectedDirectory() { + val directory = File("selected-directory") + + val result = resolveSwingPickerResult( + returnValue = JFileChooser.APPROVE_OPTION, + selectedFiles = emptyArray(), + selectedFile = directory, + ) + + assertEquals(listOf(directory), result) + } + + @Test + fun SwingDirectoryPicker_cancelledSelection_returnsNull() { + val result = resolveSwingPickerResult( + returnValue = JFileChooser.CANCEL_OPTION, + selectedFiles = emptyArray(), + selectedFile = null, + ) + + assertNull(result) + } + + @Test + fun SwingDirectoryPicker_headlessFailure_throwsDialogOperationalFailureWithCause() = runTest { + val headlessFailure = HeadlessException("No graphics environment") + + val failure = assertFailsWith { + runSwingDirectoryPicker { + throw headlessFailure + } + } + + assertSame(headlessFailure, failure.cause) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDirectoryPickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDirectoryPickerFailureTest.kt new file mode 100644 index 00000000..fb76a093 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDirectoryPickerFailureTest.kt @@ -0,0 +1,36 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.windows + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class WindowsDirectoryPickerFailureTest { + @Test + fun WindowsDirectoryPicker_comInitializationFailure_throwsDialogOperationalFailureWithCause() = runTest { + val executor = WindowsDialogExecutor( + comRuntime = object : WindowsComRuntime { + override fun initializeSta(): Int = 0x8007000E.toInt() + + override fun uninitialize() = Unit + }, + ) + + try { + val failure = assertFailsWith { + WindowsFilePicker(executor).openDirectoryPicker( + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } finally { + executor.close() + } + } +} diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 3518005c..67bff918 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -67,6 +67,21 @@ private val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() private val ERROR_FILE_NOT_FOUND_HRESULT = 0x80070002u.toInt() private val ERROR_INVALID_DRIVE_HRESULT = 0x8007000Fu.toInt() +private class WindowsDialogOperationalException( + message: String, +) : RuntimeException(message) + +private enum class WindowsDialogFailurePolicy { + Legacy, + Directory, + ; + + fun createFailure(message: String): RuntimeException = when (this) { + Legacy -> IllegalStateException(message) + Directory -> WindowsDialogOperationalException(message) + } +} + internal actual suspend fun FileKit.platformOpenFilePicker( type: FileKitType, mode: PickerMode, @@ -86,9 +101,21 @@ internal actual suspend fun FileKit.platformOpenFilePicker( public actual suspend fun FileKit.openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, -): PlatformFile? = - showOpenDialog(null, directory, dialogSettings.title, pickFolders = true, allowMultiple = false) - ?.firstOrNull() +): PlatformFile? = try { + showOpenDialog( + extensions = null, + directory = directory, + title = dialogSettings.title, + pickFolders = true, + allowMultiple = false, + failurePolicy = WindowsDialogFailurePolicy.Directory, + )?.firstOrNull() +} catch (failure: WindowsDialogOperationalException) { + throw FileKitDialogException( + message = "The Windows directory picker could not complete the operation.", + cause = failure, + ) +} internal actual suspend fun FileKit.platformOpenFileSaver( suggestedName: String, @@ -115,53 +142,64 @@ private fun showOpenDialog( title: String?, pickFolders: Boolean, allowMultiple: Boolean, + failurePolicy: WindowsDialogFailurePolicy = WindowsDialogFailurePolicy.Legacy, ): List? = memScoped { - val comInitialized = initializeComForDialogs() + val comInitialized = initializeComForDialogs(failurePolicy) val ppDlg = alloc() try { val createHr = fk_create_open_dialog(ppDlg.ptr.reinterpret()) if (createHr != S_OK) { - throw IllegalStateException("CoCreateInstance(IFileOpenDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "CoCreateInstance(IFileOpenDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", + ) } val dlg = ppDlg.value - ?: throw IllegalStateException("CoCreateInstance(IFileOpenDialog) returned a null dialog pointer") + ?: throw failurePolicy.createFailure("CoCreateInstance(IFileOpenDialog) returned a null dialog pointer") // Options val optsVar = alloc() val getOptionsHr = fk_dialog_get_options(dlg.reinterpret(), optsVar.ptr) if (getOptionsHr != S_OK) { - throw IllegalStateException("IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}", + ) } var opts = optsVar.value.toInt() or FK_FOS_FORCEFILESYSTEM or FK_FOS_PATHMUSTEXIST if (pickFolders) opts = opts or FK_FOS_PICKFOLDERS else opts = opts or FK_FOS_FILEMUSTEXIST if (allowMultiple) opts = opts or FK_FOS_ALLOWMULTISELECT val setOptionsHr = fk_dialog_set_options(dlg.reinterpret(), opts.toUInt()) if (setOptionsHr != S_OK) { - throw IllegalStateException("IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}", + ) } title?.let { val setTitleHr = fk_dialog_set_title(dlg.reinterpret(), it) if (setTitleHr != S_OK) { - throw IllegalStateException("IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", + ) } } - directory?.let { setFolder(dlg, it) } - if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions) + directory?.let { setFolder(dlg, it, failurePolicy) } + if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions, failurePolicy) val hr = fk_dialog_show(dlg.reinterpret(), null) if (hr != S_OK) { if (hr == ERROR_CANCELLED_HRESULT) { return@memScoped null } - throw IllegalStateException("IFileOpenDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileOpenDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } if (allowMultiple) { - getMultipleResults(dlg) + getMultipleResults(dlg, failurePolicy) } else { val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() - getSingleResult(dlg, sigdn)?.let { listOf(it) } + getSingleResult(dlg, sigdn, failurePolicy)?.let { listOf(it) } } } finally { ppDlg.value?.let { fk_open_dialog_release(it.reinterpret()) } @@ -178,12 +216,15 @@ private fun showSaveDialog( directory: PlatformFile?, title: String?, ): PlatformFile? = memScoped { - val comInitialized = initializeComForDialogs() + val failurePolicy = WindowsDialogFailurePolicy.Legacy + val comInitialized = initializeComForDialogs(failurePolicy) val ppDlg = alloc() try { val createHr = fk_create_save_dialog(ppDlg.ptr.reinterpret()) if (createHr != S_OK) { - throw IllegalStateException("CoCreateInstance(IFileSaveDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}") + throw IllegalStateException( + "CoCreateInstance(IFileSaveDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", + ) } val dlg = ppDlg.value ?: throw IllegalStateException("CoCreateInstance(IFileSaveDialog) returned a null dialog pointer") @@ -191,23 +232,31 @@ private fun showSaveDialog( val optsVar = alloc() val getOptionsHr = fk_dialog_get_options(dlg.reinterpret(), optsVar.ptr) if (getOptionsHr != S_OK) { - throw IllegalStateException("IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}") + throw IllegalStateException( + "IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}", + ) } val opts = optsVar.value.toInt() or FK_FOS_FORCEFILESYSTEM or FK_FOS_PATHMUSTEXIST or FK_FOS_OVERWRITEPROMPT val setOptionsHr = fk_dialog_set_options(dlg.reinterpret(), opts.toUInt()) if (setOptionsHr != S_OK) { - throw IllegalStateException("IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}") + throw IllegalStateException( + "IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}", + ) } title?.let { val setTitleHr = fk_dialog_set_title(dlg.reinterpret(), it) if (setTitleHr != S_OK) { - throw IllegalStateException("IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}") + throw IllegalStateException( + "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", + ) } } val setFilenameHr = fk_dialog_set_filename(dlg.reinterpret(), suggestedName) if (setFilenameHr != S_OK) { - throw IllegalStateException("IFileDialog::SetFileName failed with HRESULT 0x${setFilenameHr.toUInt().toString(16)}") + throw IllegalStateException( + "IFileDialog::SetFileName failed with HRESULT 0x${setFilenameHr.toUInt().toString(16)}", + ) } defaultExtension?.let { val setDefaultExtensionHr = fk_dialog_set_default_extension(dlg.reinterpret(), it) @@ -218,17 +267,19 @@ private fun showSaveDialog( } } val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } - filterExtensions?.let { setFileTypes(dlg, it) } - directory?.let { setFolder(dlg, it) } + filterExtensions?.let { setFileTypes(dlg, it, failurePolicy) } + directory?.let { setFolder(dlg, it, failurePolicy) } val hr = fk_dialog_show(dlg.reinterpret(), null) if (hr != S_OK) { if (hr == ERROR_CANCELLED_HRESULT) { return@memScoped null } - throw IllegalStateException("IFileSaveDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw IllegalStateException( + "IFileSaveDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } - getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt()) + getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt(), failurePolicy) } finally { ppDlg.value?.let { fk_save_dialog_release(it.reinterpret()) } if (comInitialized) { @@ -239,7 +290,9 @@ private fun showSaveDialog( // region Helpers -private fun initializeComForDialogs(): Boolean { +private fun initializeComForDialogs( + failurePolicy: WindowsDialogFailurePolicy, +): Boolean { val result = CoInitializeEx( null, COINIT_APARTMENTTHREADED or COINIT_DISABLE_OLE1DDE, @@ -249,17 +302,23 @@ private fun initializeComForDialogs(): Boolean { return true } - throw IllegalStateException("CoInitializeEx failed with HRESULT 0x${result.toUInt().toString(16)}") + throw failurePolicy.createFailure("CoInitializeEx failed with HRESULT 0x${result.toUInt().toString(16)}") } -private fun MemScope.setFolder(dlg: ComPtr, dir: PlatformFile) { +private fun MemScope.setFolder( + dlg: ComPtr, + dir: PlatformFile, + failurePolicy: WindowsDialogFailurePolicy, +) { val ppsi = alloc() val hr = fk_create_shell_item_from_path(dir.path, ppsi.ptr.reinterpret()) if (hr != S_OK) { if (hr == ERROR_FILE_NOT_FOUND_HRESULT || hr == ERROR_INVALID_DRIVE_HRESULT) { return } - throw IllegalStateException("SHCreateItemFromParsingName failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "SHCreateItemFromParsingName failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val folder = ppsi.value ?: return try { @@ -269,7 +328,11 @@ private fun MemScope.setFolder(dlg: ComPtr, dir: PlatformFile) { } } -private fun MemScope.setFileTypes(dlg: ComPtr, exts: Set) { +private fun MemScope.setFileTypes( + dlg: ComPtr, + exts: Set, + failurePolicy: WindowsDialogFailurePolicy, +) { val display = exts.joinToString(", ") { "*.$it" } val pattern = exts.joinToString(";") { "*.$it" } // COMDLG_FILTERSPEC = { LPCWSTR pszName; LPCWSTR pszSpec; } = two consecutive pointers @@ -278,49 +341,66 @@ private fun MemScope.setFileTypes(dlg: ComPtr, exts: Set) { spec[1] = pattern.wcstr.ptr val hr = fk_dialog_set_file_types(dlg.reinterpret(), 1u, spec.reinterpret()) if (hr != S_OK) { - throw IllegalStateException("IFileDialog::SetFileTypes failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileDialog::SetFileTypes failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } } -private fun MemScope.getSingleResult(dlg: ComPtr, sigdn: Int): PlatformFile? { +private fun MemScope.getSingleResult( + dlg: ComPtr, + sigdn: Int, + failurePolicy: WindowsDialogFailurePolicy, +): PlatformFile? { val ppsi = alloc() val hr = fk_dialog_get_result(dlg.reinterpret(), ppsi.ptr.reinterpret()) if (hr != S_OK) { - throw IllegalStateException("IFileDialog::GetResult failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileDialog::GetResult failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val item = ppsi.value - ?: throw IllegalStateException("IFileDialog::GetResult returned a null result item") + ?: throw failurePolicy.createFailure("IFileDialog::GetResult returned a null result item") try { - return shellItemToFile(item, sigdn) + return shellItemToFile(item, sigdn, failurePolicy) } finally { fk_shell_item_release(item.reinterpret()) } } -private fun MemScope.getMultipleResults(dlg: ComPtr): List { +private fun MemScope.getMultipleResults( + dlg: ComPtr, + failurePolicy: WindowsDialogFailurePolicy, +): List { val ppArr = alloc() val resultsHr = fk_open_dialog_get_results(dlg.reinterpret(), ppArr.ptr.reinterpret()) if (resultsHr != S_OK) { - throw IllegalStateException("IFileOpenDialog::GetResults failed with HRESULT 0x${resultsHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IFileOpenDialog::GetResults failed with HRESULT 0x${resultsHr.toUInt().toString(16)}", + ) } val arr = ppArr.value - ?: throw IllegalStateException("IFileOpenDialog::GetResults returned a null result array") + ?: throw failurePolicy.createFailure("IFileOpenDialog::GetResults returned a null result array") try { val cntVar = alloc() val countHr = fk_shell_item_array_get_count(arr.reinterpret(), cntVar.ptr) if (countHr != S_OK) { - throw IllegalStateException("IShellItemArray::GetCount failed with HRESULT 0x${countHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IShellItemArray::GetCount failed with HRESULT 0x${countHr.toUInt().toString(16)}", + ) } return (0 until cntVar.value.toInt()).mapNotNull { i -> val ppsi = alloc() val itemHr = fk_shell_item_array_get_item_at(arr.reinterpret(), i.toUInt(), ppsi.ptr.reinterpret()) if (itemHr != S_OK) { - throw IllegalStateException("IShellItemArray::GetItemAt failed with HRESULT 0x${itemHr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IShellItemArray::GetItemAt failed with HRESULT 0x${itemHr.toUInt().toString(16)}", + ) } val item = ppsi.value - ?: throw IllegalStateException("IShellItemArray::GetItemAt returned a null shell item") + ?: throw failurePolicy.createFailure("IShellItemArray::GetItemAt returned a null shell item") try { - shellItemToFile(item, FK_SIGDN_FILESYSPATH.toInt()) + shellItemToFile(item, FK_SIGDN_FILESYSPATH.toInt(), failurePolicy) } finally { fk_shell_item_release(item.reinterpret()) } @@ -330,14 +410,20 @@ private fun MemScope.getMultipleResults(dlg: ComPtr): List { } } -private fun MemScope.shellItemToFile(item: ComPtr, sigdn: Int): PlatformFile? { +private fun MemScope.shellItemToFile( + item: ComPtr, + sigdn: Int, + failurePolicy: WindowsDialogFailurePolicy, +): PlatformFile? { val ppName = alloc>() val hr = fk_shell_item_get_display_name(item.reinterpret(), sigdn, ppName.ptr.reinterpret()) if (hr != S_OK) { - throw IllegalStateException("IShellItem::GetDisplayName failed with HRESULT 0x${hr.toUInt().toString(16)}") + throw failurePolicy.createFailure( + "IShellItem::GetDisplayName failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val namePtr = ppName.value - ?: throw IllegalStateException("IShellItem::GetDisplayName returned a null display name") + ?: throw failurePolicy.createFailure("IShellItem::GetDisplayName returned a null display name") try { return PlatformFile(namePtr.toKStringFromUtf16()) } finally { diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt index 6cda39aa..e9f2aee6 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name @@ -68,25 +69,37 @@ private fun DirectoryPickerScreen( var startDirectory by remember { mutableStateOf(null) } var pickedDirectories by remember { mutableStateOf(emptyList()) } var selectedFile by remember { mutableStateOf(null) } + var directoryError by remember { mutableStateOf(null) } val dialogSettings = dialogSettingsTransform(FileKitDialogSettings.createDefault()) + val onDirectoryError: (FileKitDialogException) -> Unit = { failure -> + buttonState = AppScreenHeaderButtonState.Enabled + directoryError = failure.message + } + val directoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, dialogSettings = dialogSettings, - ) { directory -> - buttonState = AppScreenHeaderButtonState.Enabled - if (directory != null) { - pickedDirectories = listOf(directory) + pickedDirectories - } - } + onError = onDirectoryError, + onResult = { directory -> + buttonState = AppScreenHeaderButtonState.Enabled + directoryError = null + if (directory != null) { + pickedDirectories = listOf(directory) + pickedDirectories + } + }, + ) val startDirectoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, dialogSettings = dialogSettings, - ) { directory -> - if (directory != null) { - startDirectory = directory - } - } + onError = onDirectoryError, + onResult = { directory -> + directoryError = null + if (directory != null) { + startDirectory = directory + } + }, + ) fun openDirectoryPicker() { buttonState = AppScreenHeaderButtonState.Loading @@ -149,7 +162,7 @@ private fun DirectoryPickerScreen( item { AppPickerResultsCard( files = pickedDirectories, - emptyText = "No directory selected yet", + emptyText = directoryError ?: "No directory selected yet", emptyIcon = LucideIcons.Folder, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), From 6bd5ae8cba9f7f25a357702420dd9cd389befeba Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 6 Aug 2026 22:54:14 +0200 Subject: [PATCH 04/40] =?UTF-8?q?=E2=9C=A8=20Make=20file=20saver=20launche?= =?UTF-8?q?r=20failures=20observable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/file-saver.mdx | 36 +++++--- docs/quickstart.mdx | 12 ++- .../AndroidComposePickerReliabilityTest.kt | 36 ++++++++ .../dialogs/compose/FileKitCompose.android.kt | 51 +++++++++-- .../filekit/dialogs/compose/FileKitCompose.kt | 12 +++ .../compose/FileKitComposeFailureTest.kt | 64 +++++++++++++ .../compose/FileKitCompose.nativeAndJvm.kt | 22 +++-- .../dialogs/compose/FileKitCompose.jvm.kt | 11 +++ .../LegacyPickerLauncherConsumer.java | 15 ++++ .../compose/FileKitPickerJvmCallShape.kt | 6 ++ .../compose/FileSaverLauncherJvmTest.kt | 28 ++++++ .../LegacyPickerLauncherConsumer.class | Bin 3625 -> 4434 bytes .../dialogs/compose/FileKitCompose.nonWeb.kt | 35 ++++++-- .../dialogs/compose/FileSaverCallShape.kt | 19 ++++ .../vinceglb/filekit/dialogs/FileKit.ios.kt | 40 +++++++-- .../filekit/dialogs/AppleSaverFailureTest.kt | 34 +++++++ .../dialogs/platform/awt/AwtFileSaver.kt | 85 ++++++++++-------- .../platform/windows/WindowsFilePicker.kt | 49 ++++++---- .../platform/awt/AwtFileSaverFailureTest.kt | 25 ++++++ .../windows/WindowsFileSaverFailureTest.kt | 39 ++++++++ .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 33 ++++--- .../filekit/dialogs/FileKit.nonWeb.kt | 2 + .../ui/screens/filesaver/FileSaverLauncher.kt | 2 + .../ui/screens/filesaver/FileSaverScreen.kt | 24 +++-- .../filesaver/FileSaverLauncher.jvm.kt | 3 + .../filesaver/FileSaverLauncher.macos.kt | 3 + .../filesaver/FileSaverLauncher.mobile.kt | 3 + .../filesaver/FileSaverLauncher.web.kt | 2 + 28 files changed, 576 insertions(+), 115 deletions(-) create mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt create mode 100644 filekit-dialogs-compose/src/nonWebTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverCallShape.kt create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFileSaverFailureTest.kt diff --git a/docs/dialogs/file-saver.mdx b/docs/dialogs/file-saver.mdx index 8aead236..e78d1826 100644 --- a/docs/dialogs/file-saver.mdx +++ b/docs/dialogs/file-saver.mdx @@ -30,14 +30,22 @@ if (file != null) { ```kotlin filekit-dialogs-compose val scope = rememberCoroutineScope() -val launcher = rememberFileSaverLauncher { file -> - // Write your data to the file - if (file != null) { - scope.launch { - file.write(bytes) +val launcher = rememberFileSaverLauncher( + dialogSettings = FileKitDialogSettings.createDefault(), + onError = { failure -> + // A valid file-saving operation could not be completed + showError(failure.message) + }, + onResult = { file -> + if (file == null) { + // The user cancelled the saver + } else { + scope.launch { + file.write(bytes) + } } - } -} + }, +) Button(onClick = { launcher.launch( @@ -50,6 +58,10 @@ Button(onClick = { ``` +`onError` receives a `FileKitDialogException` only when FileKit cannot complete a valid file-saving operation. User cancellation is not a failure: it invokes `onResult(null)`. Coroutine cancellation, invalid arguments or unsupported argument combinations, and unexpected defects continue to propagate normally. + +The compatibility overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. + ## Parameters The file saver can be customized with several parameters: @@ -73,10 +85,12 @@ val file = FileKit.openFileSaver( ```kotlin filekit-dialogs-compose val launcher = rememberFileSaverLauncher( - dialogSettings = FileKitDialogSettings.createDefault() -) { file -> - // Handle the selected save location -} + dialogSettings = FileKitDialogSettings.createDefault(), + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the selected save location, or null when the user cancelled + }, +) launcher.launch( suggestedName = "my-document", diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 0afb92d5..0846668f 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -113,10 +113,14 @@ file?.writeString(contentToSave) ```kotlin filekit-dialogs-compose // Create a file saver launcher -val launcher = rememberFileSaverLauncher { file -> - // Handle the saved file - file?.let { saveFile(it) } -} +val launcher = rememberFileSaverLauncher( + dialogSettings = FileKitDialogSettings.createDefault(), + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Cancellation is reported as null, not as an error + file?.let { saveFile(it) } + }, +) // Display a button to open the file saver dialog Button(onClick = { diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index bb28f902..fea88165 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -181,6 +181,42 @@ class AndroidComposePickerReliabilityTest { assertTrue(launched) } + @Test + fun FileSaverLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No file saver activity") + + val result = launchFileSaverSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + + @Test + fun FileSaverLaunchSafely_whenUnexpectedFailure_propagates() { + val failure = IllegalStateException("Unexpected saver defect") + + val thrown = kotlin.test.assertFailsWith { + launchFileSaverSafely { throw failure } + } + + assertSame(failure, thrown) + } + + @Test + fun FileSaverLaunchSafely_whenNoError_returnsLaunched() { + var launched = false + + val result = launchFileSaverSafely { + launched = true + } + + assertIs(result) + assertTrue(launched) + } + @Test fun PickerLaunchOutcome_primaryFailsAndFallbackSucceeds_returnsFallbackLaunched() { var fallbackCalls = 0 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 fe2e10dd..faff6d96 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 @@ -329,10 +329,12 @@ public actual fun rememberDirectoryPickerLauncher( @Composable internal actual fun rememberPlatformFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): SaverResultLauncher { InitializeAndroidFileKit() + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) var hasPendingLaunch by rememberSaveable { mutableStateOf(false) } @@ -361,13 +363,26 @@ internal actual fun rememberPlatformFileSaverLauncher( } hasPendingLaunch = true - launcher.launch( - CreateDocumentInput( - mimeType = mimeType, - fileName = fileName, - allowedMimeTypes = allowedMimeTypes, - ), - ) + when ( + val launchResult = launchFileSaverSafely { + launcher.launch( + CreateDocumentInput( + mimeType = mimeType, + fileName = fileName, + allowedMimeTypes = allowedMimeTypes, + ), + ) + } + ) { + SaverLaunchResult.Launched -> { + // Await the Activity Result callback. + } + + is SaverLaunchResult.Failed -> { + hasPendingLaunch = false + currentOnError(launchResult.failure) + } + } } } } @@ -552,6 +567,28 @@ internal sealed interface DirectoryLaunchResult { ) : DirectoryLaunchResult } +internal fun launchFileSaverSafely( + launch: () -> Unit, +): SaverLaunchResult = try { + launch() + SaverLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + SaverLaunchResult.Failed( + FileKitDialogException( + message = "No Android activity is available to open the file saver.", + cause = failure, + ), + ) +} + +internal sealed interface SaverLaunchResult { + data object Launched : SaverLaunchResult + + data class Failed( + val failure: FileKitDialogException, + ) : SaverLaunchResult +} + internal sealed interface PickerLaunchResult { data object Launched : PickerLaunchResult diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index b72895d5..1226edfd 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -231,3 +231,15 @@ internal suspend fun runDirectoryPickerLauncher( onResult = onResult, ) } + +internal suspend fun runFileSaverLauncher( + openFileSaver: suspend () -> PlatformFile?, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + runDialogOperation( + operation = openFileSaver, + onError = onError, + onResult = onResult, + ) +} diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index f01153a0..228633ce 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -18,6 +18,70 @@ import kotlin.test.assertFalse import kotlin.test.assertSame class FileKitComposeFailureTest { + @Test + fun runFileSaverLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { + val failure = FileKitDialogException("The file saver could not be opened.") + val reportedFailures = mutableListOf() + var resultInvoked = false + + runFileSaverLauncher( + openFileSaver = { throw failure }, + onError = reportedFailures::add, + onResult = { resultInvoked = true }, + ) + + assertEquals(listOf(failure), reportedFailures) + assertFalse(resultInvoked) + } + + @Test + fun runFileSaverLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { + val results = mutableListOf() + var errorInvoked = false + + runFileSaverLauncher( + openFileSaver = { null }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertEquals(1, results.size) + assertEquals(null, results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runFileSaverLauncher_invalidInvocation_propagates_withoutInvokingCallbacks() = runTest { + val failure = IllegalArgumentException("Unsupported saver arguments") + var errorInvoked = false + var resultInvoked = false + + val thrown = assertFailsWith { + runFileSaverLauncher( + openFileSaver = { throw failure }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + assertSame(failure, thrown) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + + @Test + fun runFileSaverLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { + var resultInvoked = false + + runFileSaverLauncher( + openFileSaver = { throw FileKitDialogException("Ignored compatibility failure") }, + onError = {}, + onResult = { resultInvoked = true }, + ) + + assertFalse(resultInvoked) + } + @Test fun runDirectoryPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitDialogException("The directory picker could not be opened.") diff --git a/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt b/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt index d78a02d3..f74658a5 100644 --- a/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt +++ b/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.openFileSaver import kotlinx.coroutines.launch @@ -14,24 +15,31 @@ import kotlinx.coroutines.launch @Composable internal actual fun rememberPlatformFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): SaverResultLauncher { val coroutineScope = rememberCoroutineScope() val stableDialogSettings = rememberStableDialogSettings(dialogSettings) val currentDialogSettings by rememberUpdatedState(stableDialogSettings) + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) return remember { SaverResultLauncher { suggestedName, defaultExtension, allowedExtensions, directory -> coroutineScope.launch { - val result = FileKit.openFileSaver( - suggestedName = suggestedName, - defaultExtension = defaultExtension, - allowedExtensions = allowedExtensions, - directory = directory, - dialogSettings = currentDialogSettings, + runFileSaverLauncher( + openFileSaver = { + FileKit.openFileSaver( + suggestedName = suggestedName, + defaultExtension = defaultExtension, + allowedExtensions = allowedExtensions, + directory = directory, + dialogSettings = currentDialogSettings, + ) + }, + onError = currentOnError, + onResult = currentOnResult, ) - currentOnResult(result) } } } diff --git a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt index 7b851fac..cafc4511 100644 --- a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt +++ b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt @@ -105,6 +105,17 @@ public fun WindowScope.rememberFileSaverLauncher( onResult = onResult, ) +@Composable +public fun WindowScope.rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings? = null, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +): SaverResultLauncher = io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher( + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), + onError = onError, + onResult = onResult, +) + internal fun injectDialogSettings( dialogSettings: FileKitDialogSettings?, parent: FileKitDialogParent, diff --git a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java index 6f0815a5..8490a55b 100644 --- a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java +++ b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java @@ -8,6 +8,7 @@ import io.github.vinceglb.filekit.dialogs.compose.FileKitComposeKt; import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_jvmKt; import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_nonAndroidKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_nonWebKt; import kotlin.Unit; import kotlin.jvm.functions.Function1; @@ -55,6 +56,20 @@ public static void linkLegacyOverloads() { 0, 0 )); + link(() -> FileKitCompose_nonWebKt.rememberFileSaverLauncher( + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberFileSaverLauncher( + null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); } private static void link(LinkageCall call) { diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt index 3e7a016b..089d15dc 100644 --- a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt @@ -21,4 +21,10 @@ private fun WindowScope.CompileJvmPickerCallShapes() { onError = { _: FileKitDialogException -> }, onResult = { _: PlatformFile? -> }, ) + + val legacySaver = rememberFileSaverLauncher { _: PlatformFile? -> } + val explicitSaver = rememberFileSaverLauncher( + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) } diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt new file mode 100644 index 00000000..b7555e1c --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt @@ -0,0 +1,28 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertSame + +class FileSaverLauncherJvmTest { + @Test + fun runFileSaverLauncher_success_invokesDestinationOnce_withoutInvokingError() = runTest { + val destination = PlatformFile(File("saved-document.txt")) + val results = mutableListOf() + var errorInvoked = false + + runFileSaverLauncher( + openFileSaver = { destination }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertSame(destination, results.single()) + assertFalse(errorInvoked) + } +} diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class index d1dac8566b6fd88df77698d6f5f1c933cfed4f64..7c04c393ac3585c6333491e45db763044cd4f602 100644 GIT binary patch delta 1163 zcmZuvOHUI~6#i~IooQ!A!15>*s0A@0ecW6_Pzq>#>S5N zhWY)F;Pt};uZ93B6;$zeY?)Pa-Bn#)%TT&xZkUmzxw;UUOr%pU%s5|k{jRM_TPxN| z+)58FCasvcVWp$y`s!T5N;CZ73HJb>^}cmefGlm}qBY|dgjM`{cAXCSPjphWJ*AcbR6M+_V4BKc?#S)ekBz3G{ zH9Mo$F_z}9G((~`g!oT2z{8r%e`w_yA6zsC84NdQK(%BUS79?-l81=xkQhm(%=JGRuZWsWVk?V)T4N``a;@S+YSFi?pWgwcgY^rDG!sVJuX zTxru}U39w0_<{kL3b!dbN?K#oqH&x+47Vtv z!9AoP6YNunQlg5^IhoGcOu8>)`v}nr)2es7chN9)SbVN0y~LymI81}ywfOu;h`TTf zcwNL3wPkXbYZ5)AuTrFp9AQ)1<^p;P(dkh*X(LX-f#b;S1!RNB^uBB|^}p50T}|xH z;}otGDAbVxy;moF2hC4>ryPO4<3z3(hyOa|@uiO9t delta 840 zcmZuuO-~b16g{t<&eZAjBS1?jP+CG086%~lQ0rF#saA@LqE+#0&;}JklP+-Kq6-t3 zCgg2hxHFo#Xqx!7P}lDCAGq`nxFW_g(?VG=$<4jz-FxnN@64~^w*&f*e_y@=IE4od z{iX2w5Jp5puY4!gCi?^;PpnNVy>4x+rb~}E)~waaT&-5EY3LW|?(~)()v8aemGz2- zL7DfMs)iwftlaXN^0gk2FI=yho1UzYrs5X``P-F~f7NYeTwshdV}2b+Frne7fn%6# zX1vdYl5O?Ou7&EuirvrI%@ab*AKY3TX@>OYLjLr9C|uBCVBWwi)S!w58(lOoXzB=4R4N<-( zhLGLedC)jC_JODn-!Z_5GZKtpxBC-D%AI@L3?M@`Ep6X3?dSr%`;pB(8brRkMGfJNk*X11kk8C6qpsR3*#Is nll3*YaUMlRt198}PI+nf@CvKjBy@2}n7-aob=<{$tf22dgHLqG diff --git a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt index 42da6b85..324a917c 100644 --- a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt +++ b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings /** @@ -10,19 +11,43 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings * @param dialogSettings Platform-specific settings for the dialog. * @param onResult Callback invoked with the saved file path, or null if cancelled. * @return A [SaverResultLauncher] that can be used to launch the saver. + * + * Operational file-saver failures are ignored without logging by this compatibility overload. + * Use the overload with `onError` to observe them. User cancellation remains an [onResult] value. + */ +@Composable +public fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, + onResult: (PlatformFile?) -> Unit, +): SaverResultLauncher = rememberFileSaverLauncher( + dialogSettings = dialogSettings, + onError = {}, + onResult = onResult, +) + +/** + * Creates and remembers a [SaverResultLauncher] for saving a file. + * + * @param dialogSettings Platform-specific settings for the dialog. + * @param onError Callback invoked when a valid file-saving operation cannot complete. It is not invoked for user + * cancellation, coroutine cancellation, invalid invocations, or unexpected defects. + * @param onResult Callback invoked with the saved file path, or null if cancelled. + * @return A [SaverResultLauncher] that can be used to launch the saver. */ @Composable public fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, -): SaverResultLauncher = - rememberPlatformFileSaverLauncher( - dialogSettings = dialogSettings, - onResult = onResult, - ) +): SaverResultLauncher = rememberPlatformFileSaverLauncher( + dialogSettings = dialogSettings, + onError = onError, + onResult = onResult, +) @Composable internal expect fun rememberPlatformFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): SaverResultLauncher diff --git a/filekit-dialogs-compose/src/nonWebTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverCallShape.kt b/filekit-dialogs-compose/src/nonWebTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverCallShape.kt new file mode 100644 index 00000000..1637eb1b --- /dev/null +++ b/filekit-dialogs-compose/src/nonWebTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverCallShape.kt @@ -0,0 +1,19 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings + +@Composable +private fun CompileFileSaverCallShapes() { + val settings = FileKitDialogSettings.createDefault() + val legacy = rememberFileSaverLauncher(settings) { _: PlatformFile? -> } + val explicit = rememberFileSaverLauncher( + dialogSettings = settings, + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) +} 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 32e75cfe..5ff38cf4 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 @@ -183,22 +183,32 @@ internal actual suspend fun FileKit.platformOpenFileSaver( extension = normalizedDefaultExtension, ) + val presenter = requireAppleSaverResource( + resource = dialogSettings.presenterViewController(), + failureMessage = "No active view controller is available to present the file saver.", + ) + // Get the fileManager val fileManager = NSFileManager.defaultManager // Get the temporary directory - val fileComponents = fileManager.temporaryDirectory.pathComponents?.plus(fileName) - ?: throw IllegalStateException("Failed to get temporary directory") + val fileComponents = requireAppleSaverResource( + resource = fileManager.temporaryDirectory.pathComponents?.plus(fileName), + failureMessage = "Failed to prepare a temporary file path for saving.", + ) // Create a file URL - val fileUrl = NSURL.fileURLWithPathComponents(fileComponents) - ?: throw IllegalStateException("Failed to create file URL") + val fileUrl = requireAppleSaverResource( + resource = NSURL.fileURLWithPathComponents(fileComponents), + failureMessage = "Failed to create a temporary file URL for saving.", + ) // Write an empty string to the file to ensure it exists val emptyData = NSData() - if (!emptyData.writeToURL(fileUrl, true)) { - throw IllegalStateException("Failed to write to file URL") - } + requireAppleSaverPreparation( + successful = emptyData.writeToURL(fileUrl, true), + failureMessage = "Failed to write the temporary file for saving.", + ) // Create a picker controller val pickerController = UIDocumentPickerViewController( @@ -212,7 +222,7 @@ internal actual suspend fun FileKit.platformOpenFileSaver( pickerController.delegate = documentPickerDelegate // Present the picker controller - dialogSettings.presenterViewController()?.presentViewController( + presenter.presentViewController( pickerController, animated = true, completion = null, @@ -220,6 +230,20 @@ internal actual suspend fun FileKit.platformOpenFileSaver( } } +internal fun requireAppleSaverResource( + resource: T?, + failureMessage: String, +): T = resource ?: throw FileKitDialogException(failureMessage) + +internal fun requireAppleSaverPreparation( + successful: Boolean, + failureMessage: String, +) { + if (!successful) { + throw FileKitDialogException(failureMessage) + } +} + /** * Opens a camera picker dialog. * diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt new file mode 100644 index 00000000..199cff4a --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt @@ -0,0 +1,34 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import platform.Foundation.NSURL +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AppleSaverFailureTest { + @Test + fun AppleSaver_missingPreparationResource_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + requireAppleSaverResource( + resource = null, + failureMessage = "Failed to prepare a temporary file for saving.", + ) + } + + assertEquals("Failed to prepare a temporary file for saving.", failure.message) + } + + @Test + fun AppleSaver_failedPreparationOperation_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + requireAppleSaverPreparation( + successful = false, + failureMessage = "Failed to write the temporary file for saving.", + ) + } + + assertEquals("Failed to write the temporary file for saving.", failure.message) + } +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt index 2ff102c6..94241bfd 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt @@ -1,12 +1,14 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine import java.awt.Dialog import java.awt.FileDialog import java.awt.Frame +import java.awt.HeadlessException import java.io.File import kotlin.coroutines.resume @@ -17,53 +19,66 @@ internal object AwtFileSaver { allowedExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings?, - ): File? = suspendCancellableCoroutine { continuation -> - fun handleResult(value: Boolean, files: Array?) { - if (value) { - val file = files?.firstOrNull() - continuation.resume(file) + ): File? = runAwtFileSaver { + suspendCancellableCoroutine { continuation -> + fun handleResult(value: Boolean, files: Array?) { + if (value) { + val file = files?.firstOrNull() + continuation.resume(file) + } } - } - val parentWindow = dialogSettings?.parent.resolveAwtFileDialogOwner() + val parentWindow = dialogSettings?.parent.resolveAwtFileDialogOwner() - // Handle parentWindow: Dialog, Frame, or null - val dialog = when (parentWindow) { - is Dialog -> object : FileDialog(parentWindow, "Save dialog", SAVE) { - override fun setVisible(value: Boolean) { - super.setVisible(value) - handleResult(value, files) + // Handle parentWindow: Dialog, Frame, or null + val dialog = when (parentWindow) { + is Dialog -> object : FileDialog(parentWindow, "Save dialog", SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } } - } - else -> object : FileDialog(parentWindow as? Frame, "Save dialog", SAVE) { - override fun setVisible(value: Boolean) { - super.setVisible(value) - handleResult(value, files) + else -> object : FileDialog(parentWindow as? Frame, "Save dialog", SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } } } - } - // Set initial directory - directory?.let { dialog.directory = directory.path } + // Set initial directory + directory?.let { dialog.directory = directory.path } - val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } - filterExtensions?.let { extensions -> - dialog.filenameFilter = java.io.FilenameFilter { _, name -> - extensions.any { extension -> name.endsWith(".$extension", ignoreCase = true) } + val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } + filterExtensions?.let { extensions -> + dialog.filenameFilter = java.io.FilenameFilter { _, name -> + extensions.any { extension -> name.endsWith(".$extension", ignoreCase = true) } + } } - } - // Set file name - dialog.file = when { - defaultExtension != null -> "$suggestedName.$defaultExtension" - else -> suggestedName - } + // Set file name + dialog.file = when { + defaultExtension != null -> "$suggestedName.$defaultExtension" + else -> suggestedName + } - // Show the dialog - dialog.isVisible = true + // Show the dialog + dialog.isVisible = true - // Dispose the dialog when the continuation is cancelled - continuation.invokeOnCancellation { dialog.dispose() } + // Dispose the dialog when the continuation is cancelled + continuation.invokeOnCancellation { dialog.dispose() } + } } } + +internal suspend fun runAwtFileSaver( + operation: suspend () -> T, +): T = try { + operation() +} catch (failure: HeadlessException) { + throw FileKitDialogException( + message = "The AWT file saver is unavailable in a headless environment.", + cause = failure, + ) +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index f275cdae..8a27f335 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -130,30 +130,36 @@ internal class WindowsFilePicker( allowedExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = useFileDialog(FileDialogType.Save) { fileSaveDialog -> - // Set the initial directory - directory?.let { fileSaveDialog.setDefaultPath(it) } - - // Set the default file name - fileSaveDialog - .SetFileName(WString(suggestedName)) - .verify("SetFileName failed") + ): File? = try { + useFileDialog(FileDialogType.Save) { fileSaveDialog -> + // Set the initial directory + directory?.let { fileSaveDialog.setDefaultPath(it) } - // Set the default extension - defaultExtension?.let { + // Set the default file name fileSaveDialog - .SetDefaultExtension(WString(defaultExtension)) - .verify("SetDefaultExtension failed") - } + .SetFileName(WString(suggestedName)) + .verify("SetFileName failed") + + // Set the default extension + defaultExtension?.let { + fileSaveDialog + .SetDefaultExtension(WString(defaultExtension)) + .verify("SetDefaultExtension failed") + } - // Set filters - val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } - filterExtensions?.let { fileSaveDialog.addFiltersToDialog(it) } + // Set filters + val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } + filterExtensions?.let { fileSaveDialog.addFiltersToDialog(it) } - // Show the dialog to the user - fileSaveDialog.show(dialogSettings.resolveWindowsDialogHandle()) { - it.getResult(SIGDN_FILESYSPATH) + // Show the dialog to the user + fileSaveDialog.show(dialogSettings.resolveWindowsDialogHandle()) { + it.getResult(SIGDN_FILESYSPATH) + } } + } catch (failure: Win32Exception) { + throw failure.toFileSaverFailure() + } catch (failure: WindowsDialogOperationalException) { + throw failure.toFileSaverFailure() } private suspend fun useFileDialog( @@ -399,6 +405,11 @@ private fun Throwable.toDirectoryPickerFailure(): FileKitDialogException = FileK cause = this, ) +private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDialogException( + message = "The Windows file saver could not complete the operation.", + cause = this, +) + internal fun showWindowsDialog( parentHandle: Long?, show: (WinDef.HWND?) -> T, diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt new file mode 100644 index 00000000..295480ea --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt @@ -0,0 +1,25 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.awt + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import kotlinx.coroutines.test.runTest +import java.awt.HeadlessException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class AwtFileSaverFailureTest { + @Test + fun AwtFileSaver_headlessFailure_throwsDialogOperationalFailureWithCause() = runTest { + val headlessFailure = HeadlessException("No graphics environment") + + val failure = assertFailsWith { + runAwtFileSaver { + throw headlessFailure + } + } + + assertSame(headlessFailure, failure.cause) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFileSaverFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFileSaverFailureTest.kt new file mode 100644 index 00000000..b5717ac1 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFileSaverFailureTest.kt @@ -0,0 +1,39 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.windows + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class WindowsFileSaverFailureTest { + @Test + fun WindowsFileSaver_comInitializationFailure_throwsDialogOperationalFailureWithCause() = runTest { + val executor = WindowsDialogExecutor( + comRuntime = object : WindowsComRuntime { + override fun initializeSta(): Int = 0x8007000E.toInt() + + override fun uninitialize() = Unit + }, + ) + + try { + val failure = assertFailsWith { + WindowsFilePicker(executor).openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } finally { + executor.close() + } + } +} diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 67bff918..29144512 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -74,11 +74,15 @@ private class WindowsDialogOperationalException( private enum class WindowsDialogFailurePolicy { Legacy, Directory, + Saver, ; fun createFailure(message: String): RuntimeException = when (this) { Legacy -> IllegalStateException(message) - Directory -> WindowsDialogOperationalException(message) + + Directory, + Saver, + -> WindowsDialogOperationalException(message) } } @@ -123,10 +127,15 @@ internal actual suspend fun FileKit.platformOpenFileSaver( allowedExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, -): PlatformFile? { +): PlatformFile? = try { val ext = normalizeFileSaverExtension(defaultExtension) val filters = normalizeFileSaverExtensions(allowedExtensions) - return showSaveDialog(buildFileSaverSuggestedName(suggestedName, ext), ext, filters, directory, dialogSettings.title) + showSaveDialog(buildFileSaverSuggestedName(suggestedName, ext), ext, filters, directory, dialogSettings.title) +} catch (failure: WindowsDialogOperationalException) { + throw FileKitDialogException( + message = "The Windows file saver could not complete the operation.", + cause = failure, + ) } public actual fun FileKit.openFileWithDefaultApplication( @@ -216,30 +225,30 @@ private fun showSaveDialog( directory: PlatformFile?, title: String?, ): PlatformFile? = memScoped { - val failurePolicy = WindowsDialogFailurePolicy.Legacy + val failurePolicy = WindowsDialogFailurePolicy.Saver val comInitialized = initializeComForDialogs(failurePolicy) val ppDlg = alloc() try { val createHr = fk_create_save_dialog(ppDlg.ptr.reinterpret()) if (createHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "CoCreateInstance(IFileSaveDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", ) } val dlg = ppDlg.value - ?: throw IllegalStateException("CoCreateInstance(IFileSaveDialog) returned a null dialog pointer") + ?: throw failurePolicy.createFailure("CoCreateInstance(IFileSaveDialog) returned a null dialog pointer") val optsVar = alloc() val getOptionsHr = fk_dialog_get_options(dlg.reinterpret(), optsVar.ptr) if (getOptionsHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}", ) } val opts = optsVar.value.toInt() or FK_FOS_FORCEFILESYSTEM or FK_FOS_PATHMUSTEXIST or FK_FOS_OVERWRITEPROMPT val setOptionsHr = fk_dialog_set_options(dlg.reinterpret(), opts.toUInt()) if (setOptionsHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}", ) } @@ -247,21 +256,21 @@ private fun showSaveDialog( title?.let { val setTitleHr = fk_dialog_set_title(dlg.reinterpret(), it) if (setTitleHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", ) } } val setFilenameHr = fk_dialog_set_filename(dlg.reinterpret(), suggestedName) if (setFilenameHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileDialog::SetFileName failed with HRESULT 0x${setFilenameHr.toUInt().toString(16)}", ) } defaultExtension?.let { val setDefaultExtensionHr = fk_dialog_set_default_extension(dlg.reinterpret(), it) if (setDefaultExtensionHr != S_OK) { - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileDialog::SetDefaultExtension failed with HRESULT 0x${setDefaultExtensionHr.toUInt().toString(16)}", ) } @@ -275,7 +284,7 @@ private fun showSaveDialog( if (hr == ERROR_CANCELLED_HRESULT) { return@memScoped null } - throw IllegalStateException( + throw failurePolicy.createFailure( "IFileSaveDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } diff --git a/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt b/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt index 94f4b3f2..d2809132 100644 --- a/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt +++ b/filekit-dialogs/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.nonWeb.kt @@ -17,6 +17,8 @@ import io.github.vinceglb.filekit.PlatformFile * @param directory The initial directory. Supported on desktop platforms. * @param dialogSettings Platform-specific settings for the dialog. * @return The path where the file should be saved as a [PlatformFile], or null if cancelled. + * @throws FileKitDialogException When a valid file-saving operation cannot be prepared, presented, or completed. + * Invalid arguments and unsupported argument combinations remain caller-contract violations and are not wrapped in this type. */ public suspend fun FileKit.openFileSaver( suggestedName: String, diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt index 245b95e0..c7861fbf 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings internal interface FileSaverLauncher { @@ -18,5 +19,6 @@ internal interface FileSaverLauncher { @Composable internal expect fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt index 393351e3..0c637825 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name @@ -71,16 +72,25 @@ private fun FileSaverScreen( var allowedExtensions by remember { mutableStateOf("pdf, txt") } var saveDirectory by remember { mutableStateOf(null) } var savedFiles by remember { mutableStateOf(emptyList()) } + var saverError by remember { mutableStateOf(null) } val dialogSettings = dialogSettingsTransform(FileKitDialogSettings.createDefault()) - val fileSaverLauncher = rememberFileSaverLauncher( - dialogSettings = dialogSettings, - ) { file -> + val onSaverError: (FileKitDialogException) -> Unit = { failure -> buttonState = AppScreenHeaderButtonState.Enabled - if (file != null) { - savedFiles = listOf(file) + savedFiles - } + saverError = failure.message } + + val fileSaverLauncher = rememberFileSaverLauncher( + dialogSettings = dialogSettings, + onError = onSaverError, + onResult = { file -> + buttonState = AppScreenHeaderButtonState.Enabled + saverError = null + if (file != null) { + savedFiles = listOf(file) + savedFiles + } + }, + ) val directoryPickerLauncher = rememberDirectoryPickerLauncher( directory = saveDirectory, dialogSettings = dialogSettings, @@ -173,7 +183,7 @@ private fun FileSaverScreen( item { AppPickerResultsCard( files = savedFiles, - emptyText = "No save locations selected yet", + emptyText = saverError ?: "No save locations selected yet", emptyIcon = LucideIcons.File, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt index 5e2eb3ab..14af587e 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt @@ -3,16 +3,19 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as rememberFileKitSaverLauncher @Composable internal actual fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( dialogSettings = dialogSettings, + onError = onError, onResult = onResult, ) diff --git a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt index 5e2eb3ab..14af587e 100644 --- a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt +++ b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt @@ -3,16 +3,19 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as rememberFileKitSaverLauncher @Composable internal actual fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( dialogSettings = dialogSettings, + onError = onError, onResult = onResult, ) diff --git a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt index 5e2eb3ab..14af587e 100644 --- a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt +++ b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt @@ -3,16 +3,19 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as rememberFileKitSaverLauncher @Composable internal actual fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( dialogSettings = dialogSettings, + onError = onError, onResult = onResult, ) diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt index b97f82f0..3e4f7cec 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt @@ -3,11 +3,13 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings @Composable internal actual fun rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings, + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher = remember { object : FileSaverLauncher { From a3a8641dc8b367691019110c1cb7aa98edec0681 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 6 Aug 2026 23:37:34 +0200 Subject: [PATCH 05/40] =?UTF-8?q?=E2=9C=A8=20Make=20camera=20launcher=20fa?= =?UTF-8?q?ilures=20observable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/camera-picker.mdx | 41 ++-- .../LegacyCameraLauncherConsumer.java | 41 ++++ .../AndroidComposePickerReliabilityTest.kt | 154 +++++++++++++-- ...cyCameraLauncherBinaryCompatibilityTest.kt | 24 +++ .../resources/legacy-camera-consumer.jar | Bin 0 -> 2926 bytes .../dialogs/compose/FileKitCompose.android.kt | 186 ++++++++++++++---- .../filekit/dialogs/compose/FileKitCompose.kt | 12 ++ .../compose/FileKitComposeFailureTest.kt | 45 +++++ .../dialogs/compose/FileKitCompose.ios.kt | 39 +++- .../compose/CameraLauncherIosCallShape.kt | 22 +++ .../dialogs/compose/FileKitCompose.mobile.kt | 24 +++ .../compose/CameraLauncherCallShape.kt | 16 ++ .../vinceglb/filekit/dialogs/FileKit.ios.kt | 158 ++++++++++++--- .../dialogs/util/CameraControllerDelegate.kt | 3 +- .../filekit/dialogs/AppleCameraFailureTest.kt | 99 ++++++++++ .../camerapicker/CameraPickerLauncher.kt | 2 + .../camerapicker/CameraPickerScreen.kt | 22 ++- .../camerapicker/CameraPickerLauncher.jvm.kt | 2 + .../CameraPickerLauncher.macos.kt | 2 + .../CameraPickerLauncher.mobile.kt | 7 +- .../camerapicker/CameraPickerLauncher.web.kt | 2 + 21 files changed, 794 insertions(+), 107 deletions(-) create mode 100644 filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyCameraLauncherConsumer.java create mode 100644 filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyCameraLauncherBinaryCompatibilityTest.kt create mode 100644 filekit-dialogs-compose/src/androidHostTest/resources/legacy-camera-consumer.jar create mode 100644 filekit-dialogs-compose/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherIosCallShape.kt create mode 100644 filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherCallShape.kt create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleCameraFailureTest.kt diff --git a/docs/dialogs/camera-picker.mdx b/docs/dialogs/camera-picker.mdx index 1ff26df2..91dc7749 100644 --- a/docs/dialogs/camera-picker.mdx +++ b/docs/dialogs/camera-picker.mdx @@ -18,9 +18,19 @@ val file = FileKit.openCameraPicker() ``` ```kotlin filekit-dialogs-compose -val launcher = rememberCameraPickerLauncher { file -> - // Handle the captured photo -} +val launcher = rememberCameraPickerLauncher( + onError = { failure -> + // A valid camera operation could not start or complete + showError(failure.message) + }, + onResult = { file -> + if (file == null) { + // The user dismissed the camera, or denied camera permission on Android + } else { + // Handle the captured photo + } + }, +) Button(onClick = { launcher.launch() }) { Text("Take a photo") @@ -28,6 +38,10 @@ Button(onClick = { launcher.launch() }) { ``` +`onError` receives a `FileKitDialogException` only when FileKit cannot start or complete a valid camera operation, such as when no Android camera activity is available or iOS cannot prepare, present, encode, or write the capture. User dismissal is not a failure and invokes `onResult(null)`. Android camera-permission denial also invokes `onResult(null)`. Coroutine cancellation, invalid invocation, and unexpected defects continue to propagate normally. + +On iOS, the suspending `FileKit.openCameraPicker()` function throws the same `FileKitDialogException` for operational failures. Android's lifecycle-safe Compose launcher owns the Android launch-failure callback behavior described above. The compatibility Compose overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. + The captured media file is automatically saved to the specified location (or cache directory by default). If you need to keep the file permanently, make sure to copy it to a permanent storage location. ## Android camera permission behavior @@ -53,9 +67,10 @@ val file = FileKit.openCameraPicker(type = FileKitCameraType.Photo) ``` ```kotlin filekit-dialogs-compose -val launcher = rememberCameraPickerLauncher { file -> - // Handle the captured photo -} +val launcher = rememberCameraPickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { file -> /* Handle the captured photo, or null for cancellation */ }, +) Button(onClick = { launcher.launch(type = FileKitCameraType.Photo) }) { Text("Take a photo") @@ -83,9 +98,10 @@ val file = FileKit.openCameraPicker(cameraFacing = FileKitCameraFacing.Front) ``` ```kotlin filekit-dialogs-compose -val launcher = rememberCameraPickerLauncher { file -> - // Handle the captured photo -} +val launcher = rememberCameraPickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { file -> /* Handle the captured photo, or null for cancellation */ }, +) Button(onClick = { launcher.launch(cameraFacing = FileKitCameraFacing.Front) }) { Text("Take a selfie") @@ -114,9 +130,10 @@ val file = FileKit.openCameraPicker(destinationFile = customFile) ``` ```kotlin filekit-dialogs-compose -val launcher = rememberCameraPickerLauncher { file -> - // Handle the captured photo -} +val launcher = rememberCameraPickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { file -> /* Handle the captured photo, or null for cancellation */ }, +) // Using default destination Button(onClick = { launcher.launch() }) { diff --git a/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyCameraLauncherConsumer.java b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyCameraLauncherConsumer.java new file mode 100644 index 00000000..82daf453 --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyCameraLauncherConsumer.java @@ -0,0 +1,41 @@ +package io.github.vinceglb.filekit.dialogs.compose.compatibility; + +import androidx.compose.runtime.Composer; +import io.github.vinceglb.filekit.PlatformFile; +import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_androidKt; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; + +/** + * Source for the class fixture in androidHostTest/resources. Compile this source only against the + * fixed-point FileKit artifacts so the runtime test proves that precompiled legacy camera consumers + * still link. + */ +public final class LegacyCameraLauncherConsumer { + private LegacyCameraLauncherConsumer() {} + + public static void linkLegacyOverload() { + link(() -> FileKitCompose_androidKt.rememberCameraPickerLauncher( + (FileKitOpenCameraSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + } + + private static void link(LinkageCall call) { + try { + call.invoke(); + } catch (LinkageError failure) { + throw failure; + } catch (Throwable expectedEntryFailure) { + // Null arguments are intentional: reaching the entry point proves method resolution. + } + } + + private interface LinkageCall { + void invoke(); + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index fea88165..c97a600f 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -90,36 +90,168 @@ class AndroidComposePickerReliabilityTest { } @Test - fun CameraLaunchSafely_whenSecurityException_returnsFalse() { - val launched = launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) { - throw SecurityException("camera permission denied") + fun CameraPermission_denied_clearsPendingStateBeforeReturningNull_andAllowsImmediateRelaunch() { + var hasPendingLaunch = true + val results = mutableListOf() + + dispatchCameraPermissionResolution( + resolution = CameraPermissionResolution.ReturnNullResult, + launchCamera = { error("Camera must not launch after permission denial") }, + clearPendingState = { hasPendingLaunch = false }, + onError = { error("Permission denial must not be reported as an error") }, + onResult = { result -> + assertFalse(hasPendingLaunch) + results += result + hasPendingLaunch = true + }, + ) + + assertEquals(listOf(null), results) + assertTrue(hasPendingLaunch) + + dispatchCameraResult( + success = false, + pendingDestinationUri = "content://example.provider/camera/relaunch.jpg".takeIf { hasPendingLaunch }, + clearPendingState = { hasPendingLaunch = false }, + onResult = results::add, + ) + + assertEquals(listOf(null, null), results) + assertFalse(hasPendingLaunch) + } + + @Test + fun CameraLaunchFailure_clearsPendingStateBeforeReportingError_andAllowsImmediateRelaunch() { + var hasPendingLaunch = true + val launchFailure = FileKitDialogException("Camera unavailable") + val failures = mutableListOf() + val results = mutableListOf() + + dispatchCameraLaunchResult( + result = CameraLaunchResult.Failed(launchFailure), + clearPendingState = { hasPendingLaunch = false }, + onError = { failure -> + assertFalse(hasPendingLaunch) + failures += failure + hasPendingLaunch = true + }, + ) + + assertEquals(listOf(launchFailure), failures) + assertTrue(hasPendingLaunch) + + dispatchCameraResult( + success = true, + pendingDestinationUri = "content://example.provider/camera/relaunch.jpg".takeIf { hasPendingLaunch }, + clearPendingState = { hasPendingLaunch = false }, + onResult = results::add, + ) + + assertEquals("content://example.provider/camera/relaunch.jpg", results.single()?.path) + assertFalse(hasPendingLaunch) + } + + @Test + fun CameraResult_success_clearsPendingStateBeforeReturningFile_andAllowsImmediateRelaunch() { + var hasPendingLaunch = true + val results = mutableListOf() + + dispatchCameraResult( + success = true, + pendingDestinationUri = "content://example.provider/camera/photo.jpg", + clearPendingState = { hasPendingLaunch = false }, + onResult = { result -> + assertFalse(hasPendingLaunch) + results += result + hasPendingLaunch = true + }, + ) + + assertEquals(1, results.size) + assertEquals("content://example.provider/camera/photo.jpg", results.single()?.path) + assertTrue(hasPendingLaunch) + } + + @Test + fun CameraLaunchSafely_whenSecurityException_returnsOperationalFailureWithCause() { + val launchFailure = SecurityException("camera permission denied") + + val result = launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) { + throw launchFailure } - assertFalse(launched) + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) } @Test - fun CameraLaunchSafely_whenActivityNotFound_returnsFalse() { - val launched = launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) { - throw ActivityNotFoundException("No activity found") + fun CameraLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No activity found") + + val result = launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) { + throw launchFailure } - assertFalse(launched) + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) } @Test - fun CameraLaunchSafely_whenNoError_returnsTrue() { + fun CameraLaunchSafely_whenNoError_returnsLaunched() { val expectedUri = Uri.parse("content://example.provider/camera/photo.jpg") var launchedUri: Uri? = null - val launched = launchCameraSafely(expectedUri) { uri -> + val result = launchCameraSafely(expectedUri) { uri -> launchedUri = uri } - assertTrue(launched) + assertIs(result) assertEquals(expectedUri, launchedUri) } + @Test + fun CameraLaunchSafely_whenUnexpectedFailure_propagates() { + val failure = IllegalStateException("Unexpected camera launcher defect") + + val thrown = kotlin.test.assertFailsWith { + launchCameraSafely(Uri.parse("content://example.provider/camera/photo.jpg")) { + throw failure + } + } + + assertSame(failure, thrown) + } + + @Test + fun CameraPermissionLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No permission activity") + + val result = launchCameraPermissionSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertSame(launchFailure, failure.cause) + } + + @Test + fun LegacyCameraLauncher_androidBinarySignature_remainsAvailable() { + val composeFileClass = Class.forName( + "io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_androidKt", + ) + + composeFileClass.getDeclaredMethod( + "rememberCameraPickerLauncher", + io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings::class.java, + Class.forName("kotlin.jvm.functions.Function1"), + androidx.compose.runtime.Composer::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + } + @Test fun PickerLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { val launchFailure = ActivityNotFoundException("No activity found") diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyCameraLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyCameraLauncherBinaryCompatibilityTest.kt new file mode 100644 index 00000000..65fe902d --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyCameraLauncherBinaryCompatibilityTest.kt @@ -0,0 +1,24 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import java.net.URLClassLoader +import kotlin.test.Test +import kotlin.test.assertNotNull + +class LegacyCameraLauncherBinaryCompatibilityTest { + @Test + fun LegacyCameraLauncher_precompiledConsumer_linksAgainstCurrentArtifacts() { + val fixture = assertNotNull(javaClass.getResource("/legacy-camera-consumer.jar")) + + URLClassLoader(arrayOf(fixture), javaClass.classLoader).use { loader -> + val consumer = Class.forName( + "io.github.vinceglb.filekit.dialogs.compose.compatibility.LegacyCameraLauncherConsumer", + true, + loader, + ) + + consumer.getMethod("linkLegacyOverload").invoke(null) + } + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/resources/legacy-camera-consumer.jar b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-camera-consumer.jar new file mode 100644 index 0000000000000000000000000000000000000000..d42af77e01fae16a1b0d324b61b903e27b4eef5b GIT binary patch literal 2926 zcmWIWW@h1HVBlb2Sh|}nmH`QHGO#fCx`sIFdiuHP|2xIN5CBvv!ob17fuU3cs12^v z*U`_@%{4eg&)4m<@0rs+-nx1hdA)VD&Yd~GImqCO@q?#DdS1Rdp1v1LS8WM0FuH1d z!8qu`2QS@1Ew3|Yw8J#6XyUVorW~ zF&+c7i8CNMKer&iI2DgUmZTX3GBB|uGbuACv!oJiEF@i^$4NRR#`>hDCni@qC+4OW zCHf?m<|SvO7CGnV6_)}9RD3e?vJ=x&ofC6%^pbNDi;K7RJNg}V;Boyg;^7q~5blt~ zDE*8_Lx@SUiM463@R=)TOW0D?CRUk>$uu}C-dPv1@i2G&U;WeHe*gOSgQcMPvXC+J zq>Q;wuHLxi>2&lHgFCz!4jZFmR_(*{Q5+=w3P(Xb5uWgo3&iuE??sLc~=iAfwmHaX<-Svxc z;bbAF^ro~WSKr-xTlf3!y}R@4?SG0M(5*>M=3?c(%;&f8M8Lhr!tdB@-t4|T*Y=@w z+oM@LOLra&SYckKZSs0cv+;(LiibO;xYz>oD}r{`X0jC?pL^nh_}>MOzojU&UD^?Q zlKE!zB)ul2u=?2LvT=Z{b=ccF#vc_1DkN=UWWU8`N(&wexk~HLqxk z;{moG-tugzI$_R8u*QWfR^ZkD9ED@&+X14#PIYDhRw>|t<(A{4gr^ixr<>jYaiC?z9 z<|&IldQ05XIKWzd>a(aIIp>%2I?jaMsy$Lx8LKHQdG>hpA)XL(t;H7MN77Gz{JyjK z^~!Hcd)2P#)I5E_mS+@Wr=Vp$C04)HL$RX&x7*9wQ^r+szgNGLoi!!(&$L;F0)c0P zvn2XwpXbe%_I-P&=HijLe>eW9Kd1BbVzG4l-jlz!EEU}1b}mgqXftEKlB3SFf2B{h zO01Jx9l>4xVun?fMr_T^gf*8Y`6yN{^Stzz^OW-}?@1GFw*E1`q5Ny+q1|6}mmM>@ zw|Ms22yk?BocbfmH}j`} z{L(iU0~`8_qxLCGng1ZTYQLIyt6S)ay44xUvfDQ{e*O=x3D+;VHoY8J(d`9R*8$#) zOk51Ot8QQ+fCS*RGo&JCWD;ROROQGOET}3+08j-3*NRlJBU_GM{D3NA1XvGbf~#eO zYtc1;DrN+@0AwOp)4274DryAy0c0ZdAywPB4FFZ#2%rfpRIwO Unit, +): PhotoResultLauncher = rememberCameraPickerLauncher( + openCameraSettings = openCameraSettings, + onError = {}, + onResult = onResult, +) + +/** + * Creates and remembers a [PhotoResultLauncher] for taking a picture or video with the camera. + * + * @param openCameraSettings Platform-specific settings for the camera. + * @param onError Callback invoked when a valid camera operation cannot start or complete. It is not invoked for user + * dismissal, permission denial, coroutine cancellation, invalid invocations, or unexpected defects. + * @param onResult Callback invoked with the saved file, or null if dismissed or camera permission is denied. + * @return A [PhotoResultLauncher] that can be used to launch the camera. + */ +@Composable +public actual fun rememberCameraPickerLauncher( + openCameraSettings: FileKitOpenCameraSettings, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, ): PhotoResultLauncher { InitializeAndroidFileKit() @@ -409,17 +429,27 @@ public actual fun rememberCameraPickerLauncher( val context = LocalContext.current - // Updated callback + // Updated callbacks + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) + fun clearPendingState() { + pendingDestinationUri = null + pendingCameraFacingName = FileKitCameraFacing.System.name + hasPendingPermissionRequest = false + } + // Create a stable contract instance (reused across recompositions) val contract = remember { TakePictureWithCameraFacing() } // Create the launcher using the Activity Result API val launcher = rememberLauncherForActivityResult(contract) { success -> - val pendingUri = pendingDestinationUri ?: return@rememberLauncherForActivityResult - pendingDestinationUri = null - currentOnResult(resolveCameraResult(success, pendingUri)) + dispatchCameraResult( + success = success, + pendingDestinationUri = pendingDestinationUri, + clearPendingState = ::clearPendingState, + onResult = currentOnResult, + ) } val permissionLauncher = @@ -427,37 +457,23 @@ public actual fun rememberCameraPickerLauncher( if (!hasPendingPermissionRequest) return@rememberLauncherForActivityResult hasPendingPermissionRequest = false - when ( - val resolution = resolveCameraPermissionResult( + dispatchCameraPermissionResolution( + resolution = resolveCameraPermissionResult( permissionGranted = permissionGranted, pendingDestinationUri = pendingDestinationUri, - ) - ) { - CameraPermissionResolution.NoOp -> { - Unit - } - - CameraPermissionResolution.ReturnNullResult -> { - pendingDestinationUri = null - currentOnResult(null) - } - - is CameraPermissionResolution.LaunchCamera -> { + ), + launchCamera = { uri -> val cameraFacing = runCatching { FileKitCameraFacing.valueOf(pendingCameraFacingName) }.getOrDefault(FileKitCameraFacing.System) contract.setCameraFacing(cameraFacing) - val isLaunched = launchCameraSafely( - uri = resolution.uri, - launch = launcher::launch, - ) - if (!isLaunched) { - pendingDestinationUri = null - currentOnResult(null) - } - } - } + launchCameraSafely(uri = uri, launch = launcher::launch) + }, + clearPendingState = ::clearPendingState, + onError = currentOnError, + onResult = currentOnResult, + ) } // Return the PhotoResultLauncher wrapper @@ -470,7 +486,13 @@ public actual fun rememberCameraPickerLauncher( if (FileKitAndroidCameraPermissionInternal.needsRuntimeCameraPermission(context)) { hasPendingPermissionRequest = true - permissionLauncher.launch(Manifest.permission.CAMERA) + dispatchCameraLaunchResult( + result = launchCameraPermissionSafely { + permissionLauncher.launch(Manifest.permission.CAMERA) + }, + clearPendingState = ::clearPendingState, + onError = currentOnError, + ) return@PhotoResultLauncher } @@ -478,14 +500,14 @@ public actual fun rememberCameraPickerLauncher( contract.setCameraFacing(cameraFacing) // Launch the camera - val isLaunched = launchCameraSafely( - uri = uri, - launch = launcher::launch, + dispatchCameraLaunchResult( + result = launchCameraSafely( + uri = uri, + launch = launcher::launch, + ), + clearPendingState = ::clearPendingState, + onError = currentOnError, ) - if (!isLaunched) { - pendingDestinationUri = null - currentOnResult(null) - } } } } @@ -513,13 +535,80 @@ internal fun resolveCameraPermissionResult( internal fun launchCameraSafely( uri: Uri, launch: (Uri) -> Unit, -): Boolean = try { +): CameraLaunchResult = launchCameraActivitySafely( + activityUnavailableMessage = "No Android activity is available to capture media with the camera.", + securityFailureMessage = "Android rejected the camera launch.", +) { launch(uri) - true -} catch (_: ActivityNotFoundException) { - false -} catch (_: SecurityException) { - false +} + +internal fun launchCameraPermissionSafely( + launch: () -> Unit, +): CameraLaunchResult = launchCameraActivitySafely( + activityUnavailableMessage = "No Android activity is available to request camera permission.", + securityFailureMessage = "Android rejected the camera permission request.", + launch = launch, +) + +private fun launchCameraActivitySafely( + activityUnavailableMessage: String, + securityFailureMessage: String, + launch: () -> Unit, +): CameraLaunchResult = try { + launch() + CameraLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + CameraLaunchResult.Failed(FileKitDialogException(activityUnavailableMessage, failure)) +} catch (failure: SecurityException) { + CameraLaunchResult.Failed(FileKitDialogException(securityFailureMessage, failure)) +} + +internal sealed interface CameraLaunchResult { + data object Launched : CameraLaunchResult + + data class Failed( + val failure: FileKitDialogException, + ) : CameraLaunchResult +} + +internal fun dispatchCameraLaunchResult( + result: CameraLaunchResult, + clearPendingState: () -> Unit, + onError: (FileKitDialogException) -> Unit, +) { + when (result) { + CameraLaunchResult.Launched -> {} + + is CameraLaunchResult.Failed -> { + clearPendingState() + onError(result.failure) + } + } +} + +internal fun dispatchCameraPermissionResolution( + resolution: CameraPermissionResolution, + launchCamera: (Uri) -> CameraLaunchResult, + clearPendingState: () -> Unit, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + when (resolution) { + CameraPermissionResolution.NoOp -> {} + + CameraPermissionResolution.ReturnNullResult -> { + clearPendingState() + onResult(null) + } + + is CameraPermissionResolution.LaunchCamera -> { + dispatchCameraLaunchResult( + result = launchCamera(resolution.uri), + clearPendingState = clearPendingState, + onError = onError, + ) + } + } } internal fun launchFilePickerSafely( @@ -631,6 +720,19 @@ internal fun resolveCameraResult( return if (success) PlatformFile(uri.toUri()) else null } +internal fun dispatchCameraResult( + success: Boolean, + pendingDestinationUri: String?, + clearPendingState: () -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + if (pendingDestinationUri == null) return + + val result = resolveCameraResult(success, pendingDestinationUri) + clearPendingState() + onResult(result) +} + private data class PendingModeSnapshot( val modeId: String, val maxItems: Int?, diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 1226edfd..59d580d7 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -243,3 +243,15 @@ internal suspend fun runFileSaverLauncher( onResult = onResult, ) } + +internal suspend fun runCameraPickerLauncher( + openCameraPicker: suspend () -> PlatformFile?, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + runDialogOperation( + operation = openCameraPicker, + onError = onError, + onResult = onResult, + ) +} diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index 228633ce..8ba7293a 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -18,6 +18,51 @@ import kotlin.test.assertFalse import kotlin.test.assertSame class FileKitComposeFailureTest { + @Test + fun runCameraPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { + val failure = FileKitDialogException("The camera could not be opened.") + val reportedFailures = mutableListOf() + var resultInvoked = false + + runCameraPickerLauncher( + openCameraPicker = { throw failure }, + onError = reportedFailures::add, + onResult = { resultInvoked = true }, + ) + + assertEquals(listOf(failure), reportedFailures) + assertFalse(resultInvoked) + } + + @Test + fun runCameraPickerLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { + val results = mutableListOf() + var errorInvoked = false + + runCameraPickerLauncher( + openCameraPicker = { null }, + onError = { errorInvoked = true }, + onResult = results::add, + ) + + assertEquals(1, results.size) + assertEquals(null, results.single()) + assertFalse(errorInvoked) + } + + @Test + fun runCameraPickerLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { + var resultInvoked = false + + runCameraPickerLauncher( + openCameraPicker = { throw FileKitDialogException("Ignored compatibility failure") }, + onError = {}, + onResult = { resultInvoked = true }, + ) + + assertFalse(resultInvoked) + } + @Test fun runFileSaverLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitDialogException("The file saver could not be opened.") diff --git a/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt b/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt index 2e7221ab..e9a86a8a 100644 --- a/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt +++ b/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings import io.github.vinceglb.filekit.dialogs.openCameraPicker import kotlinx.coroutines.launch @@ -22,6 +23,26 @@ import kotlinx.coroutines.launch public actual fun rememberCameraPickerLauncher( openCameraSettings: FileKitOpenCameraSettings, onResult: (PlatformFile?) -> Unit, +): PhotoResultLauncher = rememberCameraPickerLauncher( + openCameraSettings = openCameraSettings, + onError = {}, + onResult = onResult, +) + +/** + * Creates and remembers a [PhotoResultLauncher] for taking a picture or video with the camera. + * + * @param openCameraSettings Platform-specific settings for the camera. + * @param onError Callback invoked when a valid camera operation cannot start or complete. It is not invoked for user + * dismissal, coroutine cancellation, invalid invocations, or unexpected defects. + * @param onResult Callback invoked with the saved file, or null if dismissed. + * @return A [PhotoResultLauncher] that can be used to launch the camera. + */ +@Composable +public actual fun rememberCameraPickerLauncher( + openCameraSettings: FileKitOpenCameraSettings, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, ): PhotoResultLauncher { // Coroutine val coroutineScope = rememberCoroutineScope() @@ -29,6 +50,7 @@ public actual fun rememberCameraPickerLauncher( // Updated state val currentOpenCameraSettings by rememberUpdatedState(stableOpenCameraSettings) + val currentOnError by rememberUpdatedState(onError) val currentOnResult by rememberUpdatedState(onResult) // FileKit @@ -38,13 +60,18 @@ public actual fun rememberCameraPickerLauncher( val returnedLauncher = remember { PhotoResultLauncher { type, cameraFacing, destinationFile -> coroutineScope.launch { - val result = fileKit.openCameraPicker( - type = type, - cameraFacing = cameraFacing, - destinationFile = destinationFile, - openCameraSettings = currentOpenCameraSettings, + runCameraPickerLauncher( + openCameraPicker = { + fileKit.openCameraPicker( + type = type, + cameraFacing = cameraFacing, + destinationFile = destinationFile, + openCameraSettings = currentOpenCameraSettings, + ) + }, + onError = currentOnError, + onResult = currentOnResult, ) - currentOnResult(result) } } } diff --git a/filekit-dialogs-compose/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherIosCallShape.kt b/filekit-dialogs-compose/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherIosCallShape.kt new file mode 100644 index 00000000..74776589 --- /dev/null +++ b/filekit-dialogs-compose/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherIosCallShape.kt @@ -0,0 +1,22 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings + +@Composable +private fun CompileIosCameraLauncherCallShapes() { + val settings = FileKitOpenCameraSettings.createDefault() + val legacy = rememberCameraPickerLauncher( + openCameraSettings = settings, + onResult = { _: PlatformFile? -> }, + ) + val explicit = rememberCameraPickerLauncher( + openCameraSettings = settings, + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) +} diff --git a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt index f43559d1..1d8c8740 100644 --- a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt +++ b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt @@ -1,3 +1,5 @@ +@file:Suppress("ktlint:compose:param-order-check") + package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable @@ -7,14 +9,36 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings import io.github.vinceglb.filekit.dialogs.FileKitShareSettings import io.github.vinceglb.filekit.dialogs.shareFile import kotlinx.coroutines.launch +/** + * Creates and remembers a camera launcher whose operational failures are ignored without logging. + * + * Use the overload with `onError` to observe failures. User dismissal and Android camera-permission denial remain + * nullable [onResult] outcomes. + */ +@Composable +public expect fun rememberCameraPickerLauncher( + openCameraSettings: FileKitOpenCameraSettings = FileKitOpenCameraSettings.createDefault(), + onResult: (PlatformFile?) -> Unit, +): PhotoResultLauncher + +/** + * Creates and remembers a camera launcher with explicit operational-failure handling. + * + * @param openCameraSettings Platform-specific settings for the camera. + * @param onError Callback invoked when a valid camera operation cannot start or complete. It is not invoked for user + * dismissal, Android camera-permission denial, coroutine cancellation, invalid invocations, or unexpected defects. + * @param onResult Callback invoked with the saved file, or null if dismissed or Android camera permission is denied. + */ @Composable public expect fun rememberCameraPickerLauncher( openCameraSettings: FileKitOpenCameraSettings = FileKitOpenCameraSettings.createDefault(), + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): PhotoResultLauncher diff --git a/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherCallShape.kt b/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherCallShape.kt new file mode 100644 index 00000000..daf927d3 --- /dev/null +++ b/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/CameraLauncherCallShape.kt @@ -0,0 +1,16 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException + +@Composable +private fun CompileCameraLauncherCallShapes() { + val legacy = rememberCameraPickerLauncher { _: PlatformFile? -> } + val explicit = rememberCameraPickerLauncher( + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) +} 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 5ff38cf4..fabfaaf8 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 @@ -252,6 +252,7 @@ internal fun requireAppleSaverPreparation( * @param destinationFile The file where the captured media will be saved. * @param openCameraSettings Platform-specific settings for the camera. * @return The saved file as a [PlatformFile], or null if canceled. + * @throws FileKitDialogException When a valid camera operation cannot start or complete. */ public actual suspend fun FileKit.openCameraPicker( type: FileKitCameraType, @@ -260,33 +261,59 @@ public actual suspend fun FileKit.openCameraPicker( openCameraSettings: FileKitOpenCameraSettings, ): PlatformFile? { val image = withContext(Dispatchers.Main) { + val cameraSource = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera + val requestedCamera = when (cameraFacing) { + FileKitCameraFacing.Front -> { + AppleCameraDeviceRequest( + device = UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceFront, + available = UIImagePickerController.isCameraDeviceAvailable( + UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceFront, + ), + unavailableMessage = "The requested front camera is not available on this device.", + ) + } + + FileKitCameraFacing.Back -> { + AppleCameraDeviceRequest( + device = UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceRear, + available = UIImagePickerController.isCameraDeviceAvailable( + UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceRear, + ), + unavailableMessage = "The requested rear camera is not available on this device.", + ) + } + + FileKitCameraFacing.System -> { + null + } + } + val presentation = prepareAppleCameraPresentation( + sourceAvailable = UIImagePickerController.isSourceTypeAvailable(cameraSource), + presenter = openCameraSettings.presenterViewController(), + requestedCamera = requestedCamera, + ) + suspendCancellableCoroutine { continuation -> cameraControllerDelegate = CameraControllerDelegate( onImagePicked = { image -> - continuation.resume(image) + try { + continuation.resume( + requireAppleCameraImage(image), + ) + } catch (failure: FileKitDialogException) { + continuation.resumeWithException(failure) + } }, + onPickerCancelled = { continuation.resume(null) }, ) val pickerController = UIImagePickerController() - pickerController.sourceType = - UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera + pickerController.sourceType = cameraSource pickerController.delegate = cameraControllerDelegate - when (cameraFacing) { - FileKitCameraFacing.Front -> { - pickerController.cameraDevice = - UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceFront - } - - FileKitCameraFacing.Back -> { - pickerController.cameraDevice = - UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceRear - } - - FileKitCameraFacing.System -> {} - } + presentation.cameraDevice?.let { pickerController.cameraDevice = it } - openCameraSettings.presenterViewController()?.presentViewController( + presentation.presenter.presentViewController( pickerController, animated = true, completion = null, @@ -297,18 +324,95 @@ public actual suspend fun FileKit.openCameraPicker( // Encode and write off the main thread: JPEG encoding a full-resolution photo // at quality 1.0 is expensive and used to freeze the UI right after the capture return withContext(Dispatchers.IO) { - // Convert UIImage to NSData (JPEG format with compression quality 1.0) - val imageData = UIImageJPEGRepresentation(image, 1.0) + completeAppleCameraCapture( + image = image, + destinationFile = destinationFile, + encodeImage = { capturedImage -> UIImageJPEGRepresentation(capturedImage, 1.0) }, + writeImage = { imageData, fileUrl -> imageData.writeToURL(fileUrl, true) }, + ) + } +} - // Create an NSURL for the file path - val fileUrl = NSURL.fileURLWithPath(destinationFile.path) +internal data class AppleCameraDeviceRequest( + val device: UIImagePickerControllerCameraDevice, + val available: Boolean, + val unavailableMessage: String, +) + +internal data class AppleCameraPresentation( + val presenter: UIViewController, + val cameraDevice: UIImagePickerControllerCameraDevice?, +) + +internal fun prepareAppleCameraPresentation( + sourceAvailable: Boolean, + presenter: UIViewController?, + requestedCamera: AppleCameraDeviceRequest?, +): AppleCameraPresentation { + requireAppleCameraAvailability( + available = sourceAvailable, + failureMessage = "The camera is not available on this device.", + ) + val availablePresenter = requireAppleCameraResource( + resource = presenter, + failureMessage = "No active view controller is available to present the camera.", + ) + requestedCamera?.let { request -> + requireAppleCameraAvailability( + available = request.available, + failureMessage = request.unavailableMessage, + ) + } - // Write the NSData to the file, returning the saved file on success - if (imageData?.writeToURL(fileUrl, true) == true) { - destinationFile - } else { - null - } + return AppleCameraPresentation( + presenter = availablePresenter, + cameraDevice = requestedCamera?.device, + ) +} + +internal fun completeAppleCameraCapture( + image: UIImage, + destinationFile: PlatformFile, + encodeImage: (UIImage) -> NSData?, + writeImage: (NSData, NSURL) -> Boolean, +): PlatformFile { + val imageData = requireAppleCameraResource( + resource = encodeImage(image), + failureMessage = "Failed to encode the captured image.", + ) + val fileUrl = NSURL.fileURLWithPath(destinationFile.path) + requireAppleCameraPreparation( + successful = writeImage(imageData, fileUrl), + failureMessage = "Failed to write the captured image to its destination.", + ) + return destinationFile +} + +internal fun requireAppleCameraImage(image: UIImage?): UIImage = requireAppleCameraResource( + resource = image, + failureMessage = "The camera completed without returning a captured image.", +) + +internal fun requireAppleCameraAvailability( + available: Boolean, + failureMessage: String, +) { + if (!available) { + throw FileKitDialogException(failureMessage) + } +} + +internal fun requireAppleCameraResource( + resource: T?, + failureMessage: String, +): T = resource ?: throw FileKitDialogException(failureMessage) + +internal fun requireAppleCameraPreparation( + successful: Boolean, + failureMessage: String, +) { + if (!successful) { + throw FileKitDialogException(failureMessage) } } diff --git a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraControllerDelegate.kt b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraControllerDelegate.kt index 8ba302a8..967acbb6 100644 --- a/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraControllerDelegate.kt +++ b/filekit-dialogs/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/util/CameraControllerDelegate.kt @@ -9,6 +9,7 @@ import platform.darwin.NSObject internal class CameraControllerDelegate( private val onImagePicked: (UIImage?) -> Unit, + private val onPickerCancelled: () -> Unit, ) : NSObject(), UIImagePickerControllerDelegateProtocol, UINavigationControllerDelegateProtocol { @@ -26,7 +27,7 @@ internal class CameraControllerDelegate( override fun imagePickerControllerDidCancel(picker: UIImagePickerController) { picker.dismissViewControllerAnimated(true) { - onImagePicked.invoke(null) + onPickerCancelled.invoke() } } } diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleCameraFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleCameraFailureTest.kt new file mode 100644 index 00000000..2c0dfe89 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleCameraFailureTest.kt @@ -0,0 +1,99 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.PlatformFile +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.UIKit.UIImage +import platform.UIKit.UIImagePickerControllerCameraDevice +import platform.UIKit.UIViewController +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AppleCameraFailureTest { + @Test + fun AppleCamera_unavailableSource_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + prepareAppleCameraPresentation( + sourceAvailable = false, + presenter = UIViewController(), + requestedCamera = null, + ) + } + + assertEquals("The camera is not available on this device.", failure.message) + } + + @Test + fun AppleCamera_unavailableRequestedDevice_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + prepareAppleCameraPresentation( + sourceAvailable = true, + presenter = UIViewController(), + requestedCamera = AppleCameraDeviceRequest( + device = UIImagePickerControllerCameraDevice.UIImagePickerControllerCameraDeviceFront, + available = false, + unavailableMessage = "The requested front camera is not available on this device.", + ), + ) + } + + assertEquals("The requested front camera is not available on this device.", failure.message) + } + + @Test + fun AppleCamera_missingPresenter_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + prepareAppleCameraPresentation( + sourceAvailable = true, + presenter = null, + requestedCamera = null, + ) + } + + assertEquals("No active view controller is available to present the camera.", failure.message) + } + + @Test + fun AppleCamera_missingCapturedImage_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + requireAppleCameraImage(null) + } + + assertEquals("The camera completed without returning a captured image.", failure.message) + } + + @Test + fun AppleCamera_failedImageEncoding_throwsDialogOperationalFailure() { + val destination = PlatformFile(NSURL.fileURLWithPath("/tmp/filekit-camera.jpg")) + + val failure = assertFailsWith { + completeAppleCameraCapture( + image = UIImage(), + destinationFile = destination, + encodeImage = { null }, + writeImage = { _, _ -> error("Write must not run when encoding fails") }, + ) + } + + assertEquals("Failed to encode the captured image.", failure.message) + } + + @Test + fun AppleCamera_failedDestinationWrite_throwsDialogOperationalFailure() { + val destination = PlatformFile(NSURL.fileURLWithPath("/tmp/filekit-camera.jpg")) + + val failure = assertFailsWith { + completeAppleCameraCapture( + image = UIImage(), + destinationFile = destination, + encodeImage = { NSData() }, + writeImage = { _, _ -> false }, + ) + } + + assertEquals("Failed to write the captured image to its destination.", failure.message) + } +} diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.kt index 6947f7dc..7ee0086b 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException internal enum class CameraFacingOption { System, @@ -17,5 +18,6 @@ internal interface CameraPickerLauncher { @Composable internal expect fun rememberCameraPickerLauncher( + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): CameraPickerLauncher diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerScreen.kt index cea247f6..7ff450bc 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerScreen.kt @@ -63,13 +63,21 @@ private fun CameraPickerScreen( var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var cameraFacing by remember { mutableStateOf(CameraFacingOption.System) } var capturedFiles by remember { mutableStateOf(emptyList()) } + var cameraError by remember { mutableStateOf(null) } - val cameraLauncher = rememberCameraPickerLauncher { file -> - buttonState = AppScreenHeaderButtonState.Enabled - if (file != null) { - capturedFiles = listOf(file) + capturedFiles - } - } + val cameraLauncher = rememberCameraPickerLauncher( + onError = { failure -> + buttonState = AppScreenHeaderButtonState.Enabled + cameraError = failure.message + }, + onResult = { file -> + buttonState = AppScreenHeaderButtonState.Enabled + cameraError = null + if (file != null) { + capturedFiles = listOf(file) + capturedFiles + } + }, + ) val isSupported = cameraLauncher.isSupported val primaryButtonText = if (isSupported) "Open Camera" else "Camera Unavailable" @@ -130,7 +138,7 @@ private fun CameraPickerScreen( item { AppPickerResultsCard( files = capturedFiles, - emptyText = "No photos captured yet", + emptyText = cameraError ?: "No photos captured yet", emptyIcon = LucideIcons.Camera, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.jvm.kt index 3684d9a0..360d161b 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.jvm.kt @@ -3,9 +3,11 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal actual fun rememberCameraPickerLauncher( + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): CameraPickerLauncher = remember { object : CameraPickerLauncher { diff --git a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.macos.kt b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.macos.kt index 3684d9a0..360d161b 100644 --- a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.macos.kt +++ b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.macos.kt @@ -3,9 +3,11 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal actual fun rememberCameraPickerLauncher( + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): CameraPickerLauncher = remember { object : CameraPickerLauncher { diff --git a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.mobile.kt b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.mobile.kt index a249870f..af63fb2a 100644 --- a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.mobile.kt +++ b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.mobile.kt @@ -4,13 +4,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitCameraFacing +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.compose.rememberCameraPickerLauncher as rememberFileKitCameraPickerLauncher @Composable internal actual fun rememberCameraPickerLauncher( + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): CameraPickerLauncher { - val launcher = rememberFileKitCameraPickerLauncher(onResult = onResult) + val launcher = rememberFileKitCameraPickerLauncher( + onError = onError, + onResult = onResult, + ) return remember(launcher) { object : CameraPickerLauncher { diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.web.kt index 3684d9a0..360d161b 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/camerapicker/CameraPickerLauncher.web.kt @@ -3,9 +3,11 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal actual fun rememberCameraPickerLauncher( + onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): CameraPickerLauncher = remember { object : CameraPickerLauncher { From fb1712647fdec987e6363a3820dc2c5de88a248c Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 00:31:26 +0200 Subject: [PATCH 06/40] =?UTF-8?q?=E2=9C=A8=20Make=20sharing=20launcher=20f?= =?UTF-8?q?ailures=20observable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/share-file.mdx | 28 +++++----- .../LegacySharingLauncherConsumer.java | 37 +++++++++++++ ...ySharingLauncherBinaryCompatibilityTest.kt | 24 +++++++++ .../resources/legacy-sharing-consumer.jar | Bin 0 -> 2816 bytes .../filekit/dialogs/compose/FileKitCompose.kt | 11 ++++ .../compose/FileKitComposeFailureTest.kt | 33 ++++++++++++ .../dialogs/compose/FileKitCompose.mobile.kt | 30 ++++++++++- .../compose/SharingLauncherCallShape.kt | 14 +++++ .../dialogs/AndroidSharingFailureTest.kt | 38 +++++++++++++ .../filekit/dialogs/FileKit.android.kt | 17 +++++- .../vinceglb/filekit/dialogs/FileKit.ios.kt | 40 +++++++++++--- .../dialogs/AppleSharingFailureTest.kt | 50 ++++++++++++++++++ .../filekit/dialogs/FileKit.mobile.kt | 10 ++++ .../ui/screens/sharefile/ShareFileLauncher.kt | 5 +- .../ui/screens/sharefile/ShareFileScreen.kt | 17 +++++- .../sharefile/ShareFileLauncher.jvm.kt | 5 +- .../sharefile/ShareFileLauncher.macos.kt | 5 +- .../sharefile/ShareFileLauncher.mobile.kt | 7 ++- .../sharefile/ShareFileLauncher.web.kt | 5 +- 19 files changed, 347 insertions(+), 29 deletions(-) create mode 100644 filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacySharingLauncherConsumer.java create mode 100644 filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacySharingLauncherBinaryCompatibilityTest.kt create mode 100644 filekit-dialogs-compose/src/androidHostTest/resources/legacy-sharing-consumer.jar create mode 100644 filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/SharingLauncherCallShape.kt create mode 100644 filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSharingFailureTest.kt diff --git a/docs/dialogs/share-file.mdx b/docs/dialogs/share-file.mdx index 973eec72..bfca2db4 100644 --- a/docs/dialogs/share-file.mdx +++ b/docs/dialogs/share-file.mdx @@ -23,17 +23,18 @@ val file = PlatformFile("/path/to/file.txt") // share a single file FileKit.shareFile(file) // share multiple files -FileKit.shareFiles(listOf(file1, file2)) +FileKit.shareFile(listOf(file1, file2)) ``` ```kotlin filekit-dialogs-compose -val launcher = rememberShareFileLauncher() +var shareError by remember { mutableStateOf(null) } +val launcher = rememberShareFileLauncher( + onError = { failure -> shareError = failure.message }, +) Button(onClick = { - // share a single file + // Successful sharing is callback-less. launcher.launch(file) - // share multiple files - launcher.launch(listOf(file1, file2)) }) { Text("Share file") } @@ -41,7 +42,8 @@ Button(onClick = { -Ensure the file you are sharing exists and is accessible. Sharing a non-existent file will result in an error. +The Compose launcher reports FileKit-owned operational failures through `onError`. Successful sharing remains +callback-less. The legacy overload without `onError` remains available and ignores normalized failures without logging. ## Android setup @@ -62,16 +64,16 @@ FileKit.shareFile( ``` ```kotlin filekit-dialogs-compose -val launcher = rememberShareFileLauncher() +val launcher = rememberShareFileLauncher( + shareSettings = FileKitShareSettings( + authority = "${context.packageName}.fileprovider", + ), + onError = { failure -> /* Show or log the failure */ }, +) val file = FileKit.filesDir / "my_file.txt" Button(onClick = { - launcher.launch( - file = file, - shareSettings = FileKitShareSettings( - authority = "${context.packageName}.fileprovider" - ) - ) + launcher.launch(file) }) { Text("Share file") } diff --git a/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacySharingLauncherConsumer.java b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacySharingLauncherConsumer.java new file mode 100644 index 00000000..74c2e59e --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacySharingLauncherConsumer.java @@ -0,0 +1,37 @@ +package io.github.vinceglb.filekit.dialogs.compose.compatibility; + +import androidx.compose.runtime.Composer; +import io.github.vinceglb.filekit.dialogs.FileKitShareSettings; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_mobileKt; + +/** + * Source for the class fixture in androidHostTest/resources. Compile this source only against the + * fixed-point FileKit artifacts so the runtime test proves that precompiled legacy sharing consumers + * still link. + */ +public final class LegacySharingLauncherConsumer { + private LegacySharingLauncherConsumer() {} + + public static void linkLegacyOverload() { + link(() -> FileKitCompose_mobileKt.rememberShareFileLauncher( + (FileKitShareSettings) null, + (Composer) null, + 0, + 0 + )); + } + + private static void link(LinkageCall call) { + try { + call.invoke(); + } catch (LinkageError failure) { + throw failure; + } catch (Throwable expectedEntryFailure) { + // Null arguments are intentional: reaching the entry point proves method resolution. + } + } + + private interface LinkageCall { + void invoke(); + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacySharingLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacySharingLauncherBinaryCompatibilityTest.kt new file mode 100644 index 00000000..8f07fbdf --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacySharingLauncherBinaryCompatibilityTest.kt @@ -0,0 +1,24 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import java.net.URLClassLoader +import kotlin.test.Test +import kotlin.test.assertNotNull + +class LegacySharingLauncherBinaryCompatibilityTest { + @Test + fun LegacySharingLauncher_precompiledConsumer_linksAgainstCurrentArtifacts() { + val fixture = assertNotNull(javaClass.getResource("/legacy-sharing-consumer.jar")) + + URLClassLoader(arrayOf(fixture), javaClass.classLoader).use { loader -> + val consumer = Class.forName( + "io.github.vinceglb.filekit.dialogs.compose.compatibility.LegacySharingLauncherConsumer", + true, + loader, + ) + + consumer.getMethod("linkLegacyOverload").invoke(null) + } + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/resources/legacy-sharing-consumer.jar b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-sharing-consumer.jar new file mode 100644 index 0000000000000000000000000000000000000000..a02613b0a215783217df6c3c526c06f3a7618e90 GIT binary patch literal 2816 zcmWIWW@h1HVBlb2*u0l5mH`QHGO#fCx`sIFdiuHP|2xIN5CBvv!ob17fuU3cs12^v z*U`_@%{4eg&)4m<@0rs+-nx1hdA)VD&Yd~GImqCO@q?#DdS1Rdp1v1LS8WM0FuH1d z!8qu`2QS@1Ew3|Yw8J#6XRKOW=SR3SnTN{gA!wXQqvQYD}yrU3$T1uFLoSJM;VB&p%8bI$jG2 zJ1ILxdcM7|B5+UQYpJb*$LFei)w{(iJ(Y8JzrfEGR-Vd!m*TGGY`wWY>TdV;t#W%Z zcgC~Hx66Jn-}=KZIxg$E*Bs%zZB`frynh@pMNxkO`_1V){SA&j z4b(ofIjqvy`O&Hsb&R0gP+#!6`w1{yn;G%t$QYW1JS4RDhMo<+>>%=A_o&-thOJy( z4H{y(ft~kPxbl^hr0^IdOWITvn7zHU&Uf?Nl$^%;VKFuyo+t^Y zS&4rWe2l(Sr%YKm>$kk))9b+}9?WQb92mBe>D@tTQ-ebWmsXcdFSX|gNqC@gc%xkY zTCo-4cfPhX2J;kG7rbM9*uSLjkAUhzQ%hkfiFAw8f4vr@JbhaJzj@mFROcH<`Q&C; z^m;O<>&!VSa`=&5(8aB{u6|jw@J@%rTWhI~wu~K09kN|Vwgn&lkmaxXFV=ze`TBz* z)24kh72EjlxKd7K)$PMfrpbY!8AsYyxFszWJ-SgWv{_NOG;{fr)w7j8h^#l@nPQ@U zeR}0)ZIfpo+oB)doV)3!Hcy)8)~2F4!fh*KZ>O8R;%K|l#qqUh&bbNGjxGway26;R z#~m9y)u8uDfY9cP3vP+&9k~%Ie7)fA0bXwN!tOU;s#b5d&r$z2G2{bp+%>I?$#akO z-Dnwl0_y z<-N~;=GO`du2V^`mY>{n+F_thED0X&fV4a=X9HMaOQW5 zQy-+-ia##?thP+m^Jf8%?i>ZLCC^0?=e}1dJnnJbqEP+BL}Ls8IU+*dY;EGayC#|~ zW_vBQ%OTPAyuk04z^dayo#itsK4m*(3Y`{w<1A-fsF%@KFwswJ?mC4=?;SqxWde;< zrYin=yQI@v Unit, + onError: (FileKitDialogException) -> Unit, +) { + runDialogOperation( + operation = shareFiles, + onError = onError, + onResult = {}, + ) +} diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index 8ba7293a..21a1e5a5 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -18,6 +18,39 @@ import kotlin.test.assertFalse import kotlin.test.assertSame class FileKitComposeFailureTest { + @Test + fun runShareFileLauncher_operationalFailure_invokesErrorOnce() = runTest { + val failure = FileKitDialogException("The share sheet could not be opened.") + val reportedFailures = mutableListOf() + + runShareFileLauncher( + shareFiles = { throw failure }, + onError = reportedFailures::add, + ) + + assertEquals(listOf(failure), reportedFailures) + } + + @Test + fun runShareFileLauncher_success_doesNotInvokeError() = runTest { + var errorInvoked = false + + runShareFileLauncher( + shareFiles = {}, + onError = { errorInvoked = true }, + ) + + assertFalse(errorInvoked) + } + + @Test + fun runShareFileLauncher_legacyIgnoredFailure_invokesNoCallback() = runTest { + runShareFileLauncher( + shareFiles = { throw FileKitDialogException("Ignored compatibility failure") }, + onError = {}, + ) + } + @Test fun runCameraPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitDialogException("The camera could not be opened.") diff --git a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt index 1d8c8740..5fa88c45 100644 --- a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt +++ b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt @@ -42,14 +42,39 @@ public expect fun rememberCameraPickerLauncher( onResult: (PlatformFile?) -> Unit, ): PhotoResultLauncher +/** + * Creates and remembers a sharing launcher whose operational failures are ignored without logging. + * + * Sharing success remains callback-less. Use the overload with `onError` to observe failures. + */ +@Composable +public fun rememberShareFileLauncher( + shareSettings: FileKitShareSettings = FileKitShareSettings.createDefault(), +): ShareResultLauncher = rememberShareFileLauncher( + shareSettings = shareSettings, + onError = {}, +) + +/** + * Creates and remembers a sharing launcher with explicit operational-failure handling. + * + * Sharing success remains callback-less. [onError] is invoked when a valid sharing operation cannot start or complete. + * It is not invoked for coroutine cancellation, invalid invocations, or unexpected defects. + * + * @param shareSettings Platform-specific settings for sharing. + * @param onError Callback invoked when a valid sharing operation cannot start or complete. + * @return A [ShareResultLauncher] that can be used to launch the share sheet. + */ @Composable public fun rememberShareFileLauncher( shareSettings: FileKitShareSettings = FileKitShareSettings.createDefault(), + onError: (FileKitDialogException) -> Unit, ): ShareResultLauncher { // Coroutine val coroutineScope = rememberCoroutineScope() val stableShareSettings = rememberStableShareSettings(shareSettings) val currentShareSettings by rememberUpdatedState(stableShareSettings) + val currentOnError by rememberUpdatedState(onError) // FileKit val fileKit = remember { FileKit } @@ -58,7 +83,10 @@ public fun rememberShareFileLauncher( val returnedLauncher = remember { ShareResultLauncher { files -> coroutineScope.launch { - fileKit.shareFile(files, currentShareSettings) + runShareFileLauncher( + shareFiles = { fileKit.shareFile(files, currentShareSettings) }, + onError = currentOnError, + ) } } } diff --git a/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/SharingLauncherCallShape.kt b/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/SharingLauncherCallShape.kt new file mode 100644 index 00000000..231954b2 --- /dev/null +++ b/filekit-dialogs-compose/src/mobileTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/SharingLauncherCallShape.kt @@ -0,0 +1,14 @@ +@file:Suppress("UNUSED_VARIABLE") + +package io.github.vinceglb.filekit.dialogs.compose + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.dialogs.FileKitDialogException + +@Composable +private fun CompileSharingLauncherCallShapes() { + val legacy = rememberShareFileLauncher() + val explicit = rememberShareFileLauncher( + onError = { _: FileKitDialogException -> }, + ) +} diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt new file mode 100644 index 00000000..1d966dbd --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt @@ -0,0 +1,38 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import android.content.ActivityNotFoundException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class AndroidSharingFailureTest { + @Test + fun AndroidSharing_missingActivity_throwsDialogOperationalFailureWithCause() { + val platformFailure = ActivityNotFoundException("No sharing activity") + + val failure = assertFailsWith { + launchAndroidShareIntent { + throw platformFailure + } + } + + assertEquals("No Android activity is available to share the selected files.", failure.message) + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidSharing_unexpectedFailure_propagates() { + val platformFailure = IllegalStateException("Unexpected sharing defect") + + val failure = assertFailsWith { + launchAndroidShareIntent { + throw platformFailure + } + } + + assertSame(platformFailure, failure) + } +} diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index 8bf6fb15..bd3d7709 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -259,6 +259,7 @@ public class TakePictureWithCameraFacing( * * @param file The file to share. * @param shareSettings Platform-specific settings for sharing. + * @throws FileKitDialogException When no Android activity is available to share the file. */ public actual suspend fun FileKit.shareFile( file: PlatformFile, @@ -275,6 +276,7 @@ public actual suspend fun FileKit.shareFile( * * @param files The list of files to share. * @param shareSettings Platform-specific settings for sharing. + * @throws FileKitDialogException When no Android activity is available to share the files. */ public actual suspend fun FileKit.shareFile( files: List, @@ -322,7 +324,20 @@ public actual suspend fun FileKit.shareFile( } shareSettings.addOptionChooseIntent(chooseIntent) - context.startActivity(chooseIntent) + launchAndroidShareIntent { + context.startActivity(chooseIntent) + } +} + +internal fun launchAndroidShareIntent(launch: () -> Unit) { + try { + launch() + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = "No Android activity is available to share the selected files.", + cause = failure, + ) + } } /** 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 fabfaaf8..8aaa8569 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 @@ -421,6 +421,7 @@ internal fun requireAppleCameraPreparation( * * @param file The file to share. * @param shareSettings Platform-specific settings for sharing. + * @throws FileKitDialogException When the share sheet cannot be presented or the selected activity reports an error. */ @OptIn(ExperimentalForeignApi::class) public actual suspend fun FileKit.shareFile( @@ -438,6 +439,7 @@ public actual suspend fun FileKit.shareFile( * * @param files The list of files to share. * @param shareSettings Platform-specific settings for sharing. + * @throws FileKitDialogException When the share sheet cannot be presented or the selected activity reports an error. */ @OptIn(ExperimentalForeignApi::class) public actual suspend fun FileKit.shareFile( @@ -446,7 +448,7 @@ public actual suspend fun FileKit.shareFile( ) { if (files.isEmpty()) return - val viewController = shareSettings.presenterViewController() ?: return + val viewController = requireAppleSharePresenter(shareSettings.presenterViewController()) files.forEach { it.startAccessingSecurityScopedResource() } // Ensure we always pass a file URL to the activity items; otherwise iOS may treat the @@ -466,14 +468,38 @@ public actual suspend fun FileKit.shareFile( shareSettings.addOptionUIActivityViewController(shareVC) - shareVC.setCompletionWithItemsHandler { _, _, _, _ -> - files.forEach { it.stopAccessingSecurityScopedResource() } + suspendCancellableCoroutine { continuation -> + shareVC.setCompletionWithItemsHandler { _, _, _, error -> + files.forEach { it.stopAccessingSecurityScopedResource() } + if (continuation.isActive) { + val failure = appleShareCompletionFailure(error) + if (failure == null) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(failure) + } + } + } + + viewController.presentViewController( + viewControllerToPresent = shareVC, + animated = true, + completion = null, + ) } +} - viewController.presentViewController( - viewControllerToPresent = shareVC, - animated = true, - completion = null, +internal fun requireAppleSharePresenter(presenter: UIViewController?): UIViewController = presenter + ?: throw FileKitDialogException("No active view controller is available to present the share sheet.") + +internal class AppleShareExceptionCause( + val error: NSError, +) : Exception(error.localizedDescription) + +internal fun appleShareCompletionFailure(error: NSError?): FileKitDialogException? = error?.let { + FileKitDialogException( + message = "The share operation failed: ${it.localizedDescription}", + cause = AppleShareExceptionCause(it), ) } diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSharingFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSharingFailureTest.kt new file mode 100644 index 00000000..50360ed8 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSharingFailureTest.kt @@ -0,0 +1,50 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import platform.Foundation.NSError +import platform.UIKit.UIViewController +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame + +class AppleSharingFailureTest { + @Test + fun AppleSharing_completionError_returnsDialogOperationalFailureWithCause() { + val nativeError = NSError.errorWithDomain( + domain = "io.github.vinceglb.filekit.tests", + code = 42, + userInfo = null, + ) + + val failure = assertIs(appleShareCompletionFailure(nativeError)) + + assertEquals("The share operation failed: ${nativeError.localizedDescription}", failure.message) + val cause = assertIs(failure.cause) + assertSame(nativeError, cause.error) + } + + @Test + fun AppleSharing_completionWithoutError_returnsNoFailure() { + assertNull(appleShareCompletionFailure(null)) + } + + @Test + fun AppleSharing_missingPresenter_throwsDialogOperationalFailure() { + val failure = assertFailsWith { + requireAppleSharePresenter(null) + } + + assertEquals("No active view controller is available to present the share sheet.", failure.message) + } + + @Test + fun AppleSharing_availablePresenter_isReturned() { + val presenter = UIViewController() + + assertSame(presenter, requireAppleSharePresenter(presenter)) + } +} diff --git a/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt b/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt index 5cf7288d..c70a3d12 100644 --- a/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt +++ b/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt @@ -21,11 +21,21 @@ public expect suspend fun FileKit.openCameraPicker( openCameraSettings: FileKitOpenCameraSettings = FileKitOpenCameraSettings.createDefault(), ): PlatformFile? +/** + * Shares [file] with the platform share sheet. + * + * @throws FileKitDialogException When a valid sharing operation cannot start or complete. + */ public expect suspend fun FileKit.shareFile( file: PlatformFile, shareSettings: FileKitShareSettings = FileKitShareSettings.createDefault(), ) +/** + * Shares [files] with the platform share sheet. + * + * @throws FileKitDialogException When a valid sharing operation cannot start or complete. + */ public expect suspend fun FileKit.shareFile( files: List, shareSettings: FileKitShareSettings = FileKitShareSettings.createDefault(), diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.kt index 1bfcd471..45ea6933 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.sharefile import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException internal interface ShareFileLauncher { val isSupported: Boolean @@ -10,4 +11,6 @@ internal interface ShareFileLauncher { } @Composable -internal expect fun rememberShareFileLauncher(): ShareFileLauncher +internal expect fun rememberShareFileLauncher( + onError: (FileKitDialogException) -> Unit, +): ShareFileLauncher diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt index d257d594..98ee5dc0 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt @@ -65,8 +65,11 @@ private fun ShareFileScreen( val buttonState = AppScreenHeaderButtonState.Enabled var pickerMode by remember { mutableStateOf(ShareMode.Multiple) } var selectedFiles by remember { mutableStateOf(emptyList()) } + var shareError by remember { mutableStateOf(null) } - val shareLauncher = rememberShareFileLauncher() + val shareLauncher = rememberShareFileLauncher( + onError = { failure -> shareError = failure.message }, + ) val isSupported = shareLauncher.isSupported val primaryButtonText = when (selectedFiles.size) { 0 -> "Share File" @@ -99,6 +102,7 @@ private fun ShareFileScreen( if (!isSupported || selectedFiles.isEmpty()) { return } + shareError = null shareLauncher.launch(selectedFiles) } @@ -157,6 +161,17 @@ private fun ShareFileScreen( } } + shareError?.let { failureMessage -> + item { + Text( + text = failureMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), + ) + } + } + item { AppPickerResultsCard( files = selectedFiles, diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.jvm.kt index fb9094d5..4aad964b 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.jvm.kt @@ -3,9 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.sharefile import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable -internal actual fun rememberShareFileLauncher(): ShareFileLauncher = remember { +internal actual fun rememberShareFileLauncher( + onError: (FileKitDialogException) -> Unit, +): ShareFileLauncher = remember { object : ShareFileLauncher { override val isSupported: Boolean = false diff --git a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.macos.kt b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.macos.kt index fb9094d5..4aad964b 100644 --- a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.macos.kt +++ b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.macos.kt @@ -3,9 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.sharefile import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable -internal actual fun rememberShareFileLauncher(): ShareFileLauncher = remember { +internal actual fun rememberShareFileLauncher( + onError: (FileKitDialogException) -> Unit, +): ShareFileLauncher = remember { object : ShareFileLauncher { override val isSupported: Boolean = false diff --git a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.mobile.kt b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.mobile.kt index 007a913e..1a4e7152 100644 --- a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.mobile.kt +++ b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.mobile.kt @@ -3,11 +3,14 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.sharefile import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.compose.rememberShareFileLauncher as rememberFileKitShareLauncher @Composable -internal actual fun rememberShareFileLauncher(): ShareFileLauncher { - val launcher = rememberFileKitShareLauncher() +internal actual fun rememberShareFileLauncher( + onError: (FileKitDialogException) -> Unit, +): ShareFileLauncher { + val launcher = rememberFileKitShareLauncher(onError = onError) return remember(launcher) { object : ShareFileLauncher { diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.web.kt index fb9094d5..4aad964b 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileLauncher.web.kt @@ -3,9 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.sharefile import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable -internal actual fun rememberShareFileLauncher(): ShareFileLauncher = remember { +internal actual fun rememberShareFileLauncher( + onError: (FileKitDialogException) -> Unit, +): ShareFileLauncher = remember { object : ShareFileLauncher { override val isSupported: Boolean = false From 4de163789dd17b8250b40528e4bce8246bd10c0a Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 00:41:30 +0200 Subject: [PATCH 07/40] =?UTF-8?q?=E2=9C=85=20Verify=20launcher=20error=20c?= =?UTF-8?q?ontract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/bookmark-data.mdx | 19 +-- docs/dialogs/camera-picker.mdx | 2 + docs/dialogs/directory-picker.mdx | 2 + docs/dialogs/error-handling.mdx | 39 ++++++ docs/dialogs/file-picker.mdx | 42 +++---- docs/dialogs/file-saver.mdx | 2 + docs/dialogs/gallery-picker.mdx | 2 + docs/dialogs/share-file.mdx | 2 + docs/docs.json | 3 +- docs/quickstart.mdx | 27 +++-- .../LegacyAndroidLauncherConsumer.java | 113 ++++++++++++++++++ ...yAndroidLauncherBinaryCompatibilityTest.kt | 26 ++++ .../legacy-android-launcher-consumer.jar | Bin 0 -> 2537 bytes .../filekit/dialogs/compose/FileKitCompose.kt | 3 + .../LegacyPickerLauncherConsumer.java | 71 +++++++++++ ...cyPickerLauncherBinaryCompatibilityTest.kt | 2 + ...cyPickerLauncherConsumer$LinkageCall.class | Bin 384 -> 384 bytes .../LegacyPickerLauncherConsumer.class | Bin 4434 -> 7623 bytes .../dialogs/compose/FileKitCompose.mobile.kt | 2 + .../dialogs/compose/FileKitCompose.nonWeb.kt | 1 + .../vinceglb/filekit/dialogs/FileKit.ios.kt | 59 +++------ .../filekit/dialogs/AppleSaverFailureTest.kt | 6 +- .../dialogs/FileKitDialogParent.jvm.kt | 10 +- .../dialogs/platform/awt/AwtDialogParent.kt | 13 +- .../dialogs/FileKitDialogParentTest.kt | 10 +- .../platform/awt/AwtDialogOwnerTest.kt | 12 +- .../platform/linux/LinuxFilePickerTest.kt | 9 +- .../ui/screens/bookmarks/BookmarksScreen.kt | 48 +++++--- .../shared/ui/screens/debug/DebugScreen.kt | 56 ++++++--- .../ui/screens/filepicker/FilePickerScreen.kt | 13 +- .../ui/screens/filesaver/FileSaverScreen.kt | 12 +- .../gallerypicker/GalleryPickerScreen.kt | 1 + .../components/GalleryPickerDirectory.kt | 2 + .../ui/screens/sharefile/ShareFileScreen.kt | 18 ++- .../components/GalleryPickerDirectory.jvm.kt | 11 +- .../GalleryPickerDirectory.macos.kt | 11 +- .../GalleryPickerDirectory.mobile.kt | 2 + .../components/GalleryPickerDirectory.web.kt | 2 + 38 files changed, 487 insertions(+), 166 deletions(-) create mode 100644 docs/dialogs/error-handling.mdx create mode 100644 filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyAndroidLauncherConsumer.java create mode 100644 filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyAndroidLauncherBinaryCompatibilityTest.kt create mode 100644 filekit-dialogs-compose/src/androidHostTest/resources/legacy-android-launcher-consumer.jar diff --git a/docs/core/bookmark-data.mdx b/docs/core/bookmark-data.mdx index 56f9504c..5ae7e999 100644 --- a/docs/core/bookmark-data.mdx +++ b/docs/core/bookmark-data.mdx @@ -120,15 +120,18 @@ fun MyScreen() { file = BookmarkManager.load() } - val picker = rememberFilePickerLauncher { pickedFile -> - file = pickedFile - // Save the bookmark in a coroutine - pickedFile?.let { - coroutineScope.launch { - BookmarkManager.save(it) + val picker = rememberFilePickerLauncher( + onError = { failure -> println("Picker failed: ${failure.message}") }, + onResult = { pickedFile -> + file = pickedFile + // Save the bookmark in a coroutine + pickedFile?.let { + coroutineScope.launch { + BookmarkManager.save(it) + } } - } - } + }, + ) // UI to show file details and a button to launch the picker Column { diff --git a/docs/dialogs/camera-picker.mdx b/docs/dialogs/camera-picker.mdx index 91dc7749..93040c41 100644 --- a/docs/dialogs/camera-picker.mdx +++ b/docs/dialogs/camera-picker.mdx @@ -42,6 +42,8 @@ Button(onClick = { launcher.launch() }) { On iOS, the suspending `FileKit.openCameraPicker()` function throws the same `FileKitDialogException` for operational failures. Android's lifecycle-safe Compose launcher owns the Android launch-failure callback behavior described above. The compatibility Compose overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + The captured media file is automatically saved to the specified location (or cache directory by default). If you need to keep the file permanently, make sure to copy it to a permanent storage location. ## Android camera permission behavior diff --git a/docs/dialogs/directory-picker.mdx b/docs/dialogs/directory-picker.mdx index d6e29448..673bd694 100644 --- a/docs/dialogs/directory-picker.mdx +++ b/docs/dialogs/directory-picker.mdx @@ -40,6 +40,8 @@ Button(onClick = { launcher.launch() }) { The compatibility overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + ## Customizing the dialog You can customize the dialog by setting the initial directory and platform-specific `dialogSettings`, such as a title on supported platforms. diff --git a/docs/dialogs/error-handling.mdx b/docs/dialogs/error-handling.mdx new file mode 100644 index 00000000..0d17a2c2 --- /dev/null +++ b/docs/dialogs/error-handling.mdx @@ -0,0 +1,39 @@ +--- +title: 'Dialog error handling' +sidebarTitle: 'Error handling' +description: 'Handle FileKit dialog results, cancellation, and operational failures' +--- + +FileKit keeps successful results, user cancellation, operational failures, and programmer errors separate. New Compose integrations should use launcher overloads with an explicit `onError` callback. + +## Callback contract + +| Outcome | `onResult` | `onError` | Propagates | +| --- | --- | --- | --- | +| A value-returning picker, directory picker, saver, or camera succeeds | Called once with the value | Not called | No | +| The user dismisses a value-returning dialog | Called once with `null` | Not called | No | +| Android camera permission is denied | Called once with `null` | Not called | No | +| A valid FileKit operation cannot start or complete | Not called | Called once with a `FileKitDialogException` subtype | No | +| The launcher coroutine is cancelled | Not called | Not called | Cancellation | +| The invocation is invalid or an unexpected defect occurs | Not called | Not called | The original exception | +| Your `onResult` or `onError` callback throws | No compensating callback is delivered | No compensating callback is delivered | The callback exception | + +`FileKitPickerException` is the picker-specific subtype of `FileKitDialogException`. FileKit does not convert invalid arguments, unsupported argument combinations, or unexpected defects into operational failures. + +## State-tracking picker modes + +`SingleWithState` and `MultipleWithState` report progress and their represented terminal outcome through `onResult`: + +- `Started` and `Progress` are non-terminal values. +- Exactly one of `Completed`, `Cancelled`, or `Failed` is the represented terminal value. +- `FileKitPickerState.Failed` remains data delivered to `onResult`; it is not duplicated through `onError`. +- A picker failure thrown outside the state stream is delivered to `onError`. +- Coroutine cancellation stops delivery and produces no later terminal callback. + +## Sharing + +Sharing has no success callback. A successful share launch, including user dismissal after the system share UI appears, remains callback-less. A FileKit-owned operational failure is delivered to `onError`; coroutine cancellation, invalid invocation, unexpected defects, and callback exceptions propagate. + +## Compatibility overloads + +Launcher overloads without `onError` remain available for source and binary compatibility. They preserve their historical callback shape and ignore normalized operational failures without logging. They do not swallow coroutine cancellation, invalid invocation, unexpected defects, or exceptions thrown by your callbacks. diff --git a/docs/dialogs/file-picker.mdx b/docs/dialogs/file-picker.mdx index 5374030c..de4f5396 100644 --- a/docs/dialogs/file-picker.mdx +++ b/docs/dialogs/file-picker.mdx @@ -16,9 +16,12 @@ val file = FileKit.openFilePicker() ``` ```kotlin filekit-dialogs-compose -val launcher = rememberFilePickerLauncher { file -> - // Handle the file -} +val launcher = rememberFilePickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the file, or null when the user cancelled + }, +) Button(onClick = { launcher.launch() }) { Text("Pick a file") @@ -26,20 +29,9 @@ Button(onClick = { launcher.launch() }) { ``` -The short Compose form above is kept for source and binary compatibility. It ignores operational picker failures without logging. -For new integrations, provide `onError` explicitly: +The shorter Compose overload without `onError` remains available for source and binary compatibility. It ignores operational picker failures without logging. New integrations should use the explicit form shown above. -```kotlin -val launcher = rememberFilePickerLauncher( - onError = { failure -> - // The valid picker operation could not be completed. - println("Picker failed: ${failure.message}") - }, - onResult = { file -> - // A file was selected, or the user cancelled when file is null. - }, -) -``` +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. On iOS, remember FileKit Compose launchers from a stable/root Compose scope, not @@ -175,10 +167,12 @@ val file = FileKit.openFilePicker(type = FileKitType.File(listOf("pdf", "docx")) ```kotlin filekit-dialogs-compose val launcher = rememberFilePickerLauncher( - type = FileKitType.File(extensions = listOf("pdf", "docx")) -) { file -> - // Handle the pdf or docx file -} + type = FileKitType.File(extensions = listOf("pdf", "docx")), + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the pdf or docx file, or null when the user cancelled + }, +) ``` @@ -200,9 +194,11 @@ val file = FileKit.openFilePicker( val launcher = rememberFilePickerLauncher( directory = PlatformFile("/custom/initial/path"), dialogSettings = FileKitDialogSettings.createDefault(), -) { file -> - // Handle the file -} + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the file, or null when the user cancelled + }, +) ``` diff --git a/docs/dialogs/file-saver.mdx b/docs/dialogs/file-saver.mdx index e78d1826..a516544f 100644 --- a/docs/dialogs/file-saver.mdx +++ b/docs/dialogs/file-saver.mdx @@ -62,6 +62,8 @@ Button(onClick = { The compatibility overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + ## Parameters The file saver can be customized with several parameters: diff --git a/docs/dialogs/gallery-picker.mdx b/docs/dialogs/gallery-picker.mdx index 686b15b1..a01d25db 100644 --- a/docs/dialogs/gallery-picker.mdx +++ b/docs/dialogs/gallery-picker.mdx @@ -40,6 +40,8 @@ val launcher = rememberFilePickerLauncher( ``` +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + On iOS, remember FileKit Compose launchers from a stable/root Compose scope, not inside transient surfaces such as `ModalBottomSheet`, dialogs, popups, or diff --git a/docs/dialogs/share-file.mdx b/docs/dialogs/share-file.mdx index bfca2db4..59d1f174 100644 --- a/docs/dialogs/share-file.mdx +++ b/docs/dialogs/share-file.mdx @@ -46,6 +46,8 @@ The Compose launcher reports FileKit-owned operational failures through `onError callback-less. The legacy overload without `onError` remains available and ignores normalized failures without logging. +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + ## Android setup diff --git a/docs/docs.json b/docs/docs.json index d4ca5973..0a282e54 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -37,6 +37,7 @@ "group": "FileKit Dialogs", "pages": [ "dialogs/setup", + "dialogs/error-handling", "dialogs/file-picker", "dialogs/gallery-picker", "dialogs/directory-picker", @@ -87,4 +88,4 @@ "apiKey": "f4fd93ebc8fad4322b99fa1d99a6815" } } -} \ No newline at end of file +} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 0846668f..8872f07e 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -31,9 +31,12 @@ val imageFile = FileKit.openFilePicker(type = FileKitType.Image) ```kotlin filekit-dialogs-compose // Pick a single file -val launcher = rememberFilePickerLauncher { file -> - // Handle the selected file -} +val launcher = rememberFilePickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the selected file, or null when the user cancelled + }, +) Button(onClick = { launcher.launch() }) { Text("Pick a file") @@ -55,9 +58,12 @@ val directory = FileKit.openDirectoryPicker() ```kotlin filekit-dialogs-compose // Pick a single directory -val launcher = rememberDirectoryPickerLauncher { directory -> - // Handle the selected directory -} +val launcher = rememberDirectoryPickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { directory -> + // Handle the selected directory, or null when the user cancelled + }, +) Button(onClick = { launcher.launch() }) { Text("Pick a directory") @@ -79,9 +85,12 @@ val imageFile = FileKit.openCameraPicker() ```kotlin filekit-dialogs-compose // Pick a single image -val launcher = rememberCameraPickerLauncher { imageFile -> - // Handle the selected image -} +val launcher = rememberCameraPickerLauncher( + onError = { failure -> showError(failure.message) }, + onResult = { imageFile -> + // Handle the selected image, or null when the user dismissed the camera + }, +) Button(onClick = { launcher.launch() }) { Text("Pick an image") diff --git a/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyAndroidLauncherConsumer.java b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyAndroidLauncherConsumer.java new file mode 100644 index 00000000..5b754b1f --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyAndroidLauncherConsumer.java @@ -0,0 +1,113 @@ +package io.github.vinceglb.filekit.dialogs.compose.compatibility; + +import androidx.compose.runtime.Composer; +import io.github.vinceglb.filekit.PlatformFile; +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings; +import io.github.vinceglb.filekit.dialogs.FileKitMode; +import io.github.vinceglb.filekit.dialogs.FileKitOpenCameraSettings; +import io.github.vinceglb.filekit.dialogs.FileKitPickerException; +import io.github.vinceglb.filekit.dialogs.FileKitShareSettings; +import io.github.vinceglb.filekit.dialogs.FileKitType; +import io.github.vinceglb.filekit.dialogs.compose.FileKitComposeKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_androidKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_mobileKt; +import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_nonWebKt; +import kotlin.Unit; +import kotlin.jvm.functions.Function1; + +/** + * Source for the class fixture in androidHostTest/resources. Compile this source only against the + * fixed-point FileKit artifacts so the runtime test proves that precompiled consumers of every + * legacy Android launcher family still link. + */ +public final class LegacyAndroidLauncherConsumer { + private LegacyAndroidLauncherConsumer() {} + + public static int legacyOverloadCount() { + return 8; + } + + public static void linkLegacyOverloads() { + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_androidKt.rememberDirectoryPickerLauncher( + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_nonWebKt.rememberFileSaverLauncher( + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0 + )); + link(() -> FileKitCompose_androidKt.rememberCameraPickerLauncher( + (FileKitOpenCameraSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_mobileKt.rememberShareFileLauncher( + (FileKitShareSettings) null, + (Composer) null, + 0, + 0 + )); + } + + private static void link(LinkageCall call) { + try { + call.invoke(); + } catch (LinkageError failure) { + throw failure; + } catch (Throwable expectedEntryFailure) { + // Null arguments are intentional: reaching the entry point proves method resolution. + } + } + + private interface LinkageCall { + void invoke(); + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyAndroidLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyAndroidLauncherBinaryCompatibilityTest.kt new file mode 100644 index 00000000..086d6721 --- /dev/null +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyAndroidLauncherBinaryCompatibilityTest.kt @@ -0,0 +1,26 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import java.net.URLClassLoader +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class LegacyAndroidLauncherBinaryCompatibilityTest { + @Test + fun LegacyAndroidLaunchers_precompiledConsumer_linksEveryLegacyFamilyAgainstCurrentArtifacts() { + val fixture = assertNotNull(javaClass.getResource("/legacy-android-launcher-consumer.jar")) + + URLClassLoader(arrayOf(fixture), javaClass.classLoader).use { loader -> + val consumer = Class.forName( + "io.github.vinceglb.filekit.dialogs.compose.compatibility.LegacyAndroidLauncherConsumer", + true, + loader, + ) + + assertEquals(8, consumer.getMethod("legacyOverloadCount").invoke(null)) + consumer.getMethod("linkLegacyOverloads").invoke(null) + } + } +} diff --git a/filekit-dialogs-compose/src/androidHostTest/resources/legacy-android-launcher-consumer.jar b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-android-launcher-consumer.jar new file mode 100644 index 0000000000000000000000000000000000000000..a651c2b96018dc16aadb492c12d20be50165f56c GIT binary patch literal 2537 zcmcJRdoum!D8ltRl*_GF>)GxmNC? z5=v5Y3CX2g7M0tmm4x4Xzvq+nU_w7Dzl7^I_PD|FD+N}w%4RXD5%N+jbmijhSr?M-4~ z*n=}CdLGrA9*rJCjanV!H*uF6m+QF*?Y>C}DM&v5M9sTOzgO7^xZelU(%!2-XBQUL zMSw$}!3Q)rY?K|G1LSYFcnM1EJ4V!pL+)=zez{F%4bqwWgWuf@?+9qWrwUWJEGTnT z1g6y;-`)wEy>;-m?|E|PtAyGN*ftDW5yNf1($APx()^s>;%KAOvIkD|xMfPrwZ7)( z$DGLie7Rb&YLGsEr0K!uBpaOgHRAlrJJjC8fwm~NiJZs2m{#^ z&U^F9h^Gpw6)>P-Lv?54b$DH9?1QZaySZg^N}yff3Vv2#rP%U7{^S)1K`XF!V}s*F z$6Zf5=npV+^bS{iW&QhxomB_ z-bY(>aW7b3jo1XahIZb9*#YJ7RM(pMO|x7WWxM(2t~%IGyz#c@Wqw#3IB&7n5-1@c zgWK+@OM+7`$UcxuXsioV{+6|O#J57HA2A+7760+$uEL#}i`OhqJe5fvd(9h@Z%E-u zGO+hbts0HaYN}rA;fpH7-02T`_E6tgrUgH>f9~V_<-PvmbMeI%%C#G#Hswx-(bS1= z)VzKAhX->$V+ELuE|3`77@@4SsXEZenQX&#n0MwVD38_BR9BYY*LNU34{$DrUDPnBbgDcL2`$Un_n%W|dxYfFRHPPBM z(vUoY+kNShUHm~BBg2dnUkRM}!PI|fP3RB6naO+cg0PO0`9~IU&wRvVu0SH*I=i88vYf-x$?g=dSCfp78siGdOb2E8C?p*(+ za9pzjQ(YQybE}S1k<8nVT$OM`J$KMitf}(D3=NOI^CI@ob(_&0zSi}$l&Y83D(xSZ zrt7CV;)WEGT*|A~70^{mzI*QKQk6ef8C~g5)DDW?c^5Gfys1VGRCT6%A)+O3(gHu$ znRoohd8eL*(9@idr3sGMNov^*IeLJB=`grr82D8{d}DWJGrMD#6+|s9X8t?%u!b)3 zNZ+!UiE*@yLHStTYkavfBnqGK>CHa9+4Go2k+dDks4Q*IHK|$dVlpKuxk@SPK==k# zawGA6&Eo0s98^=^)6HIb1AUq)QRCIM;Fkrtp*x-5B2gE6%(3Hn3%!S$>cSE>p!gm) z-b`Qbc9fPBYl<>EoM&nq*(0_G881n>v1qqpJav|D&V%PhYdUY%8s&9iq#F`};xcQahu4YgZC#gXKYo#1@JaI31c~s-#zV?=6G|> z0rBIRTMyob;3&!X*74Lx5!IiYZS$ra`H+2SSXwS z#3=_B&4kVRBEL7qLg|3mLbVT=4XP~5gnSnL~3`=pg*hKj%Lp%SL<~h1*cD|94%C@=nw)b!tB!mYSNdRKCp%IG8T=il zuHFCh$`TEz*?Pq6R-U};IRs&GuU1@B>9^ipYMORWes1s(8U04rZ@K6?Y9$~|t#75# zJERi}af7WFtMv2LS8P}_bgW@SIM&l5D}xsil?MDfHwe>K=@%OOO%WoZih$o7vD)2N zIO4bTgyFBFtEgh7`05*0Z^Q}({8ouD{F6-Zzn!ce(aHw?R-MG3>g1pM=!{+?tOgL` Qn>gUA@MoC_;SK=!FAgjs`~Uy| literal 0 HcmV?d00001 diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index aa8cd2b7..059eaa64 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -52,6 +52,7 @@ public fun rememberFilePickerLauncher( * coroutine cancellation, invalid invocations, unexpected defects, or [io.github.vinceglb.filekit.dialogs.FileKitPickerState.Failed] * values delivered by state-tracking modes. * @param onResult Callback invoked with the result. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable @@ -109,6 +110,7 @@ public fun rememberFilePickerLauncher( * @param onError Callback invoked when a valid picker operation cannot complete. It is not invoked for user cancellation, * coroutine cancellation, invalid invocations, or unexpected defects. * @param onResult Callback invoked with the picked file, or null if cancelled. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable @@ -210,6 +212,7 @@ public expect fun rememberDirectoryPickerLauncher( * @param onError Callback invoked when a valid directory operation cannot complete. It is not invoked for user cancellation, * coroutine cancellation, invalid invocations, or unexpected defects. * @param onResult Callback invoked with the picked directory, or null if cancelled. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable diff --git a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java index 8490a55b..b7f8cbd9 100644 --- a/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java +++ b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java @@ -1,9 +1,11 @@ package io.github.vinceglb.filekit.dialogs.compose.compatibility; import androidx.compose.runtime.Composer; +import androidx.compose.ui.window.WindowScope; import io.github.vinceglb.filekit.PlatformFile; import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings; import io.github.vinceglb.filekit.dialogs.FileKitMode; +import io.github.vinceglb.filekit.dialogs.FileKitPickerException; import io.github.vinceglb.filekit.dialogs.FileKitType; import io.github.vinceglb.filekit.dialogs.compose.FileKitComposeKt; import io.github.vinceglb.filekit.dialogs.compose.FileKitCompose_jvmKt; @@ -19,6 +21,10 @@ public final class LegacyPickerLauncherConsumer { private LegacyPickerLauncherConsumer() {} + public static int legacyOverloadCount() { + return 12; + } + public static void linkLegacyOverloads() { link(() -> FileKitComposeKt.rememberFilePickerLauncher( (FileKitType) null, @@ -39,6 +45,71 @@ public static void linkLegacyOverloads() { 0, 0 )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitComposeKt.rememberFilePickerLauncher( + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberFilePickerLauncher( + (WindowScope) null, + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberFilePickerLauncher( + (WindowScope) null, + (FileKitType) null, + (FileKitMode) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberFilePickerLauncher( + (WindowScope) null, + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberFilePickerLauncher( + (WindowScope) null, + (FileKitType) null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Function1) null, + (Composer) null, + 0, + 0 + )); link(() -> FileKitCompose_nonAndroidKt.rememberDirectoryPickerLauncher( (PlatformFile) null, (FileKitDialogSettings) null, diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt index b64d8904..a10442a4 100644 --- a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit.dialogs.compose import kotlin.test.Test +import kotlin.test.assertEquals class LegacyPickerLauncherBinaryCompatibilityTest { @Test @@ -11,6 +12,7 @@ class LegacyPickerLauncherBinaryCompatibilityTest { "io.github.vinceglb.filekit.dialogs.compose.compatibility.LegacyPickerLauncherConsumer", ) + assertEquals(12, consumer.getMethod("legacyOverloadCount").invoke(null)) consumer.getMethod("linkLegacyOverloads").invoke(null) } } diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class index 8b1d18017319ae6583f06426af60f3c5d4e70990..ea54534a882544d9da02335598783ec06670ad69 100644 GIT binary patch delta 17 YcmZo*ZeZp(^>5cc1_lPljT{w>05~}X!T5cc1_lP(jT{w>05~NDz5oCK diff --git a/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class index 7c04c393ac3585c6333491e45db763044cd4f602..1734057ea857f007838c20c68f76f089be26e2d0 100644 GIT binary patch literal 7623 zcmeHM>sJ#;6#tE27Qj8p0HIQXQiEcvt!|PfEZOYhW&>j1_GRC; zzN&q{Yfr!Usn!pzpL_a`^z?t|>7B_Yge2_23bggG=VWH?-o1D3{O-Loo4ddLb?X-Z zd+~!Gb5J6oRK{HR7%B$UVO23yGo~C24{8yI!M9U4b!QhtNnQPsg(OubV;=m((9K~h zt}$qJ^+DZIV!AVs3M<398PQ@!Sn1adEv`FCR96ivmQ*5EV#rErqE{U~tQ)#Bssy!| z8X4`;BXP|Rswp!vpxJGfnM@@#yC$fcaW$s3sfH2oL&kgw_sLj*3WmeeG9yE&L31+n z*Ui9^IGzk;3&{u6l<=mMH*m9JI5jAXR64o=cOv1Jfij*$h*(U-eq&bdm#*z|h8CJ$E zha92|4kwg;3Jyo7&`>(uQL}`4hBc}gwJkk*IvZhj%5?OErnIFa&i11b8znp_V-pmH z-IFjW6RfUjuBH=YM;GNprL85jL|C&qJERVeg)YM%b-@CIQo1stn^9{-=@VTjVhw45 z$wXLDXaYfRSOSF>)wIk$EzFU< zJ*m8#K2+`+6baiHYCWLnF;u7DvJ-p_JTO~i?7+het1>aQPq%5~wd}l2k+DTn5?Q(P z|F1$YeMS_-;7&XuVHXE+H$#U9@cHME8$_AZrWo9t-bi>H{^=Qq_ZEf{_A+dk+@>X= zjbYOyP}kU^CZtp1b$r2CPGdk^x_Hbg(n${ zPbrRhxX*4tdH<|>ah&7+6k}CWiu+8x?99yJ%QuP>9H*0vbxkQw|JC@M8C;CA-}_$-QvyO?#-q@_Fi*u^=4Cr#7laMH+!=;n_%-& zPN?}qR?3cO{7(^;%)F|un|LXYq5OcBbh<^Ai=0@|YFSRwvDKk&%^9$w$qM)u_%O<= zvS(!2f_j!y&&fC?s24c(qKqLyo#oWaGAu#8%Bk06n1VXTsW)UK1oalD-j-nq>RnE~ zCnGMX_c`@}j6p$NO&a=g1W}3k7UFI^@$&!;xh@K%lHCcGOQgdSEj4j zN>BtPEl%xMMfHUcNaRw#*98lN(pc=Lq%^c-P0+I9si8p5V-U=p-iQ2epOIU9~`LqtrAsDJ)?eZ!%tv0XuuGrma~B+RO5~yN@MxDanDB!TunS5!MK+0aEz7{1ze=|DseB7uFJ%|LY7>k*+3r) zN;)O1Zk0%)!q2n8ooJ&F1Qs)P8#VOXK)whUJ|K-lnW z9SQHDUu&L{?ArGgDC#Fgv=h0Cwv%)DwQ2%I0>J$RN{*8f+T2_vyU9!Zs?AZ-HIYT# z1!8N_O;mcJyX;^iFnkK0CUQ9bL|juF~i$nxB6qtR@@2_WAJ* JZ4B2@`8N|4RrCM= delta 1171 zcmZvbO;1xn6o%jFZ9C=mQfgDUg+ieqiWjg26p+eS6%?tZh=_`!MG^VNsw8aeUog(b zf8bWqh3GlnZAV2KOO;{)fdx(%me+x$RWB z8$BGo8v4-B5G$m9^&AoFu8=L*kZVyZ89PTvo7Rm1nK{VVaD&)(B}5-{i7h)9uZw?) z8h@3Dm-@tl-^VJ&Gk;Kwm;RofP@&bubU?*X~ zw-4Nev6stIG7uwBZZ3bgsCf}UK$eW`qp57mUfiTxiKa$dbi~@Qei(fEAe@CkG(8%N zZnJ6!iS9Z~;p{;L$ghmvpe4{j{gc0HOCWxj$oYcE`pz5C>9$6Rvr&h}XttA{j;mz# rkoH3;7{@gldFw{Ox>d056s+ljHAB`4Y4}K2r;e{WRXoCDJVp6`eyOLj diff --git a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt index 5fa88c45..c9b77aa0 100644 --- a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt +++ b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt @@ -34,6 +34,7 @@ public expect fun rememberCameraPickerLauncher( * @param onError Callback invoked when a valid camera operation cannot start or complete. It is not invoked for user * dismissal, Android camera-permission denial, coroutine cancellation, invalid invocations, or unexpected defects. * @param onResult Callback invoked with the saved file, or null if dismissed or Android camera permission is denied. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. */ @Composable public expect fun rememberCameraPickerLauncher( @@ -60,6 +61,7 @@ public fun rememberShareFileLauncher( * * Sharing success remains callback-less. [onError] is invoked when a valid sharing operation cannot start or complete. * It is not invoked for coroutine cancellation, invalid invocations, or unexpected defects. + * Exceptions thrown by [onError] propagate. * * @param shareSettings Platform-specific settings for sharing. * @param onError Callback invoked when a valid sharing operation cannot start or complete. diff --git a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt index 324a917c..ce45c68d 100644 --- a/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt +++ b/filekit-dialogs-compose/src/nonWebMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonWeb.kt @@ -32,6 +32,7 @@ public fun rememberFileSaverLauncher( * @param onError Callback invoked when a valid file-saving operation cannot complete. It is not invoked for user * cancellation, coroutine cancellation, invalid invocations, or unexpected defects. * @param onResult Callback invoked with the saved file path, or null if cancelled. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [SaverResultLauncher] that can be used to launch the saver. */ @Composable 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 8aaa8569..85d63d7f 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 @@ -183,7 +183,7 @@ internal actual suspend fun FileKit.platformOpenFileSaver( extension = normalizedDefaultExtension, ) - val presenter = requireAppleSaverResource( + val presenter = requireAppleDialogResource( resource = dialogSettings.presenterViewController(), failureMessage = "No active view controller is available to present the file saver.", ) @@ -192,21 +192,21 @@ internal actual suspend fun FileKit.platformOpenFileSaver( val fileManager = NSFileManager.defaultManager // Get the temporary directory - val fileComponents = requireAppleSaverResource( + val fileComponents = requireAppleDialogResource( resource = fileManager.temporaryDirectory.pathComponents?.plus(fileName), failureMessage = "Failed to prepare a temporary file path for saving.", ) // Create a file URL - val fileUrl = requireAppleSaverResource( + val fileUrl = requireAppleDialogResource( resource = NSURL.fileURLWithPathComponents(fileComponents), failureMessage = "Failed to create a temporary file URL for saving.", ) // Write an empty string to the file to ensure it exists val emptyData = NSData() - requireAppleSaverPreparation( - successful = emptyData.writeToURL(fileUrl, true), + requireAppleDialogCondition( + satisfied = emptyData.writeToURL(fileUrl, true), failureMessage = "Failed to write the temporary file for saving.", ) @@ -230,16 +230,16 @@ internal actual suspend fun FileKit.platformOpenFileSaver( } } -internal fun requireAppleSaverResource( +internal fun requireAppleDialogResource( resource: T?, failureMessage: String, ): T = resource ?: throw FileKitDialogException(failureMessage) -internal fun requireAppleSaverPreparation( - successful: Boolean, +internal fun requireAppleDialogCondition( + satisfied: Boolean, failureMessage: String, ) { - if (!successful) { + if (!satisfied) { throw FileKitDialogException(failureMessage) } } @@ -349,17 +349,17 @@ internal fun prepareAppleCameraPresentation( presenter: UIViewController?, requestedCamera: AppleCameraDeviceRequest?, ): AppleCameraPresentation { - requireAppleCameraAvailability( - available = sourceAvailable, + requireAppleDialogCondition( + satisfied = sourceAvailable, failureMessage = "The camera is not available on this device.", ) - val availablePresenter = requireAppleCameraResource( + val availablePresenter = requireAppleDialogResource( resource = presenter, failureMessage = "No active view controller is available to present the camera.", ) requestedCamera?.let { request -> - requireAppleCameraAvailability( - available = request.available, + requireAppleDialogCondition( + satisfied = request.available, failureMessage = request.unavailableMessage, ) } @@ -376,46 +376,23 @@ internal fun completeAppleCameraCapture( encodeImage: (UIImage) -> NSData?, writeImage: (NSData, NSURL) -> Boolean, ): PlatformFile { - val imageData = requireAppleCameraResource( + val imageData = requireAppleDialogResource( resource = encodeImage(image), failureMessage = "Failed to encode the captured image.", ) val fileUrl = NSURL.fileURLWithPath(destinationFile.path) - requireAppleCameraPreparation( - successful = writeImage(imageData, fileUrl), + requireAppleDialogCondition( + satisfied = writeImage(imageData, fileUrl), failureMessage = "Failed to write the captured image to its destination.", ) return destinationFile } -internal fun requireAppleCameraImage(image: UIImage?): UIImage = requireAppleCameraResource( +internal fun requireAppleCameraImage(image: UIImage?): UIImage = requireAppleDialogResource( resource = image, failureMessage = "The camera completed without returning a captured image.", ) -internal fun requireAppleCameraAvailability( - available: Boolean, - failureMessage: String, -) { - if (!available) { - throw FileKitDialogException(failureMessage) - } -} - -internal fun requireAppleCameraResource( - resource: T?, - failureMessage: String, -): T = resource ?: throw FileKitDialogException(failureMessage) - -internal fun requireAppleCameraPreparation( - successful: Boolean, - failureMessage: String, -) { - if (!successful) { - throw FileKitDialogException(failureMessage) - } -} - /** * Shares a file using the iOS share sheet. * diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt index 199cff4a..7bc65488 100644 --- a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/AppleSaverFailureTest.kt @@ -11,7 +11,7 @@ class AppleSaverFailureTest { @Test fun AppleSaver_missingPreparationResource_throwsDialogOperationalFailure() { val failure = assertFailsWith { - requireAppleSaverResource( + requireAppleDialogResource( resource = null, failureMessage = "Failed to prepare a temporary file for saving.", ) @@ -23,8 +23,8 @@ class AppleSaverFailureTest { @Test fun AppleSaver_failedPreparationOperation_throwsDialogOperationalFailure() { val failure = assertFailsWith { - requireAppleSaverPreparation( - successful = false, + requireAppleDialogCondition( + satisfied = false, failureMessage = "Failed to write the temporary file for saving.", ) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt index 635f558f..45c9e8ce 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt @@ -146,7 +146,7 @@ public sealed class FileKitDialogParent { private fun unsupportedParent( adapter: String, supported: String, - ): Nothing = throw FileKitPickerException( + ): Nothing = throw IllegalArgumentException( "$adapter does not support ${kindName()} dialog parents. Supported parents: $supported.", ) @@ -181,14 +181,14 @@ internal fun resolveAwtNativeIdentifier( val identifier = try { conversion() } catch (cause: Exception) { - throw FileKitPickerException( - message = "The AWT dialog parent could not resolve to a usable $identifierName.", - cause = cause, + throw IllegalArgumentException( + "The AWT dialog parent could not resolve to a usable $identifierName.", + cause, ) } if (identifier == 0L) { - throw FileKitPickerException( + throw IllegalArgumentException( "The AWT dialog parent resolved to an invalid zero $identifierName.", ) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt index 4ab3d743..848fa9dd 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt @@ -1,7 +1,6 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.dialogs.FileKitDialogParent -import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import java.awt.Dialog import java.awt.Frame @@ -9,14 +8,16 @@ import java.awt.Window internal fun FileKitDialogParent?.resolveAwtFileDialogOwner(): Window? { val window = requireAwtWindowOrNull("AWT file dialogs") ?: return null - if (!isSupportedAwtFileDialogOwner(window.javaClass)) { - throw FileKitPickerException( - "AWT file dialogs require an AWT Frame or Dialog parent.", - ) - } + requireSupportedAwtFileDialogOwner(window.javaClass) return window } +internal fun requireSupportedAwtFileDialogOwner(windowClass: Class) { + require(isSupportedAwtFileDialogOwner(windowClass)) { + "AWT file dialogs require an AWT Frame or Dialog parent." + } +} + internal fun isSupportedAwtFileDialogOwner( windowClass: Class, ): Boolean = Frame::class.java.isAssignableFrom(windowClass) || diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt index bbf9e591..a67c7582 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt @@ -93,8 +93,8 @@ class FileKitDialogParentTest { } @Test - fun FileKitDialogParent_withIncompatibleAdapter_throwsPickerExceptionWithoutRawValue() { - val error = assertFailsWith { + fun FileKitDialogParent_withIncompatibleAdapter_throwsInvalidInvocationWithoutRawValue() { + val error = assertFailsWith { FileKitDialogParent.windows(0x1234).resolveXdgPortalParent { error("unused") } } @@ -110,13 +110,13 @@ class FileKitDialogParentTest { } @Test - fun AwtNativeIdentifier_withZeroOrException_throwsPickerException() { - assertFailsWith { + fun AwtNativeIdentifier_withZeroOrException_throwsInvalidInvocation() { + assertFailsWith { resolveAwtNativeIdentifier("Windows HWND") { 0 } } val cause = IllegalStateException("Component must be displayable") - val error = assertFailsWith { + val error = assertFailsWith { resolveAwtNativeIdentifier("X11 XID") { throw cause } } diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt index 67156dc3..876a845c 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt @@ -4,7 +4,6 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings -import io.github.vinceglb.filekit.dialogs.FileKitPickerException import kotlinx.coroutines.test.runTest import java.awt.Dialog import java.awt.Frame @@ -22,9 +21,16 @@ class AwtDialogOwnerTest { assertFalse(isSupportedAwtFileDialogOwner(Window::class.java)) } + @Test + fun requireSupportedAwtFileDialogOwner_withUnsupportedWindow_throwsInvalidInvocation() { + assertFailsWith { + requireSupportedAwtFileDialogOwner(Window::class.java) + } + } + @Test fun AwtFilePicker_withNativeParent_failsBeforeOpeningUi() = runTest { - assertFailsWith { + assertFailsWith { AwtFilePicker().openFilePicker( fileExtensions = null, directory = null, @@ -37,7 +43,7 @@ class AwtDialogOwnerTest { @Test fun AwtFileSaver_withNativeParent_failsBeforeOpeningUi() = runTest { - assertFailsWith { + assertFailsWith { AwtFileSaver.saveFile( suggestedName = "example", defaultExtension = "txt", diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt index 5a2cb35f..460c956b 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt @@ -5,7 +5,6 @@ package io.github.vinceglb.filekit.dialogs.platform.linux import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings -import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.awt.AwtFilePicker import io.github.vinceglb.filekit.dialogs.platform.swing.SwingFilePicker @@ -59,16 +58,16 @@ class LinuxFilePickerTest { ) val settings = FileKitDialogSettings(parent = FileKitDialogParent.x11(42)) - assertFailsWith { + assertFailsWith { picker.openFilePicker(null, null, settings) } - assertFailsWith { + assertFailsWith { picker.openFilesPicker(null, null, settings) } - assertFailsWith { + assertFailsWith { picker.openDirectoryPicker(null, settings) } - assertFailsWith { + assertFailsWith { picker.openFileSaver("example", "txt", null, null, settings) } } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt index 26d32fd8..636a4c0d 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/bookmarks/BookmarksScreen.kt @@ -68,27 +68,41 @@ private fun BookmarksScreen( var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var bookmarkedFile by remember { mutableStateOf(null) } var bookmarkedDirectory by remember { mutableStateOf(null) } + var pickerError by remember { mutableStateOf(null) } - val filePickerLauncher = rememberFilePickerLauncher { file -> - scope.launch { - if (file != null) { - storage.save(BookmarkKind.File, file) - bookmarkedFile = file - } + val filePickerLauncher = rememberFilePickerLauncher( + onError = { failure -> buttonState = AppScreenHeaderButtonState.Enabled - } - } + pickerError = failure.message + }, + onResult = { file -> + pickerError = null + scope.launch { + if (file != null) { + storage.save(BookmarkKind.File, file) + bookmarkedFile = file + } + buttonState = AppScreenHeaderButtonState.Enabled + } + }, + ) val directoryPickerLauncher = rememberDirectoryPickerLauncher( directory = bookmarkedDirectory, - ) { directory -> - scope.launch { - if (directory != null) { - storage.save(BookmarkKind.Directory, directory) - bookmarkedDirectory = directory - } + onError = { failure -> buttonState = AppScreenHeaderButtonState.Enabled - } - } + pickerError = failure.message + }, + onResult = { directory -> + pickerError = null + scope.launch { + if (directory != null) { + storage.save(BookmarkKind.Directory, directory) + bookmarkedDirectory = directory + } + buttonState = AppScreenHeaderButtonState.Enabled + } + }, + ) LaunchedEffect(storage) { bookmarkedFile = storage.load(BookmarkKind.File) @@ -182,7 +196,7 @@ private fun BookmarksScreen( item { AppPickerResultsCard( files = bookmarkedItems, - emptyText = "No bookmarks saved yet", + emptyText = pickerError ?: "No bookmarks saved yet", emptyIcon = LucideIcons.BookOpenText, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/debug/DebugScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/debug/DebugScreen.kt index 9a7cf1cf..9295854a 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/debug/DebugScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/debug/DebugScreen.kt @@ -59,34 +59,54 @@ private fun DebugScreen( ) { var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var files by remember { mutableStateOf(emptyList()) } + var pickerError by remember { mutableStateOf(null) } var showPickerReproSheet by remember { mutableStateOf(false) } var launchImagePickerAfterSheetDismiss by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val pickerReproSheetState = rememberModalBottomSheetState() - val picker = rememberFilePickerLauncher { file -> - buttonState = AppScreenHeaderButtonState.Enabled - files = file?.let(::listOf) ?: emptyList() + val picker = rememberFilePickerLauncher( + onError = { failure -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = failure.message + }, + onResult = { file -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = null + files = file?.let(::listOf) ?: emptyList() - scope.launch { - file?.let { debugPlatformTest(it) } - } - } + scope.launch { + file?.let { debugPlatformTest(it) } + } + }, + ) val imagePicker = rememberFilePickerLauncher( type = FileKitType.Image, mode = FileKitMode.Multiple(), - ) { pickedFiles -> - files = pickedFiles ?: emptyList() - } + onError = { failure -> pickerError = failure.message }, + onResult = { pickedFiles -> + pickerError = null + files = pickedFiles ?: emptyList() + }, + ) - val folderPicker = rememberDirectoryPickerLauncher(directory = null) { folder -> - scope.launch { - folder?.let { - debugPlatformTest(folder) - // bookmarkFolder(folder) + val folderPicker = rememberDirectoryPickerLauncher( + directory = null, + onError = { failure -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = failure.message + }, + onResult = { folder -> + buttonState = AppScreenHeaderButtonState.Enabled + pickerError = null + scope.launch { + folder?.let { + debugPlatformTest(folder) + // bookmarkFolder(folder) + } } - } - } + }, + ) fun test() { scope.launch { @@ -151,7 +171,7 @@ private fun DebugScreen( item { AppPickerResultsCard( files = files, - emptyText = "No files selected yet", + emptyText = pickerError ?: "No files selected yet", emptyIcon = LucideIcons.MessageCircleCode, onFileClick = onDisplayFileDetails, modifier = Modifier.sizeIn(maxWidth = AppMaxWidth), 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 4a771b0e..07137964 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 @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException @@ -88,11 +89,13 @@ private fun FilePickerScreen( val startDirectoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, dialogSettings = dialogSettings, - ) { directory -> - if (directory != null) { - startDirectory = directory - } - } + onError = { failure: FileKitDialogException -> pickerError = failure.message }, + onResult = { directory -> + if (directory != null) { + startDirectory = directory + } + }, + ) val resolvedType = resolveFilePickerType(customExtensions) diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt index 0c637825..5d5ca630 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt @@ -94,11 +94,13 @@ private fun FileSaverScreen( val directoryPickerLauncher = rememberDirectoryPickerLauncher( directory = saveDirectory, dialogSettings = dialogSettings, - ) { directory -> - if (directory != null) { - saveDirectory = directory - } - } + onError = onSaverError, + onResult = { directory -> + if (directory != null) { + saveDirectory = directory + } + }, + ) val isSupported = fileSaverLauncher.isSupported val primaryButtonText = if (isSupported) "Save File" else "File Saver Unavailable" 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 a2d53073..bda3b002 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 @@ -240,6 +240,7 @@ private fun GalleryPickerScreen( GalleryPickerDirectory( directory = pickerDirectory, + onError = { failure -> pickerError = failure.message }, onPickDirectory = { pickerDirectory = it }, ) } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.kt index 63b6f67c..c23631c9 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.kt @@ -3,10 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.gallerypicker.compon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal expect fun GalleryPickerDirectory( directory: PlatformFile?, + onError: (FileKitDialogException) -> Unit, onPickDirectory: (directory: PlatformFile?) -> Unit, modifier: Modifier = Modifier, ) diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt index 98ee5dc0..d197b72d 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/sharefile/ShareFileScreen.kt @@ -80,16 +80,22 @@ private fun ShareFileScreen( val singlePicker = rememberFilePickerLauncher( type = FileKitType.File(), mode = FileKitMode.Single, - ) { file -> - selectedFiles = file?.let(::listOf) ?: emptyList() - } + onError = { failure -> shareError = failure.message }, + onResult = { file -> + shareError = null + selectedFiles = file?.let(::listOf) ?: emptyList() + }, + ) val multiplePicker = rememberFilePickerLauncher( type = FileKitType.File(), mode = FileKitMode.Multiple(), - ) { files -> - selectedFiles = files ?: emptyList() - } + onError = { failure -> shareError = failure.message }, + onResult = { files -> + shareError = null + selectedFiles = files ?: emptyList() + }, + ) fun pickFiles() { when (pickerMode) { diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.jvm.kt index fc364a57..529ab150 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.jvm.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.gallerypicker.compon import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.ui.Modifier +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSelectionButton @@ -11,12 +12,16 @@ import io.github.vinceglb.filekit.sample.shared.ui.icons.LucideIcons @androidx.compose.runtime.Composable internal actual fun GalleryPickerDirectory( directory: io.github.vinceglb.filekit.PlatformFile?, + onError: (FileKitDialogException) -> Unit, onPickDirectory: (directory: io.github.vinceglb.filekit.PlatformFile?) -> Unit, modifier: Modifier, ) { - val directoryPicker = rememberDirectoryPickerLauncher { pickedDirectory -> - pickedDirectory?.let { onPickDirectory(pickedDirectory) } - } + val directoryPicker = rememberDirectoryPickerLauncher( + onError = onError, + onResult = { pickedDirectory -> + pickedDirectory?.let { onPickDirectory(pickedDirectory) } + }, + ) AppPickerSelectionButton( label = "Directory", diff --git a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.macos.kt b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.macos.kt index 730b5b8c..d8a4466b 100644 --- a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.macos.kt +++ b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.macos.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.sample.shared.ui.components.AppPickerSelectionButton @@ -13,12 +14,16 @@ import io.github.vinceglb.filekit.sample.shared.ui.icons.LucideIcons @Composable internal actual fun GalleryPickerDirectory( directory: PlatformFile?, + onError: (FileKitDialogException) -> Unit, onPickDirectory: (directory: PlatformFile?) -> Unit, modifier: Modifier, ) { - val directoryPicker = rememberDirectoryPickerLauncher { pickedDirectory -> - pickedDirectory?.let { onPickDirectory(pickedDirectory) } - } + val directoryPicker = rememberDirectoryPickerLauncher( + onError = onError, + onResult = { pickedDirectory -> + pickedDirectory?.let { onPickDirectory(pickedDirectory) } + }, + ) AppPickerSelectionButton( label = "Directory", diff --git a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.mobile.kt b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.mobile.kt index d56f325b..9d3aff07 100644 --- a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.mobile.kt +++ b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.mobile.kt @@ -3,10 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.gallerypicker.compon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal actual fun GalleryPickerDirectory( directory: PlatformFile?, + onError: (FileKitDialogException) -> Unit, onPickDirectory: (directory: PlatformFile?) -> Unit, modifier: Modifier, ) { diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.web.kt index 051c9842..68497e66 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/gallerypicker/components/GalleryPickerDirectory.web.kt @@ -3,10 +3,12 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.gallerypicker.compon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException @Composable internal actual fun GalleryPickerDirectory( directory: PlatformFile?, + onError: (FileKitDialogException) -> Unit, onPickDirectory: (directory: PlatformFile?) -> Unit, modifier: Modifier, ) {} From d9bf00f732e7d7b1095bfa5b984e6062d910b6f2 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 20:43:47 +0200 Subject: [PATCH 08/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20JVM=20Windows?= =?UTF-8?q?=20picker=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/windows/WindowsFilePicker.kt | 114 +++++++++++------- .../windows/WindowsFilePickerFailureTest.kt | 79 ++++++++++++ 2 files changed, 147 insertions(+), 46 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 8a27f335..e64d006a 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -6,6 +6,7 @@ import com.sun.jna.WString import com.sun.jna.platform.win32.COM.COMUtils.FAILED import com.sun.jna.platform.win32.Guid import com.sun.jna.platform.win32.Ole32 +import com.sun.jna.platform.win32.W32Errors.HRESULT_FROM_WIN32 import com.sun.jna.platform.win32.WTypes import com.sun.jna.platform.win32.Win32Exception import com.sun.jna.platform.win32.WinDef @@ -18,6 +19,7 @@ import com.sun.jna.ptr.PointerByReference import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileDialog import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileOpenDialog @@ -46,53 +48,61 @@ internal class WindowsFilePicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = useFileDialog(FileDialogType.Open) { fileOpenDialog -> - // Set the initial directory - directory?.let { fileOpenDialog.setDefaultPath(it) } + ): File? = try { + useFileDialog(FileDialogType.Open) { fileOpenDialog -> + // Set the initial directory + directory?.let { fileOpenDialog.setDefaultPath(it) } - // Set title - dialogSettings.title?.let { - fileOpenDialog - .SetTitle(WString(dialogSettings.title)) - .verify("SetTitle failed") - } + // Set title + dialogSettings.title?.let { + fileOpenDialog + .SetTitle(WString(dialogSettings.title)) + .verify("SetTitle failed") + } - // Add filters - fileExtensions - ?.takeIf { it.isNotEmpty() } - ?.let { fileOpenDialog.addFiltersToDialog(it) } + // Add filters + fileExtensions + ?.takeIf { it.isNotEmpty() } + ?.let { fileOpenDialog.addFiltersToDialog(it) } - fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { - it.getResult(SIGDN_FILESYSPATH) + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { + it.getResult(SIGDN_FILESYSPATH) + } } + } catch (failure: WindowsDialogOperationalException) { + throw failure.toFilePickerFailure() } override suspend fun openFilesPicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): List? = useFileDialog(FileDialogType.Open) { fileOpenDialog -> - // Set the initial directory - directory?.let { fileOpenDialog.setDefaultPath(it) } + ): List? = try { + useFileDialog(FileDialogType.Open) { fileOpenDialog -> + // Set the initial directory + directory?.let { fileOpenDialog.setDefaultPath(it) } - // Set title - dialogSettings.title?.let { - fileOpenDialog - .SetTitle(WString(dialogSettings.title)) - .verify("SetTitle failed") - } + // Set title + dialogSettings.title?.let { + fileOpenDialog + .SetTitle(WString(dialogSettings.title)) + .verify("SetTitle failed") + } - // Add filters - fileExtensions - ?.takeIf { it.isNotEmpty() } - ?.let { fileOpenDialog.addFiltersToDialog(it) } + // Add filters + fileExtensions + ?.takeIf { it.isNotEmpty() } + ?.let { fileOpenDialog.addFiltersToDialog(it) } - // Set a flag for multiple options - fileOpenDialog.setFlag(FOS_ALLOWMULTISELECT) + // Set a flag for multiple options + fileOpenDialog.setFlag(FOS_ALLOWMULTISELECT) - fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { - it.getResults() + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { + it.getResults() + } } + } catch (failure: WindowsDialogOperationalException) { + throw failure.toFilePickerFailure() } override suspend fun openDirectoryPicker( @@ -281,21 +291,9 @@ internal class WindowsFilePicker( ): T? { // Show the dialog to the user val openDialogResult = showWindowsDialog(parentHandle, this::Show) - - // Valid error code: User canceled the dialog - val userCanceledException = Win32Exception(ERROR_CANCELLED) - if (openDialogResult == userCanceledException.hr) { - return null - } - - // Invalid error codes: throw exception - if (FAILED(openDialogResult)) { - throw WindowsDialogOperationalException( - "Show failed with HRESULT 0x${openDialogResult.toInt().toUInt().toString(16)}", - ) + return handleWindowsDialogResult(openDialogResult) { + block(this) } - - return block(this) } private fun FileDialog.getResult(sigdnName: Long): File { @@ -410,11 +408,35 @@ private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDial cause = this, ) +private fun Throwable.toFilePickerFailure(): FileKitPickerException = FileKitPickerException( + message = "The Windows file picker could not complete the operation.", + cause = this, +) + internal fun showWindowsDialog( parentHandle: Long?, show: (WinDef.HWND?) -> T, ): T = show(parentHandle?.let(::toWindowsHwnd)) +internal fun handleWindowsDialogResult( + openDialogResult: HRESULT, + block: () -> T, +): T? { + // Valid error code: User canceled the dialog + if (openDialogResult == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + return null + } + + // Invalid error codes: throw exception + if (FAILED(openDialogResult)) { + throw WindowsDialogOperationalException( + "Show failed with HRESULT 0x${openDialogResult.toInt().toUInt().toString(16)}", + ) + } + + return block() +} + internal fun toWindowsHwnd(handle: Long): WinDef.HWND = WinDef.HWND(Pointer(handle)) internal fun requiredFileDialogOptions(options: Int): Int = options or FOS_FORCEFILESYSTEM diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt new file mode 100644 index 00000000..a859cc8a --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt @@ -0,0 +1,79 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.windows + +import com.sun.jna.platform.win32.W32Errors.HRESULT_FROM_WIN32 +import com.sun.jna.platform.win32.WinError.ERROR_CANCELLED +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull + +class WindowsFilePickerFailureTest { + @Test + fun WindowsFilePicker_comInitializationFailure_throwsPickerOperationalFailureWithCause() = runTest { + val executor = failingWindowsDialogExecutor() + + try { + val failure = assertFailsWith { + WindowsFilePicker(executor).openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } finally { + executor.close() + } + } + + @Test + fun WindowsFilesPicker_comInitializationFailure_throwsPickerOperationalFailureWithCause() = runTest { + val executor = failingWindowsDialogExecutor() + + try { + val failure = assertFailsWith { + WindowsFilePicker(executor).openFilesPicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } finally { + executor.close() + } + } + + @Test + fun WindowsFilePicker_cancelledDialog_returnsNullWithoutResolvingSelection() { + var selectionResolved = false + + val result = handleWindowsDialogResult(HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + selectionResolved = true + "selected.txt" + } + + assertNull(result) + assertFalse(selectionResolved) + } + + private fun failingWindowsDialogExecutor(): WindowsDialogExecutor = WindowsDialogExecutor( + comRuntime = object : WindowsComRuntime { + override fun initializeSta(): Int = E_OUTOFMEMORY + + override fun uninitialize() = Unit + }, + ) + + private companion object { + val E_OUTOFMEMORY = 0x8007000Eu.toInt() + } +} From 3f178583f4dc1497141b44035384e0d68cdcef15 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 20:54:16 +0200 Subject: [PATCH 09/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20headless=20AWT?= =?UTF-8?q?=20picker=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- filekit-dialogs/build.gradle.kts | 18 +++++++ .../dialogs/platform/awt/AwtFilePicker.kt | 53 +++++++++++-------- .../platform/awt/AwtFilePickerFailureTest.kt | 31 +++++++++++ 3 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt diff --git a/filekit-dialogs/build.gradle.kts b/filekit-dialogs/build.gradle.kts index e943aa32..a7020701 100644 --- a/filekit-dialogs/build.gradle.kts +++ b/filekit-dialogs/build.gradle.kts @@ -1,8 +1,26 @@ +import org.gradle.api.tasks.testing.Test + plugins { alias(libs.plugins.filekit.kotlinMultiplatformLibrary) alias(libs.plugins.vanniktech.mavenPublish) } +val jvmTest = tasks.named("jvmTest") +val headlessAwtFilePickerTest = tasks.register("headlessAwtFilePickerTest") { + dependsOn(tasks.named("jvmTestClasses")) + testClassesDirs = jvmTest.get().testClassesDirs + classpath = jvmTest.get().classpath + filter.includeTestsMatching( + "io.github.vinceglb.filekit.dialogs.platform.awt.AwtFilePickerFailureTest", + ) + systemProperty("filekit.test.headlessAwtFilePicker", "true") + systemProperty("java.awt.headless", "true") +} + +jvmTest.configure { + dependsOn(headlessAwtFilePickerTest) +} + kotlin { android { androidResources { diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt index a741effb..147456a7 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine @@ -11,6 +12,7 @@ import java.awt.EventQueue import java.awt.FileDialog import java.awt.FileDialog.LOAD import java.awt.Frame +import java.awt.HeadlessException import java.awt.Window import java.io.File import java.io.FilenameFilter @@ -52,34 +54,41 @@ internal class AwtFilePicker : PlatformFilePicker { directory: PlatformFile?, fileExtensions: Set?, parentWindow: Window?, - ): List? = suspendCancellableCoroutine { continuation -> - // Handle parentWindow: Dialog, Frame, or null - val dialog = when (parentWindow) { - is Dialog -> FileDialog(parentWindow, title, LOAD) - else -> FileDialog(parentWindow as? Frame, title, LOAD) - } + ): List? = try { + suspendCancellableCoroutine { continuation -> + // Handle parentWindow: Dialog, Frame, or null + val dialog = when (parentWindow) { + is Dialog -> FileDialog(parentWindow, title, LOAD) + else -> FileDialog(parentWindow as? Frame, title, LOAD) + } - EventQueue.invokeLater { - // Set multiple mode - dialog.isMultipleMode = isMultipleMode + EventQueue.invokeLater { + // Set multiple mode + dialog.isMultipleMode = isMultipleMode - // Set mime types - dialog.filenameFilter = FilenameFilter { _, name -> - fileExtensions?.any { name.endsWith(suffix = it) } ?: true - } + // Set mime types + dialog.filenameFilter = FilenameFilter { _, name -> + fileExtensions?.any { name.endsWith(suffix = it) } ?: true + } - // Set initial directory - directory?.let { dialog.directory = directory.path } + // Set initial directory + directory?.let { dialog.directory = directory.path } - // Show the dialog - dialog.isVisible = true + // Show the dialog + dialog.isVisible = true - val files = dialog.files.takeIf { it.isNotEmpty() } - val result = files ?: dialog.file?.let { arrayOf(File(it)) } + val files = dialog.files.takeIf { it.isNotEmpty() } + val result = files ?: dialog.file?.let { arrayOf(File(it)) } - continuation.resume(value = result?.toList()) - } + continuation.resume(value = result?.toList()) + } - continuation.invokeOnCancellation { dialog.dispose() } + continuation.invokeOnCancellation { dialog.dispose() } + } + } catch (failure: HeadlessException) { + throw FileKitPickerException( + message = "The AWT file picker is unavailable in a headless environment.", + cause = failure, + ) } } diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt new file mode 100644 index 00000000..41357577 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt @@ -0,0 +1,31 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.awt + +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import kotlinx.coroutines.test.runTest +import org.junit.Assume.assumeTrue +import java.awt.GraphicsEnvironment +import java.awt.HeadlessException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class AwtFilePickerFailureTest { + @Test + fun AwtFilePicker_headlessFailure_throwsPickerOperationalFailureWithCause() = runTest { + assumeTrue(System.getProperty("filekit.test.headlessAwtFilePicker") == "true") + check(GraphicsEnvironment.isHeadless()) + + val failure = assertFailsWith { + AwtFilePicker().openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } +} From df27673e7b4d0a74caf8f6555fc1db0dd41d54f7 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 21:23:45 +0200 Subject: [PATCH 10/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20native=20Windo?= =?UTF-8?q?ws=20picker=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native picker calls inherited the legacy Windows dialog failure policy, so expected HRESULT failures leaked as IllegalStateException. Select the picker operational policy and wrap only its internal failure as FileKitPickerException, with retained native coverage for cancellation and exclusions. --- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 68 ++++++++--- .../dialogs/WindowsNativePickerFailureTest.kt | 106 ++++++++++++++++++ 2 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 29144512..a033c7af 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -67,12 +67,13 @@ private val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() private val ERROR_FILE_NOT_FOUND_HRESULT = 0x80070002u.toInt() private val ERROR_INVALID_DRIVE_HRESULT = 0x8007000Fu.toInt() -private class WindowsDialogOperationalException( +internal class WindowsDialogOperationalException( message: String, ) : RuntimeException(message) -private enum class WindowsDialogFailurePolicy { +internal enum class WindowsDialogFailurePolicy { Legacy, + Picker, Directory, Saver, ; @@ -80,6 +81,7 @@ private enum class WindowsDialogFailurePolicy { fun createFailure(message: String): RuntimeException = when (this) { Legacy -> IllegalStateException(message) + Picker, Directory, Saver, -> WindowsDialogOperationalException(message) @@ -98,8 +100,25 @@ internal actual suspend fun FileKit.platformOpenFilePicker( FileKitType.ImageAndVideo -> imageExtensions + videoExtensions is FileKitType.File -> type.extensions } - return showOpenDialog(extensions, directory, dialogSettings.title, pickFolders = false, mode is PickerMode.Multiple) - .toPickerStateFlow() + return runWindowsNativePickerOperation { + showOpenDialog( + extensions = extensions, + directory = directory, + title = dialogSettings.title, + pickFolders = false, + allowMultiple = mode is PickerMode.Multiple, + failurePolicy = WindowsDialogFailurePolicy.Picker, + ) + }.toPickerStateFlow() +} + +internal fun runWindowsNativePickerOperation(operation: () -> T): T = try { + operation() +} catch (failure: WindowsDialogOperationalException) { + throw FileKitPickerException( + message = "The Windows file picker could not complete the operation.", + cause = failure, + ) } public actual suspend fun FileKit.openDirectoryPicker( @@ -194,21 +213,17 @@ private fun showOpenDialog( directory?.let { setFolder(dlg, it, failurePolicy) } if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions, failurePolicy) - val hr = fk_dialog_show(dlg.reinterpret(), null) - if (hr != S_OK) { - if (hr == ERROR_CANCELLED_HRESULT) { - return@memScoped null + handleWindowsNativeDialogResult( + result = fk_dialog_show(dlg.reinterpret(), null), + failurePolicy = failurePolicy, + operation = "IFileOpenDialog::Show", + ) { + if (allowMultiple) { + getMultipleResults(dlg, failurePolicy) + } else { + val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() + getSingleResult(dlg, sigdn, failurePolicy)?.let { listOf(it) } } - throw failurePolicy.createFailure( - "IFileOpenDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", - ) - } - - if (allowMultiple) { - getMultipleResults(dlg, failurePolicy) - } else { - val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() - getSingleResult(dlg, sigdn, failurePolicy)?.let { listOf(it) } } } finally { ppDlg.value?.let { fk_open_dialog_release(it.reinterpret()) } @@ -218,6 +233,23 @@ private fun showOpenDialog( } } +internal fun handleWindowsNativeDialogResult( + result: Int, + failurePolicy: WindowsDialogFailurePolicy, + operation: String, + resolveResult: () -> T, +): T? { + if (result == ERROR_CANCELLED_HRESULT) { + return null + } + if (result != S_OK) { + throw failurePolicy.createFailure( + "$operation failed with HRESULT 0x${result.toUInt().toString(16)}", + ) + } + return resolveResult() +} + private fun showSaveDialog( suggestedName: String, defaultExtension: String?, diff --git a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt new file mode 100644 index 00000000..76c90359 --- /dev/null +++ b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt @@ -0,0 +1,106 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.FileKit +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.test.runTest +import platform.windows.COINIT_MULTITHREADED +import platform.windows.CoInitializeEx +import platform.windows.CoUninitialize +import platform.windows.S_OK +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame + +@OptIn(ExperimentalForeignApi::class) +class WindowsNativePickerFailureTest { + @Test + fun SinglePicker_incompatibleComApartment_throwsPickerOperationalFailureWithCause() = runTest { + assertIncompatibleComApartmentFailure(FileKitMode.Single) + } + + @Test + fun MultiplePicker_incompatibleComApartment_throwsPickerOperationalFailureWithCause() = runTest { + assertIncompatibleComApartmentFailure(FileKitMode.Multiple()) + } + + private suspend fun assertIncompatibleComApartmentFailure( + mode: FileKitMode, + ) { + val initializationResult = CoInitializeEx(null, COINIT_MULTITHREADED) + assertEquals(S_OK, initializationResult) + + try { + val failure = assertFailsWith { + FileKit.openFilePicker( + type = FileKitType.File(), + mode = mode, + ) + } + + assertEquals("The Windows file picker could not complete the operation.", failure.message) + val cause = assertNotNull(failure.cause) + assertIs(cause) + assertEquals("CoInitializeEx failed with HRESULT 0x80010106", cause.message) + } finally { + CoUninitialize() + } + } + + @Test + fun OpenPicker_cancelledDialog_returnsNullWithoutResolvingSelection() { + var selectionResolved = false + + val result = handleWindowsNativeDialogResult( + result = ERROR_CANCELLED_HRESULT, + failurePolicy = WindowsDialogFailurePolicy.Picker, + operation = "IFileOpenDialog::Show", + ) { + selectionResolved = true + "selected.txt" + } + + assertNull(result) + assertFalse(selectionResolved) + } + + @Test + fun PickerOperation_unexpectedFailure_propagatesUnchanged() { + val sentinel = UnexpectedPickerFailure() + + val thrown = assertFailsWith { + runWindowsNativePickerOperation { + throw sentinel + } + } + + assertSame(sentinel, thrown) + } + + @Test + fun MultiplePicker_invalidMaxItems_failsFast() = runTest { + val failure = assertFailsWith { + FileKit.openFilePicker( + type = FileKitType.File(), + mode = FileKitMode.Multiple(maxItems = 0), + ) + } + + assertEquals( + "maxItems must be contained between 1 <= maxItems <= 50 but current value is 0", + failure.message, + ) + } + + private companion object { + val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() + } + + private class UnexpectedPickerFailure : RuntimeException() +} From 9e595ba5c3751aaf70f198daf12ea3014efb9c80 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 21:58:16 +0200 Subject: [PATCH 11/40] =?UTF-8?q?=F0=9F=90=9B=20Fail=20iOS=20pickers=20wit?= =?UTF-8?q?hout=20a=20presenter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKit.ios.kt | 49 ++++++++++++++--- .../ApplePickerPresenterFailureTest.kt | 52 +++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerPresenterFailureTest.kt 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 85d63d7f..b5a57442 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 @@ -518,8 +518,12 @@ private fun isIpad(): Boolean { return device.userInterfaceIdiom == UIUserInterfaceIdiomPad } -private fun FileKitDialogSettings.presenterViewController(): UIViewController? = - presenter ?: UIApplication.sharedApplication.topMostViewController() +private fun activeAppleViewController(): UIViewController? = + UIApplication.sharedApplication.topMostViewController() + +private fun FileKitDialogSettings.presenterViewController( + activeViewController: () -> UIViewController? = ::activeAppleViewController, +): UIViewController? = presenter ?: activeViewController() private fun FileKitOpenCameraSettings.presenterViewController(): UIViewController? = presenter ?: UIApplication.sharedApplication.topMostViewController() @@ -553,10 +557,14 @@ private suspend fun callPicker( pickerController.delegate = documentPickerDelegate // Present the picker controller - dialogSettings.presenterViewController()?.presentViewController( - pickerController, - animated = true, - completion = null, + presentApplePickerController( + dialogSettings = dialogSettings, + controller = pickerController, + operation = if (mode == Mode.Directory) { + ApplePickerPresentationOperation.Directory + } else { + ApplePickerPresentationOperation.Document + }, ) } } @@ -607,7 +615,34 @@ private suspend fun getPhPickerResults( controller.presentationController?.delegate = phPickerDismissDelegate // Present the picker controller - dialogSettings.presenterViewController()?.presentViewController( + presentApplePickerController( + dialogSettings = dialogSettings, + controller = controller, + operation = ApplePickerPresentationOperation.PhotoOrVideo, + ) +} + +internal enum class ApplePickerPresentationOperation { + Document, + PhotoOrVideo, + Directory, +} + +internal fun presentApplePickerController( + dialogSettings: FileKitDialogSettings, + controller: UIViewController, + operation: ApplePickerPresentationOperation, + activeViewController: () -> UIViewController? = ::activeAppleViewController, +) { + val presenter = dialogSettings.presenterViewController(activeViewController) + if (presenter == null) { + if (operation != ApplePickerPresentationOperation.Directory) { + throw FileKitPickerException("No active view controller is available to present the file picker.") + } + throw FileKitDialogException("No active view controller is available to present the directory picker.") + } + + presenter.presentViewController( controller, animated = true, completion = null, diff --git a/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerPresenterFailureTest.kt b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerPresenterFailureTest.kt new file mode 100644 index 00000000..f4357001 --- /dev/null +++ b/filekit-dialogs/src/iosTest/kotlin/io/github/vinceglb/filekit/dialogs/ApplePickerPresenterFailureTest.kt @@ -0,0 +1,52 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import platform.UIKit.UIViewController +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplePickerPresenterFailureTest { + @Test + fun DocumentPicker_missingPresenter_reportsPickerFailure_withoutResult() { + assertMissingPresenterFailure(ApplePickerPresentationOperation.Document) + } + + @Test + fun PhotoVideoPicker_missingPresenter_reportsPickerFailure_withoutResult() { + assertMissingPresenterFailure(ApplePickerPresentationOperation.PhotoOrVideo) + } + + @Test + fun DirectoryPicker_missingPresenter_reportsDialogFailure_withoutCancellationResult() { + assertMissingPresenterFailure(ApplePickerPresentationOperation.Directory) + } + + private inline fun assertMissingPresenterFailure( + operation: ApplePickerPresentationOperation, + ) { + var activePresenterResolutionCount = 0 + var resultCount = 0 + val failures = mutableListOf() + + try { + presentApplePickerController( + dialogSettings = FileKitDialogSettings(presenter = null), + controller = UIViewController(), + operation = operation, + activeViewController = { + activePresenterResolutionCount++ + null + }, + ) + resultCount++ + } catch (failure: FileKitDialogException) { + failures += failure + } + + assertEquals(1, activePresenterResolutionCount) + assertEquals(0, resultCount) + assertEquals(1, failures.size) + assertEquals(Failure::class, failures.single()::class) + } +} From e956f16cf1e80388625250d60751ef3b499366a1 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Fri, 7 Aug 2026 22:51:56 +0200 Subject: [PATCH 12/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Deduplicate=20Window?= =?UTF-8?q?s=20picker=20failure=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/FileKitPickerException.kt | 3 +++ .../platform/windows/WindowsFilePicker.kt | 17 ++++++++++------- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) 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 index 69aa796c..c0c68341 100644 --- 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 @@ -8,3 +8,6 @@ public class FileKitPickerException : FileKitDialogException { public constructor(message: String, cause: Throwable) : super(message, cause) } + +internal const val WINDOWS_FILE_PICKER_FAILURE_MESSAGE: String = + "The Windows file picker could not complete the operation." diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index e64d006a..2885bf81 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -20,6 +20,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.WINDOWS_FILE_PICKER_FAILURE_MESSAGE import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileDialog import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileOpenDialog @@ -48,7 +49,7 @@ internal class WindowsFilePicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = try { + ): File? = runWindowsFilePickerOperation { useFileDialog(FileDialogType.Open) { fileOpenDialog -> // Set the initial directory directory?.let { fileOpenDialog.setDefaultPath(it) } @@ -69,15 +70,13 @@ internal class WindowsFilePicker( it.getResult(SIGDN_FILESYSPATH) } } - } catch (failure: WindowsDialogOperationalException) { - throw failure.toFilePickerFailure() } override suspend fun openFilesPicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): List? = try { + ): List? = runWindowsFilePickerOperation { useFileDialog(FileDialogType.Open) { fileOpenDialog -> // Set the initial directory directory?.let { fileOpenDialog.setDefaultPath(it) } @@ -101,8 +100,6 @@ internal class WindowsFilePicker( it.getResults() } } - } catch (failure: WindowsDialogOperationalException) { - throw failure.toFilePickerFailure() } override suspend fun openDirectoryPicker( @@ -409,10 +406,16 @@ private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDial ) private fun Throwable.toFilePickerFailure(): FileKitPickerException = FileKitPickerException( - message = "The Windows file picker could not complete the operation.", + message = WINDOWS_FILE_PICKER_FAILURE_MESSAGE, cause = this, ) +private suspend fun runWindowsFilePickerOperation(operation: suspend () -> T): T = try { + operation() +} catch (failure: WindowsDialogOperationalException) { + throw failure.toFilePickerFailure() +} + internal fun showWindowsDialog( parentHandle: Long?, show: (WinDef.HWND?) -> T, diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index a033c7af..7722700e 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -116,7 +116,7 @@ internal fun runWindowsNativePickerOperation(operation: () -> T): T = try { operation() } catch (failure: WindowsDialogOperationalException) { throw FileKitPickerException( - message = "The Windows file picker could not complete the operation.", + message = WINDOWS_FILE_PICKER_FAILURE_MESSAGE, cause = failure, ) } From 099c7d8293368eb4310a286f093fb81a13b951ed Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 00:14:35 +0200 Subject: [PATCH 13/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20XDG=20portal?= =?UTF-8?q?=20request=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/xdg/XdgFilePickerPortal.kt | 53 +++++- .../platform/xdg/XdgOperationalFailureTest.kt | 151 ++++++++++++++++++ 2 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index cef7cbac..59e810e3 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -2,7 +2,9 @@ package io.github.vinceglb.filekit.dialogs.platform.xdg import com.sun.jna.Native import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.resolveXdgPortalParent import io.github.vinceglb.filekit.path @@ -16,6 +18,8 @@ import org.freedesktop.dbus.annotations.DBusProperty.Access import org.freedesktop.dbus.annotations.Position import org.freedesktop.dbus.connections.impl.DBusConnection import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder +import org.freedesktop.dbus.exceptions.DBusException +import org.freedesktop.dbus.exceptions.DBusExecutionException import org.freedesktop.dbus.interfaces.DBusInterface import org.freedesktop.dbus.interfaces.DBusSigHandler import org.freedesktop.dbus.interfaces.Properties @@ -86,12 +90,19 @@ internal class XdgFilePickerPortal( fileExtensions?.let { options["filters"] = createFilterOption(it) } directory?.let { options["current_folder"] = createCurrentFolderOption(it) } - return transport - .openFile( + return runXdgRequest( + toFailure = if (openDirectory) { + Throwable::toDirectoryPickerFailure + } else { + Throwable::toFilePickerFailure + }, + ) { + transport.openFile( parentWindow = parentWindow, title = title ?: "", options = options, - )?.map { File(it) } + ) + }?.map { File(it) } } override suspend fun openFileSaver( @@ -111,12 +122,14 @@ internal class XdgFilePickerPortal( filterExtensions?.let { options["filters"] = createFilterOption(it) } directory?.let { options["current_folder"] = createCurrentFolderOption(it) } - return transport - .saveFile( - parentWindow = dialogSettings.resolveXdgPortalParent(), + val parentWindow = dialogSettings.resolveXdgPortalParent() + return runXdgRequest(Throwable::toFileSaverFailure) { + transport.saveFile( + parentWindow = parentWindow, title = "", options = options, - )?.first() + ) + }?.first() ?.let { File(it) } } @@ -140,6 +153,32 @@ internal class XdgFilePickerPortal( } } +private fun Throwable.toFilePickerFailure(): FileKitPickerException = FileKitPickerException( + message = "The XDG file picker could not complete the operation.", + cause = this, +) + +private fun Throwable.toDirectoryPickerFailure(): FileKitDialogException = FileKitDialogException( + message = "The XDG directory picker could not complete the operation.", + cause = this, +) + +private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDialogException( + message = "The XDG file saver could not complete the operation.", + cause = this, +) + +private suspend fun runXdgRequest( + toFailure: (Throwable) -> FileKitDialogException, + request: suspend () -> T, +): T = try { + request() +} catch (failure: DBusExecutionException) { + throw toFailure(failure) +} catch (failure: DBusException) { + throw toFailure(failure) +} + internal interface XdgFileChooserTransport { fun isAvailable(): Boolean diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt new file mode 100644 index 00000000..8c5009e5 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt @@ -0,0 +1,151 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.xdg + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.freedesktop.dbus.exceptions.DBusException +import org.freedesktop.dbus.exceptions.DBusExecutionException +import org.freedesktop.dbus.types.Variant +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class XdgOperationalFailureTest { + @Test + fun XdgFilePickerPortal_filePickerDbusExecutionFailure_throwsPickerOperationalFailureWithCause() = runTest { + val cause = DBusExecutionException("Portal request failed") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(cause)) + + val failure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(cause, failure.cause) + } + + @Test + fun XdgFilePickerPortal_directoryPickerDbusFailure_throwsDialogOperationalFailureWithCause() = runTest { + val cause = DBusExecutionException("Portal request failed") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(cause)) + + val failure = assertFailsWith { + picker.openDirectoryPicker( + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(cause, failure.cause) + } + + @Test + fun XdgFilePickerPortal_fileSaverDbusFailure_throwsDialogOperationalFailureWithCause() = runTest { + val cause = DBusExecutionException("Portal request failed") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(cause)) + + val failure = assertFailsWith { + picker.openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(cause, failure.cause) + } + + @Test + fun XdgFilePickerPortal_sessionBusFailure_throwsPickerOperationalFailureWithCause() = runTest { + val cause = DBusException("Session bus unavailable") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(cause)) + + val failure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(cause, failure.cause) + } + + @Test + fun XdgFilePickerPortal_cancellation_propagatesUnchanged() = runTest { + val cancellation = CancellationException("Picker cancelled") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(cancellation)) + + val failure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(cancellation, failure) + } + + @Test + fun XdgFilePickerPortal_unexpectedRuntimeFailure_propagatesUnchanged() = runTest { + val defect = IllegalStateException("Unexpected defect") + val picker = XdgFilePickerPortal(ThrowingXdgFileChooserTransport(defect)) + + val failure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertSame(defect, failure) + } + + @Test + fun XdgFilePickerPortal_invalidDialogParent_failsBeforeOperationalNormalization() = runTest { + val picker = XdgFilePickerPortal( + ThrowingXdgFileChooserTransport(DBusExecutionException("Transport must not run")), + ) + + assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.windows(0x1234), + ), + ) + } + } +} + +private class ThrowingXdgFileChooserTransport( + private val failure: Throwable, +) : XdgFileChooserTransport { + override fun isAvailable(): Boolean = true + + override suspend fun openFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = throw failure + + override suspend fun saveFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = throw failure +} From 4d32980e4edd59e4d111d6401dcd03a7509e3cf5 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 00:40:25 +0200 Subject: [PATCH 14/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20XDG=20and=20An?= =?UTF-8?q?droid=20saver=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/AndroidFileSaverFailureTest.kt | 72 ++++++++++++++++ .../filekit/dialogs/FileKit.android.kt | 25 ++++-- .../platform/xdg/XdgFilePickerPortal.kt | 48 ++++++++--- .../platform/xdg/XdgOperationalFailureTest.kt | 85 +++++++++++++++++++ 4 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt new file mode 100644 index 00000000..0d19a502 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt @@ -0,0 +1,72 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import android.content.ActivityNotFoundException +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat +import io.github.vinceglb.filekit.FileKit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +class AndroidFileSaverFailureTest { + @Test + fun AndroidFileSaver_missingActivity_throwsDialogOperationalFailureWithCause() { + val platformFailure = ActivityNotFoundException("No activity for file saver") + FileKit.init(throwingActivityResultRegistry(platformFailure)) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidFileSaver_cancellation_propagatesUnchanged() { + val cancellation = CancellationException("Saver cancelled") + FileKit.init(throwingActivityResultRegistry(cancellation)) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertEquals(cancellation.message, failure.message) + } + + @Test + fun AndroidFileSaver_unexpectedFailure_propagatesUnchanged() { + val defect = IllegalStateException("Unexpected saver defect") + FileKit.init(throwingActivityResultRegistry(defect)) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertEquals(defect.message, failure.message) + } + + private suspend fun openFileSaver() = + FileKit.openFileSaver( + suggestedName = "document", + defaultExtension = null, + ) + + private fun throwingActivityResultRegistry(failure: Throwable): ActivityResultRegistry = + object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) = throw failure + } +} diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index bd3d7709..90fcd837 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -81,15 +81,22 @@ internal actual suspend fun FileKit.platformOpenFileSaver( suggestedName = suggestedName, extension = normalizedDefaultExtension, ) - val uri = awaitActivityResult( - registry = registry, - contract = contract, - input = CreateDocumentInput( - mimeType = mimeType, - fileName = fileName, - allowedMimeTypes = allowedMimeTypes, - ), - ) + val uri = try { + awaitActivityResult( + registry = registry, + contract = contract, + input = CreateDocumentInput( + mimeType = mimeType, + fileName = fileName, + allowedMimeTypes = allowedMimeTypes, + ), + ) + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = "No Android activity is available to open the file saver.", + cause = failure, + ) + } return uri?.let(::PlatformFile) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index 59e810e3..cffee4ae 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -177,6 +177,34 @@ private suspend fun runXdgRequest( throw toFailure(failure) } catch (failure: DBusException) { throw toFailure(failure) +} catch (failure: XdgPortalResponseException) { + throw toFailure(failure) +} + +internal class XdgPortalResponseException( + internal val response: Int, +) : RuntimeException("The XDG portal ended the request with response code $response.") + +internal fun resolveXdgPortalResponse( + response: Int, + results: Map>, +): List? = when (response) { + 0 -> { + @Suppress("UNCHECKED_CAST") + (results["uris"]!!.value as List).map { path -> path.toURI() } + } + + 1 -> { + null + } + + 2 -> { + throw XdgPortalResponseException(response) + } + + else -> { + error("Unexpected XDG portal response code: $response") + } } internal interface XdgFileChooserTransport { @@ -260,9 +288,11 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { val result = CompletableDeferred?>() val matchRule = DBusMatchRule("signal", "org.freedesktop.portal.Request", "Response") val registration = AtomicReference(null) - val handler = ResponseHandler(path) { uris -> - result.complete(uris) - } + val handler = ResponseHandler( + path = path, + onComplete = { uris -> result.complete(uris) }, + onFailure = { failure -> result.completeExceptionally(failure) }, + ) registration.set( addGenericSigHandlerCompat( connection = connection, @@ -279,6 +309,7 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { private class ResponseHandler( private val path: String, private val onComplete: (result: List?) -> Unit, + private val onFailure: (failure: XdgPortalResponseException) -> Unit, ) : DBusSigHandler { @Suppress("UNCHECKED_CAST") override fun handle(signal: DBusSignal) { @@ -287,13 +318,10 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { val response = params[0] as UInt32 val results = params[1] as Map> - if (response.toInt() == 0) { - val uris = (results["uris"]!!.value as List).map { path -> - path.toURI() - } - onComplete(uris) - } else { - onComplete(null) + try { + onComplete(resolveXdgPortalResponse(response.toInt(), results)) + } catch (failure: XdgPortalResponseException) { + onFailure(failure) } } } diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt index 8c5009e5..8aab0e99 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt @@ -13,7 +13,9 @@ import org.freedesktop.dbus.exceptions.DBusExecutionException import org.freedesktop.dbus.types.Variant import java.net.URI import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertSame class XdgOperationalFailureTest { @@ -82,6 +84,65 @@ class XdgOperationalFailureTest { assertSame(cause, failure.cause) } + @Test + fun XdgFilePickerPortal_otherPortalResponse_throwsPickerOperationalFailureWithCause() = runTest { + val picker = XdgFilePickerPortal(RespondingXdgFileChooserTransport(response = 2)) + + val failure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertEquals(2, (failure.cause as XdgPortalResponseException).response) + } + + @Test + fun XdgFilePickerPortal_otherDirectoryResponse_throwsDialogOperationalFailureWithCause() = runTest { + val picker = XdgFilePickerPortal(RespondingXdgFileChooserTransport(response = 2)) + + val failure = assertFailsWith { + picker.openDirectoryPicker( + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertEquals(2, (failure.cause as XdgPortalResponseException).response) + } + + @Test + fun XdgFilePickerPortal_otherSaverResponse_throwsDialogOperationalFailureWithCause() = runTest { + val picker = XdgFilePickerPortal(RespondingXdgFileChooserTransport(response = 2)) + + val failure = assertFailsWith { + picker.openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertEquals(2, (failure.cause as XdgPortalResponseException).response) + } + + @Test + fun XdgFilePickerPortal_cancelledPortalResponse_returnsNull() = runTest { + val picker = XdgFilePickerPortal(RespondingXdgFileChooserTransport(response = 1)) + + val result = picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + + assertNull(result) + } + @Test fun XdgFilePickerPortal_cancellation_propagatesUnchanged() = runTest { val cancellation = CancellationException("Picker cancelled") @@ -149,3 +210,27 @@ private class ThrowingXdgFileChooserTransport( options: MutableMap>, ): List? = throw failure } + +private class RespondingXdgFileChooserTransport( + private val response: Int, +) : XdgFileChooserTransport { + override fun isAvailable(): Boolean = true + + override suspend fun openFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = resolveXdgPortalResponse( + response = response, + results = emptyMap>(), + ) + + override suspend fun saveFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = resolveXdgPortalResponse( + response = response, + results = emptyMap>(), + ) +} From 398ee62430e1d3158ef1ff5a0ab688b15ab1ce80 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 01:45:04 +0200 Subject: [PATCH 15/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20Android=20dire?= =?UTF-8?q?ctory=20picker=20launch=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AndroidDirectoryPickerFailureTest.kt | 76 +++++++++++++++++++ .../filekit/dialogs/FileKit.android.kt | 17 +++-- 2 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt new file mode 100644 index 00000000..620e5dd7 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt @@ -0,0 +1,76 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import android.content.ActivityNotFoundException +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.app.ActivityOptionsCompat +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.exceptions.FileKitNotInitializedException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +class AndroidDirectoryPickerFailureTest { + @Test + fun AndroidDirectoryPicker_missingActivity_throwsDialogOperationalFailureWithCause() { + val platformFailure = ActivityNotFoundException("No activity for directory picker") + FileKit.init(throwingActivityResultRegistry(platformFailure)) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidDirectoryPicker_cancellation_propagatesUnchanged() { + val cancellation = CancellationException("Directory picker cancelled") + FileKit.init(throwingActivityResultRegistry(cancellation)) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertEquals(cancellation.message, failure.message) + } + + @Test + fun AndroidDirectoryPicker_unexpectedFailure_propagatesUnchanged() { + val defect = IllegalStateException("Unexpected directory picker defect") + FileKit.init(throwingActivityResultRegistry(defect)) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertEquals(defect.message, failure.message) + } + + private fun throwingActivityResultRegistry(failure: Throwable): ActivityResultRegistry = + object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) = throw failure + } +} + +class AndroidDirectoryPickerInvalidInvocationTest { + @Test + fun AndroidDirectoryPicker_uninitializedFileKit_throwsInvalidInvocationFailure() { + assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + } +} diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index 90fcd837..edf312e5 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -114,11 +114,18 @@ public actual suspend fun FileKit.openDirectoryPicker( val registry = FileKit.registry val contract = ActivityResultContracts.OpenDocumentTree() val initialUri = directory?.path?.toUri() - val treeUri = awaitActivityResult( - registry = registry, - contract = contract, - input = initialUri, - ) + val treeUri = try { + awaitActivityResult( + registry = registry, + contract = contract, + input = initialUri, + ) + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = "No Android activity is available to open the directory picker.", + cause = failure, + ) + } return treeUri?.let(::PlatformFile) } From 8110c4380b9551bf509f02db19a5c8ea9ad623ce Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 02:16:06 +0200 Subject: [PATCH 16/40] =?UTF-8?q?=F0=9F=90=9B=20Release=20Windows=20initia?= =?UTF-8?q?l=20folder=20on=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/platform/windows/WindowsFilePicker.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 2885bf81..26bf41f3 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -249,12 +249,13 @@ internal class WindowsFilePicker( // Create ShellItem from the folder val folder = ShellItem(pbrFolder.value) - - // Set the initial directory - this.SetFolder(folder.pointer).verify("SetFolder failed") - - // Release the folder - folder.Release() + try { + // Set the initial directory + this.SetFolder(folder.pointer).verify("SetFolder failed") + } finally { + // Release the folder + folder.Release() + } } private fun FileDialog.addFiltersToDialog(fileExtensions: Set) { From db3cc9b48bd4599a084397777b1c1c4889a35a53 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 02:24:02 +0200 Subject: [PATCH 17/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20Android=20came?= =?UTF-8?q?ra=20launch=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/camera-picker.mdx | 2 +- .../dialogs/AndroidCameraPickerFailureTest.kt | 195 ++++++++++++++++++ .../filekit/dialogs/FileKit.android.kt | 28 ++- .../filekit/dialogs/FileKit.mobile.kt | 6 + 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt diff --git a/docs/dialogs/camera-picker.mdx b/docs/dialogs/camera-picker.mdx index 93040c41..87124d91 100644 --- a/docs/dialogs/camera-picker.mdx +++ b/docs/dialogs/camera-picker.mdx @@ -40,7 +40,7 @@ Button(onClick = { launcher.launch() }) { `onError` receives a `FileKitDialogException` only when FileKit cannot start or complete a valid camera operation, such as when no Android camera activity is available or iOS cannot prepare, present, encode, or write the capture. User dismissal is not a failure and invokes `onResult(null)`. Android camera-permission denial also invokes `onResult(null)`. Coroutine cancellation, invalid invocation, and unexpected defects continue to propagate normally. -On iOS, the suspending `FileKit.openCameraPicker()` function throws the same `FileKitDialogException` for operational failures. Android's lifecycle-safe Compose launcher owns the Android launch-failure callback behavior described above. The compatibility Compose overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. +The suspending `FileKit.openCameraPicker()` function throws the same `FileKitDialogException` for operational failures. On Android, this includes an unavailable or unauthorized camera-permission request or camera launch. On iOS, this includes failures while preparing, presenting, encoding, or writing the capture. Android's lifecycle-safe Compose launcher reports the equivalent launch failures through `onError`. The compatibility Compose overload without `onError` remains available and ignores normalized operational failures without logging. New integrations should use explicit error handling. See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt new file mode 100644 index 00000000..b7d2eeb1 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt @@ -0,0 +1,195 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Context +import android.net.Uri +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.app.ActivityOptionsCompat +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.manualFileKitCoreInitialization +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class AndroidCameraPickerFailureTest { + private lateinit var context: Context + private lateinit var registry: ActivityResultRegistry + private val cameraDestination = PlatformFile(Uri.parse("content://example.provider/camera/photo.jpg")) + + @Before + fun setup() { + context = RuntimeEnvironment.getApplication() + FileKit.manualFileKitCoreInitialization(context) + shadowOf(context.packageManager) + .getInternalMutablePackageInfo(context.packageName) + .requestedPermissions = emptyArray() + } + + @Test + fun AndroidCameraPicker_missingCameraActivity_throwsDialogOperationalFailureWithCause() { + val platformFailure = ActivityNotFoundException("No activity for camera") + registry = throwingActivityResultRegistry(TakePictureWithCameraFacing::class.java, platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidCameraPicker_unauthorizedCameraLaunch_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("Camera launch is not authorized") + registry = throwingActivityResultRegistry(TakePictureWithCameraFacing::class.java, platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + + @Test + fun AndroidCameraPicker_missingPermissionActivity_throwsDialogOperationalFailureWithCause() { + declareCameraPermission() + val platformFailure = ActivityNotFoundException("No activity for camera permission") + registry = throwingActivityResultRegistry(ActivityResultContracts.RequestPermission::class.java, platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidCameraPicker_unauthorizedPermissionLaunch_throwsDialogOperationalFailureWithCause() { + declareCameraPermission() + val platformFailure = SecurityException("Camera permission launch is not authorized") + registry = throwingActivityResultRegistry(ActivityResultContracts.RequestPermission::class.java, platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + + @Test + fun AndroidCameraPicker_permissionDenied_returnsNull() { + declareCameraPermission() + registry = completingActivityResultRegistry( + expectedContract = ActivityResultContracts.RequestPermission::class.java, + output = false, + ) + FileKit.init(registry) + + val result = runBlocking { openCameraPickerAtTestDestination() } + + assertNull(result) + } + + @Test + fun AndroidCameraPicker_cameraDismissed_returnsNull() { + registry = completingActivityResultRegistry( + expectedContract = TakePictureWithCameraFacing::class.java, + output = false, + ) + FileKit.init(registry) + + val result = runBlocking { openCameraPickerAtTestDestination() } + + assertNull(result) + } + + @Test + fun AndroidCameraPicker_cancellation_propagatesUnchanged() { + val cancellation = CancellationException("Camera picker cancelled") + registry = throwingActivityResultRegistry(TakePictureWithCameraFacing::class.java, cancellation) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + assertEquals(cancellation.message, failure.message) + } + + @Test + fun AndroidCameraPicker_unexpectedFailure_propagatesUnchanged() { + val defect = IllegalStateException("Unexpected camera picker defect") + registry = throwingActivityResultRegistry(TakePictureWithCameraFacing::class.java, defect) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openCameraPickerAtTestDestination() } + } + + assertEquals(defect.message, failure.message) + } + + private fun declareCameraPermission() { + shadowOf(context.packageManager) + .getInternalMutablePackageInfo(context.packageName) + .requestedPermissions = arrayOf(Manifest.permission.CAMERA) + } + + private suspend fun openCameraPickerAtTestDestination(): PlatformFile? = + FileKit.openCameraPicker(destinationFile = cameraDestination) + + private fun throwingActivityResultRegistry( + expectedContract: Class>, + failure: Throwable, + ): ActivityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + check(expectedContract.isInstance(contract)) + throw failure + } + } + + private fun completingActivityResultRegistry( + expectedContract: Class>, + output: O, + ): ActivityResultRegistry = object : ActivityResultRegistry() { + @Suppress("UNCHECKED_CAST") + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + check(expectedContract.isInstance(contract)) + dispatchResult(requestCode, output as T) + } + } +} diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index edf312e5..060985c4 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -137,6 +137,7 @@ public actual suspend fun FileKit.openDirectoryPicker( * @param destinationFile The file where the captured media will be saved. * @param openCameraSettings Platform-specific settings for the camera. * @return The saved file as a [PlatformFile], or null if cancelled. + * @throws FileKitDialogException When Android cannot launch the permission request or camera activity. */ public actual suspend fun FileKit.openCameraPicker( type: FileKitCameraType, @@ -145,7 +146,20 @@ public actual suspend fun FileKit.openCameraPicker( openCameraSettings: FileKitOpenCameraSettings, ): PlatformFile? { val registry = FileKit.registry - if (!FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context)) { + val hasCameraPermission = try { + FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context) + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = "No Android activity is available to request camera permission.", + cause = failure, + ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = "Android rejected the camera permission request.", + cause = failure, + ) + } + if (!hasCameraPermission) { return null } @@ -157,8 +171,16 @@ public actual suspend fun FileKit.openCameraPicker( contract = contract, input = uri, ) - } catch (_: SecurityException) { - return null + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = "No Android activity is available to capture media with the camera.", + cause = failure, + ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = "Android rejected the camera launch.", + cause = failure, + ) } return if (isSaved) destinationFile else null } diff --git a/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt b/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt index c70a3d12..e99873b8 100644 --- a/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt +++ b/filekit-dialogs/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mobile.kt @@ -13,6 +13,12 @@ public enum class FileKitCameraFacing { Back, } +/** + * Opens a camera picker dialog. + * + * @return The saved file as a [PlatformFile], or `null` if the user dismisses the camera or denies camera permission. + * @throws FileKitDialogException When a valid camera operation cannot start or complete. + */ @OptIn(ExperimentalUuidApi::class) public expect suspend fun FileKit.openCameraPicker( type: FileKitCameraType = FileKitCameraType.Photo, From b198eb65f1c77bd28bbca538be6129818c8f42d1 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 02:30:06 +0200 Subject: [PATCH 18/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20macOS=20JVM=20?= =?UTF-8?q?bootstrap=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/platform/mac/MacOSFilePicker.kt | 177 +++++++++++------- .../platform/mac/foundation/Foundation.kt | 12 +- .../MacOSFilePickerOperationalFailureTest.kt | 138 ++++++++++++++ .../ObjcRunnableClassRegistrationTest.kt | 6 +- 4 files changed, 261 insertions(+), 72 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePickerOperationalFailureTest.kt diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index f591c5f7..7f74ea48 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -1,11 +1,14 @@ package io.github.vinceglb.filekit.dialogs.platform.mac import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMacOSSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.buildFileSaverAllowedFileTypes import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation +import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.FoundationRunnableBootstrapException import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.ID import io.github.vinceglb.filekit.dialogs.requireMacOSCompatible import io.github.vinceglb.filekit.path @@ -70,40 +73,46 @@ internal class MacOSFilePicker : PlatformFilePicker { try { var response: File? = null - Foundation.executeOnMainThread( - withAutoreleasePool = false, - waitUntilDone = true, + normalizeRunnableBootstrapFailure( + operationalFailure = { cause -> + FileKitDialogException(MACOS_FILE_SAVER_FAILURE_MESSAGE, cause) + }, ) { - val savePanel = Foundation.invoke("NSSavePanel", "new") - - dialogSettings.title?.let { - Foundation.invoke(savePanel, "setMessage:", Foundation.nsString(it)) - } - - directory?.let { - Foundation.invoke(savePanel, "setDirectoryURL:", Foundation.nsURL(it.path)) - } - - // Set the file name without extension, NSSavePanel appends it from allowedFileTypes - Foundation.invoke( - savePanel, - "setNameFieldStringValue:", - Foundation.nsString(suggestedName), - ) - - // Default extension first so it is the one appended - val fileTypes = buildFileSaverAllowedFileTypes(defaultExtension, allowedExtensions) - savePanel.setAllowedFileTypes(fileTypes) - - Foundation.invoke( - savePanel, - "setCanCreateDirectories:", - dialogSettings.macOS.canCreateDirectories, - ) - - val result = Foundation.invoke(savePanel, "runModal") - if (result.toInt() == NS_MODAL_RESPONSE_OK) { - response = singlePath(savePanel) + Foundation.executeOnMainThread( + withAutoreleasePool = false, + waitUntilDone = true, + ) { + val savePanel = Foundation.invoke("NSSavePanel", "new") + + dialogSettings.title?.let { + Foundation.invoke(savePanel, "setMessage:", Foundation.nsString(it)) + } + + directory?.let { + Foundation.invoke(savePanel, "setDirectoryURL:", Foundation.nsURL(it.path)) + } + + // Set the file name without extension, NSSavePanel appends it from allowedFileTypes + Foundation.invoke( + savePanel, + "setNameFieldStringValue:", + Foundation.nsString(suggestedName), + ) + + // Default extension first so it is the one appended + val fileTypes = buildFileSaverAllowedFileTypes(defaultExtension, allowedExtensions) + savePanel.setAllowedFileTypes(fileTypes) + + Foundation.invoke( + savePanel, + "setCanCreateDirectories:", + dialogSettings.macOS.canCreateDirectories, + ) + + val result = Foundation.invoke(savePanel, "runModal") + if (result.toInt() == NS_MODAL_RESPONSE_OK) { + response = singlePath(savePanel) + } } } @@ -124,40 +133,42 @@ internal class MacOSFilePicker : PlatformFilePicker { try { var response: T? = null - Foundation.executeOnMainThread( - withAutoreleasePool = false, - waitUntilDone = true, - ) { - // Create the file picker - val openPanel = Foundation.invoke("NSOpenPanel", "new") - - // Setup single, multiple selection or directory mode - mode.setupPickerMode(openPanel, macOSSettings.canCreateDirectories) - - // Set the title - title?.let { - Foundation.invoke(openPanel, "setMessage:", Foundation.nsString(it)) - } - - // Set initial directory - directory?.let { - Foundation.invoke(openPanel, "setDirectoryURL:", Foundation.nsURL(it.path)) - } - - // Set file extensions - openPanel.setAllowedFileTypes(fileExtensions) - - // Set resolvesAliases - macOSSettings.resolvesAliases?.let { resolvesAliases -> - Foundation.invoke(openPanel, "setResolvesAliases:", resolvesAliases) - } - - // Open the file picker - val result = Foundation.invoke(openPanel, "runModal") - - // Get the path(s) from the file picker if the user validated the selection - if (result.toInt() == 1) { - response = mode.getResult(openPanel) + normalizeRunnableBootstrapFailure(mode::operationalFailure) { + Foundation.executeOnMainThread( + withAutoreleasePool = false, + waitUntilDone = true, + ) { + // Create the file picker + val openPanel = Foundation.invoke("NSOpenPanel", "new") + + // Setup single, multiple selection or directory mode + mode.setupPickerMode(openPanel, macOSSettings.canCreateDirectories) + + // Set the title + title?.let { + Foundation.invoke(openPanel, "setMessage:", Foundation.nsString(it)) + } + + // Set initial directory + directory?.let { + Foundation.invoke(openPanel, "setDirectoryURL:", Foundation.nsURL(it.path)) + } + + // Set file extensions + openPanel.setAllowedFileTypes(fileExtensions) + + // Set resolvesAliases + macOSSettings.resolvesAliases?.let { resolvesAliases -> + Foundation.invoke(openPanel, "setResolvesAliases:", resolvesAliases) + } + + // Open the file picker + val result = Foundation.invoke(openPanel, "runModal") + + // Get the path(s) from the file picker if the user validated the selection + if (result.toInt() == 1) { + response = mode.getResult(openPanel) + } } } @@ -169,6 +180,12 @@ internal class MacOSFilePicker : PlatformFilePicker { private companion object { const val NS_MODAL_RESPONSE_OK = 1 + const val MACOS_FILE_PICKER_FAILURE_MESSAGE = + "The macOS file picker could not complete the operation." + const val MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE = + "The macOS directory picker could not complete the operation." + const val MACOS_FILE_SAVER_FAILURE_MESSAGE = + "The macOS file saver could not complete the operation." fun Collection.toNsStringArray(): ID? { if (isEmpty()) { @@ -222,6 +239,8 @@ internal class MacOSFilePicker : PlatformFilePicker { abstract fun getResult(openPanel: ID): T? + abstract fun operationalFailure(cause: Throwable): FileKitDialogException + data object SingleFile : MacOSFilePickerMode() { override fun setupPickerMode(openPanel: ID, canCreateDirectories: Boolean) { Foundation.invoke(openPanel, "setCanChooseFiles:", true) @@ -230,6 +249,11 @@ internal class MacOSFilePicker : PlatformFilePicker { } override fun getResult(openPanel: ID): File? = singlePath(openPanel) + + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitPickerException( + MACOS_FILE_PICKER_FAILURE_MESSAGE, + cause, + ) } data object MultipleFiles : MacOSFilePickerMode>() { @@ -242,6 +266,11 @@ internal class MacOSFilePicker : PlatformFilePicker { } override fun getResult(openPanel: ID): List? = multiplePaths(openPanel) + + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitPickerException( + MACOS_FILE_PICKER_FAILURE_MESSAGE, + cause, + ) } data object Directories : MacOSFilePickerMode() { @@ -252,6 +281,20 @@ internal class MacOSFilePicker : PlatformFilePicker { } override fun getResult(openPanel: ID): File? = singlePath(openPanel) + + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitDialogException( + MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE, + cause, + ) } } } + +internal inline fun normalizeRunnableBootstrapFailure( + operationalFailure: (FoundationRunnableBootstrapException) -> FileKitDialogException, + operation: () -> T, +): T = try { + operation() +} catch (cause: FoundationRunnableBootstrapException) { + throw operationalFailure(cause) +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt index cce8c8e0..36c4a7f0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/Foundation.kt @@ -17,6 +17,10 @@ import java.util.Arrays import java.util.Collections import java.util.UUID +internal class FoundationRunnableBootstrapException( + message: String, +) : IllegalStateException(message) + internal fun registerObjcRunnableClass( className: String, allocate: (String) -> ID?, @@ -26,12 +30,16 @@ internal fun registerObjcRunnableClass( ): ID { val runnableClass = allocate(className) if (runnableClass == null || runnableClass == ID.NIL) { - throw IllegalStateException("Unable to allocate Objective-C runnable adapter class '$className'") + throw FoundationRunnableBootstrapException( + "Unable to allocate Objective-C runnable adapter class '$className'", + ) } if (!addMethod(runnableClass)) { dispose(runnableClass) - throw IllegalStateException("Unable to add run: method to Objective-C runnable adapter class '$className'") + throw FoundationRunnableBootstrapException( + "Unable to add run: method to Objective-C runnable adapter class '$className'", + ) } register(runnableClass) diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePickerOperationalFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePickerOperationalFailureTest.kt new file mode 100644 index 00000000..795a4544 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePickerOperationalFailureTest.kt @@ -0,0 +1,138 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.mac + +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation +import io.github.vinceglb.filekit.utils.Platform +import io.github.vinceglb.filekit.utils.PlatformUtil +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class MacOSFilePickerOperationalFailureTest { + @Test + fun MacOSFilePicker_runnableClassCollision_exposesOperationAppropriateFailuresWithCause() { + if (PlatformUtil.current != Platform.MacOS) return + + val javaExecutable = File(System.getProperty("java.home"), "bin/java") + val process = ProcessBuilder( + javaExecutable.absolutePath, + "-cp", + System.getProperty("java.class.path"), + MacOSFilePickerOperationalFailureHarness::class.java.name, + ).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + + assertEquals(0, process.waitFor(), output) + } + + @Test + fun MacOSFilePicker_incompatibleParent_remainsInvalidInvocation() = runBlocking { + assertFailsWith { + MacOSFilePicker().openDirectoryPicker( + directory = null, + dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.windows(1), + ), + ) + } + } + + @Test + fun MacOSFilePicker_cancellationDuringOperation_remainsUnwrapped() { + val cancellation = CancellationException("Cancelled") + + val failure = assertFailsWith { + normalizeRunnableBootstrapFailure( + operationalFailure = { cause -> FileKitDialogException("Operational failure", cause) }, + ) { + throw cancellation + } + } + + assertSame(cancellation, failure) + } + + @Test + fun MacOSFilePicker_unexpectedFailureDuringOperation_remainsUnwrapped() { + val unexpectedFailure = IllegalStateException("Unexpected failure") + + val failure = assertFailsWith { + normalizeRunnableBootstrapFailure( + operationalFailure = { cause -> FileKitDialogException("Operational failure", cause) }, + ) { + throw unexpectedFailure + } + } + + assertSame(unexpectedFailure, failure) + } +} + +internal object MacOSFilePickerOperationalFailureHarness { + @JvmStatic + fun main(args: Array) { + runBlocking { + registerForeignRunnableClass() + val picker = MacOSFilePicker() + val settings = FileKitDialogSettings() + + val pickerFailure = assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = settings, + ) + } + assertIs(pickerFailure.cause) + assertTrue(pickerFailure.cause?.message?.contains(RUNNABLE_ADAPTER_CLASS_NAME) == true) + + val directoryFailure = assertFailsWith { + picker.openDirectoryPicker( + directory = null, + dialogSettings = settings, + ) + } + assertIs(directoryFailure.cause) + assertTrue(directoryFailure.cause?.message?.contains(RUNNABLE_ADAPTER_CLASS_NAME) == true) + + val saverFailure = assertFailsWith { + picker.openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = settings, + ) + } + assertIs(saverFailure.cause) + assertTrue(saverFailure.cause?.message?.contains(RUNNABLE_ADAPTER_CLASS_NAME) == true) + } + } + + private fun registerForeignRunnableClass() { + val nsObject = Foundation.getObjcClass("NSObject") + check(!Foundation.isNil(nsObject)) { + "Unable to resolve NSObject while preparing the runnable adapter collision" + } + + val foreignClass = Foundation.allocateObjcClassPair(nsObject, RUNNABLE_ADAPTER_CLASS_NAME) + check(!Foundation.isNil(foreignClass)) { + "Unable to allocate the foreign $RUNNABLE_ADAPTER_CLASS_NAME class" + } + + Foundation.registerObjcClassPair(foreignClass) + } + + private const val RUNNABLE_ADAPTER_CLASS_NAME = "FileKitMainThreadRunnable" +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt index e3f3f986..4a4dc3c8 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/foundation/ObjcRunnableClassRegistrationTest.kt @@ -11,7 +11,7 @@ class ObjcRunnableClassRegistrationTest { fun ObjcRunnableClassRegistration_nullAllocation_rejectsBeforeNativeMutation() { val events = mutableListOf() - val exception = assertFailsWith { + val exception = assertFailsWith { registerObjcRunnableClass( className = CLASS_NAME, allocate = { @@ -35,7 +35,7 @@ class ObjcRunnableClassRegistrationTest { fun ObjcRunnableClassRegistration_zeroValuedAllocation_rejectsBeforeNativeMutation() { val events = mutableListOf() - val exception = assertFailsWith { + val exception = assertFailsWith { registerObjcRunnableClass( className = CLASS_NAME, allocate = { @@ -60,7 +60,7 @@ class ObjcRunnableClassRegistrationTest { val runnableClass = ID(42) val events = mutableListOf() - val exception = assertFailsWith { + val exception = assertFailsWith { registerObjcRunnableClass( className = CLASS_NAME, allocate = { From f1cae7aaa7873f99bcdf941cb8b7fcc8292ccf1c Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 03:06:17 +0200 Subject: [PATCH 19/40] =?UTF-8?q?=F0=9F=90=9B=20Handle=20native=20Windows?= =?UTF-8?q?=20SetFolder=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 21 ++++++++- .../dialogs/WindowsNativePickerFailureTest.kt | 44 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 7722700e..fff1ef4e 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -362,10 +362,27 @@ private fun MemScope.setFolder( ) } val folder = ppsi.value ?: return + setWindowsNativeDialogFolder( + failurePolicy = failurePolicy, + setFolder = { fk_dialog_set_folder(dlg.reinterpret(), folder.reinterpret()) }, + releaseFolder = { fk_shell_item_release(folder.reinterpret()) }, + ) +} + +internal fun setWindowsNativeDialogFolder( + failurePolicy: WindowsDialogFailurePolicy, + setFolder: () -> Int, + releaseFolder: () -> Unit, +) { try { - fk_dialog_set_folder(dlg.reinterpret(), folder.reinterpret()) + val result = setFolder() + if (result != S_OK) { + throw failurePolicy.createFailure( + "IFileDialog::SetFolder failed with HRESULT 0x${result.toUInt().toString(16)}", + ) + } } finally { - fk_shell_item_release(folder.reinterpret()) + releaseFolder() } } diff --git a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt index 76c90359..9ff93a98 100644 --- a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt +++ b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt @@ -17,6 +17,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue @OptIn(ExperimentalForeignApi::class) class WindowsNativePickerFailureTest { @@ -70,6 +71,48 @@ class WindowsNativePickerFailureTest { assertFalse(selectionResolved) } + @Test + fun PickerSetFolder_failedHresult_throwsPickerOperationalFailureAndReleasesShellItem() { + var shellItemReleased = false + + val failure = assertFailsWith { + runWindowsNativePickerOperation { + setWindowsNativeDialogFolder( + failurePolicy = WindowsDialogFailurePolicy.Picker, + setFolder = { E_FAIL_HRESULT }, + releaseFolder = { shellItemReleased = true }, + ) + } + } + + assertEquals("The Windows file picker could not complete the operation.", failure.message) + val cause = assertNotNull(failure.cause) + assertIs(cause) + assertEquals("IFileDialog::SetFolder failed with HRESULT 0x80004005", cause.message) + assertTrue(shellItemReleased) + } + + @Test + fun DirectoryAndSaverSetFolder_failedHresult_remainsDialogOperationalFailure() { + listOf( + WindowsDialogFailurePolicy.Directory, + WindowsDialogFailurePolicy.Saver, + ).forEach { failurePolicy -> + var shellItemReleased = false + + val failure = assertFailsWith { + setWindowsNativeDialogFolder( + failurePolicy = failurePolicy, + setFolder = { E_FAIL_HRESULT }, + releaseFolder = { shellItemReleased = true }, + ) + } + + assertEquals("IFileDialog::SetFolder failed with HRESULT 0x80004005", failure.message) + assertTrue(shellItemReleased) + } + } + @Test fun PickerOperation_unexpectedFailure_propagatesUnchanged() { val sentinel = UnexpectedPickerFailure() @@ -99,6 +142,7 @@ class WindowsNativePickerFailureTest { } private companion object { + val E_FAIL_HRESULT = 0x80004005u.toInt() val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() } From b976f5e30d2b84211e95eddd48b362723e2c9d19 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 03:07:03 +0200 Subject: [PATCH 20/40] =?UTF-8?q?=F0=9F=90=9B=20Resume=20XDG=20requests=20?= =?UTF-8?q?on=20unexpected=20responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/xdg/XdgFilePickerPortal.kt | 32 +++++++++++-------- .../platform/xdg/XdgOperationalFailureTest.kt | 27 ++++++++++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index cffee4ae..509d9881 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -290,8 +290,7 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { val registration = AtomicReference(null) val handler = ResponseHandler( path = path, - onComplete = { uris -> result.complete(uris) }, - onFailure = { failure -> result.completeExceptionally(failure) }, + result = result, ) registration.set( addGenericSigHandlerCompat( @@ -308,21 +307,11 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { private class ResponseHandler( private val path: String, - private val onComplete: (result: List?) -> Unit, - private val onFailure: (failure: XdgPortalResponseException) -> Unit, + private val result: CompletableDeferred?>, ) : DBusSigHandler { - @Suppress("UNCHECKED_CAST") override fun handle(signal: DBusSignal) { if (path == signal.path) { - val params = signal.parameters - val response = params[0] as UInt32 - val results = params[1] as Map> - - try { - onComplete(resolveXdgPortalResponse(response.toInt(), results)) - } catch (failure: XdgPortalResponseException) { - onFailure(failure) - } + dispatchXdgPortalResponse(signal.parameters, result) } } } @@ -368,6 +357,21 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { ) } +@Suppress("UNCHECKED_CAST") +internal fun dispatchXdgPortalResponse( + parameters: Array, + result: CompletableDeferred?>, +) { + runCatching { + val response = parameters[0] as UInt32 + val results = parameters[1] as Map> + resolveXdgPortalResponse(response.toInt(), results) + }.fold( + onSuccess = { uris -> result.complete(uris) }, + onFailure = { failure -> result.completeExceptionally(failure) }, + ) +} + @DBusInterfaceName(value = "org.freedesktop.portal.FileChooser") @Suppress("FunctionName") internal interface FileChooserDbusInterface : DBusInterface { diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt index 8aab0e99..53e64419 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt @@ -7,9 +7,11 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.freedesktop.dbus.exceptions.DBusException import org.freedesktop.dbus.exceptions.DBusExecutionException +import org.freedesktop.dbus.types.UInt32 import org.freedesktop.dbus.types.Variant import java.net.URI import kotlin.test.Test @@ -17,8 +19,33 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class XdgOperationalFailureTest { + @Test + fun XdgResponseDispatcher_unexpectedResponse_completesWaitingRequestExceptionallyOnce() = runTest { + val result = CompletableDeferred?>() + val unexpectedResponse = arrayOf( + UInt32(99), + emptyMap>(), + ) + + dispatchXdgPortalResponse( + parameters = unexpectedResponse, + result = result, + ) + + assertTrue(result.isCompleted, "The response dispatcher left the waiting request suspended") + + dispatchXdgPortalResponse( + parameters = arrayOf(UInt32(1), emptyMap>()), + result = result, + ) + + val failure = assertFailsWith { result.await() } + assertEquals("Unexpected XDG portal response code: 99", failure.message) + } + @Test fun XdgFilePickerPortal_filePickerDbusExecutionFailure_throwsPickerOperationalFailureWithCause() = runTest { val cause = DBusExecutionException("Portal request failed") From 7367b44c5fa2104788e2dd93d8b2b978e79642e7 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 03:07:25 +0200 Subject: [PATCH 21/40] =?UTF-8?q?=F0=9F=90=9B=20Classify=20native=20macOS?= =?UTF-8?q?=20dialog=20aborts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKit.macos.kt | 23 +++- .../dialogs/MacOSModalResponseFailureTest.kt | 116 ++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 filekit-dialogs/src/macosTest/kotlin/io/github/vinceglb/filekit/dialogs/MacOSModalResponseFailureTest.kt diff --git a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt index 7523fa8e..f9e1bb89 100644 --- a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt +++ b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt @@ -5,6 +5,7 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.absolutePath import io.github.vinceglb.filekit.path import kotlinx.coroutines.flow.Flow +import platform.AppKit.NSModalResponseCancel import platform.AppKit.NSModalResponseOK import platform.AppKit.NSOpenPanel import platform.AppKit.NSSavePanel @@ -91,9 +92,10 @@ internal actual suspend fun FileKit.platformOpenFileSaver( // Run the NSSavePanel val result = nsSavePanel.runModal() - // If the user canceled the operation, return null - if (result != NSModalResponseOK) { - return null + when (result) { + NSModalResponseOK -> Unit + NSModalResponseCancel -> return null + else -> throw FileKitDialogException("The macOS file saver could not complete the operation.") } // Return the result @@ -147,9 +149,18 @@ private fun callPicker( // Run the NSOpenPanel val result = nsOpenPanel.runModal() - // If the user canceled the operation, return null - if (result != NSModalResponseOK) { - return null + when (result) { + NSModalResponseOK -> Unit + + NSModalResponseCancel -> return null + + else -> throw when (mode) { + Mode.Single, + Mode.Multiple, + -> FileKitPickerException("The macOS file picker could not complete the operation.") + + Mode.Directory -> FileKitDialogException("The macOS directory picker could not complete the operation.") + } } // Return the result diff --git a/filekit-dialogs/src/macosTest/kotlin/io/github/vinceglb/filekit/dialogs/MacOSModalResponseFailureTest.kt b/filekit-dialogs/src/macosTest/kotlin/io/github/vinceglb/filekit/dialogs/MacOSModalResponseFailureTest.kt new file mode 100644 index 00000000..7b7d7b0f --- /dev/null +++ b/filekit-dialogs/src/macosTest/kotlin/io/github/vinceglb/filekit/dialogs/MacOSModalResponseFailureTest.kt @@ -0,0 +1,116 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.FileKit +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.CFunction +import kotlinx.cinterop.COpaquePointer +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.staticCFunction +import kotlinx.coroutines.test.runTest +import platform.AppKit.NSModalResponse +import platform.AppKit.NSModalResponseAbort +import platform.AppKit.NSModalResponseCancel +import platform.Foundation.NSClassFromString +import platform.Foundation.NSSelectorFromString +import platform.objc.class_getInstanceMethod +import platform.objc.method_setImplementation +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +class MacOSModalResponseFailureTest { + @Test + fun FilePicker_abortedPanel_throwsPickerOperationalFailure() = runTest { + withSavePanelModalResponse(NSModalResponseAbort) { + assertFailsWith { + FileKit.openFilePicker() + } + } + } + + @Test + fun DirectoryPicker_abortedPanel_throwsDialogOperationalFailure() = runTest { + withSavePanelModalResponse(NSModalResponseAbort) { + assertFailsWith { + FileKit.openDirectoryPicker() + } + } + } + + @Test + fun FileSaver_abortedPanel_throwsDialogOperationalFailure() = runTest { + withSavePanelModalResponse(NSModalResponseAbort) { + assertFailsWith { + FileKit.openFileSaver( + suggestedName = "example", + defaultExtension = null, + allowedExtensions = null, + ) + } + } + } + + @Test + fun FilePicker_cancelledPanel_returnsNull() = runTest { + withSavePanelModalResponse(NSModalResponseCancel) { + assertNull(FileKit.openFilePicker()) + } + } + + @Test + fun DirectoryPicker_cancelledPanel_returnsNull() = runTest { + withSavePanelModalResponse(NSModalResponseCancel) { + assertNull(FileKit.openDirectoryPicker()) + } + } + + @Test + fun FileSaver_cancelledPanel_returnsNull() = runTest { + withSavePanelModalResponse(NSModalResponseCancel) { + assertNull( + FileKit.openFileSaver( + suggestedName = "example", + defaultExtension = null, + allowedExtensions = null, + ), + ) + } + } +} + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +private inline fun withSavePanelModalResponse( + response: NSModalResponse, + block: () -> Result, +): Result { + val replacement = when (response) { + NSModalResponseAbort -> abortRunModalImplementation + NSModalResponseCancel -> cancelRunModalImplementation + else -> error("Unsupported intercepted modal response: $response") + } + val panelClass = checkNotNull(NSClassFromString("NSSavePanel")) + val runModalSelector = NSSelectorFromString("runModal") + val runModalMethod = checkNotNull(class_getInstanceMethod(panelClass, runModalSelector)) + val original = method_setImplementation(runModalMethod, replacement.reinterpret()) + + try { + return block() + } finally { + method_setImplementation(runModalMethod, original) + } +} + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +private val abortRunModalImplementation: + CPointer NSModalResponse>> = + staticCFunction { _, _ -> NSModalResponseAbort } + +@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) +private val cancelRunModalImplementation: + CPointer NSModalResponse>> = + staticCFunction { _, _ -> NSModalResponseCancel } From f275dc93d5bb598dfeb55924e3969c9363b315be Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 11:56:33 +0200 Subject: [PATCH 22/40] =?UTF-8?q?=F0=9F=90=9B=20Classify=20Swing=20dialog?= =?UTF-8?q?=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/platform/swing/SwingFilePicker.kt | 9 +++++---- .../platform/swing/SwingDirectoryPickerResultTest.kt | 11 +++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt index f94e578f..4c27f8ff 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt @@ -109,8 +109,9 @@ internal fun resolveSwingPickerResult( returnValue: Int, selectedFiles: Array, selectedFile: File?, -): List? = if (returnValue == JFileChooser.APPROVE_OPTION) { - selectedFiles.toList().takeIf { it.isNotEmpty() } ?: selectedFile?.let(::listOf) -} else { - null +): List? = when (returnValue) { + JFileChooser.APPROVE_OPTION -> selectedFiles.toList().takeIf { it.isNotEmpty() } ?: selectedFile?.let(::listOf) + JFileChooser.CANCEL_OPTION -> null + JFileChooser.ERROR_OPTION -> throw FileKitDialogException("The Swing directory picker failed to display.") + else -> throw FileKitDialogException("The Swing directory picker returned an unknown result: $returnValue.") } diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt index 556f5367..53755688 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt @@ -38,6 +38,17 @@ class SwingDirectoryPickerResultTest { assertNull(result) } + @Test + fun SwingDirectoryPicker_errorSelection_throwsDialogOperationalFailure() { + assertFailsWith { + resolveSwingPickerResult( + returnValue = JFileChooser.ERROR_OPTION, + selectedFiles = emptyArray(), + selectedFile = null, + ) + } + } + @Test fun SwingDirectoryPicker_headlessFailure_throwsDialogOperationalFailureWithCause() = runTest { val headlessFailure = HeadlessException("No graphics environment") From 6f0b3aa448b010c4a10e63d5b1d4ab54d7c650dd Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 11:57:14 +0200 Subject: [PATCH 23/40] =?UTF-8?q?=F0=9F=90=9B=20Classify=20JVM=20macOS=20d?= =?UTF-8?q?ialog=20aborts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/platform/mac/MacOSFilePicker.kt | 32 +++- .../mac/MacOSModalResponseFailureTest.kt | 162 ++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSModalResponseFailureTest.kt diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index 7f74ea48..21ce7c28 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -72,6 +72,7 @@ internal class MacOSFilePicker : PlatformFilePicker { val pool = Foundation.NSAutoreleasePool() try { var response: File? = null + var modalFailure: FileKitDialogException? = null normalizeRunnableBootstrapFailure( operationalFailure = { cause -> @@ -110,12 +111,15 @@ internal class MacOSFilePicker : PlatformFilePicker { ) val result = Foundation.invoke(savePanel, "runModal") - if (result.toInt() == NS_MODAL_RESPONSE_OK) { - response = singlePath(savePanel) + when (result.toInt()) { + NS_MODAL_RESPONSE_OK -> response = singlePath(savePanel) + NS_MODAL_RESPONSE_CANCEL -> Unit + else -> modalFailure = FileKitDialogException(MACOS_FILE_SAVER_FAILURE_MESSAGE) } } } + modalFailure?.let { throw it } response } finally { pool.drain() @@ -132,6 +136,7 @@ internal class MacOSFilePicker : PlatformFilePicker { val pool = Foundation.NSAutoreleasePool() try { var response: T? = null + var modalFailure: FileKitDialogException? = null normalizeRunnableBootstrapFailure(mode::operationalFailure) { Foundation.executeOnMainThread( @@ -165,13 +170,15 @@ internal class MacOSFilePicker : PlatformFilePicker { // Open the file picker val result = Foundation.invoke(openPanel, "runModal") - // Get the path(s) from the file picker if the user validated the selection - if (result.toInt() == 1) { - response = mode.getResult(openPanel) + when (result.toInt()) { + NS_MODAL_RESPONSE_OK -> response = mode.getResult(openPanel) + NS_MODAL_RESPONSE_CANCEL -> Unit + else -> modalFailure = mode.operationalFailure() } } } + modalFailure?.let { throw it } response } finally { pool.drain() @@ -180,6 +187,7 @@ internal class MacOSFilePicker : PlatformFilePicker { private companion object { const val NS_MODAL_RESPONSE_OK = 1 + const val NS_MODAL_RESPONSE_CANCEL = 0 const val MACOS_FILE_PICKER_FAILURE_MESSAGE = "The macOS file picker could not complete the operation." const val MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE = @@ -239,6 +247,8 @@ internal class MacOSFilePicker : PlatformFilePicker { abstract fun getResult(openPanel: ID): T? + abstract fun operationalFailure(): FileKitDialogException + abstract fun operationalFailure(cause: Throwable): FileKitDialogException data object SingleFile : MacOSFilePickerMode() { @@ -250,6 +260,10 @@ internal class MacOSFilePicker : PlatformFilePicker { override fun getResult(openPanel: ID): File? = singlePath(openPanel) + override fun operationalFailure(): FileKitDialogException = FileKitPickerException( + MACOS_FILE_PICKER_FAILURE_MESSAGE, + ) + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitPickerException( MACOS_FILE_PICKER_FAILURE_MESSAGE, cause, @@ -267,6 +281,10 @@ internal class MacOSFilePicker : PlatformFilePicker { override fun getResult(openPanel: ID): List? = multiplePaths(openPanel) + override fun operationalFailure(): FileKitDialogException = FileKitPickerException( + MACOS_FILE_PICKER_FAILURE_MESSAGE, + ) + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitPickerException( MACOS_FILE_PICKER_FAILURE_MESSAGE, cause, @@ -282,6 +300,10 @@ internal class MacOSFilePicker : PlatformFilePicker { override fun getResult(openPanel: ID): File? = singlePath(openPanel) + override fun operationalFailure(): FileKitDialogException = FileKitDialogException( + MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE, + ) + override fun operationalFailure(cause: Throwable): FileKitDialogException = FileKitDialogException( MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE, cause, diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSModalResponseFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSModalResponseFailureTest.kt new file mode 100644 index 00000000..cec9f918 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSModalResponseFailureTest.kt @@ -0,0 +1,162 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.mac + +import com.sun.jna.Callback +import com.sun.jna.CallbackReference +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer +import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation +import io.github.vinceglb.filekit.utils.Platform +import io.github.vinceglb.filekit.utils.PlatformUtil +import kotlinx.coroutines.runBlocking +import java.io.File +import java.lang.ref.Reference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class MacOSModalResponseFailureTest { + @Test + fun MacOSFilePicker_modalResponses_distinguishAbortFromCancel() { + if (PlatformUtil.current != Platform.MacOS) return + + val javaExecutable = File(System.getProperty("java.home"), "bin/java") + val process = ProcessBuilder( + javaExecutable.absolutePath, + "-cp", + System.getProperty("java.class.path"), + MacOSModalResponseFailureHarness::class.java.name, + ).redirectErrorStream(true).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + + assertEquals(0, process.waitFor(), output) + } +} + +internal object MacOSModalResponseFailureHarness { + @JvmStatic + fun main(args: Array) { + runBlocking { + val picker = MacOSFilePicker() + val settings = FileKitDialogSettings() + val failures = mutableListOf() + + withSavePanelModalResponse(NS_MODAL_RESPONSE_ABORT) { + verify("file-picker", failures) { + assertFailsWith { + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = settings, + ) + } + } + verify("directory", failures) { + val failure = assertFailsWith { + picker.openDirectoryPicker( + directory = null, + dialogSettings = settings, + ) + } + assertEquals(FileKitDialogException::class, failure::class) + } + verify("saver", failures) { + val failure = assertFailsWith { + picker.openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = settings, + ) + } + assertEquals(FileKitDialogException::class, failure::class) + } + } + + withSavePanelModalResponse(NS_MODAL_RESPONSE_CANCEL) { + verify("file-picker cancel", failures) { + assertNull( + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = settings, + ), + ) + } + verify("directory cancel", failures) { + assertNull( + picker.openDirectoryPicker( + directory = null, + dialogSettings = settings, + ), + ) + } + verify("saver cancel", failures) { + assertNull( + picker.openFileSaver( + suggestedName = "document", + defaultExtension = "txt", + allowedExtensions = setOf("txt"), + directory = null, + dialogSettings = settings, + ), + ) + } + } + + check(failures.isEmpty()) { failures.joinToString(separator = "\n") } + } + } + + private inline fun verify( + operation: String, + failures: MutableList, + block: () -> Unit, + ) { + runCatching(block).exceptionOrNull()?.let { failure -> + failures += "$operation: ${failure.message}" + } + } + + private inline fun withSavePanelModalResponse( + response: Long, + block: () -> T, + ): T { + val objc = NativeLibrary.getInstance("objc") + val panelClass = checkNotNull(Foundation.getObjcClass("NSSavePanel")) + val runModalSelector = checkNotNull(Foundation.createSelector("runModal")) + val runModalMethod = checkNotNull( + objc.getFunction("class_getInstanceMethod").invokePointer( + arrayOf(panelClass, runModalSelector), + ), + ) + val replacement = RunModalCallback { _, _ -> response } + val replacementPointer = CallbackReference.getFunctionPointer(replacement) + val methodSetImplementation = objc.getFunction("method_setImplementation") + val original = checkNotNull( + methodSetImplementation.invokePointer( + arrayOf(runModalMethod, replacementPointer), + ), + ) + + try { + return block() + } finally { + methodSetImplementation.invokePointer(arrayOf(runModalMethod, original)) + Reference.reachabilityFence(replacement) + } + } + + private fun interface RunModalCallback : Callback { + fun invoke(self: Pointer?, selector: Pointer?): Long + } + + private const val NS_MODAL_RESPONSE_CANCEL = 0L + private const val NS_MODAL_RESPONSE_ABORT = -1001L +} From ebdc7910630e9578a75692f09d29441092c5c9eb Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 16:06:50 +0200 Subject: [PATCH 24/40] =?UTF-8?q?=F0=9F=90=9B=20Verify=20Windows=20file=20?= =?UTF-8?q?filter=20HRESULT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/windows/WindowsFilePicker.kt | 43 +++++++++++-------- .../windows/WindowsFilePickerFailureTest.kt | 14 ++++++ 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 26bf41f3..90406ae5 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -259,15 +259,7 @@ internal class WindowsFilePicker( } private fun FileDialog.addFiltersToDialog(fileExtensions: Set) { - // Create the filter string - val filterString = fileExtensions.joinToString(";") { "*.$it" } - - val filterSpec = COMDLG_FILTERSPEC() - filterSpec.pszName = WString(filterString) - filterSpec.pszSpec = WString(filterString) - - // Set the filter - this.SetFileTypes(1, arrayOf(filterSpec)) + setWindowsFileTypes(fileExtensions, this::SetFileTypes) } private fun FileDialog.setFlag(flag: Int) { @@ -380,22 +372,35 @@ internal class WindowsFilePicker( } } - private fun HRESULT.verify(exceptionMessage: String): HRESULT { - if (FAILED(this)) { - throw WindowsDialogOperationalException( - "$exceptionMessage with HRESULT 0x${toInt().toUInt().toString(16)}", - ) - } else { - return this - } - } - private fun FileKitDialogSettings.resolveWindowsDialogHandle(): Long? = parent.resolveWindowsHandle { window -> Pointer.nativeValue(Native.getWindowPointer(window)) } } +internal fun setWindowsFileTypes( + fileExtensions: Set, + setFileTypes: (Int, Array?) -> HRESULT, +) { + val filterString = fileExtensions.joinToString(";") { "*.$it" } + val filterSpec = COMDLG_FILTERSPEC().apply { + pszName = WString(filterString) + pszSpec = WString(filterString) + } + + setFileTypes(1, arrayOf(filterSpec)).verify("SetFileTypes failed") +} + +private fun HRESULT.verify(exceptionMessage: String): HRESULT { + if (FAILED(this)) { + throw WindowsDialogOperationalException( + "$exceptionMessage with HRESULT 0x${toInt().toUInt().toString(16)}", + ) + } else { + return this + } +} + private fun Throwable.toDirectoryPickerFailure(): FileKitDialogException = FileKitDialogException( message = "The Windows directory picker could not complete the operation.", cause = this, diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt index a859cc8a..504352da 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt @@ -4,10 +4,12 @@ package io.github.vinceglb.filekit.dialogs.platform.windows import com.sun.jna.platform.win32.W32Errors.HRESULT_FROM_WIN32 import com.sun.jna.platform.win32.WinError.ERROR_CANCELLED +import com.sun.jna.platform.win32.WinNT.HRESULT import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException import kotlinx.coroutines.test.runTest import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs @@ -65,6 +67,18 @@ class WindowsFilePickerFailureTest { assertFalse(selectionResolved) } + @Test + fun WindowsFilePicker_setFileTypesFailure_throwsOperationalFailure() { + val failure = assertFailsWith { + setWindowsFileTypes(setOf("txt")) { _, _ -> HRESULT(E_OUTOFMEMORY) } + } + + assertEquals( + "SetFileTypes failed with HRESULT 0x8007000e", + failure.message, + ) + } + private fun failingWindowsDialogExecutor(): WindowsDialogExecutor = WindowsDialogExecutor( comRuntime = object : WindowsComRuntime { override fun initializeSta(): Int = E_OUTOFMEMORY From c54ae48b7779503fb59a4c2b491ec60ffd3e446d Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 16:10:09 +0200 Subject: [PATCH 25/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20Android=20secu?= =?UTF-8?q?rity=20launch=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AndroidComposePickerReliabilityTest.kt | 82 ++++++++++++++++++- .../dialogs/compose/FileKitCompose.android.kt | 36 +++++++- .../AndroidDirectoryPickerFailureTest.kt | 14 ++++ .../dialogs/AndroidFileSaverFailureTest.kt | 14 ++++ .../AndroidPickerLaunchFallbackTest.kt | 43 ++++++++++ .../filekit/dialogs/FileKit.android.kt | 20 +++++ 6 files changed, 202 insertions(+), 7 deletions(-) diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index c97a600f..c2c06257 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -265,6 +265,19 @@ class AndroidComposePickerReliabilityTest { assertSame(launchFailure, failure.cause) } + @Test + fun PickerLaunchSafely_whenSecurityException_returnsOperationalFailureWithCause() { + val launchFailure = SecurityException("Picker launch rejected") + + val result = launchFilePickerSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + @Test fun PickerLaunchSafely_whenNoError_returnsLaunched() { var launched = false @@ -290,6 +303,19 @@ class AndroidComposePickerReliabilityTest { assertSame(launchFailure, failure.cause) } + @Test + fun DirectoryLaunchSafely_whenSecurityException_returnsOperationalFailureWithCause() { + val launchFailure = SecurityException("Directory picker launch rejected") + + val result = launchDirectoryPickerSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + @Test fun DirectoryLaunchSafely_whenUnexpectedFailure_propagates() { val failure = IllegalStateException("Unexpected launcher defect") @@ -326,6 +352,19 @@ class AndroidComposePickerReliabilityTest { assertSame(launchFailure, failure.cause) } + @Test + fun FileSaverLaunchSafely_whenSecurityException_returnsOperationalFailureWithCause() { + val launchFailure = SecurityException("File saver launch rejected") + + val result = launchFileSaverSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + @Test fun FileSaverLaunchSafely_whenUnexpectedFailure_propagates() { val failure = IllegalStateException("Unexpected saver defect") @@ -354,7 +393,12 @@ class AndroidComposePickerReliabilityTest { var fallbackCalls = 0 val outcome = resolvePickerLaunchOutcome( - launchPrimary = { PickerLaunchResult.Failed(FileKitPickerException("Primary failed")) }, + launchPrimary = { + PickerLaunchResult.Failed( + failure = FileKitPickerException("Primary failed"), + isFallbackEligible = true, + ) + }, launchFallback = { fallbackCalls++ PickerLaunchResult.Launched @@ -365,13 +409,45 @@ class AndroidComposePickerReliabilityTest { assertEquals(1, fallbackCalls) } + @Test + fun PickerLaunchOutcome_primarySecurityFailure_doesNotLaunchFallback() { + val launchFailure = SecurityException("Visual picker launch rejected") + var fallbackCalls = 0 + + val outcome = resolvePickerLaunchOutcome( + launchPrimary = { + launchFilePickerSafely { + throw launchFailure + } + }, + launchFallback = { + fallbackCalls++ + PickerLaunchResult.Launched + }, + ) + + val failure = assertIs(outcome).failure + assertSame(launchFailure, failure.cause) + assertEquals(0, fallbackCalls) + } + @Test fun PickerLaunchOutcome_primaryAndFallbackFail_returnsFallbackOperationalFailure() { val fallbackFailure = FileKitPickerException("Fallback failed") val outcome = resolvePickerLaunchOutcome( - launchPrimary = { PickerLaunchResult.Failed(FileKitPickerException("Primary failed")) }, - launchFallback = { PickerLaunchResult.Failed(fallbackFailure) }, + launchPrimary = { + PickerLaunchResult.Failed( + failure = FileKitPickerException("Primary failed"), + isFallbackEligible = true, + ) + }, + launchFallback = { + PickerLaunchResult.Failed( + failure = fallbackFailure, + isFallbackEligible = false, + ) + }, ) val failure = assertIs(outcome) 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 ed3e08cf..a2b01bfe 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 @@ -622,6 +622,15 @@ internal fun launchFilePickerSafely( message = "No Android activity is available to open the file picker.", cause = failure, ), + isFallbackEligible = true, + ) +} catch (failure: SecurityException) { + PickerLaunchResult.Failed( + FileKitPickerException( + message = "Android rejected the file picker launch.", + cause = failure, + ), + isFallbackEligible = false, ) } @@ -646,6 +655,13 @@ internal fun launchDirectoryPickerSafely( cause = failure, ), ) +} catch (failure: SecurityException) { + DirectoryLaunchResult.Failed( + FileKitDialogException( + message = "Android rejected the directory picker launch.", + cause = failure, + ), + ) } internal sealed interface DirectoryLaunchResult { @@ -668,6 +684,13 @@ internal fun launchFileSaverSafely( cause = failure, ), ) +} catch (failure: SecurityException) { + SaverLaunchResult.Failed( + FileKitDialogException( + message = "Android rejected the file saver launch.", + cause = failure, + ), + ) } internal sealed interface SaverLaunchResult { @@ -683,6 +706,7 @@ internal sealed interface PickerLaunchResult { data class Failed( val failure: FileKitPickerException, + val isFallbackEligible: Boolean, ) : PickerLaunchResult } @@ -699,15 +723,19 @@ internal sealed interface PickerLaunchOutcome { internal fun resolvePickerLaunchOutcome( launchPrimary: () -> PickerLaunchResult, launchFallback: () -> PickerLaunchResult, -): PickerLaunchOutcome = when (launchPrimary()) { +): PickerLaunchOutcome = when (val primaryResult = launchPrimary()) { PickerLaunchResult.Launched -> { PickerLaunchOutcome.PrimaryLaunched } is PickerLaunchResult.Failed -> { - when (val fallbackResult = launchFallback()) { - PickerLaunchResult.Launched -> PickerLaunchOutcome.FallbackLaunched - is PickerLaunchResult.Failed -> PickerLaunchOutcome.Failed(fallbackResult.failure) + if (!primaryResult.isFallbackEligible) { + PickerLaunchOutcome.Failed(primaryResult.failure) + } else { + when (val fallbackResult = launchFallback()) { + PickerLaunchResult.Launched -> PickerLaunchOutcome.FallbackLaunched + is PickerLaunchResult.Failed -> PickerLaunchOutcome.Failed(fallbackResult.failure) + } } } } diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt index 620e5dd7..0312f838 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt @@ -15,6 +15,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertSame @RunWith(RobolectricTestRunner::class) @@ -31,6 +32,19 @@ class AndroidDirectoryPickerFailureTest { assertSame(platformFailure, failure.cause) } + @Test + fun AndroidDirectoryPicker_securityRejection_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("Directory picker launch rejected") + FileKit.init(throwingActivityResultRegistry(platformFailure)) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + @Test fun AndroidDirectoryPicker_cancellation_propagatesUnchanged() { val cancellation = CancellationException("Directory picker cancelled") diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt index 0d19a502..b86894dd 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt @@ -14,6 +14,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs import kotlin.test.assertSame @RunWith(RobolectricTestRunner::class) @@ -30,6 +31,19 @@ class AndroidFileSaverFailureTest { assertSame(platformFailure, failure.cause) } + @Test + fun AndroidFileSaver_securityRejection_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("File saver launch rejected") + FileKit.init(throwingActivityResultRegistry(platformFailure)) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + @Test fun AndroidFileSaver_cancellation_propagatesUnchanged() { val cancellation = CancellationException("Saver cancelled") diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt index 356ce0f1..aa7c3545 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt @@ -70,6 +70,49 @@ class AndroidPickerLaunchFallbackTest { assertSame(launchFailure, failure.cause) } + @Test + fun PickerLaunch_primaryThrowsSecurityException_doesNotInvokeFallbackAndThrowsPickerFailureWithCause() { + val launchFailure = SecurityException("Visual picker launch rejected") + var fallbackCalls = 0 + + val failure = assertFailsWith { + runBlocking { + runPickerLaunchWithActivityNotFoundFallback( + primary = { + throw launchFailure + }, + fallback = { + fallbackCalls++ + "fallback-result" + }, + ) + } + } + + assertSame(launchFailure, failure.cause) + assertEquals(0, fallbackCalls) + } + + @Test + fun PickerLaunch_fallbackThrowsSecurityException_throwsPickerFailureWithCause() { + val launchFailure = SecurityException("Document picker launch rejected") + + val failure = assertFailsWith { + runBlocking { + runPickerLaunchWithActivityNotFoundFallback( + primary = { + throw ActivityNotFoundException("No activity for visual picker") + }, + fallback = { + throw launchFailure + }, + ) + } + } + + assertSame(launchFailure, failure.cause) + } + @Test fun PickerLaunch_primaryReturnsNull_doesNotInvokeFallback() = runBlocking { var fallbackCalls = 0 diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index 060985c4..bc482658 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -96,6 +96,11 @@ internal actual suspend fun FileKit.platformOpenFileSaver( message = "No Android activity is available to open the file saver.", cause = failure, ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = "Android rejected the file saver launch.", + cause = failure, + ) } return uri?.let(::PlatformFile) } @@ -125,6 +130,11 @@ public actual suspend fun FileKit.openDirectoryPicker( message = "No Android activity is available to open the directory picker.", cause = failure, ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = "Android rejected the directory picker launch.", + cause = failure, + ) } return treeUri?.let(::PlatformFile) } @@ -509,7 +519,17 @@ internal suspend fun runPickerLaunchWithActivityNotFoundFallback( message = "No Android activity is available to open the file picker.", cause = fallbackFailure, ) + } catch (fallbackFailure: SecurityException) { + throw FileKitPickerException( + message = "Android rejected the file picker launch.", + cause = fallbackFailure, + ) } +} catch (primaryFailure: SecurityException) { + throw FileKitPickerException( + message = "Android rejected the file picker launch.", + cause = primaryFailure, + ) } internal fun FileKitType.toVisualFallbackMimeTypes(): Array = when (this) { From f9500dabaf43881f16f6f333d2d9ae12957bcded Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 16:23:01 +0200 Subject: [PATCH 26/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20Android=20shar?= =?UTF-8?q?ing=20security=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/AndroidSharingFailureTest.kt | 14 ++++++++++++++ .../vinceglb/filekit/dialogs/FileKit.android.kt | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt index 1d966dbd..4f858212 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt @@ -23,6 +23,20 @@ class AndroidSharingFailureTest { assertSame(platformFailure, failure.cause) } + @Test + fun AndroidSharing_securityRejection_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("Sharing launch rejected") + + val failure = assertFailsWith { + launchAndroidShareIntent { + throw platformFailure + } + } + + assertEquals("Android rejected the sharing launch.", failure.message) + assertSame(platformFailure, failure.cause) + } + @Test fun AndroidSharing_unexpectedFailure_propagates() { val platformFailure = IllegalStateException("Unexpected sharing defect") diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index bc482658..3f5879f7 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -383,6 +383,11 @@ internal fun launchAndroidShareIntent(launch: () -> Unit) { message = "No Android activity is available to share the selected files.", cause = failure, ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = "Android rejected the sharing launch.", + cause = failure, + ) } } From 319797dd737017612225d506b7b6d5f63dee0961 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 17:23:09 +0200 Subject: [PATCH 27/40] =?UTF-8?q?=F0=9F=90=9B=20Suppress=20dialog=20callba?= =?UTF-8?q?cks=20after=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compose/DialogOperationDispatcher.kt | 4 ++ .../compose/FileKitComposeFailureTest.kt | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt index f6923bfd..7a5e7cbf 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt @@ -1,6 +1,8 @@ package io.github.vinceglb.filekit.dialogs.compose import io.github.vinceglb.filekit.dialogs.FileKitDialogException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive internal suspend fun runDialogOperation( operation: suspend () -> OperationResult, @@ -10,9 +12,11 @@ internal suspend fun runDialogOperation( val result = try { operation() } catch (failure: FileKitDialogException) { + currentCoroutineContext().ensureActive() onError(failure) return } + currentCoroutineContext().ensureActive() onResult(result) } diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index 21a1e5a5..083ea04b 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -8,14 +8,18 @@ import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitPickerState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest +import kotlin.coroutines.suspendCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertSame +import kotlin.test.assertTrue class FileKitComposeFailureTest { @Test @@ -273,6 +277,60 @@ class FileKitComposeFailureTest { assertFalse(resultInvoked) } + @Test + fun runDialogOperation_cancelledJobAfterNonCooperativeSuccess_invokesNoCallbacks() = runTest { + lateinit var completeOperation: (Result) -> Unit + var errorInvoked = false + var resultInvoked = false + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + runDialogOperation( + operation = { + suspendCoroutine { continuation -> + completeOperation = continuation::resumeWith + } + }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + job.cancel() + completeOperation(Result.success("selected")) + job.join() + + assertTrue(job.isCancelled) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + + @Test + fun runDialogOperation_cancelledJobAfterNonCooperativeFailure_invokesNoCallbacks() = runTest { + lateinit var completeOperation: (Result) -> Unit + var errorInvoked = false + var resultInvoked = false + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + runDialogOperation( + operation = { + suspendCoroutine { continuation -> + completeOperation = continuation::resumeWith + } + }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + job.cancel() + completeOperation(Result.failure(FileKitDialogException("Late operational failure"))) + job.join() + + assertTrue(job.isCancelled) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + @Test fun runDialogOperation_unexpectedFailure_propagates_withoutInvokingCallbacks() = runTest { val failure = IllegalStateException("Unexpected picker defect") From e38cd9978bf69b5679c446617a802f3aec9406f0 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 17:24:10 +0200 Subject: [PATCH 28/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Share=20platform=20d?= =?UTF-8?q?ialog=20failure=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/FileKitPickerException.kt | 15 +++++++++++++++ .../dialogs/platform/mac/MacOSFilePicker.kt | 9 +++------ .../dialogs/platform/windows/WindowsFilePicker.kt | 6 ++++-- .../vinceglb/filekit/dialogs/FileKit.macos.kt | 6 +++--- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 4 ++-- 5 files changed, 27 insertions(+), 13 deletions(-) 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 index c0c68341..79d2c9a1 100644 --- 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 @@ -11,3 +11,18 @@ public class FileKitPickerException : FileKitDialogException { internal const val WINDOWS_FILE_PICKER_FAILURE_MESSAGE: String = "The Windows file picker could not complete the operation." + +internal const val WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE: String = + "The Windows directory picker could not complete the operation." + +internal const val WINDOWS_FILE_SAVER_FAILURE_MESSAGE: String = + "The Windows file saver could not complete the operation." + +internal const val MACOS_FILE_PICKER_FAILURE_MESSAGE: String = + "The macOS file picker could not complete the operation." + +internal const val MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE: String = + "The macOS directory picker could not complete the operation." + +internal const val MACOS_FILE_SAVER_FAILURE_MESSAGE: String = + "The macOS file saver could not complete the operation." diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index 21ce7c28..664bae88 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -5,6 +5,9 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMacOSSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE +import io.github.vinceglb.filekit.dialogs.MACOS_FILE_PICKER_FAILURE_MESSAGE +import io.github.vinceglb.filekit.dialogs.MACOS_FILE_SAVER_FAILURE_MESSAGE import io.github.vinceglb.filekit.dialogs.buildFileSaverAllowedFileTypes import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation @@ -188,12 +191,6 @@ internal class MacOSFilePicker : PlatformFilePicker { private companion object { const val NS_MODAL_RESPONSE_OK = 1 const val NS_MODAL_RESPONSE_CANCEL = 0 - const val MACOS_FILE_PICKER_FAILURE_MESSAGE = - "The macOS file picker could not complete the operation." - const val MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE = - "The macOS directory picker could not complete the operation." - const val MACOS_FILE_SAVER_FAILURE_MESSAGE = - "The macOS file saver could not complete the operation." fun Collection.toNsStringArray(): ID? { if (isEmpty()) { diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 90406ae5..e5cc64e3 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -20,7 +20,9 @@ import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE import io.github.vinceglb.filekit.dialogs.WINDOWS_FILE_PICKER_FAILURE_MESSAGE +import io.github.vinceglb.filekit.dialogs.WINDOWS_FILE_SAVER_FAILURE_MESSAGE import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileDialog import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileOpenDialog @@ -402,12 +404,12 @@ private fun HRESULT.verify(exceptionMessage: String): HRESULT { } private fun Throwable.toDirectoryPickerFailure(): FileKitDialogException = FileKitDialogException( - message = "The Windows directory picker could not complete the operation.", + message = WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE, cause = this, ) private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDialogException( - message = "The Windows file saver could not complete the operation.", + message = WINDOWS_FILE_SAVER_FAILURE_MESSAGE, cause = this, ) diff --git a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt index f9e1bb89..15e5bb25 100644 --- a/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt +++ b/filekit-dialogs/src/macosMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.macos.kt @@ -95,7 +95,7 @@ internal actual suspend fun FileKit.platformOpenFileSaver( when (result) { NSModalResponseOK -> Unit NSModalResponseCancel -> return null - else -> throw FileKitDialogException("The macOS file saver could not complete the operation.") + else -> throw FileKitDialogException(MACOS_FILE_SAVER_FAILURE_MESSAGE) } // Return the result @@ -157,9 +157,9 @@ private fun callPicker( else -> throw when (mode) { Mode.Single, Mode.Multiple, - -> FileKitPickerException("The macOS file picker could not complete the operation.") + -> FileKitPickerException(MACOS_FILE_PICKER_FAILURE_MESSAGE) - Mode.Directory -> FileKitDialogException("The macOS directory picker could not complete the operation.") + Mode.Directory -> FileKitDialogException(MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE) } } diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index fff1ef4e..1a7c5f47 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -135,7 +135,7 @@ public actual suspend fun FileKit.openDirectoryPicker( )?.firstOrNull() } catch (failure: WindowsDialogOperationalException) { throw FileKitDialogException( - message = "The Windows directory picker could not complete the operation.", + message = WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE, cause = failure, ) } @@ -152,7 +152,7 @@ internal actual suspend fun FileKit.platformOpenFileSaver( showSaveDialog(buildFileSaverSuggestedName(suggestedName, ext), ext, filters, directory, dialogSettings.title) } catch (failure: WindowsDialogOperationalException) { throw FileKitDialogException( - message = "The Windows file saver could not complete the operation.", + message = WINDOWS_FILE_SAVER_FAILURE_MESSAGE, cause = failure, ) } From e080dae27d032397bd84d9d2eb6873bea6407c2d Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 17:45:28 +0200 Subject: [PATCH 29/40] =?UTF-8?q?=E2=9C=85=20Retain=20Android=20test=20reg?= =?UTF-8?q?istries=20strongly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/AndroidDirectoryPickerFailureTest.kt | 14 ++++++++++---- .../filekit/dialogs/AndroidFileSaverFailureTest.kt | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt index 0312f838..d9f3fe9d 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt @@ -20,10 +20,13 @@ import kotlin.test.assertSame @RunWith(RobolectricTestRunner::class) class AndroidDirectoryPickerFailureTest { + private lateinit var registry: ActivityResultRegistry + @Test fun AndroidDirectoryPicker_missingActivity_throwsDialogOperationalFailureWithCause() { val platformFailure = ActivityNotFoundException("No activity for directory picker") - FileKit.init(throwingActivityResultRegistry(platformFailure)) + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { FileKit.openDirectoryPicker() } @@ -35,7 +38,8 @@ class AndroidDirectoryPickerFailureTest { @Test fun AndroidDirectoryPicker_securityRejection_throwsDialogOperationalFailureWithCause() { val platformFailure = SecurityException("Directory picker launch rejected") - FileKit.init(throwingActivityResultRegistry(platformFailure)) + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { FileKit.openDirectoryPicker() } @@ -48,7 +52,8 @@ class AndroidDirectoryPickerFailureTest { @Test fun AndroidDirectoryPicker_cancellation_propagatesUnchanged() { val cancellation = CancellationException("Directory picker cancelled") - FileKit.init(throwingActivityResultRegistry(cancellation)) + registry = throwingActivityResultRegistry(cancellation) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { FileKit.openDirectoryPicker() } @@ -60,7 +65,8 @@ class AndroidDirectoryPickerFailureTest { @Test fun AndroidDirectoryPicker_unexpectedFailure_propagatesUnchanged() { val defect = IllegalStateException("Unexpected directory picker defect") - FileKit.init(throwingActivityResultRegistry(defect)) + registry = throwingActivityResultRegistry(defect) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { FileKit.openDirectoryPicker() } diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt index b86894dd..153926a6 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt @@ -19,10 +19,13 @@ import kotlin.test.assertSame @RunWith(RobolectricTestRunner::class) class AndroidFileSaverFailureTest { + private lateinit var registry: ActivityResultRegistry + @Test fun AndroidFileSaver_missingActivity_throwsDialogOperationalFailureWithCause() { val platformFailure = ActivityNotFoundException("No activity for file saver") - FileKit.init(throwingActivityResultRegistry(platformFailure)) + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { openFileSaver() } @@ -34,7 +37,8 @@ class AndroidFileSaverFailureTest { @Test fun AndroidFileSaver_securityRejection_throwsDialogOperationalFailureWithCause() { val platformFailure = SecurityException("File saver launch rejected") - FileKit.init(throwingActivityResultRegistry(platformFailure)) + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { openFileSaver() } @@ -47,7 +51,8 @@ class AndroidFileSaverFailureTest { @Test fun AndroidFileSaver_cancellation_propagatesUnchanged() { val cancellation = CancellationException("Saver cancelled") - FileKit.init(throwingActivityResultRegistry(cancellation)) + registry = throwingActivityResultRegistry(cancellation) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { openFileSaver() } @@ -59,7 +64,8 @@ class AndroidFileSaverFailureTest { @Test fun AndroidFileSaver_unexpectedFailure_propagatesUnchanged() { val defect = IllegalStateException("Unexpected saver defect") - FileKit.init(throwingActivityResultRegistry(defect)) + registry = throwingActivityResultRegistry(defect) + FileKit.init(registry) val failure = assertFailsWith { runBlocking { openFileSaver() } From db1fde3509be281d695aaf34069e7dd4f7e97646 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:13:29 +0200 Subject: [PATCH 30/40] =?UTF-8?q?=F0=9F=90=9B=20Prevent=20state-flow=20cal?= =?UTF-8?q?lbacks=20after=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/compose/FileKitCompose.kt | 7 ++++- .../compose/FileKitComposeFailureTest.kt | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 059eaa64..b054f392 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -9,6 +9,8 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.FileKitType +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -178,7 +180,10 @@ private suspend fun FileKitMode) .catch { failure -> when (failure) { - is FileKitPickerException -> onFailure(failure) + is FileKitPickerException -> { + currentCoroutineContext().ensureActive() + onFailure(failure) + } else -> throw failure } }.collect(onConsumed) diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index 083ea04b..8617cdb5 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -460,6 +460,37 @@ class FileKitComposeFailureTest { assertEquals(listOf(failure), reportedFailures) } + @Test + fun runFilePickerLauncher_cancelledJobAfterNonCooperativeStateFailure_invokesNoCallbacks() = runTest { + lateinit var resumeStateStream: () -> Unit + var errorInvoked = false + var resultInvoked = false + + val job = launch(start = CoroutineStart.UNDISPATCHED) { + runFilePickerLauncher( + mode = FileKitMode.SingleWithState, + openPicker = { + flow> { + suspendCoroutine { continuation -> + resumeStateStream = { continuation.resumeWith(Result.success(Unit)) } + } + throw FileKitPickerException("Late state-stream failure") + } + }, + onError = { errorInvoked = true }, + onResult = { resultInvoked = true }, + ) + } + + job.cancel() + resumeStateStream() + job.join() + + assertTrue(job.isCancelled) + assertFalse(errorInvoked) + assertFalse(resultInvoked) + } + @Test fun runFilePickerLauncher_stateCallbackFailure_propagates_withoutInvokingError() = runTest { val callbackFailure = IllegalStateException("Consumer state callback failed") From 1cca2e01e4af4337e18fb515282799387ea370ba Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:16:41 +0200 Subject: [PATCH 31/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Simplify=20native=20?= =?UTF-8?q?Windows=20dialog=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 110 +++++++----------- .../dialogs/WindowsNativePickerFailureTest.kt | 81 +++++++------ 2 files changed, 87 insertions(+), 104 deletions(-) diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 1a7c5f47..7788d316 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -71,23 +71,6 @@ internal class WindowsDialogOperationalException( message: String, ) : RuntimeException(message) -internal enum class WindowsDialogFailurePolicy { - Legacy, - Picker, - Directory, - Saver, - ; - - fun createFailure(message: String): RuntimeException = when (this) { - Legacy -> IllegalStateException(message) - - Picker, - Directory, - Saver, - -> WindowsDialogOperationalException(message) - } -} - internal actual suspend fun FileKit.platformOpenFilePicker( type: FileKitType, mode: PickerMode, @@ -107,7 +90,6 @@ internal actual suspend fun FileKit.platformOpenFilePicker( title = dialogSettings.title, pickFolders = false, allowMultiple = mode is PickerMode.Multiple, - failurePolicy = WindowsDialogFailurePolicy.Picker, ) }.toPickerStateFlow() } @@ -131,7 +113,6 @@ public actual suspend fun FileKit.openDirectoryPicker( title = dialogSettings.title, pickFolders = true, allowMultiple = false, - failurePolicy = WindowsDialogFailurePolicy.Directory, )?.firstOrNull() } catch (failure: WindowsDialogOperationalException) { throw FileKitDialogException( @@ -170,25 +151,24 @@ private fun showOpenDialog( title: String?, pickFolders: Boolean, allowMultiple: Boolean, - failurePolicy: WindowsDialogFailurePolicy = WindowsDialogFailurePolicy.Legacy, ): List? = memScoped { - val comInitialized = initializeComForDialogs(failurePolicy) + val comInitialized = initializeComForDialogs() val ppDlg = alloc() try { val createHr = fk_create_open_dialog(ppDlg.ptr.reinterpret()) if (createHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "CoCreateInstance(IFileOpenDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", ) } val dlg = ppDlg.value - ?: throw failurePolicy.createFailure("CoCreateInstance(IFileOpenDialog) returned a null dialog pointer") + ?: throw WindowsDialogOperationalException("CoCreateInstance(IFileOpenDialog) returned a null dialog pointer") // Options val optsVar = alloc() val getOptionsHr = fk_dialog_get_options(dlg.reinterpret(), optsVar.ptr) if (getOptionsHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}", ) } @@ -197,7 +177,7 @@ private fun showOpenDialog( if (allowMultiple) opts = opts or FK_FOS_ALLOWMULTISELECT val setOptionsHr = fk_dialog_set_options(dlg.reinterpret(), opts.toUInt()) if (setOptionsHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}", ) } @@ -205,24 +185,23 @@ private fun showOpenDialog( title?.let { val setTitleHr = fk_dialog_set_title(dlg.reinterpret(), it) if (setTitleHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", ) } } - directory?.let { setFolder(dlg, it, failurePolicy) } - if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions, failurePolicy) + directory?.let { setFolder(dlg, it) } + if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions) handleWindowsNativeDialogResult( result = fk_dialog_show(dlg.reinterpret(), null), - failurePolicy = failurePolicy, operation = "IFileOpenDialog::Show", ) { if (allowMultiple) { - getMultipleResults(dlg, failurePolicy) + getMultipleResults(dlg) } else { val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() - getSingleResult(dlg, sigdn, failurePolicy)?.let { listOf(it) } + getSingleResult(dlg, sigdn)?.let { listOf(it) } } } } finally { @@ -235,7 +214,6 @@ private fun showOpenDialog( internal fun handleWindowsNativeDialogResult( result: Int, - failurePolicy: WindowsDialogFailurePolicy, operation: String, resolveResult: () -> T, ): T? { @@ -243,7 +221,7 @@ internal fun handleWindowsNativeDialogResult( return null } if (result != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "$operation failed with HRESULT 0x${result.toUInt().toString(16)}", ) } @@ -257,30 +235,29 @@ private fun showSaveDialog( directory: PlatformFile?, title: String?, ): PlatformFile? = memScoped { - val failurePolicy = WindowsDialogFailurePolicy.Saver - val comInitialized = initializeComForDialogs(failurePolicy) + val comInitialized = initializeComForDialogs() val ppDlg = alloc() try { val createHr = fk_create_save_dialog(ppDlg.ptr.reinterpret()) if (createHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "CoCreateInstance(IFileSaveDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", ) } val dlg = ppDlg.value - ?: throw failurePolicy.createFailure("CoCreateInstance(IFileSaveDialog) returned a null dialog pointer") + ?: throw WindowsDialogOperationalException("CoCreateInstance(IFileSaveDialog) returned a null dialog pointer") val optsVar = alloc() val getOptionsHr = fk_dialog_get_options(dlg.reinterpret(), optsVar.ptr) if (getOptionsHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}", ) } val opts = optsVar.value.toInt() or FK_FOS_FORCEFILESYSTEM or FK_FOS_PATHMUSTEXIST or FK_FOS_OVERWRITEPROMPT val setOptionsHr = fk_dialog_set_options(dlg.reinterpret(), opts.toUInt()) if (setOptionsHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}", ) } @@ -288,39 +265,39 @@ private fun showSaveDialog( title?.let { val setTitleHr = fk_dialog_set_title(dlg.reinterpret(), it) if (setTitleHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", ) } } val setFilenameHr = fk_dialog_set_filename(dlg.reinterpret(), suggestedName) if (setFilenameHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetFileName failed with HRESULT 0x${setFilenameHr.toUInt().toString(16)}", ) } defaultExtension?.let { val setDefaultExtensionHr = fk_dialog_set_default_extension(dlg.reinterpret(), it) if (setDefaultExtensionHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetDefaultExtension failed with HRESULT 0x${setDefaultExtensionHr.toUInt().toString(16)}", ) } } val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } - filterExtensions?.let { setFileTypes(dlg, it, failurePolicy) } - directory?.let { setFolder(dlg, it, failurePolicy) } + filterExtensions?.let { setFileTypes(dlg, it) } + directory?.let { setFolder(dlg, it) } val hr = fk_dialog_show(dlg.reinterpret(), null) if (hr != S_OK) { if (hr == ERROR_CANCELLED_HRESULT) { return@memScoped null } - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileSaveDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } - getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt(), failurePolicy) + getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt()) } finally { ppDlg.value?.let { fk_save_dialog_release(it.reinterpret()) } if (comInitialized) { @@ -331,9 +308,7 @@ private fun showSaveDialog( // region Helpers -private fun initializeComForDialogs( - failurePolicy: WindowsDialogFailurePolicy, -): Boolean { +private fun initializeComForDialogs(): Boolean { val result = CoInitializeEx( null, COINIT_APARTMENTTHREADED or COINIT_DISABLE_OLE1DDE, @@ -343,13 +318,12 @@ private fun initializeComForDialogs( return true } - throw failurePolicy.createFailure("CoInitializeEx failed with HRESULT 0x${result.toUInt().toString(16)}") + throw WindowsDialogOperationalException("CoInitializeEx failed with HRESULT 0x${result.toUInt().toString(16)}") } private fun MemScope.setFolder( dlg: ComPtr, dir: PlatformFile, - failurePolicy: WindowsDialogFailurePolicy, ) { val ppsi = alloc() val hr = fk_create_shell_item_from_path(dir.path, ppsi.ptr.reinterpret()) @@ -357,27 +331,25 @@ private fun MemScope.setFolder( if (hr == ERROR_FILE_NOT_FOUND_HRESULT || hr == ERROR_INVALID_DRIVE_HRESULT) { return } - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "SHCreateItemFromParsingName failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } val folder = ppsi.value ?: return setWindowsNativeDialogFolder( - failurePolicy = failurePolicy, setFolder = { fk_dialog_set_folder(dlg.reinterpret(), folder.reinterpret()) }, releaseFolder = { fk_shell_item_release(folder.reinterpret()) }, ) } internal fun setWindowsNativeDialogFolder( - failurePolicy: WindowsDialogFailurePolicy, setFolder: () -> Int, releaseFolder: () -> Unit, ) { try { val result = setFolder() if (result != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetFolder failed with HRESULT 0x${result.toUInt().toString(16)}", ) } @@ -389,7 +361,6 @@ internal fun setWindowsNativeDialogFolder( private fun MemScope.setFileTypes( dlg: ComPtr, exts: Set, - failurePolicy: WindowsDialogFailurePolicy, ) { val display = exts.joinToString(", ") { "*.$it" } val pattern = exts.joinToString(";") { "*.$it" } @@ -399,7 +370,7 @@ private fun MemScope.setFileTypes( spec[1] = pattern.wcstr.ptr val hr = fk_dialog_set_file_types(dlg.reinterpret(), 1u, spec.reinterpret()) if (hr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::SetFileTypes failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } @@ -408,19 +379,18 @@ private fun MemScope.setFileTypes( private fun MemScope.getSingleResult( dlg: ComPtr, sigdn: Int, - failurePolicy: WindowsDialogFailurePolicy, ): PlatformFile? { val ppsi = alloc() val hr = fk_dialog_get_result(dlg.reinterpret(), ppsi.ptr.reinterpret()) if (hr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileDialog::GetResult failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } val item = ppsi.value - ?: throw failurePolicy.createFailure("IFileDialog::GetResult returned a null result item") + ?: throw WindowsDialogOperationalException("IFileDialog::GetResult returned a null result item") try { - return shellItemToFile(item, sigdn, failurePolicy) + return shellItemToFile(item, sigdn) } finally { fk_shell_item_release(item.reinterpret()) } @@ -428,22 +398,21 @@ private fun MemScope.getSingleResult( private fun MemScope.getMultipleResults( dlg: ComPtr, - failurePolicy: WindowsDialogFailurePolicy, ): List { val ppArr = alloc() val resultsHr = fk_open_dialog_get_results(dlg.reinterpret(), ppArr.ptr.reinterpret()) if (resultsHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IFileOpenDialog::GetResults failed with HRESULT 0x${resultsHr.toUInt().toString(16)}", ) } val arr = ppArr.value - ?: throw failurePolicy.createFailure("IFileOpenDialog::GetResults returned a null result array") + ?: throw WindowsDialogOperationalException("IFileOpenDialog::GetResults returned a null result array") try { val cntVar = alloc() val countHr = fk_shell_item_array_get_count(arr.reinterpret(), cntVar.ptr) if (countHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IShellItemArray::GetCount failed with HRESULT 0x${countHr.toUInt().toString(16)}", ) } @@ -451,14 +420,14 @@ private fun MemScope.getMultipleResults( val ppsi = alloc() val itemHr = fk_shell_item_array_get_item_at(arr.reinterpret(), i.toUInt(), ppsi.ptr.reinterpret()) if (itemHr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IShellItemArray::GetItemAt failed with HRESULT 0x${itemHr.toUInt().toString(16)}", ) } val item = ppsi.value - ?: throw failurePolicy.createFailure("IShellItemArray::GetItemAt returned a null shell item") + ?: throw WindowsDialogOperationalException("IShellItemArray::GetItemAt returned a null shell item") try { - shellItemToFile(item, FK_SIGDN_FILESYSPATH.toInt(), failurePolicy) + shellItemToFile(item, FK_SIGDN_FILESYSPATH.toInt()) } finally { fk_shell_item_release(item.reinterpret()) } @@ -471,17 +440,16 @@ private fun MemScope.getMultipleResults( private fun MemScope.shellItemToFile( item: ComPtr, sigdn: Int, - failurePolicy: WindowsDialogFailurePolicy, ): PlatformFile? { val ppName = alloc>() val hr = fk_shell_item_get_display_name(item.reinterpret(), sigdn, ppName.ptr.reinterpret()) if (hr != S_OK) { - throw failurePolicy.createFailure( + throw WindowsDialogOperationalException( "IShellItem::GetDisplayName failed with HRESULT 0x${hr.toUInt().toString(16)}", ) } val namePtr = ppName.value - ?: throw failurePolicy.createFailure("IShellItem::GetDisplayName returned a null display name") + ?: throw WindowsDialogOperationalException("IShellItem::GetDisplayName returned a null display name") try { return PlatformFile(namePtr.toKStringFromUtf16()) } finally { diff --git a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt index 9ff93a98..a25976b1 100644 --- a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt +++ b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt @@ -34,33 +34,70 @@ class WindowsNativePickerFailureTest { private suspend fun assertIncompatibleComApartmentFailure( mode: FileKitMode, ) { + val failure = runIncompatibleComApartmentOperation { + FileKit.openFilePicker( + type = FileKitType.File(), + mode = mode, + ) + } + + assertIs(failure) + assertEquals("The Windows file picker could not complete the operation.", failure.message) + assertIncompatibleComApartmentCause(failure) + } + + @Test + fun DirectoryPicker_incompatibleComApartment_throwsDialogOperationalFailureWithCause() = runTest { + val failure = runIncompatibleComApartmentOperation { + FileKit.openDirectoryPicker() + } + + assertEquals(FileKitDialogException::class, failure::class) + assertEquals("The Windows directory picker could not complete the operation.", failure.message) + assertIncompatibleComApartmentCause(failure) + } + + @Test + fun FileSaver_incompatibleComApartment_throwsDialogOperationalFailureWithCause() = runTest { + val failure = runIncompatibleComApartmentOperation { + FileKit.openFileSaver( + suggestedName = "report.txt", + allowedExtensions = null, + ) + } + + assertEquals(FileKitDialogException::class, failure::class) + assertEquals("The Windows file saver could not complete the operation.", failure.message) + assertIncompatibleComApartmentCause(failure) + } + + private suspend fun runIncompatibleComApartmentOperation( + operation: suspend () -> Unit, + ): FileKitDialogException { val initializationResult = CoInitializeEx(null, COINIT_MULTITHREADED) assertEquals(S_OK, initializationResult) try { - val failure = assertFailsWith { - FileKit.openFilePicker( - type = FileKitType.File(), - mode = mode, - ) + return assertFailsWith { + operation() } - - assertEquals("The Windows file picker could not complete the operation.", failure.message) - val cause = assertNotNull(failure.cause) - assertIs(cause) - assertEquals("CoInitializeEx failed with HRESULT 0x80010106", cause.message) } finally { CoUninitialize() } } + private fun assertIncompatibleComApartmentCause(failure: FileKitDialogException) { + val cause = assertNotNull(failure.cause) + assertIs(cause) + assertEquals("CoInitializeEx failed with HRESULT 0x80010106", cause.message) + } + @Test fun OpenPicker_cancelledDialog_returnsNullWithoutResolvingSelection() { var selectionResolved = false val result = handleWindowsNativeDialogResult( result = ERROR_CANCELLED_HRESULT, - failurePolicy = WindowsDialogFailurePolicy.Picker, operation = "IFileOpenDialog::Show", ) { selectionResolved = true @@ -78,7 +115,6 @@ class WindowsNativePickerFailureTest { val failure = assertFailsWith { runWindowsNativePickerOperation { setWindowsNativeDialogFolder( - failurePolicy = WindowsDialogFailurePolicy.Picker, setFolder = { E_FAIL_HRESULT }, releaseFolder = { shellItemReleased = true }, ) @@ -92,27 +128,6 @@ class WindowsNativePickerFailureTest { assertTrue(shellItemReleased) } - @Test - fun DirectoryAndSaverSetFolder_failedHresult_remainsDialogOperationalFailure() { - listOf( - WindowsDialogFailurePolicy.Directory, - WindowsDialogFailurePolicy.Saver, - ).forEach { failurePolicy -> - var shellItemReleased = false - - val failure = assertFailsWith { - setWindowsNativeDialogFolder( - failurePolicy = failurePolicy, - setFolder = { E_FAIL_HRESULT }, - releaseFolder = { shellItemReleased = true }, - ) - } - - assertEquals("IFileDialog::SetFolder failed with HRESULT 0x80004005", failure.message) - assertTrue(shellItemReleased) - } - } - @Test fun PickerOperation_unexpectedFailure_propagatesUnchanged() { val sentinel = UnexpectedPickerFailure() From 97a221dd05bc13f0b9e52049afe6d45bae53c06e Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:15:42 +0200 Subject: [PATCH 32/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Deduplicate=20Androi?= =?UTF-8?q?d=20dialog=20launch=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AndroidComposePickerReliabilityTest.kt | 29 +++--- .../dialogs/compose/FileKitCompose.android.kt | 98 ++++++------------- 2 files changed, 46 insertions(+), 81 deletions(-) diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index c2c06257..acf4cd5d 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -128,7 +128,7 @@ class AndroidComposePickerReliabilityTest { val results = mutableListOf() dispatchCameraLaunchResult( - result = CameraLaunchResult.Failed(launchFailure), + result = AndroidDialogLaunchResult.Failed(launchFailure), clearPendingState = { hasPendingLaunch = false }, onError = { failure -> assertFalse(hasPendingLaunch) @@ -180,8 +180,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("Android rejected the camera launch.", failure.message) assertSame(launchFailure, failure.cause) } @@ -193,8 +194,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("No Android activity is available to capture media with the camera.", failure.message) assertSame(launchFailure, failure.cause) } @@ -207,7 +209,7 @@ class AndroidComposePickerReliabilityTest { launchedUri = uri } - assertIs(result) + assertIs(result) assertEquals(expectedUri, launchedUri) } @@ -232,7 +234,8 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure + assertEquals("No Android activity is available to request camera permission.", failure.message) assertSame(launchFailure, failure.cause) } @@ -298,8 +301,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("No Android activity is available to open the directory picker.", failure.message) assertSame(launchFailure, failure.cause) } @@ -311,8 +315,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("Android rejected the directory picker launch.", failure.message) assertSame(launchFailure, failure.cause) } @@ -335,7 +340,7 @@ class AndroidComposePickerReliabilityTest { launched = true } - assertIs(result) + assertIs(result) assertTrue(launched) } @@ -347,8 +352,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("No Android activity is available to open the file saver.", failure.message) assertSame(launchFailure, failure.cause) } @@ -360,8 +366,9 @@ class AndroidComposePickerReliabilityTest { throw launchFailure } - val failure = assertIs(result).failure + val failure = assertIs(result).failure assertIs(failure) + assertEquals("Android rejected the file saver launch.", failure.message) assertSame(launchFailure, failure.cause) } @@ -384,7 +391,7 @@ class AndroidComposePickerReliabilityTest { launched = true } - assertIs(result) + assertIs(result) assertTrue(launched) } 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 a2b01bfe..96390794 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 @@ -313,11 +313,11 @@ public actual fun rememberDirectoryPickerLauncher( launcher.launch(initialUri) } ) { - DirectoryLaunchResult.Launched -> { + AndroidDialogLaunchResult.Launched -> { // Await the Activity Result callback. } - is DirectoryLaunchResult.Failed -> { + is AndroidDialogLaunchResult.Failed -> { hasPendingLaunch = false currentOnError(launchResult.failure) } @@ -374,11 +374,11 @@ internal actual fun rememberPlatformFileSaverLauncher( ) } ) { - SaverLaunchResult.Launched -> { + AndroidDialogLaunchResult.Launched -> { // Await the Activity Result callback. } - is SaverLaunchResult.Failed -> { + is AndroidDialogLaunchResult.Failed -> { hasPendingLaunch = false currentOnError(launchResult.failure) } @@ -535,7 +535,7 @@ internal fun resolveCameraPermissionResult( internal fun launchCameraSafely( uri: Uri, launch: (Uri) -> Unit, -): CameraLaunchResult = launchCameraActivitySafely( +): AndroidDialogLaunchResult = launchAndroidDialogSafely( activityUnavailableMessage = "No Android activity is available to capture media with the camera.", securityFailureMessage = "Android rejected the camera launch.", ) { @@ -544,42 +544,42 @@ internal fun launchCameraSafely( internal fun launchCameraPermissionSafely( launch: () -> Unit, -): CameraLaunchResult = launchCameraActivitySafely( +): AndroidDialogLaunchResult = launchAndroidDialogSafely( activityUnavailableMessage = "No Android activity is available to request camera permission.", securityFailureMessage = "Android rejected the camera permission request.", launch = launch, ) -private fun launchCameraActivitySafely( +private fun launchAndroidDialogSafely( activityUnavailableMessage: String, securityFailureMessage: String, launch: () -> Unit, -): CameraLaunchResult = try { +): AndroidDialogLaunchResult = try { launch() - CameraLaunchResult.Launched + AndroidDialogLaunchResult.Launched } catch (failure: ActivityNotFoundException) { - CameraLaunchResult.Failed(FileKitDialogException(activityUnavailableMessage, failure)) + AndroidDialogLaunchResult.Failed(FileKitDialogException(activityUnavailableMessage, failure)) } catch (failure: SecurityException) { - CameraLaunchResult.Failed(FileKitDialogException(securityFailureMessage, failure)) + AndroidDialogLaunchResult.Failed(FileKitDialogException(securityFailureMessage, failure)) } -internal sealed interface CameraLaunchResult { - data object Launched : CameraLaunchResult +internal sealed interface AndroidDialogLaunchResult { + data object Launched : AndroidDialogLaunchResult data class Failed( val failure: FileKitDialogException, - ) : CameraLaunchResult + ) : AndroidDialogLaunchResult } internal fun dispatchCameraLaunchResult( - result: CameraLaunchResult, + result: AndroidDialogLaunchResult, clearPendingState: () -> Unit, onError: (FileKitDialogException) -> Unit, ) { when (result) { - CameraLaunchResult.Launched -> {} + AndroidDialogLaunchResult.Launched -> {} - is CameraLaunchResult.Failed -> { + is AndroidDialogLaunchResult.Failed -> { clearPendingState() onError(result.failure) } @@ -588,7 +588,7 @@ internal fun dispatchCameraLaunchResult( internal fun dispatchCameraPermissionResolution( resolution: CameraPermissionResolution, - launchCamera: (Uri) -> CameraLaunchResult, + launchCamera: (Uri) -> AndroidDialogLaunchResult, clearPendingState: () -> Unit, onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, @@ -645,61 +645,19 @@ internal fun launchPickerSafely( internal fun launchDirectoryPickerSafely( launch: () -> Unit, -): DirectoryLaunchResult = try { - launch() - DirectoryLaunchResult.Launched -} catch (failure: ActivityNotFoundException) { - DirectoryLaunchResult.Failed( - FileKitDialogException( - message = "No Android activity is available to open the directory picker.", - cause = failure, - ), - ) -} catch (failure: SecurityException) { - DirectoryLaunchResult.Failed( - FileKitDialogException( - message = "Android rejected the directory picker launch.", - cause = failure, - ), - ) -} - -internal sealed interface DirectoryLaunchResult { - data object Launched : DirectoryLaunchResult - - data class Failed( - val failure: FileKitDialogException, - ) : DirectoryLaunchResult -} +): AndroidDialogLaunchResult = launchAndroidDialogSafely( + activityUnavailableMessage = "No Android activity is available to open the directory picker.", + securityFailureMessage = "Android rejected the directory picker launch.", + launch = launch, +) internal fun launchFileSaverSafely( launch: () -> Unit, -): SaverLaunchResult = try { - launch() - SaverLaunchResult.Launched -} catch (failure: ActivityNotFoundException) { - SaverLaunchResult.Failed( - FileKitDialogException( - message = "No Android activity is available to open the file saver.", - cause = failure, - ), - ) -} catch (failure: SecurityException) { - SaverLaunchResult.Failed( - FileKitDialogException( - message = "Android rejected the file saver launch.", - cause = failure, - ), - ) -} - -internal sealed interface SaverLaunchResult { - data object Launched : SaverLaunchResult - - data class Failed( - val failure: FileKitDialogException, - ) : SaverLaunchResult -} +): AndroidDialogLaunchResult = launchAndroidDialogSafely( + activityUnavailableMessage = "No Android activity is available to open the file saver.", + securityFailureMessage = "Android rejected the file saver launch.", + launch = launch, +) internal sealed interface PickerLaunchResult { data object Launched : PickerLaunchResult From db40e507964c19ac5cd7c7b9b7666bf2265e8087 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:10:46 +0200 Subject: [PATCH 33/40] =?UTF-8?q?=F0=9F=94=A5=20Remove=20orphaned=20Androi?= =?UTF-8?q?d=20picker=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/compose/FileKitCompose.android.kt | 9 --------- 1 file changed, 9 deletions(-) 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 96390794..5ee58707 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 @@ -634,15 +634,6 @@ internal fun launchFilePickerSafely( ) } -internal fun launchPickerSafely( - launch: () -> Unit, -): Boolean = try { - launch() - true -} catch (_: ActivityNotFoundException) { - false -} - internal fun launchDirectoryPickerSafely( launch: () -> Unit, ): AndroidDialogLaunchResult = launchAndroidDialogSafely( From cd70bf880fe5273e597e8b7e1f8535d19def697b Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:25:50 +0200 Subject: [PATCH 34/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Deduplicate=20Androi?= =?UTF-8?q?d=20dialog=20launch=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AndroidComposePickerReliabilityTest.kt | 24 ++++++---- .../dialogs/compose/FileKitCompose.android.kt | 46 +++++++------------ 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index acf4cd5d..55fa324b 100644 --- a/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt +++ b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt @@ -121,13 +121,12 @@ class AndroidComposePickerReliabilityTest { } @Test - fun CameraLaunchFailure_clearsPendingStateBeforeReportingError_andAllowsImmediateRelaunch() { + fun AndroidDialogLaunchResult_failed_clearsPendingStateBeforeReportingError_andAllowsImmediateRelaunch() { var hasPendingLaunch = true - val launchFailure = FileKitDialogException("Camera unavailable") + val launchFailure = FileKitDialogException("Dialog unavailable") val failures = mutableListOf() - val results = mutableListOf() - dispatchCameraLaunchResult( + dispatchAndroidDialogLaunchResult( result = AndroidDialogLaunchResult.Failed(launchFailure), clearPendingState = { hasPendingLaunch = false }, onError = { failure -> @@ -139,16 +138,21 @@ class AndroidComposePickerReliabilityTest { assertEquals(listOf(launchFailure), failures) assertTrue(hasPendingLaunch) + } - dispatchCameraResult( - success = true, - pendingDestinationUri = "content://example.provider/camera/relaunch.jpg".takeIf { hasPendingLaunch }, + @Test + fun AndroidDialogLaunchResult_launched_keepsPendingStateAndDoesNotReportError() { + var hasPendingLaunch = true + val failures = mutableListOf() + + dispatchAndroidDialogLaunchResult( + result = AndroidDialogLaunchResult.Launched, clearPendingState = { hasPendingLaunch = false }, - onResult = results::add, + onError = failures::add, ) - assertEquals("content://example.provider/camera/relaunch.jpg", results.single()?.path) - assertFalse(hasPendingLaunch) + assertTrue(hasPendingLaunch) + assertTrue(failures.isEmpty()) } @Test 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 5ee58707..5560764e 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 @@ -308,20 +308,13 @@ public actual fun rememberDirectoryPickerLauncher( PickerResultLauncher { val initialUri = currentDirectory?.path?.toUri() hasPendingLaunch = true - when ( - val launchResult = launchDirectoryPickerSafely { + dispatchAndroidDialogLaunchResult( + result = launchDirectoryPickerSafely { launcher.launch(initialUri) - } - ) { - AndroidDialogLaunchResult.Launched -> { - // Await the Activity Result callback. - } - - is AndroidDialogLaunchResult.Failed -> { - hasPendingLaunch = false - currentOnError(launchResult.failure) - } - } + }, + clearPendingState = { hasPendingLaunch = false }, + onError = currentOnError, + ) } } } @@ -363,8 +356,8 @@ internal actual fun rememberPlatformFileSaverLauncher( } hasPendingLaunch = true - when ( - val launchResult = launchFileSaverSafely { + dispatchAndroidDialogLaunchResult( + result = launchFileSaverSafely { launcher.launch( CreateDocumentInput( mimeType = mimeType, @@ -372,17 +365,10 @@ internal actual fun rememberPlatformFileSaverLauncher( allowedMimeTypes = allowedMimeTypes, ), ) - } - ) { - AndroidDialogLaunchResult.Launched -> { - // Await the Activity Result callback. - } - - is AndroidDialogLaunchResult.Failed -> { - hasPendingLaunch = false - currentOnError(launchResult.failure) - } - } + }, + clearPendingState = { hasPendingLaunch = false }, + onError = currentOnError, + ) } } } @@ -486,7 +472,7 @@ public actual fun rememberCameraPickerLauncher( if (FileKitAndroidCameraPermissionInternal.needsRuntimeCameraPermission(context)) { hasPendingPermissionRequest = true - dispatchCameraLaunchResult( + dispatchAndroidDialogLaunchResult( result = launchCameraPermissionSafely { permissionLauncher.launch(Manifest.permission.CAMERA) }, @@ -500,7 +486,7 @@ public actual fun rememberCameraPickerLauncher( contract.setCameraFacing(cameraFacing) // Launch the camera - dispatchCameraLaunchResult( + dispatchAndroidDialogLaunchResult( result = launchCameraSafely( uri = uri, launch = launcher::launch, @@ -571,7 +557,7 @@ internal sealed interface AndroidDialogLaunchResult { ) : AndroidDialogLaunchResult } -internal fun dispatchCameraLaunchResult( +internal fun dispatchAndroidDialogLaunchResult( result: AndroidDialogLaunchResult, clearPendingState: () -> Unit, onError: (FileKitDialogException) -> Unit, @@ -602,7 +588,7 @@ internal fun dispatchCameraPermissionResolution( } is CameraPermissionResolution.LaunchCamera -> { - dispatchCameraLaunchResult( + dispatchAndroidDialogLaunchResult( result = launchCamera(resolution.uri), clearPendingState = clearPendingState, onError = onError, From ded031e6a8fa55d844da252361fc8acc24b5eece Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:32:47 +0200 Subject: [PATCH 35/40] =?UTF-8?q?=F0=9F=93=9D=20Document=20Nucleus=20launc?= =?UTF-8?q?her=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/dialog-settings.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/dialogs/dialog-settings.mdx b/docs/dialogs/dialog-settings.mdx index 0b1ec223..fd4e1915 100644 --- a/docs/dialogs/dialog-settings.mdx +++ b/docs/dialogs/dialog-settings.mdx @@ -113,9 +113,13 @@ private fun NucleusWindow.fileKitDialogParent(): FileKitDialogParent? { } val settings = FileKitDialogSettings(parent = nucleusWindow.fileKitDialogParent()) -val launcher = rememberFilePickerLauncher(dialogSettings = settings) { file -> - // Use the selected file. -} +val launcher = rememberFilePickerLauncher( + dialogSettings = settings, + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Use the selected file, or null when the user cancelled. + }, +) ``` Do not pass `nucleusWindow.unsafe.taoHandle`: it is an opaque Tao event-loop From e460adbde7f0767ce3ea04d1c8b85aa859ed63a8 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:42:03 +0200 Subject: [PATCH 36/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20launcher=20?= =?UTF-8?q?dispatcher=20middlemen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/compose/FileKitCompose.kt | 47 ---- .../compose/FileKitComposeFailureTest.kt | 206 ------------------ .../dialogs/compose/FileKitCompose.ios.kt | 4 +- .../compose/FileKitCompose.nativeAndJvm.kt | 4 +- .../compose/DirectoryLauncherJvmTest.kt | 28 --- .../compose/FileSaverLauncherJvmTest.kt | 28 --- .../dialogs/compose/FileKitCompose.mobile.kt | 5 +- .../compose/FileKitCompose.nonAndroid.kt | 4 +- 8 files changed, 9 insertions(+), 317 deletions(-) delete mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt delete mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index b054f392..3d411d32 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -227,50 +227,3 @@ public expect fun rememberDirectoryPickerLauncher( onError: (FileKitDialogException) -> Unit, onResult: (PlatformFile?) -> Unit, ): PickerResultLauncher - -internal suspend fun runDirectoryPickerLauncher( - openDirectoryPicker: suspend () -> PlatformFile?, - onError: (FileKitDialogException) -> Unit, - onResult: (PlatformFile?) -> Unit, -) { - runDialogOperation( - operation = openDirectoryPicker, - onError = onError, - onResult = onResult, - ) -} - -internal suspend fun runFileSaverLauncher( - openFileSaver: suspend () -> PlatformFile?, - onError: (FileKitDialogException) -> Unit, - onResult: (PlatformFile?) -> Unit, -) { - runDialogOperation( - operation = openFileSaver, - onError = onError, - onResult = onResult, - ) -} - -internal suspend fun runCameraPickerLauncher( - openCameraPicker: suspend () -> PlatformFile?, - onError: (FileKitDialogException) -> Unit, - onResult: (PlatformFile?) -> Unit, -) { - runDialogOperation( - operation = openCameraPicker, - onError = onError, - onResult = onResult, - ) -} - -internal suspend fun runShareFileLauncher( - shareFiles: suspend () -> Unit, - onError: (FileKitDialogException) -> Unit, -) { - runDialogOperation( - operation = shareFiles, - onError = onError, - onResult = {}, - ) -} diff --git a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt index 8617cdb5..aabe54cc 100644 --- a/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeFailureTest.kt @@ -22,212 +22,6 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class FileKitComposeFailureTest { - @Test - fun runShareFileLauncher_operationalFailure_invokesErrorOnce() = runTest { - val failure = FileKitDialogException("The share sheet could not be opened.") - val reportedFailures = mutableListOf() - - runShareFileLauncher( - shareFiles = { throw failure }, - onError = reportedFailures::add, - ) - - assertEquals(listOf(failure), reportedFailures) - } - - @Test - fun runShareFileLauncher_success_doesNotInvokeError() = runTest { - var errorInvoked = false - - runShareFileLauncher( - shareFiles = {}, - onError = { errorInvoked = true }, - ) - - assertFalse(errorInvoked) - } - - @Test - fun runShareFileLauncher_legacyIgnoredFailure_invokesNoCallback() = runTest { - runShareFileLauncher( - shareFiles = { throw FileKitDialogException("Ignored compatibility failure") }, - onError = {}, - ) - } - - @Test - fun runCameraPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { - val failure = FileKitDialogException("The camera could not be opened.") - val reportedFailures = mutableListOf() - var resultInvoked = false - - runCameraPickerLauncher( - openCameraPicker = { throw failure }, - onError = reportedFailures::add, - onResult = { resultInvoked = true }, - ) - - assertEquals(listOf(failure), reportedFailures) - assertFalse(resultInvoked) - } - - @Test - fun runCameraPickerLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { - val results = mutableListOf() - var errorInvoked = false - - runCameraPickerLauncher( - openCameraPicker = { null }, - onError = { errorInvoked = true }, - onResult = results::add, - ) - - assertEquals(1, results.size) - assertEquals(null, results.single()) - assertFalse(errorInvoked) - } - - @Test - fun runCameraPickerLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { - var resultInvoked = false - - runCameraPickerLauncher( - openCameraPicker = { throw FileKitDialogException("Ignored compatibility failure") }, - onError = {}, - onResult = { resultInvoked = true }, - ) - - assertFalse(resultInvoked) - } - - @Test - fun runFileSaverLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { - val failure = FileKitDialogException("The file saver could not be opened.") - val reportedFailures = mutableListOf() - var resultInvoked = false - - runFileSaverLauncher( - openFileSaver = { throw failure }, - onError = reportedFailures::add, - onResult = { resultInvoked = true }, - ) - - assertEquals(listOf(failure), reportedFailures) - assertFalse(resultInvoked) - } - - @Test - fun runFileSaverLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { - val results = mutableListOf() - var errorInvoked = false - - runFileSaverLauncher( - openFileSaver = { null }, - onError = { errorInvoked = true }, - onResult = results::add, - ) - - assertEquals(1, results.size) - assertEquals(null, results.single()) - assertFalse(errorInvoked) - } - - @Test - fun runFileSaverLauncher_invalidInvocation_propagates_withoutInvokingCallbacks() = runTest { - val failure = IllegalArgumentException("Unsupported saver arguments") - var errorInvoked = false - var resultInvoked = false - - val thrown = assertFailsWith { - runFileSaverLauncher( - openFileSaver = { throw failure }, - onError = { errorInvoked = true }, - onResult = { resultInvoked = true }, - ) - } - - assertSame(failure, thrown) - assertFalse(errorInvoked) - assertFalse(resultInvoked) - } - - @Test - fun runFileSaverLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { - var resultInvoked = false - - runFileSaverLauncher( - openFileSaver = { throw FileKitDialogException("Ignored compatibility failure") }, - onError = {}, - onResult = { resultInvoked = true }, - ) - - assertFalse(resultInvoked) - } - - @Test - fun runDirectoryPickerLauncher_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { - val failure = FileKitDialogException("The directory picker could not be opened.") - val reportedFailures = mutableListOf() - var resultInvoked = false - - runDirectoryPickerLauncher( - openDirectoryPicker = { throw failure }, - onError = reportedFailures::add, - onResult = { resultInvoked = true }, - ) - - assertEquals(listOf(failure), reportedFailures) - assertFalse(resultInvoked) - } - - @Test - fun runDirectoryPickerLauncher_userCancellation_invokesNullResultOnce_withoutInvokingError() = runTest { - val results = mutableListOf() - var errorInvoked = false - - runDirectoryPickerLauncher( - openDirectoryPicker = { null }, - onError = { errorInvoked = true }, - onResult = results::add, - ) - - assertEquals(1, results.size) - assertEquals(null, results.single()) - assertFalse(errorInvoked) - } - - @Test - fun runDirectoryPickerLauncher_invalidInvocation_propagates_withoutInvokingCallbacks() = runTest { - val failure = IllegalArgumentException("Unsupported directory argument") - var errorInvoked = false - var resultInvoked = false - - val thrown = assertFailsWith { - runDirectoryPickerLauncher( - openDirectoryPicker = { throw failure }, - onError = { errorInvoked = true }, - onResult = { resultInvoked = true }, - ) - } - - assertSame(failure, thrown) - assertFalse(errorInvoked) - assertFalse(resultInvoked) - } - - @Test - fun runDirectoryPickerLauncher_legacyIgnoredFailure_invokesNoResult() = runTest { - var resultInvoked = false - - runDirectoryPickerLauncher( - openDirectoryPicker = { throw FileKitDialogException("Ignored compatibility failure") }, - onError = {}, - onResult = { resultInvoked = true }, - ) - - assertFalse(resultInvoked) - } - @Test fun runDialogOperation_operationalFailure_invokesErrorOnce_withoutInvokingResult() = runTest { val failure = FileKitDialogException("The system dialog could not be opened.") diff --git a/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt b/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt index e9a86a8a..a1d869cb 100644 --- a/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt +++ b/filekit-dialogs-compose/src/iosMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.ios.kt @@ -60,8 +60,8 @@ public actual fun rememberCameraPickerLauncher( val returnedLauncher = remember { PhotoResultLauncher { type, cameraFacing, destinationFile -> coroutineScope.launch { - runCameraPickerLauncher( - openCameraPicker = { + runDialogOperation( + operation = { fileKit.openCameraPicker( type = type, cameraFacing = cameraFacing, diff --git a/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt b/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt index f74658a5..7179a2bc 100644 --- a/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt +++ b/filekit-dialogs-compose/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nativeAndJvm.kt @@ -27,8 +27,8 @@ internal actual fun rememberPlatformFileSaverLauncher( return remember { SaverResultLauncher { suggestedName, defaultExtension, allowedExtensions, directory -> coroutineScope.launch { - runFileSaverLauncher( - openFileSaver = { + runDialogOperation( + operation = { FileKit.openFileSaver( suggestedName = suggestedName, defaultExtension = defaultExtension, diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt deleted file mode 100644 index 120dbad0..00000000 --- a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/DirectoryLauncherJvmTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") - -package io.github.vinceglb.filekit.dialogs.compose - -import io.github.vinceglb.filekit.PlatformFile -import kotlinx.coroutines.test.runTest -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertSame - -class DirectoryLauncherJvmTest { - @Test - fun runDirectoryPickerLauncher_success_invokesResultOnce_withoutInvokingError() = runTest { - val directory = PlatformFile(File("selected-directory")) - val results = mutableListOf() - var errorInvoked = false - - runDirectoryPickerLauncher( - openDirectoryPicker = { directory }, - onError = { errorInvoked = true }, - onResult = results::add, - ) - - assertSame(directory, results.single()) - assertFalse(errorInvoked) - } -} diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt deleted file mode 100644 index b7555e1c..00000000 --- a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileSaverLauncherJvmTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") - -package io.github.vinceglb.filekit.dialogs.compose - -import io.github.vinceglb.filekit.PlatformFile -import kotlinx.coroutines.test.runTest -import java.io.File -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertSame - -class FileSaverLauncherJvmTest { - @Test - fun runFileSaverLauncher_success_invokesDestinationOnce_withoutInvokingError() = runTest { - val destination = PlatformFile(File("saved-document.txt")) - val results = mutableListOf() - var errorInvoked = false - - runFileSaverLauncher( - openFileSaver = { destination }, - onError = { errorInvoked = true }, - onResult = results::add, - ) - - assertSame(destination, results.single()) - assertFalse(errorInvoked) - } -} diff --git a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt index c9b77aa0..c6546bf6 100644 --- a/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt +++ b/filekit-dialogs-compose/src/mobileMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.mobile.kt @@ -85,9 +85,10 @@ public fun rememberShareFileLauncher( val returnedLauncher = remember { ShareResultLauncher { files -> coroutineScope.launch { - runShareFileLauncher( - shareFiles = { fileKit.shareFile(files, currentShareSettings) }, + runDialogOperation( + operation = { fileKit.shareFile(files, currentShareSettings) }, onError = currentOnError, + onResult = {}, ) } } diff --git a/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt b/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt index b01c15a4..26cca0c3 100644 --- a/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt +++ b/filekit-dialogs-compose/src/nonAndroidMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.nonAndroid.kt @@ -63,8 +63,8 @@ public actual fun rememberDirectoryPickerLauncher( return remember { PickerResultLauncher { coroutineScope.launch { - runDirectoryPickerLauncher( - openDirectoryPicker = { + runDialogOperation( + operation = { FileKit.openDirectoryPicker( directory = currentDirectory, dialogSettings = currentDialogSettings, From 45ae33cc24001423d87874728402b00ff7da74b6 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:50:21 +0200 Subject: [PATCH 37/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Deduplicate=20Androi?= =?UTF-8?q?d=20operation=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/AndroidCameraPickerFailureTest.kt | 4 + .../AndroidDirectoryPickerFailureTest.kt | 2 + .../dialogs/AndroidFileSaverFailureTest.kt | 2 + .../filekit/dialogs/FileKit.android.kt | 78 ++++++++----------- 4 files changed, 39 insertions(+), 47 deletions(-) diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt index b7d2eeb1..aeff4cb2 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt @@ -54,6 +54,7 @@ class AndroidCameraPickerFailureTest { runBlocking { openCameraPickerAtTestDestination() } } + assertEquals("No Android activity is available to capture media with the camera.", failure.message) assertSame(platformFailure, failure.cause) } @@ -67,6 +68,7 @@ class AndroidCameraPickerFailureTest { runBlocking { openCameraPickerAtTestDestination() } } + assertEquals("Android rejected the camera launch.", failure.message) val cause = assertIs(failure.cause) assertEquals(platformFailure.message, cause.message) } @@ -82,6 +84,7 @@ class AndroidCameraPickerFailureTest { runBlocking { openCameraPickerAtTestDestination() } } + assertEquals("No Android activity is available to request camera permission.", failure.message) assertSame(platformFailure, failure.cause) } @@ -96,6 +99,7 @@ class AndroidCameraPickerFailureTest { runBlocking { openCameraPickerAtTestDestination() } } + assertEquals("Android rejected the camera permission request.", failure.message) val cause = assertIs(failure.cause) assertEquals(platformFailure.message, cause.message) } diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt index d9f3fe9d..288e330d 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt @@ -32,6 +32,7 @@ class AndroidDirectoryPickerFailureTest { runBlocking { FileKit.openDirectoryPicker() } } + assertEquals("No Android activity is available to open the directory picker.", failure.message) assertSame(platformFailure, failure.cause) } @@ -45,6 +46,7 @@ class AndroidDirectoryPickerFailureTest { runBlocking { FileKit.openDirectoryPicker() } } + assertEquals("Android rejected the directory picker launch.", failure.message) val cause = assertIs(failure.cause) assertEquals(platformFailure.message, cause.message) } diff --git a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt index 153926a6..e0991d92 100644 --- a/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt @@ -31,6 +31,7 @@ class AndroidFileSaverFailureTest { runBlocking { openFileSaver() } } + assertEquals("No Android activity is available to open the file saver.", failure.message) assertSame(platformFailure, failure.cause) } @@ -44,6 +45,7 @@ class AndroidFileSaverFailureTest { runBlocking { openFileSaver() } } + assertEquals("Android rejected the file saver launch.", failure.message) val cause = assertIs(failure.cause) assertEquals(platformFailure.message, cause.message) } diff --git a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt index 3f5879f7..6e568bb0 100644 --- a/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt +++ b/filekit-dialogs/src/androidMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.android.kt @@ -81,7 +81,10 @@ internal actual suspend fun FileKit.platformOpenFileSaver( suggestedName = suggestedName, extension = normalizedDefaultExtension, ) - val uri = try { + val uri = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to open the file saver.", + securityExceptionMessage = "Android rejected the file saver launch.", + ) { awaitActivityResult( registry = registry, contract = contract, @@ -91,16 +94,6 @@ internal actual suspend fun FileKit.platformOpenFileSaver( allowedMimeTypes = allowedMimeTypes, ), ) - } catch (failure: ActivityNotFoundException) { - throw FileKitDialogException( - message = "No Android activity is available to open the file saver.", - cause = failure, - ) - } catch (failure: SecurityException) { - throw FileKitDialogException( - message = "Android rejected the file saver launch.", - cause = failure, - ) } return uri?.let(::PlatformFile) } @@ -119,22 +112,15 @@ public actual suspend fun FileKit.openDirectoryPicker( val registry = FileKit.registry val contract = ActivityResultContracts.OpenDocumentTree() val initialUri = directory?.path?.toUri() - val treeUri = try { + val treeUri = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to open the directory picker.", + securityExceptionMessage = "Android rejected the directory picker launch.", + ) { awaitActivityResult( registry = registry, contract = contract, input = initialUri, ) - } catch (failure: ActivityNotFoundException) { - throw FileKitDialogException( - message = "No Android activity is available to open the directory picker.", - cause = failure, - ) - } catch (failure: SecurityException) { - throw FileKitDialogException( - message = "Android rejected the directory picker launch.", - cause = failure, - ) } return treeUri?.let(::PlatformFile) } @@ -156,18 +142,11 @@ public actual suspend fun FileKit.openCameraPicker( openCameraSettings: FileKitOpenCameraSettings, ): PlatformFile? { val registry = FileKit.registry - val hasCameraPermission = try { + val hasCameraPermission = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to request camera permission.", + securityExceptionMessage = "Android rejected the camera permission request.", + ) { FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context) - } catch (failure: ActivityNotFoundException) { - throw FileKitDialogException( - message = "No Android activity is available to request camera permission.", - cause = failure, - ) - } catch (failure: SecurityException) { - throw FileKitDialogException( - message = "Android rejected the camera permission request.", - cause = failure, - ) } if (!hasCameraPermission) { return null @@ -175,22 +154,15 @@ public actual suspend fun FileKit.openCameraPicker( val contract = TakePictureWithCameraFacing(cameraFacing) val uri = destinationFile.toAndroidUri(openCameraSettings.authority) - val isSaved = try { + val isSaved = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to capture media with the camera.", + securityExceptionMessage = "Android rejected the camera launch.", + ) { awaitActivityResult( registry = registry, contract = contract, input = uri, ) - } catch (failure: ActivityNotFoundException) { - throw FileKitDialogException( - message = "No Android activity is available to capture media with the camera.", - cause = failure, - ) - } catch (failure: SecurityException) { - throw FileKitDialogException( - message = "Android rejected the camera launch.", - cause = failure, - ) } return if (isSaved) destinationFile else null } @@ -376,16 +348,28 @@ public actual suspend fun FileKit.shareFile( } internal fun launchAndroidShareIntent(launch: () -> Unit) { + runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to share the selected files.", + securityExceptionMessage = "Android rejected the sharing launch.", + operation = launch, + ) +} + +private inline fun runAndroidDialogOperation( + activityNotFoundMessage: String, + securityExceptionMessage: String, + operation: () -> O, +): O { try { - launch() + return operation() } catch (failure: ActivityNotFoundException) { throw FileKitDialogException( - message = "No Android activity is available to share the selected files.", + message = activityNotFoundMessage, cause = failure, ) } catch (failure: SecurityException) { throw FileKitDialogException( - message = "Android rejected the sharing launch.", + message = securityExceptionMessage, cause = failure, ) } From b36774e1ca95a6b568f43261d389277bc04706be Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:51:03 +0200 Subject: [PATCH 38/40] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Reuse=20Windows=20sa?= =?UTF-8?q?ver=20result=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vinceglb/filekit/dialogs/FileKit.mingw.kt | 14 +++++------- .../dialogs/WindowsNativePickerFailureTest.kt | 22 +++++++++++++++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt index 7788d316..a9266659 100644 --- a/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt +++ b/filekit-dialogs/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.mingw.kt @@ -288,16 +288,12 @@ private fun showSaveDialog( filterExtensions?.let { setFileTypes(dlg, it) } directory?.let { setFolder(dlg, it) } - val hr = fk_dialog_show(dlg.reinterpret(), null) - if (hr != S_OK) { - if (hr == ERROR_CANCELLED_HRESULT) { - return@memScoped null - } - throw WindowsDialogOperationalException( - "IFileSaveDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}", - ) + handleWindowsNativeDialogResult( + result = fk_dialog_show(dlg.reinterpret(), null), + operation = "IFileSaveDialog::Show", + ) { + getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt()) } - getSingleResult(dlg, FK_SIGDN_FILESYSPATH.toInt()) } finally { ppDlg.value?.let { fk_save_dialog_release(it.reinterpret()) } if (comInitialized) { diff --git a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt index a25976b1..17446627 100644 --- a/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt +++ b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt @@ -93,12 +93,12 @@ class WindowsNativePickerFailureTest { } @Test - fun OpenPicker_cancelledDialog_returnsNullWithoutResolvingSelection() { + fun FileSaver_cancelledDialog_returnsNullWithoutResolvingSelection() { var selectionResolved = false val result = handleWindowsNativeDialogResult( result = ERROR_CANCELLED_HRESULT, - operation = "IFileOpenDialog::Show", + operation = "IFileSaveDialog::Show", ) { selectionResolved = true "selected.txt" @@ -108,6 +108,24 @@ class WindowsNativePickerFailureTest { assertFalse(selectionResolved) } + @Test + fun FileSaver_failedDialog_throwsOperationalFailureWithoutResolvingSelection() { + var selectionResolved = false + + val failure = assertFailsWith { + handleWindowsNativeDialogResult( + result = E_FAIL_HRESULT, + operation = "IFileSaveDialog::Show", + ) { + selectionResolved = true + "selected.txt" + } + } + + assertEquals("IFileSaveDialog::Show failed with HRESULT 0x80004005", failure.message) + assertFalse(selectionResolved) + } + @Test fun PickerSetFolder_failedHresult_throwsPickerOperationalFailureAndReleasesShellItem() { var shellItemReleased = false From 39431d0b5321cbf7ae111ce6e71e9e1fb6f1f9b5 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 18:58:47 +0200 Subject: [PATCH 39/40] =?UTF-8?q?=F0=9F=93=9D=20Fix=20camera=20FileProvide?= =?UTF-8?q?r=20Compose=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/camera-picker.mdx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/dialogs/camera-picker.mdx b/docs/dialogs/camera-picker.mdx index 87124d91..d7042cfe 100644 --- a/docs/dialogs/camera-picker.mdx +++ b/docs/dialogs/camera-picker.mdx @@ -173,13 +173,18 @@ val file = FileKit.openCameraPicker( ```kotlin filekit-dialogs-compose val customFile = FileKit.filesDir / "my_photo.jpg" -Button(onClick = { - launcher.launch( - destinationFile = customFile, - openCameraSettings = FileKitOpenCameraSettings( - authority = "${context.packageName}.fileprovider" - ) - ) +val launcher = rememberCameraPickerLauncher( + openCameraSettings = FileKitOpenCameraSettings( + authority = "${context.packageName}.fileprovider", + ), + onError = { failure -> showError(failure.message) }, + onResult = { file -> + // Handle the captured photo, or null when dismissed or camera permission is denied + }, +) + +Button(onClick = { + launcher.launch(destinationFile = customFile) }) { Text("Take a photo to custom location") } From abb11bb597c1081d02c9aaef5e58842ff3dacab8 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Sat, 8 Aug 2026 22:09:19 +0200 Subject: [PATCH 40/40] =?UTF-8?q?=F0=9F=90=9B=20Normalize=20AWT=20display?= =?UTF-8?q?=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filekit/dialogs/compose/FileKitCompose.kt | 5 +- .../dialogs/platform/awt/AwtFilePicker.kt | 55 ++++++++++++------- .../dialogs/platform/awt/AwtFileSaver.kt | 6 ++ .../platform/awt/AwtFilePickerFailureTest.kt | 15 +++++ .../platform/awt/AwtFileSaverFailureTest.kt | 14 +++++ 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt index 3d411d32..84ffb137 100644 --- a/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.kt @@ -184,7 +184,10 @@ private suspend fun FileKitMode throw failure + + else -> { + throw failure + } } }.collect(onConsumed) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt index 147456a7..41da04c0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt @@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.dialogs.FileKitPickerException import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine +import java.awt.AWTError import java.awt.Dialog import java.awt.EventQueue import java.awt.FileDialog @@ -17,6 +18,7 @@ import java.awt.Window import java.io.File import java.io.FilenameFilter import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException internal class AwtFilePicker : PlatformFilePicker { override suspend fun openFilePicker( @@ -54,7 +56,7 @@ internal class AwtFilePicker : PlatformFilePicker { directory: PlatformFile?, fileExtensions: Set?, parentWindow: Window?, - ): List? = try { + ): List? = runAwtFilePicker { suspendCancellableCoroutine { continuation -> // Handle parentWindow: Dialog, Frame, or null val dialog = when (parentWindow) { @@ -63,32 +65,47 @@ internal class AwtFilePicker : PlatformFilePicker { } EventQueue.invokeLater { - // Set multiple mode - dialog.isMultipleMode = isMultipleMode + try { + // Set multiple mode + dialog.isMultipleMode = isMultipleMode - // Set mime types - dialog.filenameFilter = FilenameFilter { _, name -> - fileExtensions?.any { name.endsWith(suffix = it) } ?: true - } + // Set mime types + dialog.filenameFilter = FilenameFilter { _, name -> + fileExtensions?.any { name.endsWith(suffix = it) } ?: true + } - // Set initial directory - directory?.let { dialog.directory = directory.path } + // Set initial directory + directory?.let { dialog.directory = directory.path } - // Show the dialog - dialog.isVisible = true + // Show the dialog + dialog.isVisible = true - val files = dialog.files.takeIf { it.isNotEmpty() } - val result = files ?: dialog.file?.let { arrayOf(File(it)) } + val files = dialog.files.takeIf { it.isNotEmpty() } + val result = files ?: dialog.file?.let { arrayOf(File(it)) } - continuation.resume(value = result?.toList()) + continuation.resume(value = result?.toList()) + } catch (failure: AWTError) { + continuation.resumeWithException(failure) + } } continuation.invokeOnCancellation { dialog.dispose() } } - } catch (failure: HeadlessException) { - throw FileKitPickerException( - message = "The AWT file picker is unavailable in a headless environment.", - cause = failure, - ) } } + +internal suspend fun runAwtFilePicker( + operation: suspend () -> T, +): T = try { + operation() +} catch (failure: HeadlessException) { + throw FileKitPickerException( + message = "The AWT file picker is unavailable in a headless environment.", + cause = failure, + ) +} catch (failure: AWTError) { + throw FileKitPickerException( + message = "The AWT file picker could not connect to the display environment.", + cause = failure, + ) +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt index 94241bfd..4960607e 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt @@ -5,6 +5,7 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogException import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine +import java.awt.AWTError import java.awt.Dialog import java.awt.FileDialog import java.awt.Frame @@ -81,4 +82,9 @@ internal suspend fun runAwtFileSaver( message = "The AWT file saver is unavailable in a headless environment.", cause = failure, ) +} catch (failure: AWTError) { + throw FileKitDialogException( + message = "The AWT file saver could not connect to the display environment.", + cause = failure, + ) } diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt index 41357577..eaf3b9ce 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt @@ -6,13 +6,28 @@ import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitPickerException import kotlinx.coroutines.test.runTest import org.junit.Assume.assumeTrue +import java.awt.AWTError import java.awt.GraphicsEnvironment import java.awt.HeadlessException import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertSame class AwtFilePickerFailureTest { + @Test + fun AwtFilePicker_displayConnectionFailure_throwsPickerOperationalFailureWithCause() = runTest { + val displayFailure = AWTError("Cannot connect to display") + + val failure = assertFailsWith { + runAwtFilePicker { + throw displayFailure + } + } + + assertSame(displayFailure, failure.cause) + } + @Test fun AwtFilePicker_headlessFailure_throwsPickerOperationalFailureWithCause() = runTest { assumeTrue(System.getProperty("filekit.test.headlessAwtFilePicker") == "true") diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt index 295480ea..ef7f0aa3 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt @@ -4,12 +4,26 @@ package io.github.vinceglb.filekit.dialogs.platform.awt import io.github.vinceglb.filekit.dialogs.FileKitDialogException import kotlinx.coroutines.test.runTest +import java.awt.AWTError import java.awt.HeadlessException import kotlin.test.Test import kotlin.test.assertFailsWith import kotlin.test.assertSame class AwtFileSaverFailureTest { + @Test + fun AwtFileSaver_displayConnectionFailure_throwsDialogOperationalFailureWithCause() = runTest { + val displayFailure = AWTError("Cannot connect to display") + + val failure = assertFailsWith { + runAwtFileSaver { + throw displayFailure + } + } + + assertSame(displayFailure, failure.cause) + } + @Test fun AwtFileSaver_headlessFailure_throwsDialogOperationalFailureWithCause() = runTest { val headlessFailure = HeadlessException("No graphics environment")