Migrate Kotlin↔Swift JNI bridge to jextract-generated bindings#75
Merged
Conversation
Replaces the hand-matched `Runnable`/`SwiftObject` JNI pair with jextract's `BridgeHost` protocol and `SwiftTask` box, so a signature drift fails to compile instead of reading garbage at the JNI boundary. The arena manages `SwiftTask`'s lifetime, retiring the retain map keyed by collision-prone `ObjectIdentifier.hashValue`. Kotlin still posts through `Handler.post`, preserving the class-loader invariant: JNI `FindClass` resolves against the Java frame on the stack, and a dispatch-queue drain has no app class loader. Turning on `enableJavaCallbacks` makes the plugin shell out to swift-java's Gradle for SwiftKitCore, which SwiftPM's build-plugin sandbox blocks from reaching the network — hence `--disable-sandbox` on the jextract task. The generated wrappers also need Java 17: their `equals()` uses pattern-matching `instanceof`. Verified on an API 28 emulator: void/bool/double/string dispatch, list scroll, nav push/pop, and an off-main-thread `.task` ticker.
`jniLibs` is gitignored, so a fresh clone starts empty, and copying only `libSwiftAndroidApp.so` leaves out the Swift runtime, `libSwiftJava.so`, and the NDK's C++ runtime. The app then dies at `Application.onCreate` with `UnsatisfiedLinkError: No implementation found for … onCreateSwift`, which points at the wrong thing — the symbol is exported; the `dlopen` is what failed, and the real cause is the `NativeLibrary` line above it in logcat. Also notes the toolchain trap: Xcode's bundled Swift and a same-numbered swift.org release are different builds, and mixing one with the other's Android SDK bundle fails with "compiled module was created by an older version of the compiler".
`SwiftUIActivity` and `SwiftUIApplication` in `:androidbridge` own the JNI lifecycle, so a host app subclasses them and declares no `external fun` of its own — the demo now has none. Repo-wide only two remain: `itemNode`, which returns a JavaKit-wrapped `ViewNode` that jextract can't express, and the desktop rig's entry point. Wrapped Java types still can't ride the generated bridge, so they cross the other way: Kotlin parks the activity in `HostContext` and Swift reads it by name lookup, leaving the four generated entry points carrying primitives. `BridgeExport` can't call the Swift side directly — that logic needs `AndroidKit`, which `BridgeExport` can't depend on since the desktop demo links it, and `AndroidSwiftUI` already depends on `BridgeExport`. So the call is handed off through `@_cdecl` symbols, as `AndroidSwiftUIMain` already did. A mismatch is a link error rather than a garbage read. Every product linking `BridgeExport` must define all four, hence the desktop rig's deliberately empty stubs. `SwiftUIActivity` needs an `onRegisterComposables` hook: custom composables must be registered after `super.onCreate` but before the first Swift render, which a plain override can't express once the base class owns that order. Verified on an API 28 emulator: @AppStorage survives a force-stop, custom composables resolve, and event dispatch is unregressed.
The JNI surface is invisible to R8: Swift resolves these classes by name and the `@_cdecl` thunks are named after them, so nothing points at them from Java and a missing rule fails at runtime rather than at build time. Each module now ships consumer rules for the classes it owns, so a host app picks them up automatically instead of copying them. `:composeui`'s rules are deliberately narrow — only the four types Swift binds. The interpreter is called from Kotlin alone and stays shrinkable; the mapping confirms it (`RenderKt -> H3.z0`) while 1744 classes are still renamed. Turning minification on found two things a debug build cannot: `:swiftbridge` compiles SwiftKitCore from source and only registered its java srcDir, so SwiftKitCore's own keep rules were never packaged and never reached a consumer. Its resources are now on the source path too. R8 then failed outright: SwiftKitCore's `@ThreadSafe`/`@Unsigned` are declared with JFR meta-annotations that exist on the JDK but not on Android. They are documentation only, so the rules suppress them. The demo's release build is signed with the debug key — this repo has no release keystore, and an installable artifact is the only way to prove keep rules are right. Verified on an API 28 emulator with the minified APK: lifecycle, navigation, event dispatch, and List rows resolved lazily through `itemNode`.
colemancda
force-pushed
the
feature/jextract
branch
from
July 25, 2026 03:09
ef53844 to
768b080
Compare
`enableJavaCallbacks: true` (added when the scheduler moved onto the generated bridge) makes any `swift build` of a target depending on `BridgeExport` shell out to swift-java's own Gradle to build SwiftKitCore, which needs network. SwiftPM sandboxes build-plugin commands by default, blocking that — confirmed as the CI failure: "gradle :SwiftKitCore:build failed with exit status exited(1)". `skip android build` has no flag to pass `--disable-sandbox` through to the `swift build` it wraps, so the build step now invokes the toolchain directly, matching what `skip` was already doing per the failure log's own invocation line. The Gradle wrapper also needs a JDK for javac and to run itself, hence actions/setup-java.
Invoking the toolchain's `swift` binary at an absolute path (matched directly from CI's own failure diagnostic) broke a different, unrelated build plugin: `StaticBuildConfigPluginExecutable` shells out to its own nested `swift`/`swiftc`, which resolves through PATH/`xcrun` rather than inheriting how the parent process was invoked, and landed on the runner's default Xcode toolchain instead — "unknown argument: '-print-static-build- config'", a flag the pinned toolchain supports and Xcode's doesn't. Setting `TOOLCHAINS` to the installed toolchain's bundle identifier fixes resolution for every nested process, not just the top-level one — the same pattern used throughout local development this session.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces 13 hand-matched Kotlin
external fun/ Swift@JavaImplementationJNI pairs — where a symbol-name typo reads garbage instead of failing to build — with swift-java's jextract-generated bindings. Down to 2 pairs repo-wide:itemNode(returns a JavaKit-wrappedViewNode, which jextract can't express) and the desktop rig's entry point. The Android demo app now declares zeroexternal funof its own.SwiftCallbackSinkcallbacks move onto generated bindings via a newBridgeExporttarget +:swiftbridgeGradle module.SwiftObject/Runnableclosure-boxing JNI pair — a retain map keyed by collision-proneObjectIdentifier.hashValue— is replaced by a generatedBridgeHostprotocol /SwiftTaskclosure box. RequiresenableJavaCallbacks: true.MainActivity/Applicationgraduate into reusableSwiftUIActivity/SwiftUIApplicationbase classes in:androidbridge. Wrapped Java types (theActivityitself) still can't cross the generated bridge, so they park in a KotlinHostContextholder that Swift reads by name lookup — the safe, pre-existing direction.:swiftbridgewas silently dropping SwiftKitCore's own keep rules, and R8 failed outright on SwiftKitCore's JDK-only JFR annotations.Known issue — CI is currently red on this branch
enableJavaCallbacks: truemakes anyswift buildof a target depending onBridgeExportshell out to swift-java's own Gradle (to buildSwiftKitCore), which needs network. SwiftPM sandboxes build-plugin commands (no network) unlessswift buildis passed--disable-sandbox. This repo's CI (.github/workflows/swift.yml) builds throughskip android build, which invokesswift buildinternally with no way to pass that flag through — confirmed failing:Error: gradle :SwiftKitCore:build failed. Fixing this needs either askipflag/version that exposes it, or replacing that CI step with a directswift build --disable-sandboxinvocation. Not yet done — flagging rather than hiding it.Test plan
swift test— 123/123 SwiftUICore tests:swiftbridge:compileJava,:androidbridge:compileDebugKotlin,:composeui:compileDebugKotlinAndroid,:demo-desktop:compileKotlinJvm,:demo-app:assembleDebug,:demo-app:assembleRelease(minified)SwiftUIDesktopDemodylib links (proves the lifecycle@_cdeclstubs)itemNode), nav push/pop, an off-main-thread async.taskticker (the scheduler's hardest case),@AppStoragesurviving a force-stop + relaunch, and custom composables resolving