Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f4f6796
📝 Add initial specifications for FileKit dialog interactions and oper…
vinceglb Aug 6, 2026
8631083
✨ Establish shared dialog failure contract
vinceglb Aug 6, 2026
7b65aff
✨ Make directory launcher failures observable
vinceglb Aug 6, 2026
6bd5ae8
✨ Make file saver launcher failures observable
vinceglb Aug 6, 2026
a3a8641
✨ Make camera launcher failures observable
vinceglb Aug 6, 2026
fb17126
✨ Make sharing launcher failures observable
vinceglb Aug 6, 2026
4de1637
✅ Verify launcher error contract
vinceglb Aug 6, 2026
d9bf00f
🐛 Normalize JVM Windows picker failures
vinceglb Aug 7, 2026
3f17858
🐛 Normalize headless AWT picker failures
vinceglb Aug 7, 2026
df27673
🐛 Normalize native Windows picker failures
vinceglb Aug 7, 2026
9e595ba
🐛 Fail iOS pickers without a presenter
vinceglb Aug 7, 2026
e956f16
♻️ Deduplicate Windows picker failure handling
vinceglb Aug 7, 2026
099c7d8
🐛 Normalize XDG portal request failures
vinceglb Aug 7, 2026
4d32980
🐛 Normalize XDG and Android saver failures
vinceglb Aug 7, 2026
398ee62
🐛 Normalize Android directory picker launch failures
vinceglb Aug 7, 2026
8110c43
🐛 Release Windows initial folder on failure
vinceglb Aug 8, 2026
db3cc9b
🐛 Normalize Android camera launch failures
vinceglb Aug 8, 2026
b198eb6
🐛 Normalize macOS JVM bootstrap failures
vinceglb Aug 8, 2026
f1cae7a
🐛 Handle native Windows SetFolder failures
vinceglb Aug 8, 2026
b976f5e
🐛 Resume XDG requests on unexpected responses
vinceglb Aug 8, 2026
7367b44
🐛 Classify native macOS dialog aborts
vinceglb Aug 8, 2026
79fcdba
🔀 Merge main into improve-launcher-error
vinceglb Aug 8, 2026
f275dc9
🐛 Classify Swing dialog errors
vinceglb Aug 8, 2026
6f0b3aa
🐛 Classify JVM macOS dialog aborts
vinceglb Aug 8, 2026
ebdc791
🐛 Verify Windows file filter HRESULT
vinceglb Aug 8, 2026
c54ae48
🐛 Normalize Android security launch failures
vinceglb Aug 8, 2026
f9500da
🐛 Normalize Android sharing security failures
vinceglb Aug 8, 2026
319797d
🐛 Suppress dialog callbacks after cancellation
vinceglb Aug 8, 2026
e38cd99
♻️ Share platform dialog failure messages
vinceglb Aug 8, 2026
e080dae
✅ Retain Android test registries strongly
vinceglb Aug 8, 2026
db1fde3
🐛 Prevent state-flow callbacks after cancellation
vinceglb Aug 8, 2026
1cca2e0
♻️ Simplify native Windows dialog failures
vinceglb Aug 8, 2026
97a221d
♻️ Deduplicate Android dialog launch handling
vinceglb Aug 8, 2026
db40e50
🔥 Remove orphaned Android picker helper
vinceglb Aug 8, 2026
cd70bf8
♻️ Deduplicate Android dialog launch dispatch
vinceglb Aug 8, 2026
ded031e
📝 Document Nucleus launcher error handling
vinceglb Aug 8, 2026
e460adb
♻️ Remove launcher dispatcher middlemen
vinceglb Aug 8, 2026
45ae33c
♻️ Deduplicate Android operation failures
vinceglb Aug 8, 2026
b36774e
♻️ Reuse Windows saver result routing
vinceglb Aug 8, 2026
39431d0
📝 Fix camera FileProvider Compose example
vinceglb Aug 8, 2026
abb11bb
🐛 Normalize AWT display failures
vinceglb Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/core/bookmark-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 43 additions & 19 deletions docs/dialogs/camera-picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,32 @@ 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")
}
```
</CodeGroup>

`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
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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() }) {
Expand Down Expand Up @@ -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")
}
Expand Down
10 changes: 7 additions & 3 deletions docs/dialogs/dialog-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions docs/dialogs/directory-picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,32 @@ 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")
}
```
</CodeGroup>

`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.
Expand All @@ -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
},
)
```
</CodeGroup>

Expand Down
39 changes: 39 additions & 0 deletions docs/dialogs/error-handling.mdx
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 68 additions & 42 deletions docs/dialogs/file-picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,23 @@ 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")
}
```
</CodeGroup>

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.

<Warning>
On iOS, remember FileKit Compose launchers from a stable/root Compose scope, not
inside transient surfaces such as `ModalBottomSheet`, dialogs, popups, or
Expand Down Expand Up @@ -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<PlatformFile>?
}
mode = FileKitMode.Multiple(maxItems = 10),
onError = { failure -> println("Picker failed: ${failure.message}") },
onResult = { files ->
// Handle multiple files: List<PlatformFile>? (null means user cancellation)
},
)
```
</CodeGroup>

Expand Down Expand Up @@ -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")
}
}
}
},
)
```
</CodeGroup>

<Info>
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.
</Info>

<Info>
The `Multiple` and `MultipleWithState` modes support a `maxItems` parameter (1-50 files). If not specified, there's no limit.

Expand All @@ -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
},
)
```
</CodeGroup>

Expand All @@ -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
},
)
```
</CodeGroup>

Expand Down
Loading