Skip to content

Migrate Kotlin↔Swift JNI bridge to jextract-generated bindings#75

Merged
colemancda merged 13 commits into
masterfrom
feature/jextract
Jul 25, 2026
Merged

Migrate Kotlin↔Swift JNI bridge to jextract-generated bindings#75
colemancda merged 13 commits into
masterfrom
feature/jextract

Conversation

@colemancda

@colemancda colemancda commented Jul 25, 2026

Copy link
Copy Markdown
Member

Summary

Replaces 13 hand-matched Kotlin external fun / Swift @JavaImplementation JNI 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-wrapped ViewNode, which jextract can't express) and the desktop rig's entry point. The Android demo app now declares zero external fun of its own.

  • Event dispatch (prior commits): the 5 scalar SwiftCallbackSink callbacks move onto generated bindings via a new BridgeExport target + :swiftbridge Gradle module.
  • Scheduler: the hand-rolled SwiftObject/Runnable closure-boxing JNI pair — a retain map keyed by collision-prone ObjectIdentifier.hashValue — is replaced by a generated BridgeHost protocol / SwiftTask closure box. Requires enableJavaCallbacks: true.
  • Lifecycle: MainActivity/Application graduate into reusable SwiftUIActivity/SwiftUIApplication base classes in :androidbridge. Wrapped Java types (the Activity itself) still can't cross the generated bridge, so they park in a Kotlin HostContext holder that Swift reads by name lookup — the safe, pre-existing direction.
  • Hardening: consumer R8 keep rules per module (so a third-party host app gets them automatically) and the demo's release build now actually minifies. This surfaced two real bugs no debug build could: :swiftbridge was 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: true 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 (no network) unless swift build is passed --disable-sandbox. This repo's CI (.github/workflows/swift.yml) builds through skip android build, which invokes swift build internally with no way to pass that flag through — confirmed failing: Error: gradle :SwiftKitCore:build failed. Fixing this needs either a skip flag/version that exposes it, or replacing that CI step with a direct swift build --disable-sandbox invocation. Not yet done — flagging rather than hiding it.

Test plan

  • swift test — 123/123 SwiftUICore tests
  • Gradle: :swiftbridge:compileJava, :androidbridge:compileDebugKotlin, :composeui:compileDebugKotlinAndroid, :demo-desktop:compileKotlinJvm, :demo-app:assembleDebug, :demo-app:assembleRelease (minified)
  • Desktop SwiftUIDesktopDemo dylib links (proves the lifecycle @_cdecl stubs)
  • On an API 28 emulator, debug build: void/bool/double/string dispatch, list scroll (itemNode), nav push/pop, an off-main-thread async .task ticker (the scheduler's hardest case), @AppStorage surviving a force-stop + relaunch, and custom composables resolving
  • Same sweep re-run against the minified release APK — mapping confirms bridge classes kept un-renamed while 1744 other classes stayed shrinkable
  • CI (currently failing — see above)

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`.
`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.
@colemancda
colemancda merged commit 579da62 into master Jul 25, 2026
6 checks passed
@colemancda
colemancda deleted the feature/jextract branch July 25, 2026 13:01
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