Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/dialogs/open-file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ FileKit.openFileWithDefaultApplication(file)

## Platform notes

Failures are not wrapped in `FileKitDialogException`. Each platform reports them its own way:
Failures are reported differently on each platform. Only the JVM wraps some of them in `FileKitDialogException`:

| Platform | Behavior |
| --- | --- |
| Android | Sends an `ACTION_VIEW` intent with the MIME type of the file extension. Throws `ActivityNotFoundException` when no app handles that type, and `IllegalArgumentException` for a file outside the directories its FileProvider covers. |
| iOS | Calls `openURL`. If that fails, shows the "Open in" menu (`UIDocumentInteractionController`) so the user can pick an app. Reports no error. |
| macOS | Uses `NSWorkspace`. Reports no error. |
| JVM | Uses `java.awt.Desktop.open` without checking `Desktop.isDesktopSupported()`. Throws on headless setups (`HeadlessException`), when the action is unsupported (`UnsupportedOperationException`), or when no app is associated (`IOException`). |
| JVM | Uses `java.awt.Desktop.open` when the desktop supports opening files. Otherwise (headless setups, Linux without GNOME libraries), on Linux it falls back to `xdg-open` and waits up to half a second for it to report an error. Throws `FileKitDialogException` when neither works: `Desktop` cannot open files and `xdg-open` is missing, cannot start, or exits with an error code. Errors from `Desktop.open` itself are not wrapped, such as `IOException` when no app is associated. |
| Linux native | Starts `xdg-open` without waiting for it. Throws `FileKitException` only if the process cannot start. |
| Windows native | Calls `ShellExecuteW` with the `open` verb. Reports no error. |
| Web | Not available. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

/**
* kotlinx.serialization serializer for [PlatformFile], encoded as its path string.
*
* Serialization keeps the path, not access rights: a file picked outside the app (an Android
* `content://` file, a security-scoped file on Apple platforms) may not be readable once restored.
* Use bookmark data for persistent access.
*
* Not supported on web targets (JS and Wasm): [serialize] and [deserialize] throw an
* [IllegalStateException] there.
*/
public expect object PlatformFileSerializer : KSerializer<PlatformFile> {
override val descriptor: SerialDescriptor

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder

private const val UNSUPPORTED_MESSAGE =
"PlatformFile serialization is not supported on Wasm JS targets"
"PlatformFile serialization is not supported on web targets (JS and Wasm)"

/**
* Web (JS and Wasm) implementation of [PlatformFileSerializer].
*
* A web [PlatformFile] wraps a browser `File` object that has no path to persist, so
* [serialize] and [deserialize] always throw an [IllegalStateException]. The serializer
* exists so that `@Serializable` classes holding a [PlatformFile] still compile in shared code.
*/
public actual object PlatformFileSerializer : KSerializer<PlatformFile> {
actual override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
serialName = "io.github.vinceglb.filekit.PlatformFile",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.awt.Desktop

internal actual suspend fun FileKit.platformOpenFilePicker(
type: FileKitType,
Expand Down Expand Up @@ -97,12 +96,19 @@ internal actual suspend fun FileKit.platformOpenFileSaver(
/**
* Opens a file with the default application associated with its file type.
*
* Uses `java.awt.Desktop` when it supports opening files. Otherwise, on Linux, falls back to
* `xdg-open` and waits half a second at most for it to report an error.
*
* @param file The file to open.
* @param openFileSettings Platform-specific settings for opening the file.
* @throws FileKitDialogException When `java.awt.Desktop` cannot open files (for example in a headless
* environment) and the `xdg-open` fallback is unavailable, cannot start, or exits with an error.
* Errors raised by `java.awt.Desktop.open` itself, such as an [java.io.IOException] when no application
* is associated with the file, are not wrapped.
*/
public actual fun FileKit.openFileWithDefaultApplication(
file: PlatformFile,
openFileSettings: FileKitOpenFileSettings,
) {
Desktop.getDesktop()?.open(file.file)
JvmDefaultApplicationOpener.Default.open(file.file)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package io.github.vinceglb.filekit.dialogs

import io.github.vinceglb.filekit.utils.Platform
import io.github.vinceglb.filekit.utils.PlatformUtil
import java.awt.Desktop
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit

// Long enough to catch xdg-open failing fast (missing file or tool), short enough for a click handler.
internal const val XDG_OPEN_EXIT_TIMEOUT_MILLIS: Long = 500

/**
* Opens files with the default application on the JVM.
*
* Uses `java.awt.Desktop` when it can open files. Otherwise, on Linux, starts `xdg-open` and waits
* at most [XDG_OPEN_EXIT_TIMEOUT_MILLIS] for it to report an error. Throws [FileKitDialogException] when neither works.
*/
internal class JvmDefaultApplicationOpener(
private val isDesktopOpenSupported: () -> Boolean,
private val desktopOpen: (File) -> Unit,
private val isLinux: Boolean,
private val startProcess: (List<String>) -> Process,
private val exitTimeoutMillis: Long = XDG_OPEN_EXIT_TIMEOUT_MILLIS,
) {
fun open(file: File) {
when {
isDesktopOpenSupported() -> desktopOpen(file)

isLinux -> openWithXdgOpen(file)

else -> throw FileKitDialogException(
"Could not open the file with the default application: " +
"java.awt.Desktop cannot open files in this environment.",
)
}
}

private fun openWithXdgOpen(file: File) {
val process = try {
// An absolute path keeps xdg-open from reading the argument as an option or a URI scheme.
startProcess(listOf("xdg-open", file.absolutePath))
} catch (e: IOException) {
throw FileKitDialogException(
"Could not open the file with the default application: " +
"java.awt.Desktop cannot open files and xdg-open could not be started.",
e,
)
}

val exited = try {
process.waitFor(exitTimeoutMillis, TimeUnit.MILLISECONDS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blocking the caller while waiting for xdg-open

When Desktop is unsupported on Linux and xdg-open stays alive while the viewer is open, this blocks the caller for the full three-second timeout even when opening succeeds. Because this API is synchronous and documented for direct click-handler use, that can freeze the UI; avoid waiting on the caller thread for this fallback.

Useful? React with 👍 / 👎.

} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return
}

// Some xdg-open fallbacks keep running until the viewer closes: treat that as success.
if (exited && process.exitValue() != 0) {
throw FileKitDialogException(
"Could not open the file with the default application: " +
"xdg-open exited with code ${process.exitValue()}.",
)
}
}

companion object {
val Default: JvmDefaultApplicationOpener = JvmDefaultApplicationOpener(
isDesktopOpenSupported = {
Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)
},
desktopOpen = { Desktop.getDesktop().open(it) },
isLinux = PlatformUtil.current == Platform.Linux,
startProcess = { command ->
ProcessBuilder(command)
.redirectInput(ProcessBuilder.Redirect.from(File("/dev/null")))
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
},
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
@file:Suppress("ktlint:standard:function-naming", "FunctionName")

package io.github.vinceglb.filekit.dialogs

import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertTrue

class JvmDefaultApplicationOpenerTest {
private val file = File("report.pdf")

@Test
fun JvmDefaultApplicationOpener_desktopSupported_usesDesktopOnly() {
val opened = mutableListOf<File>()
val commands = mutableListOf<List<String>>()
val opener = opener(
desktopSupported = true,
isLinux = true,
opened = opened,
commands = commands,
)

opener.open(file)

assertEquals(listOf(file), opened)
assertTrue(commands.isEmpty())
}

@Test
fun JvmDefaultApplicationOpener_desktopError_isNotWrapped() {
val opener = JvmDefaultApplicationOpener(
isDesktopOpenSupported = { true },
desktopOpen = { throw IOException("No application") },
isLinux = true,
startProcess = { error("xdg-open must not run") },
)

assertFailsWith<IOException> { opener.open(file) }
}

@Test
fun JvmDefaultApplicationOpener_desktopUnsupportedOnLinux_runsXdgOpenWithAbsolutePath() {
val commands = mutableListOf<List<String>>()
val opener = opener(
isLinux = true,
commands = commands,
process = FakeProcess(exitCode = 0),
)

opener.open(file)

assertEquals(listOf(listOf("xdg-open", file.absolutePath)), commands)
}

@Test
fun JvmDefaultApplicationOpener_xdgOpenStillRunning_returnsWithoutWaitingForever() {
val process = FakeProcess(exitCode = null)
val opener = opener(isLinux = true, process = process)

opener.open(file)

assertEquals(TIMEOUT_MILLIS, process.waitedMillis)
}

@Test
fun JvmDefaultApplicationOpener_xdgOpenNonZeroExit_throwsDialogException() {
val opener = opener(isLinux = true, process = FakeProcess(exitCode = 3))

val exception = assertFailsWith<FileKitDialogException> { opener.open(file) }

assertTrue(exception.message!!.contains("xdg-open exited with code 3"))
}

@Test
fun JvmDefaultApplicationOpener_xdgOpenMissing_throwsDialogExceptionWithCause() {
val opener = JvmDefaultApplicationOpener(
isDesktopOpenSupported = { false },
desktopOpen = { error("Desktop must not be used") },
isLinux = true,
startProcess = { throw IOException("Cannot run program \"xdg-open\"") },
)

val exception = assertFailsWith<FileKitDialogException> { opener.open(file) }

assertIs<IOException>(exception.cause)
}

@Test
fun JvmDefaultApplicationOpener_desktopUnsupportedOutsideLinux_throwsDialogException() {
val commands = mutableListOf<List<String>>()
val opener = opener(isLinux = false, commands = commands)

assertFailsWith<FileKitDialogException> { opener.open(file) }
assertTrue(commands.isEmpty())
}

private fun opener(
desktopSupported: Boolean = false,
isLinux: Boolean,
opened: MutableList<File> = mutableListOf(),
commands: MutableList<List<String>> = mutableListOf(),
process: Process = FakeProcess(exitCode = 0),
) = JvmDefaultApplicationOpener(
isDesktopOpenSupported = { desktopSupported },
desktopOpen = { opened += it },
isLinux = isLinux,
startProcess = { command ->
commands += command
process
},
exitTimeoutMillis = TIMEOUT_MILLIS,
)

/** A process that has exited with [exitCode], or is still running when [exitCode] is null. */
private class FakeProcess(
private val exitCode: Int?,
) : Process() {
var waitedMillis: Long? = null

override fun waitFor(timeout: Long, unit: TimeUnit): Boolean {
waitedMillis = unit.toMillis(timeout)
return exitCode != null
}

override fun waitFor(): Int = exitCode ?: error("Unbounded wait")

override fun exitValue(): Int = exitCode ?: throw IllegalThreadStateException()

override fun getOutputStream(): OutputStream = OutputStream.nullOutputStream()

override fun getInputStream(): InputStream = InputStream.nullInputStream()

override fun getErrorStream(): InputStream = InputStream.nullInputStream()

override fun destroy() = Unit
}

private companion object {
const val TIMEOUT_MILLIS = 50L
}
}

This file was deleted.

Loading