Skip to content

feat(share): Kotlin Multiplatform native share sheet - #727

Draft
kdroidFilter wants to merge 2 commits into
nucleus-2.6from
feat/share-sheet
Draft

kdroidFilter wants to merge 2 commits into
nucleus-2.6from
feat/share-sheet

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

New share module (dev.nucleusframework:nucleus.share): the system share sheet for Kotlin Multiplatform, inspired by robius-share. It is the repo's first KMP library module. Rust is used only on desktop: Android, iOS and the web are pure Kotlin.

nucleus-application gains NucleusWindow.share, which attaches the share UI to the window, the same way withFileKitDialogSettings attaches FileKit dialogs.

Platform Share UI Implementation
Android Android Sharesheet (ACTION_SEND / ACTION_SEND_MULTIPLE in a chooser) Kotlin
iOS UIActivityViewController Kotlin/Native
macOS NSSharingServicePicker Rust (JNI)
Windows Share UI (DataTransferManager desktop-window interop) Rust (JNI)
Linux XDG desktop portal: "Open With" for one file or URL, "save files" for a mixed payload, xdg-open fallback Rust (JNI)
Web (js, wasmJs) Web Share API (navigator.share) Kotlin/JS + Kotlin/Wasm (webMain)

Documentation

Installation

// commonMain (KMP) or dependencies { } (JVM desktop)
implementation("dev.nucleusframework:nucleus.share:<version>")

Sharing

scope.launch {
    try {
        ShareSheet.share {
            title = "Share"                 // chooser / Share UI title where the platform has one
            subject = "Nucleus"             // e-mail subject (Android), description (Windows)
            text("Cross-platform native share sheet")
            url("https://github.com/NucleusFramework/Nucleus")
            file("/path/to/report.pdf", mimeType = "application/pdf")
            fileUri("content://com.example/report.txt")   // Android content://, file:// on native, blob:/data:/https: on the web
        }
    } catch (e: ShareException) {
        println("${e.error}: ${e.message}")
    }
}
  • ShareSheet.share(request) is suspend and safe from any dispatcher: each platform switches to its UI thread itself.
  • It returns once the UI is on screen. On Android and Linux, it returns once the request is dispatched. On the web, it returns once the sheet is gone (the browser only answers then).
  • It does not report which target was picked, nor whether the user cancelled (same contract as robius-share).
  • A ShareRequest can also be built directly: ShareRequest(items = listOf(ShareItem.Text("…")), title = …, subject = …) or shareRequest { … }.
  • ShareSheet.isSupported: on desktop, whether the native bridge loaded.

Errors

ShareException.error is a ShareError:

Value Meaning
Empty no item
InvalidItem blank item, unparsable URL, blank MIME type
NoHandler no app can receive the payload
AlreadyOpen a share sheet is already presented (iOS, web InvalidStateError)
UnsupportedItem item kind unsupported on this platform (e.g. content:// on desktop, a local path on the web, files navigator.canShare refuses)
NoWindow no window / activity to present from
Unsupported no share UI, or the native bridge is missing
Io reading or staging a file failed
Platform the platform API failed (see the message)

Validation runs in common code before anything reaches the platform.

From a Nucleus window (desktop)

val window = LocalNucleusWindow.current
val density = LocalDensity.current
var anchor by remember { mutableStateOf<ShareAnchor?>(null) }

Button(
    onClick = { scope.launch { window.share(anchor) { url("https://…") } } },
    modifier = Modifier.onGloballyPositioned { anchor = it.shareAnchor(density) },
) { Text("Share") }

NucleusWindow.share(request, anchor) / NucleusWindow.share(anchor) { … } resolve the parent from the Tao window:

  • Windows: the window's HWND.
  • macOS: the NSWindow. The picker points at anchor, or at the top centre of the window without one. shareAnchor(density) converts a node's window bounds to points.
  • Linux X11 / XWayland: x11:<xid>.
  • Linux Wayland: an xdg_foreign export, unexported once the portal dialog is gone (see below).
  • A window without a platform identity (not realized yet, bridge missing) falls back to the app's frontmost window.

nucleus.share is a compileOnly dependency of nucleus-application, so it is never forced on consumers. An app that calls window.share already has it, since it builds a ShareRequest.

Naming the parent yourself

// Desktop (jvmMain)
ShareSheet.share(request, ShareParent.Windows(hwnd))
ShareSheet.share(request, ShareParent.MacOs(nsWindow, anchor = ShareAnchor(x, y, width, height)))
ShareSheet.share(request, ShareParent.Linux("x11:1a2b3c"))
ShareSheet.share(request, ShareParent.Linux(portal.portalParent, keepAlive = portal)) // Wayland export
ShareSheet.share(request, ShareParent.Auto)  // frontmost window of the app (default)

// Android (androidMain)
ShareSheet.share(request, activity)  // any Context; a non-Activity starts the chooser as a new task

ShareParent.Linux.keepAlive is closed exactly once:

  • once the portal dialog is gone, or right after share when no dialog was shown (xdg-open fallback);
  • or when the share fails or is cancelled before reaching the native side.

The portal requires a Wayland export to stay alive until its dialog closes, while share returns as soon as it is shown. So the lease is handed to the crate as a Runnable (Completion, run on Drop, so every path releases it), and the Linux portal thread runs it when the portal answers.

Platform notes

  • Android
    • Local files are copied to cacheDir/nucleus-share/<id>/<name> and served read-only by the library's own NucleusShareProvider. Its authority is ${applicationId}.nucleus.share, merged from the library manifest, with no AndroidX dependency.
    • A registry of original paths would die with the process, while a receiver may read the URI much later; hence the copy. Copies older than a day are purged on the next share.
    • content:// URIs are forwarded as-is, with a read grant through ClipData.
    • The same provider tracks the last resumed activity, so no init call is needed.
    • minSdk 21. Unlike robius-share, nothing is written to the MediaStore Downloads collection.
  • iOS: title and subject are ignored. On iPad the popover is centred on the presenting view controller, without an arrow. Presenting while a share sheet is up raises AlreadyOpen.
  • macOS: title and subject are ignored.
    • Called from another thread, the share hops to the AppKit main thread with a 10 s timeout. Without a running NSApplication, nothing drains the main queue, and the call now fails instead of hanging.
    • The last picker is retained until the next share.
  • Windows
    • Text and URLs are joined into the payload's text; the first URL is also set as its web link.
    • Files become read-only StorageItems. Only paths and file:// URIs are accepted.
    • Stale DataRequested handlers of a window are removed before each share.
  • Linux: Linux has no share sheet:
    • a single URL or file goes to the portal's "Open With" chooser (OpenURI / OpenFile);
    • a single text is written to a private temp file first;
    • a mixed payload goes to the "save files" dialog (FileChooser.SaveFiles);
    • xdg-open is the fallback when no portal answers.
    • Without a parent, the active X11 window is used (xprop).
  • Web (js and wasmJs, one webMain source reaching the browser through js() bodies):
    • Needs a secure context (HTTPS or localhost) and a user gesture: call share from a click handler, without awaiting anything slow first. Called outside a gesture, the browser rejects with NotAllowedError → ShareError.Platform.
    • Mapping to ShareData: title ← subject ?: title (receivers use it as a subject; there is no chooser title on the web), url ← the first URL, text ← the texts and the other URLs, in order.
    • There is no filesystem: file(path) fails with UnsupportedItem. Share fileUri("blob:…" / "data:…" / same-origin "https:…", mimeType) instead: each URI is fetched into a File, named after the URL's last segment or shared-<n>.<ext>, and the data is checked with navigator.canShare first. A failed fetch → ShareError.Io.
    • A dismissed sheet (AbortError) is no error. Browsers without navigator.share (Firefox desktop) report isSupported = false and throw Unsupported.
  • MIME type hints matter on Android and the web.

Implementation

  • Module layout
    • commonMain: API, validation, text joining, Android MIME-type rule.
    • androidMain, iosMain, webMain (shared by js and wasmJs): pure Kotlin. Only strings and lambdas cross the JS / Wasm boundary (file URIs and MIME types are newline-joined), so one source compiles for both.
    • jvmMain: NativeShareBridge plus ShareParent / ShareAnchor.
    • The Rust crate sits in share/src/main/native, with its libraries in share/src/main/resources, added to jvmMain. This is the JVM JNI-module layout, so the CI globs (*/src/main/..., cache key) stay uniform.
  • nucleus.native-module:
    • When org.jetbrains.kotlin.multiplatform is applied, it wires jvmProcessResources (the target must be named jvm) and feeds it the native-libraries manifest.
    • Its sourcesJar dependency now also matches jvmSourcesJar.
  • Crate (nucleus_share, jni 0.21):
    • windows 0.62.
    • objc2 0.6 / objc2-app-kit 0.3 / dispatch2 0.3.
    • Linux keeps robius-share's dependency-free D-Bus client (libc only).
    • JNI surface: one nativeShare call returning an error code (ShareError ordinal + 1) and a message out-param. It needs no FindClass except java.lang.Runnable.run for the completion, which is declared in reachability-metadata.json.
  • detekt on KMP: the aggregate detekt task is NO-SOURCE in a multiplatform module, so the KDoc rules were silently not enforced. The root build now wires the per-source-set detekt*MainSourceSet tasks (not the detektBaseline* ones) into check.
  • Version catalog: androidKotlinMultiplatformLibrary plugin (AGP 9.1.1), coroutines-android, coroutines-test.
  • Kotlin/JS in the build: the root project now applies base, which provides clean. Kotlin/JS applies the lifecycle plugin to the root, which forbids registering our own clean. kotlin-js-store/yarn.lock is committed (Node tests + the web demo).
  • CI:
    • share build steps (Windows / macOS / Linux) with the cache-hit guard.
    • Library entries in the Verify ... natives lists.
    • 6 arch paths in the pre-merge.yaml / publish-maven.yaml EXPECTED arrays.
  • Licensing: THIRD_PARTY_NOTICES.md §5 (robius-share, MIT) plus licenses/LICENSE-MIT-robius.txt. The notices ship in the desktop JAR's META-INF/.
  • Web demo: examples/share-web-demo (Kotlin/JS, ./gradlew :examples:share-web-demo:jsBrowserDevelopmentRun): text, link, a generated blob: file and a local path (unsupported).
  • Demo: examples/share-demo (./gradlew :examples:share-demo:run). Text, link, file, mixed and invalid payloads through nucleusWindow.share. -Dshare.demo.auto=<button> presses one at startup for smoke tests.

Test plan

  • cargo check for x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-linux-gnu
  • :share:check: common tests (builder, validation, text joining, MIME rule, Web Share mapping) on jvm, js (Node) and wasmJs (Node), + JVM lease test, detekt per source set, ktlint, apiCheck
  • jvm, android and iOS klibs compile (Kotlin 2.4 builds iOS klibs on a Windows host)
  • :nucleus-application:check, apiDump committed
  • Windows: Share UI shown from share-demo (text, link, file, mixed; clicked and auto-triggered)
  • macOS: picker, anchor placement, call from a background thread
  • Linux X11 and Wayland: portal chooser, save-files dialog for a mixed payload, export released after the dialog
  • Android: text, single / multiple files through the provider, content:// URI
  • iOS / iPadOS: activity sheet and popover anchoring
  • Web: share-web-demo bundle loads in Chrome, navigator.share detected (headless check)
  • Web: share text / link / blob: file from a click in Chrome, Edge, Safari; wasmJs in an app
  • GraalVM native image: NativeShareBridge + Runnable completion

Android and iOS in pure Kotlin, desktop through a Rust JNI bridge (Windows
DataTransferManager, macOS NSSharingServicePicker, Linux XDG portal), inspired by
robius-share. nucleus-application gains NucleusWindow.share, which resolves the
parent window (and the Wayland export's lifetime) from the Tao window.
js and wasmJs share webMain, which reaches navigator.share through js() bodies.
File items must be URIs the page can fetch (blob:, data:, same-origin https:);
local paths fail with UnsupportedItem. Adds examples/share-web-demo.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant