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 1ff26df2..d7042cfe 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,12 @@ 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. + +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. + 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 +69,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 +100,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 +132,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() }) { @@ -154,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") } 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 diff --git a/docs/dialogs/directory-picker.mdx b/docs/dialogs/directory-picker.mdx index 9efc09da..673bd694 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,12 @@ 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. + +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. @@ -41,9 +57,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/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 76de29bd..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,6 +29,10 @@ Button(onClick = { launcher.launch() }) { ``` +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. + +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 @@ -55,17 +62,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 +105,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") - } - 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") + 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.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. @@ -145,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 + }, +) ``` @@ -170,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 8aead236..a516544f 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,12 @@ 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. + +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + ## Parameters The file saver can be customized with several parameters: @@ -73,10 +87,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/dialogs/gallery-picker.mdx b/docs/dialogs/gallery-picker.mdx index b7705d82..a01d25db 100644 --- a/docs/dialogs/gallery-picker.mdx +++ b/docs/dialogs/gallery-picker.mdx @@ -32,12 +32,16 @@ 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. + }, +) ``` +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 973eec72..59d1f174 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,9 +42,12 @@ 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. +See [dialog error handling](/dialogs/error-handling) for the complete callback and propagation matrix. + ## Android setup @@ -62,16 +66,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/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 0afb92d5..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") @@ -113,10 +122,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/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/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/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/AndroidComposePickerReliabilityTest.kt b/filekit-dialogs-compose/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/AndroidComposePickerReliabilityTest.kt index fda5a4a8..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 @@ -7,6 +7,8 @@ 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 import org.junit.runner.RunWith @@ -17,6 +19,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) @@ -87,54 +90,312 @@ 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 AndroidDialogLaunchResult_failed_clearsPendingStateBeforeReportingError_andAllowsImmediateRelaunch() { + var hasPendingLaunch = true + val launchFailure = FileKitDialogException("Dialog unavailable") + val failures = mutableListOf() + + dispatchAndroidDialogLaunchResult( + result = AndroidDialogLaunchResult.Failed(launchFailure), + clearPendingState = { hasPendingLaunch = false }, + onError = { failure -> + assertFalse(hasPendingLaunch) + failures += failure + hasPendingLaunch = true + }, + ) + + assertEquals(listOf(launchFailure), failures) + assertTrue(hasPendingLaunch) + } + + @Test + fun AndroidDialogLaunchResult_launched_keepsPendingStateAndDoesNotReportError() { + var hasPendingLaunch = true + val failures = mutableListOf() + + dispatchAndroidDialogLaunchResult( + result = AndroidDialogLaunchResult.Launched, + clearPendingState = { hasPendingLaunch = false }, + onError = failures::add, + ) + + assertTrue(hasPendingLaunch) + assertTrue(failures.isEmpty()) + } + + @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) + assertEquals("Android rejected the camera launch.", failure.message) + 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) + assertEquals("No Android activity is available to capture media with the camera.", failure.message) + 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 PickerLaunchSafely_whenActivityNotFound_returnsFalse() { - val launched = launchPickerSafely { - throw ActivityNotFoundException("No activity found") + 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 + assertEquals("No Android activity is available to request camera permission.", failure.message) + 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") + + val result = launchFilePickerSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertSame(launchFailure, failure.cause) + } + + @Test + fun PickerLaunchSafely_whenSecurityException_returnsOperationalFailureWithCause() { + val launchFailure = SecurityException("Picker launch rejected") + + 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) + } + + @Test + fun DirectoryLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No directory picker activity") + + val result = launchDirectoryPickerSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertEquals("No Android activity is available to open the directory picker.", failure.message) + 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) + assertEquals("Android rejected the directory picker launch.", failure.message) + 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 FileSaverLaunchSafely_whenActivityNotFound_returnsOperationalFailureWithCause() { + val launchFailure = ActivityNotFoundException("No file saver activity") + + val result = launchFileSaverSafely { + throw launchFailure + } + + val failure = assertIs(result).failure + assertIs(failure) + assertEquals("No Android activity is available to open the file saver.", failure.message) + 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) + assertEquals("Android rejected the file saver launch.", failure.message) + 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) } @@ -143,10 +404,15 @@ class AndroidComposePickerReliabilityTest { var fallbackCalls = 0 val outcome = resolvePickerLaunchOutcome( - launchPrimary = { false }, + launchPrimary = { + PickerLaunchResult.Failed( + failure = FileKitPickerException("Primary failed"), + isFallbackEligible = true, + ) + }, launchFallback = { fallbackCalls++ - true + PickerLaunchResult.Launched }, ) @@ -155,13 +421,48 @@ class AndroidComposePickerReliabilityTest { } @Test - fun PickerLaunchOutcome_primaryAndFallbackFail_returnsCancelled() { + 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 = { false }, - launchFallback = { false }, + launchPrimary = { + PickerLaunchResult.Failed( + failure = FileKitPickerException("Primary failed"), + isFallbackEligible = true, + ) + }, + launchFallback = { + PickerLaunchResult.Failed( + failure = fallbackFailure, + isFallbackEligible = false, + ) + }, ) - assertEquals(PickerLaunchOutcome.Cancelled, outcome) + val failure = assertIs(outcome) + assertSame(fallbackFailure, failure.failure) } @Test 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/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/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-android-launcher-consumer.jar b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-android-launcher-consumer.jar new file mode 100644 index 00000000..a651c2b9 Binary files /dev/null and b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-android-launcher-consumer.jar differ 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 00000000..d42af77e Binary files /dev/null and b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-camera-consumer.jar differ 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 00000000..a02613b0 Binary files /dev/null and b/filekit-dialogs-compose/src/androidHostTest/resources/legacy-sharing-consumer.jar differ 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..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 @@ -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 @@ -82,12 +83,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 +103,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 +111,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 +172,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 +223,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) } } } @@ -277,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) @@ -297,13 +308,13 @@ public actual fun rememberDirectoryPickerLauncher( PickerResultLauncher { val initialUri = currentDirectory?.path?.toUri() hasPendingLaunch = true - val isLaunched = launchPickerSafely { - launcher.launch(initialUri) - } - if (!isLaunched) { - hasPendingLaunch = false - currentOnResult(null) - } + dispatchAndroidDialogLaunchResult( + result = launchDirectoryPickerSafely { + launcher.launch(initialUri) + }, + clearPendingState = { hasPendingLaunch = false }, + onError = currentOnError, + ) } } } @@ -311,10 +322,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) } @@ -343,12 +356,18 @@ internal actual fun rememberPlatformFileSaverLauncher( } hasPendingLaunch = true - launcher.launch( - CreateDocumentInput( - mimeType = mimeType, - fileName = fileName, - allowedMimeTypes = allowedMimeTypes, - ), + dispatchAndroidDialogLaunchResult( + result = launchFileSaverSafely { + launcher.launch( + CreateDocumentInput( + mimeType = mimeType, + fileName = fileName, + allowedMimeTypes = allowedMimeTypes, + ), + ) + }, + clearPendingState = { hasPendingLaunch = false }, + onError = currentOnError, ) } } @@ -365,6 +384,26 @@ internal actual fun rememberPlatformFileSaverLauncher( 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, 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() @@ -376,17 +415,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 = @@ -394,37 +443,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 @@ -437,7 +472,13 @@ public actual fun rememberCameraPickerLauncher( if (FileKitAndroidCameraPermissionInternal.needsRuntimeCameraPermission(context)) { hasPendingPermissionRequest = true - permissionLauncher.launch(Manifest.permission.CAMERA) + dispatchAndroidDialogLaunchResult( + result = launchCameraPermissionSafely { + permissionLauncher.launch(Manifest.permission.CAMERA) + }, + clearPendingState = ::clearPendingState, + onError = currentOnError, + ) return@PhotoResultLauncher } @@ -445,14 +486,14 @@ public actual fun rememberCameraPickerLauncher( contract.setCameraFacing(cameraFacing) // Launch the camera - val isLaunched = launchCameraSafely( - uri = uri, - launch = launcher::launch, + dispatchAndroidDialogLaunchResult( + result = launchCameraSafely( + uri = uri, + launch = launcher::launch, + ), + clearPendingState = ::clearPendingState, + onError = currentOnError, ) - if (!isLaunched) { - pendingDestinationUri = null - currentOnResult(null) - } } } } @@ -480,37 +521,158 @@ internal fun resolveCameraPermissionResult( internal fun launchCameraSafely( uri: Uri, launch: (Uri) -> Unit, -): Boolean = try { +): AndroidDialogLaunchResult = launchAndroidDialogSafely( + 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 launchPickerSafely( +internal fun launchCameraPermissionSafely( + launch: () -> Unit, +): AndroidDialogLaunchResult = launchAndroidDialogSafely( + activityUnavailableMessage = "No Android activity is available to request camera permission.", + securityFailureMessage = "Android rejected the camera permission request.", + launch = launch, +) + +private fun launchAndroidDialogSafely( + activityUnavailableMessage: String, + securityFailureMessage: String, + launch: () -> Unit, +): AndroidDialogLaunchResult = try { + launch() + AndroidDialogLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + AndroidDialogLaunchResult.Failed(FileKitDialogException(activityUnavailableMessage, failure)) +} catch (failure: SecurityException) { + AndroidDialogLaunchResult.Failed(FileKitDialogException(securityFailureMessage, failure)) +} + +internal sealed interface AndroidDialogLaunchResult { + data object Launched : AndroidDialogLaunchResult + + data class Failed( + val failure: FileKitDialogException, + ) : AndroidDialogLaunchResult +} + +internal fun dispatchAndroidDialogLaunchResult( + result: AndroidDialogLaunchResult, + clearPendingState: () -> Unit, + onError: (FileKitDialogException) -> Unit, +) { + when (result) { + AndroidDialogLaunchResult.Launched -> {} + + is AndroidDialogLaunchResult.Failed -> { + clearPendingState() + onError(result.failure) + } + } +} + +internal fun dispatchCameraPermissionResolution( + resolution: CameraPermissionResolution, + launchCamera: (Uri) -> AndroidDialogLaunchResult, + clearPendingState: () -> Unit, + onError: (FileKitDialogException) -> Unit, + onResult: (PlatformFile?) -> Unit, +) { + when (resolution) { + CameraPermissionResolution.NoOp -> {} + + CameraPermissionResolution.ReturnNullResult -> { + clearPendingState() + onResult(null) + } + + is CameraPermissionResolution.LaunchCamera -> { + dispatchAndroidDialogLaunchResult( + result = launchCamera(resolution.uri), + clearPendingState = clearPendingState, + onError = onError, + ) + } + } +} + +internal fun launchFilePickerSafely( launch: () -> Unit, -): Boolean = try { +): PickerLaunchResult = try { launch() - true -} catch (_: ActivityNotFoundException) { - false + PickerLaunchResult.Launched +} catch (failure: ActivityNotFoundException) { + PickerLaunchResult.Failed( + FileKitPickerException( + 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, + ) } -internal enum class PickerLaunchOutcome { - PrimaryLaunched, - FallbackLaunched, - Cancelled, +internal fun launchDirectoryPickerSafely( + launch: () -> Unit, +): 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, +): 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 + + data class Failed( + val failure: FileKitPickerException, + val isFallbackEligible: Boolean, + ) : 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 (val primaryResult = launchPrimary()) { + PickerLaunchResult.Launched -> { + PickerLaunchOutcome.PrimaryLaunched + } + + is PickerLaunchResult.Failed -> { + if (!primaryResult.isFallbackEligible) { + PickerLaunchOutcome.Failed(primaryResult.failure) + } else { + when (val fallbackResult = launchFallback()) { + PickerLaunchResult.Launched -> PickerLaunchOutcome.FallbackLaunched + is PickerLaunchResult.Failed -> PickerLaunchOutcome.Failed(fallbackResult.failure) + } + } + } } internal fun resolveCameraResult( @@ -521,6 +683,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/DialogOperationDispatcher.kt b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt new file mode 100644 index 00000000..7a5e7cbf --- /dev/null +++ b/filekit-dialogs-compose/src/commonMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/DialogOperationDispatcher.kt @@ -0,0 +1,22 @@ +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, + onError: (FileKitDialogException) -> Unit, + onResult: suspend (OperationResult) -> Unit, +) { + val result = try { + operation() + } catch (failure: FileKitDialogException) { + currentCoroutineContext().ensureActive() + onError(failure) + return + } + + currentCoroutineContext().ensureActive() + 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..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 @@ -4,10 +4,15 @@ 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 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 /** * Creates and remembers a [PickerResultLauncher] for picking files. @@ -19,7 +24,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,8 +50,11 @@ 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. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable @@ -77,7 +86,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,8 +109,10 @@ 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. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. * @return A [PickerResultLauncher] that can be used to launch the picker. */ @Composable @@ -135,13 +147,51 @@ 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 -> { + currentCoroutineContext().ensureActive() + onFailure(failure) + } + + else -> { + throw failure + } + } + }.collect(onConsumed) + } } - mode.consumeResult(result, onResult) } /** @@ -151,10 +201,32 @@ internal suspend fun runFilePickerLauncher( * @param dialogSettings Platform-specific settings for the dialog. * @param onResult Callback invoked with the picked directory, or null if cancelled. * @return A [PickerResultLauncher] that can be used to launch the picker. + * + * Operational directory-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 expect fun rememberDirectoryPickerLauncher( + directory: PlatformFile? = null, + dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + onResult: (PlatformFile?) -> 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. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. + * @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 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..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 @@ -2,45 +2,318 @@ 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.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 - 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_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") + 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) - assertTrue(resultInvoked) + } + + @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_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") + 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) + } + + @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..700e1429 --- /dev/null +++ b/filekit-dialogs-compose/src/commonTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerCallShape.kt @@ -0,0 +1,35 @@ +@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.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 -> }, + ) + + val legacyDirectory = rememberDirectoryPickerLauncher { _: PlatformFile? -> } + val explicitDirectory = rememberDirectoryPickerLauncher( + onError = { _: FileKitDialogException -> }, + onResult = { _: PlatformFile? -> }, + ) +} 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..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 @@ -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, + runDialogOperation( + operation = { + 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/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..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 @@ -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, + runDialogOperation( + operation = { + 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 a57f893b..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 @@ -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, @@ -91,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 new file mode 100644 index 00000000..b7f8cbd9 --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/compatibility-source/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.java @@ -0,0 +1,159 @@ +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; +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; + +/** + * 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 int legacyOverloadCount() { + return 12; + } + + 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_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, + (Function1) null, + (Composer) null, + 0, + 0 + )); + link(() -> FileKitCompose_jvmKt.rememberDirectoryPickerLauncher( + null, + (PlatformFile) null, + (FileKitDialogSettings) null, + (Function1) null, + (Composer) null, + 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) { + 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..089d15dc --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitPickerJvmCallShape.kt @@ -0,0 +1,30 @@ +@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.FileKitDialogException +import io.github.vinceglb.filekit.dialogs.FileKitPickerException + +@Composable +private fun WindowScope.CompileJvmPickerCallShapes() { + val legacy = rememberFilePickerLauncher { _: PlatformFile? -> } + val explicit = rememberFilePickerLauncher( + onError = { _: FileKitPickerException -> }, + onResult = { _: PlatformFile? -> }, + ) + + val legacyDirectory = rememberDirectoryPickerLauncher { _: PlatformFile? -> } + val explicitDirectory = rememberDirectoryPickerLauncher( + 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/LegacyPickerLauncherBinaryCompatibilityTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt new file mode 100644 index 00000000..a10442a4 --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/LegacyPickerLauncherBinaryCompatibilityTest.kt @@ -0,0 +1,18 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LegacyPickerLauncherBinaryCompatibilityTest { + @Test + fun LegacyPickerLauncher_precompiledConsumer_linksAgainstCurrentArtifacts() { + val consumer = Class.forName( + "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 new file mode 100644 index 00000000..ea54534a Binary files /dev/null and b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer$LinkageCall.class differ 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 00000000..1734057e Binary files /dev/null and b/filekit-dialogs-compose/src/jvmTest/resources/io/github/vinceglb/filekit/dialogs/compose/compatibility/LegacyPickerLauncherConsumer.class differ 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..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 @@ -1,3 +1,5 @@ +@file:Suppress("ktlint:compose:param-order-check") + package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable @@ -7,25 +9,74 @@ 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. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. + */ +@Composable +public expect fun rememberCameraPickerLauncher( + openCameraSettings: FileKitOpenCameraSettings = FileKitOpenCameraSettings.createDefault(), + onError: (FileKitDialogException) -> Unit, + 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. + * 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. + * @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 } @@ -34,7 +85,11 @@ public fun rememberShareFileLauncher( val returnedLauncher = remember { ShareResultLauncher { files -> coroutineScope.launch { - fileKit.shareFile(files, currentShareSettings) + runDialogOperation( + operation = { fileKit.shareFile(files, currentShareSettings) }, + onError = currentOnError, + onResult = {}, + ) } } } 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-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-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..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 @@ -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, + runDialogOperation( + operation = { + FileKit.openDirectoryPicker( + directory = currentDirectory, + dialogSettings = currentDialogSettings, + ) + }, + onError = currentOnError, + onResult = currentOnResult, ) - currentOnResult(result) } } } 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..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 @@ -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,44 @@ 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. + * Exceptions thrown by [onError] or [onResult] propagate without a compensating callback. + * @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/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/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..aeff4cb2 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidCameraPickerFailureTest.kt @@ -0,0 +1,199 @@ +@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() } + } + + assertEquals("No Android activity is available to capture media with the camera.", failure.message) + 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() } + } + + assertEquals("Android rejected the camera launch.", failure.message) + 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() } + } + + assertEquals("No Android activity is available to request camera permission.", failure.message) + 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() } + } + + assertEquals("Android rejected the camera permission request.", failure.message) + 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/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..288e330d --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidDirectoryPickerFailureTest.kt @@ -0,0 +1,98 @@ +@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.assertIs +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") + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertEquals("No Android activity is available to open the directory picker.", failure.message) + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidDirectoryPicker_securityRejection_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("Directory picker launch rejected") + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertEquals("Android rejected the directory picker launch.", failure.message) + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + + @Test + fun AndroidDirectoryPicker_cancellation_propagatesUnchanged() { + val cancellation = CancellationException("Directory picker cancelled") + registry = throwingActivityResultRegistry(cancellation) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { FileKit.openDirectoryPicker() } + } + + assertEquals(cancellation.message, failure.message) + } + + @Test + fun AndroidDirectoryPicker_unexpectedFailure_propagatesUnchanged() { + val defect = IllegalStateException("Unexpected directory picker defect") + registry = throwingActivityResultRegistry(defect) + FileKit.init(registry) + + 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/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..e0991d92 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidFileSaverFailureTest.kt @@ -0,0 +1,94 @@ +@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.assertIs +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") + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertEquals("No Android activity is available to open the file saver.", failure.message) + assertSame(platformFailure, failure.cause) + } + + @Test + fun AndroidFileSaver_securityRejection_throwsDialogOperationalFailureWithCause() { + val platformFailure = SecurityException("File saver launch rejected") + registry = throwingActivityResultRegistry(platformFailure) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertEquals("Android rejected the file saver launch.", failure.message) + val cause = assertIs(failure.cause) + assertEquals(platformFailure.message, cause.message) + } + + @Test + fun AndroidFileSaver_cancellation_propagatesUnchanged() { + val cancellation = CancellationException("Saver cancelled") + registry = throwingActivityResultRegistry(cancellation) + FileKit.init(registry) + + val failure = assertFailsWith { + runBlocking { openFileSaver() } + } + + assertEquals(cancellation.message, failure.message) + } + + @Test + fun AndroidFileSaver_unexpectedFailure_propagatesUnchanged() { + val defect = IllegalStateException("Unexpected saver defect") + registry = throwingActivityResultRegistry(defect) + FileKit.init(registry) + + 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/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidPickerLaunchFallbackTest.kt index 98c65643..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 @@ -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,84 @@ 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 + 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 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..4f858212 --- /dev/null +++ b/filekit-dialogs/src/androidHostTest/kotlin/io/github/vinceglb/filekit/dialogs/AndroidSharingFailureTest.kt @@ -0,0 +1,52 @@ +@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_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") + + 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 f08e4fad..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,15 +81,20 @@ 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 = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to open the file saver.", + securityExceptionMessage = "Android rejected the file saver launch.", + ) { + awaitActivityResult( + registry = registry, + contract = contract, + input = CreateDocumentInput( + mimeType = mimeType, + fileName = fileName, + allowedMimeTypes = allowedMimeTypes, + ), + ) + } return uri?.let(::PlatformFile) } @@ -107,11 +112,16 @@ 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 = 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, + ) + } return treeUri?.let(::PlatformFile) } @@ -123,6 +133,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, @@ -131,20 +142,27 @@ public actual suspend fun FileKit.openCameraPicker( openCameraSettings: FileKitOpenCameraSettings, ): PlatformFile? { val registry = FileKit.registry - if (!FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context)) { + val hasCameraPermission = runAndroidDialogOperation( + activityNotFoundMessage = "No Android activity is available to request camera permission.", + securityExceptionMessage = "Android rejected the camera permission request.", + ) { + FileKitAndroidCameraPermissionInternal.requestCameraPermissionIfNeeded(registry, context) + } + if (!hasCameraPermission) { return null } 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 (_: SecurityException) { - return null } return if (isSaved) destinationFile else null } @@ -259,6 +277,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 +294,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 +342,37 @@ public actual suspend fun FileKit.shareFile( } shareSettings.addOptionChooseIntent(chooseIntent) - context.startActivity(chooseIntent) + launchAndroidShareIntent { + context.startActivity(chooseIntent) + } +} + +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 { + return operation() + } catch (failure: ActivityNotFoundException) { + throw FileKitDialogException( + message = activityNotFoundMessage, + cause = failure, + ) + } catch (failure: SecurityException) { + throw FileKitDialogException( + message = securityExceptionMessage, + cause = failure, + ) + } } /** @@ -446,13 +496,29 @@ 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, + ) + } 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) { 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..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 @@ -1,9 +1,28 @@ 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) } + +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/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..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 @@ -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 @@ -175,22 +183,32 @@ internal actual suspend fun FileKit.platformOpenFileSaver( extension = normalizedDefaultExtension, ) + val presenter = requireAppleDialogResource( + 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 = requireAppleDialogResource( + 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 = 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() - if (!emptyData.writeToURL(fileUrl, true)) { - throw IllegalStateException("Failed to write to file URL") - } + requireAppleDialogCondition( + satisfied = emptyData.writeToURL(fileUrl, true), + failureMessage = "Failed to write the temporary file for saving.", + ) // Create a picker controller val pickerController = UIDocumentPickerViewController( @@ -204,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, @@ -212,6 +230,20 @@ internal actual suspend fun FileKit.platformOpenFileSaver( } } +internal fun requireAppleDialogResource( + resource: T?, + failureMessage: String, +): T = resource ?: throw FileKitDialogException(failureMessage) + +internal fun requireAppleDialogCondition( + satisfied: Boolean, + failureMessage: String, +) { + if (!satisfied) { + throw FileKitDialogException(failureMessage) + } +} + /** * Opens a camera picker dialog. * @@ -220,6 +252,7 @@ internal actual suspend fun FileKit.platformOpenFileSaver( * @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, @@ -228,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 - } + presentation.cameraDevice?.let { pickerController.cameraDevice = it } - FileKitCameraFacing.System -> {} - } - - openCameraSettings.presenterViewController()?.presentViewController( + presentation.presenter.presentViewController( pickerController, animated = true, completion = null, @@ -265,26 +324,81 @@ 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) - - // Create an NSURL for the file path - val fileUrl = NSURL.fileURLWithPath(destinationFile.path) + completeAppleCameraCapture( + image = image, + destinationFile = destinationFile, + encodeImage = { capturedImage -> UIImageJPEGRepresentation(capturedImage, 1.0) }, + writeImage = { imageData, fileUrl -> imageData.writeToURL(fileUrl, true) }, + ) + } +} - // Write the NSData to the file, returning the saved file on success - if (imageData?.writeToURL(fileUrl, true) == true) { - destinationFile - } else { - null - } +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 { + requireAppleDialogCondition( + satisfied = sourceAvailable, + failureMessage = "The camera is not available on this device.", + ) + val availablePresenter = requireAppleDialogResource( + resource = presenter, + failureMessage = "No active view controller is available to present the camera.", + ) + requestedCamera?.let { request -> + requireAppleDialogCondition( + satisfied = request.available, + failureMessage = request.unavailableMessage, + ) } + + return AppleCameraPresentation( + presenter = availablePresenter, + cameraDevice = requestedCamera?.device, + ) } +internal fun completeAppleCameraCapture( + image: UIImage, + destinationFile: PlatformFile, + encodeImage: (UIImage) -> NSData?, + writeImage: (NSData, NSURL) -> Boolean, +): PlatformFile { + val imageData = requireAppleDialogResource( + resource = encodeImage(image), + failureMessage = "Failed to encode the captured image.", + ) + val fileUrl = NSURL.fileURLWithPath(destinationFile.path) + requireAppleDialogCondition( + satisfied = writeImage(imageData, fileUrl), + failureMessage = "Failed to write the captured image to its destination.", + ) + return destinationFile +} + +internal fun requireAppleCameraImage(image: UIImage?): UIImage = requireAppleDialogResource( + resource = image, + failureMessage = "The camera completed without returning a captured image.", +) + /** * Shares a file using the iOS share sheet. * * @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( @@ -302,6 +416,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( @@ -310,7 +425,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 @@ -330,14 +445,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), ) } @@ -379,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() @@ -414,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 + }, ) } } @@ -468,14 +615,41 @@ 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, ) } -@OptIn(ExperimentalForeignApi::class) +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private fun callPhPicker( mode: PickerMode, type: FileKitType, @@ -496,13 +670,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 +704,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 +735,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 +781,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 +791,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/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/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/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) + } +} 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..7bc65488 --- /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 { + requireAppleDialogResource( + 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 { + requireAppleDialogCondition( + satisfied = 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/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/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/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..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 @@ -1,19 +1,24 @@ 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 +import java.awt.AWTError import java.awt.Dialog 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 import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException internal class AwtFilePicker : PlatformFilePicker { override suspend fun openFilePicker( @@ -43,7 +48,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?, @@ -51,34 +56,56 @@ 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? = runAwtFilePicker { + 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 { + 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() } + continuation.invokeOnCancellation { dialog.dispose() } + } } } + +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 2ff102c6..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 @@ -1,12 +1,15 @@ 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.AWTError 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 +20,71 @@ 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, + ) +} 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/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..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 @@ -1,11 +1,17 @@ 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.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 +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 @@ -69,44 +75,54 @@ internal class MacOSFilePicker : PlatformFilePicker { val pool = Foundation.NSAutoreleasePool() try { var response: File? = null + var modalFailure: FileKitDialogException? = 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") + 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() @@ -123,44 +139,49 @@ internal class MacOSFilePicker : PlatformFilePicker { val pool = Foundation.NSAutoreleasePool() 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) + var modalFailure: FileKitDialogException? = null + + 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") + + 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() @@ -169,6 +190,7 @@ internal class MacOSFilePicker : PlatformFilePicker { private companion object { const val NS_MODAL_RESPONSE_OK = 1 + const val NS_MODAL_RESPONSE_CANCEL = 0 fun Collection.toNsStringArray(): ID? { if (isEmpty()) { @@ -222,6 +244,10 @@ internal class MacOSFilePicker : PlatformFilePicker { abstract fun getResult(openPanel: ID): T? + abstract fun operationalFailure(): FileKitDialogException + + abstract fun operationalFailure(cause: Throwable): FileKitDialogException + data object SingleFile : MacOSFilePickerMode() { override fun setupPickerMode(openPanel: ID, canCreateDirectories: Boolean) { Foundation.invoke(openPanel, "setCanChooseFiles:", true) @@ -230,6 +256,15 @@ 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, + ) } data object MultipleFiles : MacOSFilePickerMode>() { @@ -242,6 +277,15 @@ 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, + ) } data object Directories : MacOSFilePickerMode() { @@ -252,6 +296,24 @@ 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, + ) } } } + +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/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..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 @@ -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,36 @@ 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? = 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/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..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 @@ -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 @@ -16,7 +17,12 @@ 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.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 @@ -45,24 +51,26 @@ internal class WindowsFilePicker( fileExtensions: Set?, 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? = runWindowsFilePickerOperation { + 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 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) + } } } @@ -70,51 +78,59 @@ internal class WindowsFilePicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): List? = 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") - } + ): List? = runWindowsFilePickerOperation { + 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 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() + } } } 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( @@ -123,30 +139,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( @@ -222,29 +244,24 @@ 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) - - // 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) { - // 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) { @@ -266,19 +283,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 + return handleWindowsDialogResult(openDialogResult) { + block(this) } - - // Invalid error codes: throw exception - if (FAILED(openDialogResult)) { - throw RuntimeException("Show failed") - } - - return block(this) } private fun FileDialog.getResult(sigdnName: Long): File { @@ -367,25 +374,80 @@ internal class WindowsFilePicker( } } - private fun HRESULT.verify(exceptionMessage: String): HRESULT { - if (FAILED(this)) { - throw RuntimeException(exceptionMessage) - } 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 = WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE, + cause = this, +) + +private fun Throwable.toFileSaverFailure(): FileKitDialogException = FileKitDialogException( + message = WINDOWS_FILE_SAVER_FAILURE_MESSAGE, + cause = this, +) + +private fun Throwable.toFilePickerFailure(): FileKitPickerException = FileKitPickerException( + 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, ): 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/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..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 @@ -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,60 @@ 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) +} 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 { fun isAvailable(): Boolean @@ -221,9 +288,10 @@ 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, + result = result, + ) registration.set( addGenericSigHandlerCompat( connection = connection, @@ -239,23 +307,11 @@ private class DbusXdgFileChooserTransport : XdgFileChooserTransport { private class ResponseHandler( private val path: String, - private val onComplete: (result: List?) -> 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> - - if (response.toInt() == 0) { - val uris = (results["uris"]!!.value as List).map { path -> - path.toURI() - } - onComplete(uris) - } else { - onComplete(null) - } + dispatchXdgPortalResponse(signal.parameters, result) } } } @@ -301,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/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/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/awt/AwtFilePickerFailureTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt new file mode 100644 index 00000000..eaf3b9ce --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePickerFailureTest.kt @@ -0,0 +1,46 @@ +@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.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") + check(GraphicsEnvironment.isHeadless()) + + val failure = assertFailsWith { + AwtFilePicker().openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings(), + ) + } + + assertIs(failure.cause) + } +} 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..ef7f0aa3 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaverFailureTest.kt @@ -0,0 +1,39 @@ +@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.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") + + val failure = assertFailsWith { + runAwtFileSaver { + throw headlessFailure + } + } + + assertSame(headlessFailure, failure.cause) + } +} 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/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/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 +} 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 = { 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..53755688 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingDirectoryPickerResultTest.kt @@ -0,0 +1,64 @@ +@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_errorSelection_throwsDialogOperationalFailure() { + assertFailsWith { + resolveSwingPickerResult( + returnValue = JFileChooser.ERROR_OPTION, + selectedFiles = emptyArray(), + selectedFile = null, + ) + } + } + + @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/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..504352da --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePickerFailureTest.kt @@ -0,0 +1,93 @@ +@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 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 +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) + } + + @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 + + override fun uninitialize() = Unit + }, + ) + + private companion object { + val E_OUTOFMEMORY = 0x8007000Eu.toInt() + } +} 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/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..53e64419 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgOperationalFailureTest.kt @@ -0,0 +1,263 @@ +@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.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 +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") + 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_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") + 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 +} + +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>(), + ) +} 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..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 @@ -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(MACOS_FILE_SAVER_FAILURE_MESSAGE) } // 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(MACOS_FILE_PICKER_FAILURE_MESSAGE) + + Mode.Directory -> FileKitDialogException(MACOS_DIRECTORY_PICKER_FAILURE_MESSAGE) + } } // 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 } 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..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 @@ -67,6 +67,10 @@ private val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() private val ERROR_FILE_NOT_FOUND_HRESULT = 0x80070002u.toInt() private val ERROR_INVALID_DRIVE_HRESULT = 0x8007000Fu.toInt() +internal class WindowsDialogOperationalException( + message: String, +) : RuntimeException(message) + internal actual suspend fun FileKit.platformOpenFilePicker( type: FileKitType, mode: PickerMode, @@ -79,16 +83,43 @@ 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, + ) + }.toPickerStateFlow() +} + +internal fun runWindowsNativePickerOperation(operation: () -> T): T = try { + operation() +} catch (failure: WindowsDialogOperationalException) { + throw FileKitPickerException( + message = WINDOWS_FILE_PICKER_FAILURE_MESSAGE, + cause = failure, + ) } 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, + )?.firstOrNull() +} catch (failure: WindowsDialogOperationalException) { + throw FileKitDialogException( + message = WINDOWS_DIRECTORY_PICKER_FAILURE_MESSAGE, + cause = failure, + ) +} internal actual suspend fun FileKit.platformOpenFileSaver( suggestedName: String, @@ -96,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 = WINDOWS_FILE_SAVER_FAILURE_MESSAGE, + cause = failure, + ) } public actual fun FileKit.openFileWithDefaultApplication( @@ -121,47 +157,52 @@ private fun showOpenDialog( 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 WindowsDialogOperationalException( + "CoCreateInstance(IFileOpenDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", + ) } val dlg = ppDlg.value - ?: throw IllegalStateException("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 IllegalStateException("IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}") + throw WindowsDialogOperationalException( + "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 WindowsDialogOperationalException( + "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 WindowsDialogOperationalException( + "IFileDialog::SetTitle failed with HRESULT 0x${setTitleHr.toUInt().toString(16)}", + ) } } directory?.let { setFolder(dlg, it) } if (!extensions.isNullOrEmpty() && !pickFolders) setFileTypes(dlg, extensions) - 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), + operation = "IFileOpenDialog::Show", + ) { + if (allowMultiple) { + getMultipleResults(dlg) + } else { + val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() + getSingleResult(dlg, sigdn)?.let { listOf(it) } } - throw IllegalStateException("IFileOpenDialog::Show failed with HRESULT 0x${hr.toUInt().toString(16)}") - } - - if (allowMultiple) { - getMultipleResults(dlg) - } else { - val sigdn = if (pickFolders) FK_SIGDN_DESKTOPABSOLUTEPARSING.toInt() else FK_SIGDN_FILESYSPATH.toInt() - getSingleResult(dlg, sigdn)?.let { listOf(it) } } } finally { ppDlg.value?.let { fk_open_dialog_release(it.reinterpret()) } @@ -171,6 +212,22 @@ private fun showOpenDialog( } } +internal fun handleWindowsNativeDialogResult( + result: Int, + operation: String, + resolveResult: () -> T, +): T? { + if (result == ERROR_CANCELLED_HRESULT) { + return null + } + if (result != S_OK) { + throw WindowsDialogOperationalException( + "$operation failed with HRESULT 0x${result.toUInt().toString(16)}", + ) + } + return resolveResult() +} + private fun showSaveDialog( suggestedName: String, defaultExtension: String?, @@ -183,36 +240,46 @@ private fun showSaveDialog( 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 WindowsDialogOperationalException( + "CoCreateInstance(IFileSaveDialog) failed with HRESULT 0x${createHr.toUInt().toString(16)}", + ) } val dlg = ppDlg.value - ?: throw IllegalStateException("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 IllegalStateException("IFileDialog::GetOptions failed with HRESULT 0x${getOptionsHr.toUInt().toString(16)}") + 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 IllegalStateException("IFileDialog::SetOptions failed with HRESULT 0x${setOptionsHr.toUInt().toString(16)}") + throw WindowsDialogOperationalException( + "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 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 IllegalStateException("IFileDialog::SetFileName failed with HRESULT 0x${setFilenameHr.toUInt().toString(16)}") + 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 IllegalStateException( + throw WindowsDialogOperationalException( "IFileDialog::SetDefaultExtension failed with HRESULT 0x${setDefaultExtensionHr.toUInt().toString(16)}", ) } @@ -221,14 +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 IllegalStateException("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) { @@ -249,27 +314,50 @@ private fun initializeComForDialogs(): Boolean { return true } - throw IllegalStateException("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) { +private fun MemScope.setFolder( + dlg: ComPtr, + dir: PlatformFile, +) { 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 WindowsDialogOperationalException( + "SHCreateItemFromParsingName failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val folder = ppsi.value ?: return + setWindowsNativeDialogFolder( + setFolder = { fk_dialog_set_folder(dlg.reinterpret(), folder.reinterpret()) }, + releaseFolder = { fk_shell_item_release(folder.reinterpret()) }, + ) +} + +internal fun setWindowsNativeDialogFolder( + setFolder: () -> Int, + releaseFolder: () -> Unit, +) { try { - fk_dialog_set_folder(dlg.reinterpret(), folder.reinterpret()) + val result = setFolder() + if (result != S_OK) { + throw WindowsDialogOperationalException( + "IFileDialog::SetFolder failed with HRESULT 0x${result.toUInt().toString(16)}", + ) + } } finally { - fk_shell_item_release(folder.reinterpret()) + releaseFolder() } } -private fun MemScope.setFileTypes(dlg: ComPtr, exts: Set) { +private fun MemScope.setFileTypes( + dlg: ComPtr, + exts: Set, +) { val display = exts.joinToString(", ") { "*.$it" } val pattern = exts.joinToString(";") { "*.$it" } // COMDLG_FILTERSPEC = { LPCWSTR pszName; LPCWSTR pszSpec; } = two consecutive pointers @@ -278,18 +366,25 @@ 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 WindowsDialogOperationalException( + "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, +): 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 WindowsDialogOperationalException( + "IFileDialog::GetResult failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val item = ppsi.value - ?: throw IllegalStateException("IFileDialog::GetResult returned a null result item") + ?: throw WindowsDialogOperationalException("IFileDialog::GetResult returned a null result item") try { return shellItemToFile(item, sigdn) } finally { @@ -297,28 +392,36 @@ private fun MemScope.getSingleResult(dlg: ComPtr, sigdn: Int): PlatformFile? { } } -private fun MemScope.getMultipleResults(dlg: ComPtr): List { +private fun MemScope.getMultipleResults( + dlg: ComPtr, +): 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 WindowsDialogOperationalException( + "IFileOpenDialog::GetResults failed with HRESULT 0x${resultsHr.toUInt().toString(16)}", + ) } val arr = ppArr.value - ?: throw IllegalStateException("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 IllegalStateException("IShellItemArray::GetCount failed with HRESULT 0x${countHr.toUInt().toString(16)}") + throw WindowsDialogOperationalException( + "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 WindowsDialogOperationalException( + "IShellItemArray::GetItemAt failed with HRESULT 0x${itemHr.toUInt().toString(16)}", + ) } val item = ppsi.value - ?: throw IllegalStateException("IShellItemArray::GetItemAt returned a null shell item") + ?: throw WindowsDialogOperationalException("IShellItemArray::GetItemAt returned a null shell item") try { shellItemToFile(item, FK_SIGDN_FILESYSPATH.toInt()) } finally { @@ -330,14 +433,19 @@ private fun MemScope.getMultipleResults(dlg: ComPtr): List { } } -private fun MemScope.shellItemToFile(item: ComPtr, sigdn: Int): PlatformFile? { +private fun MemScope.shellItemToFile( + item: ComPtr, + sigdn: Int, +): 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 WindowsDialogOperationalException( + "IShellItem::GetDisplayName failed with HRESULT 0x${hr.toUInt().toString(16)}", + ) } val namePtr = ppName.value - ?: throw IllegalStateException("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 new file mode 100644 index 00000000..17446627 --- /dev/null +++ b/filekit-dialogs/src/mingwX64Test/kotlin/io/github/vinceglb/filekit/dialogs/WindowsNativePickerFailureTest.kt @@ -0,0 +1,183 @@ +@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 +import kotlin.test.assertTrue + +@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 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 { + return assertFailsWith { + operation() + } + } finally { + CoUninitialize() + } + } + + private fun assertIncompatibleComApartmentCause(failure: FileKitDialogException) { + val cause = assertNotNull(failure.cause) + assertIs(cause) + assertEquals("CoInitializeEx failed with HRESULT 0x80010106", cause.message) + } + + @Test + fun FileSaver_cancelledDialog_returnsNullWithoutResolvingSelection() { + var selectionResolved = false + + val result = handleWindowsNativeDialogResult( + result = ERROR_CANCELLED_HRESULT, + operation = "IFileSaveDialog::Show", + ) { + selectionResolved = true + "selected.txt" + } + + assertNull(result) + 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 + + val failure = assertFailsWith { + runWindowsNativePickerOperation { + setWindowsNativeDialogFolder( + 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 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 E_FAIL_HRESULT = 0x80004005u.toInt() + val ERROR_CANCELLED_HRESULT = 0x800704C7u.toInt() + } + + private class UnexpectedPickerFailure : RuntimeException() +} 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..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, @@ -21,11 +27,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/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/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/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/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/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), 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..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,8 +24,10 @@ 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 import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher @@ -79,6 +81,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()) @@ -86,65 +89,85 @@ 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) + 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 +236,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/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..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 @@ -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,24 +72,35 @@ 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, - ) { 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" @@ -173,7 +185,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/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..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 @@ -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 @@ -236,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/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..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 @@ -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" @@ -77,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) { @@ -99,6 +108,7 @@ private fun ShareFileScreen( if (!isSupported || selectedFiles.isEmpty()) { return } + shareError = null shareLauncher.launch(selectedFiles) } @@ -157,6 +167,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/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/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/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/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/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/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/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/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/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/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/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/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/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 { 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 { 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, ) {} 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 diff --git a/specs/CONTEXT.md b/specs/CONTEXT.md index 4f285d9b..16163311 100644 --- a/specs/CONTEXT.md +++ b/specs/CONTEXT.md @@ -1,6 +1,20 @@ # FileKit -FileKit provides framework-independent file operations and native dialogs across platforms. This glossary names the ownership concepts shared by dialog implementations and framework adapters. +FileKit provides multiplatform file access and system-mediated file interactions through a consistent, framework-independent interface. This glossary names the interaction and ownership concepts shared by dialog implementations and framework adapters. + +## 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 ## Dialog ownership 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.