From 80953d5b972140f79bb4356b19b75eb7598ae62e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 16:06:18 -0400 Subject: [PATCH 01/13] Add a jextract-JNI export target for the event-dispatch surface --- Package.swift | 27 ++++++++++++++++ Sources/BridgeExport/BridgeExport.swift | 43 +++++++++++++++++++++++++ Sources/BridgeExport/swift-java.config | 4 +++ 3 files changed, 74 insertions(+) create mode 100644 Sources/BridgeExport/BridgeExport.swift create mode 100644 Sources/BridgeExport/swift-java.config diff --git a/Package.swift b/Package.swift index 8d694fe..48befa1 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,14 @@ let package = Package( name: "SwiftUIDesktopDemo", type: .dynamic, targets: ["SwiftUIDesktopDemo"] + ), + // The jextract-JNI export surface, shipped as its own dynamic library + // (`libBridgeExport.so`/`.dylib`) so the generated Java's + // `System.loadLibrary("BridgeExport")` resolves next to the app library. + .library( + name: "BridgeExport", + type: .dynamic, + targets: ["BridgeExport"] ) ], dependencies: [ @@ -90,6 +98,25 @@ let package = Package( .swiftLanguageMode(.v5) ] ), + // The jextract-JNI export surface. Isolated thin target: only its tiny + // public API is exported (a large surface like ComposeUI's would choke + // jextract on result builders/generics). Carries the JExtractSwiftPlugin. + .target( + name: "BridgeExport", + dependencies: [ + "ComposeUI", + .product(name: "SwiftJava", package: "swift-java") + ], + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ), .target( name: "SwiftUIDesktopDemo", dependencies: [ diff --git a/Sources/BridgeExport/BridgeExport.swift b/Sources/BridgeExport/BridgeExport.swift new file mode 100644 index 0000000..6278cb4 --- /dev/null +++ b/Sources/BridgeExport/BridgeExport.swift @@ -0,0 +1,43 @@ +// +// BridgeExport.swift +// The jextract-JNI export surface the host app consumes. +// +// jextract (mode: jni) generates a Java class `com.pureswift.bridge.BridgeExport` +// mirroring these global functions, plus the `@_cdecl` thunks, into +// `libBridgeExport.so` — its own dynamic library so the generated +// `System.loadLibrary("BridgeExport")` resolves alongside the app library. +// +// Only the event-dispatch surface lives here for now: these are plain +// Java→Swift calls (no `enableJavaCallbacks` needed), replacing the five +// hand-matched scalar `SwiftCallbackSink` externals. `itemNode` stays a +// hand-matched external (it returns a JavaKit-wrapped `ViewNode`, which +// jextract cannot express), and the main-thread scheduler / closure boxing +// move in a later phase (they need the Swift→Java callback machinery). +// + +import ComposeUI + +/// Dispatches a `() -> Void` handler by id into the active runtime. +public func bridgeInvokeVoid(_ id: Int64) { + BridgeRuntime.current?.invokeVoid(id) +} + +/// Dispatches a `(Bool) -> Void` handler by id. +public func bridgeInvokeBool(_ id: Int64, _ value: Bool) { + BridgeRuntime.current?.invokeBool(id, value) +} + +/// Dispatches a `(Double) -> Void` handler by id. +public func bridgeInvokeDouble(_ id: Int64, _ value: Double) { + BridgeRuntime.current?.invokeDouble(id, value) +} + +/// Dispatches an `(Int) -> Void` handler by id. +public func bridgeInvokeInt(_ id: Int64, _ value: Int32) { + BridgeRuntime.current?.invokeInt(id, Int(value)) +} + +/// Dispatches a `(String) -> Void` handler by id. +public func bridgeInvokeString(_ id: Int64, _ value: String) { + BridgeRuntime.current?.invokeString(id, value) +} diff --git a/Sources/BridgeExport/swift-java.config b/Sources/BridgeExport/swift-java.config new file mode 100644 index 0000000..eaeb108 --- /dev/null +++ b/Sources/BridgeExport/swift-java.config @@ -0,0 +1,4 @@ +{ + "javaPackage": "com.pureswift.bridge", + "mode": "jni" +} From c90aaf30c820573aa7bb655b5b6d461afd625ab5 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 16:06:18 -0400 Subject: [PATCH 02/13] Compile the generated bindings and SwiftKitCore in a swiftbridge module --- settings.gradle.kts | 1 + swiftbridge/build.gradle.kts | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 swiftbridge/build.gradle.kts diff --git a/settings.gradle.kts b/settings.gradle.kts index d7f1826..01f6303 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ rootProject.name = "AndroidSwiftUI" // Reusable libraries live at the repo root. include(":composeui") // Compose Multiplatform interpreter include(":androidbridge") // reusable Android JNI host glue +include(":swiftbridge") // jextract-JNI generated bindings + SwiftKitCore runtime // The demo apps consume the libraries; their sources stay under Demo/. include(":demo-app") diff --git a/swiftbridge/build.gradle.kts b/swiftbridge/build.gradle.kts new file mode 100644 index 0000000..5e55b3f --- /dev/null +++ b/swiftbridge/build.gradle.kts @@ -0,0 +1,45 @@ +// Reusable library exposing the jextract-JNI generated Java bindings for the +// Swift `BridgeExport` module, so any JVM/Android host can call the Swift +// bridge through a generated, typed API instead of hand-matched `external` +// declarations. +// +// The generated bindings and swift-java's `SwiftKitCore` runtime are both plain +// Java; we compile them here directly (our Gradle toolchain) rather than +// consuming swift-java's own Gradle build, which pins a Gradle version this +// project doesn't vendor. The generation itself is driven by SwiftPM's +// JExtractSwiftPlugin during `swift build`; the `jextract` task below runs it. +plugins { + `java-library` +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +// The pinned swift-java checkout SwiftPM resolved into `.build/checkouts`. +val swiftKitCoreSrc = rootDir.resolve(".build/checkouts/swift-java/SwiftKitCore/src/main/java") +// Where JExtractSwiftPlugin writes the generated Java for the BridgeExport target. +val jextractGenerated = rootDir.resolve( + ".build/plugins/outputs/androidswiftui/BridgeExport/destination/JExtractSwiftPlugin/src/generated/java" +) + +// Regenerate the bindings by building the Swift target. Host-side generation is +// platform-independent (the same Java serves the desktop JVM and the Android +// cross-compiled `.so`), so this runs once against the host toolchain. +val jextract by tasks.registering(Exec::class) { + workingDir = rootDir + // The plugin invokes `javac`, so it needs a JDK; reuse the one running Gradle. + environment("JAVA_HOME", System.getProperty("java.home")) + commandLine("swift", "build", "--target", "BridgeExport") + outputs.dir(jextractGenerated) +} + +sourceSets { + main { + java { + srcDir(swiftKitCoreSrc) + srcDir(jextract) + } + } +} From 09918934b3c3c0c8deb2a8ca18cad432af33d10a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 17:00:38 -0400 Subject: [PATCH 03/13] Link the export into the app image and suppress the generated loader --- Package.swift | 10 ++-------- Sources/BridgeExport/BridgeExport.swift | 10 +++++++--- Sources/BridgeExport/swift-java.config | 3 ++- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 48befa1..3a49fb5 100644 --- a/Package.swift +++ b/Package.swift @@ -27,14 +27,6 @@ let package = Package( name: "SwiftUIDesktopDemo", type: .dynamic, targets: ["SwiftUIDesktopDemo"] - ), - // The jextract-JNI export surface, shipped as its own dynamic library - // (`libBridgeExport.so`/`.dylib`) so the generated Java's - // `System.loadLibrary("BridgeExport")` resolves next to the app library. - .library( - name: "BridgeExport", - type: .dynamic, - targets: ["BridgeExport"] ) ], dependencies: [ @@ -67,6 +59,7 @@ let package = Package( name: "AndroidSwiftUI", dependencies: [ "ComposeUI", + "BridgeExport", .product( name: "SwiftUICore", package: "SwiftUICore" @@ -121,6 +114,7 @@ let package = Package( name: "SwiftUIDesktopDemo", dependencies: [ "ComposeUI", + "BridgeExport", .product(name: "SwiftUICore", package: "SwiftUICore") ], // `Playgrounds` symlinks the Android demo's shared sources; the rig diff --git a/Sources/BridgeExport/BridgeExport.swift b/Sources/BridgeExport/BridgeExport.swift index 6278cb4..4cbe416 100644 --- a/Sources/BridgeExport/BridgeExport.swift +++ b/Sources/BridgeExport/BridgeExport.swift @@ -3,9 +3,13 @@ // The jextract-JNI export surface the host app consumes. // // jextract (mode: jni) generates a Java class `com.pureswift.bridge.BridgeExport` -// mirroring these global functions, plus the `@_cdecl` thunks, into -// `libBridgeExport.so` — its own dynamic library so the generated -// `System.loadLibrary("BridgeExport")` resolves alongside the app library. +// mirroring these global functions, plus the `@_cdecl` thunks. This target is +// linked INTO the app library (the Android `.so` / desktop dylib), not shipped +// as its own — a separate library would embed a second copy of ComposeUI and +// thus a distinct, never-started `BridgeRuntime.current`, so dispatch would +// no-op. The generated Java therefore emits no `loadLibrary` (config +// `overrideStaticBlockLibraryLoading: []`): the app's boot already loaded the +// single image these thunks resolve against. // // Only the event-dispatch surface lives here for now: these are plain // Java→Swift calls (no `enableJavaCallbacks` needed), replacing the five diff --git a/Sources/BridgeExport/swift-java.config b/Sources/BridgeExport/swift-java.config index eaeb108..f702506 100644 --- a/Sources/BridgeExport/swift-java.config +++ b/Sources/BridgeExport/swift-java.config @@ -1,4 +1,5 @@ { "javaPackage": "com.pureswift.bridge", - "mode": "jni" + "mode": "jni", + "overrideStaticBlockLibraryLoading": [] } From f4d5d61703901a41f940dfda874fe3ec9f71562c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 17:00:38 -0400 Subject: [PATCH 04/13] Route Android scalar events through the generated bindings --- composeui/build.gradle.kts | 3 +++ .../pureswift/swiftui/JextractCallbackSink.kt | 25 +++++++++++++++++++ .../com/pureswift/swiftui/SwiftUIHostView.kt | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt diff --git a/composeui/build.gradle.kts b/composeui/build.gradle.kts index e4ac392..cf8b948 100644 --- a/composeui/build.gradle.kts +++ b/composeui/build.gradle.kts @@ -33,6 +33,9 @@ kotlin { // remote streams, so the Android player is Media3. implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") + // jextract-generated event-dispatch bindings (the five scalar + // callbacks); replaces the hand-matched SwiftCallbackSink externals. + implementation(project(":swiftbridge")) } // `external fun` is JVM-only; both targets are JVM, so the bridge's // Swift-implemented classes live in a source set they share. diff --git a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt new file mode 100644 index 0000000..d851096 --- /dev/null +++ b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt @@ -0,0 +1,25 @@ +package com.pureswift.swiftui + +import com.pureswift.bridge.BridgeExport + +// Routes interpreter events into Swift through the jextract-generated +// `BridgeExport` bindings — plain Java→Swift static calls, no hand-matched JNI +// symbols. Only `itemNode` still crosses through a hand-written external +// (`SwiftCallbackSink`), because it returns a JavaKit-wrapped `ViewNode` that +// jextract cannot express. +class JextractCallbackSink( + private val items: CallbackSink = SwiftCallbackSink(), +) : CallbackSink { + + override fun invokeVoid(id: Long) = BridgeExport.bridgeInvokeVoid(id) + + override fun invokeBool(id: Long, value: Boolean) = BridgeExport.bridgeInvokeBool(id, value) + + override fun invokeDouble(id: Long, value: Double) = BridgeExport.bridgeInvokeDouble(id, value) + + override fun invokeInt(id: Long, value: Int) = BridgeExport.bridgeInvokeInt(id, value) + + override fun invokeString(id: Long, value: String) = BridgeExport.bridgeInvokeString(id, value) + + override fun itemNode(id: Long, index: Int): ViewNode? = items.itemNode(id, index) +} diff --git a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt index 5a20144..46aa79e 100644 --- a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt +++ b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt @@ -17,7 +17,7 @@ class SwiftUIHostView(context: Context) : FrameLayout(context) { val store = TreeStore() init { - SwiftBridge.sink = SwiftCallbackSink() + SwiftBridge.sink = JextractCallbackSink() val composeView = ComposeView(context) composeView.setContent { MaterialTheme { From 3ea5d98b5134e4fb93813f37b6b8243f9054b32e Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 17:07:47 -0400 Subject: [PATCH 05/13] Gate Android-only catalog screens on a desktop-rig flag, not canImport --- Demo/App.swiftpm/Sources/Catalog.swift | 9 ++++++--- Package.swift | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 771054e..abe5e06 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -52,8 +52,11 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())), CatalogEntry(id: "link", title: "Link", screen: AnyCatalogScreen(LinkPlayground())), ] - // Android-only: Map (schematic tiles), Video (Media3 ExoPlayer) - #if canImport(AndroidSwiftUI) + // Android-only: Map (schematic tiles), Video (Media3 ExoPlayer). + // Gated on the desktop rig's own flag, not `canImport(AndroidSwiftUI)`: + // a sibling module's build artifact makes `canImport` true even on the + // desktop target, where these playground files are excluded from the build. + #if !DESKTOP_RIG entries.append(CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground()))) entries.append(CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground()))) #endif @@ -67,7 +70,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())), ] // Android-only: native-view interop through the composable registry - #if canImport(AndroidSwiftUI) + #if !DESKTOP_RIG entries.append(CatalogEntry(id: "representable", title: "Custom Views", screen: AnyCatalogScreen(RepresentablePlayground()))) #endif entries += [ diff --git a/Package.swift b/Package.swift index 3a49fb5..10a16fc 100644 --- a/Package.swift +++ b/Package.swift @@ -130,7 +130,10 @@ let package = Package( "Playgrounds/RepresentablePlaygrounds.swift", ], swiftSettings: [ - .swiftLanguageMode(.v5) + .swiftLanguageMode(.v5), + // Marks the desktop test rig so the shared catalog can exclude the + // Android-only screens whose playground files this target excludes. + .define("DESKTOP_RIG") ] ) ] From 7eaaee924383b77d569aef33a58fd4c069d6a2ec Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 17:18:33 -0400 Subject: [PATCH 06/13] Share the generated event sink across desktop and Android --- .../kotlin/com/pureswift/swiftui/desktop/Main.kt | 2 +- composeui/build.gradle.kts | 10 ++++++---- .../com/pureswift/swiftui/JextractCallbackSink.kt | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) rename composeui/src/{androidMain => jvmShared}/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt (94%) diff --git a/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt index ad550c1..be81ef4 100644 --- a/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt +++ b/Demo/desktop/src/jvmMain/kotlin/com/pureswift/swiftui/desktop/Main.kt @@ -86,7 +86,7 @@ fun main() { private fun LiveContent() { val store = remember { TreeStore().also { - com.pureswift.swiftui.SwiftBridge.sink = com.pureswift.swiftui.SwiftCallbackSink() + com.pureswift.swiftui.SwiftBridge.sink = com.pureswift.swiftui.JextractCallbackSink() SwiftRuntime().start(it) } } diff --git a/composeui/build.gradle.kts b/composeui/build.gradle.kts index cf8b948..6e41b2a 100644 --- a/composeui/build.gradle.kts +++ b/composeui/build.gradle.kts @@ -33,14 +33,16 @@ kotlin { // remote streams, so the Android player is Media3. implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") - // jextract-generated event-dispatch bindings (the five scalar - // callbacks); replaces the hand-matched SwiftCallbackSink externals. - implementation(project(":swiftbridge")) } // `external fun` is JVM-only; both targets are JVM, so the bridge's - // Swift-implemented classes live in a source set they share. + // Swift-implemented classes live in a source set they share — as does + // the jextract-generated event-dispatch binding that replaces the five + // scalar `SwiftCallbackSink` externals. val jvmShared by creating { dependsOn(commonMain.get()) + dependencies { + implementation(project(":swiftbridge")) + } } androidMain.get().dependsOn(jvmShared) val desktopMain by getting { diff --git a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt b/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt similarity index 94% rename from composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt rename to composeui/src/jvmShared/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt index d851096..d50f546 100644 --- a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt +++ b/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/JextractCallbackSink.kt @@ -8,7 +8,7 @@ import com.pureswift.bridge.BridgeExport // (`SwiftCallbackSink`), because it returns a JavaKit-wrapped `ViewNode` that // jextract cannot express. class JextractCallbackSink( - private val items: CallbackSink = SwiftCallbackSink(), + private val items: SwiftCallbackSink = SwiftCallbackSink(), ) : CallbackSink { override fun invokeVoid(id: Long) = BridgeExport.bridgeInvokeVoid(id) From 466b0dcab3b0ba25c71b18ca31e72a42b0f29a49 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 17:18:33 -0400 Subject: [PATCH 07/13] Delete the five hand-matched scalar callback externals --- Sources/ComposeUI/SwiftCallbackSink.swift | 35 ++++--------------- .../pureswift/swiftui/SwiftCallbackSink.kt | 28 +++++---------- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/Sources/ComposeUI/SwiftCallbackSink.swift b/Sources/ComposeUI/SwiftCallbackSink.swift index 68ac99d..6e3e1d2 100644 --- a/Sources/ComposeUI/SwiftCallbackSink.swift +++ b/Sources/ComposeUI/SwiftCallbackSink.swift @@ -2,10 +2,12 @@ // SwiftCallbackSink.swift // ComposeUI // -// The entire Kotlin→Swift bridge surface. The JNI symbol for each method -// derives from THIS signature — the Kotlin `external` declarations in -// SwiftCallbackSink.kt must stay exactly in sync, and this class must never -// grow per-view methods. +// The one remaining hand-matched Kotlin→Swift external: a lazy-row query +// returning a materialized `ViewNode` subtree. It can't move to the generated +// `BridgeExport` bindings because an exported Swift function can't return a +// JavaKit-wrapped type. The JNI symbol derives from THIS signature, so the +// Kotlin `external fun itemNode` in SwiftCallbackSink.kt must stay in sync. +// The five scalar event callbacks now cross through `BridgeExport`. // import SwiftJava @@ -17,31 +19,6 @@ open class SwiftCallbackSink: JavaObject { @JavaImplementation("com.pureswift.swiftui.SwiftCallbackSink") extension SwiftCallbackSink { - @JavaMethod - func invokeVoid(_ id: Int64) { - BridgeRuntime.current?.invokeVoid(id) - } - - @JavaMethod - func invokeBool(_ id: Int64, _ value: Bool) { - BridgeRuntime.current?.invokeBool(id, value) - } - - @JavaMethod - func invokeDouble(_ id: Int64, _ value: Double) { - BridgeRuntime.current?.invokeDouble(id, value) - } - - @JavaMethod - func invokeInt(_ id: Int64, _ value: Int32) { - BridgeRuntime.current?.invokeInt(id, Int(value)) - } - - @JavaMethod - func invokeString(_ id: Int64, _ value: String) { - BridgeRuntime.current?.invokeString(id, value) - } - @JavaMethod func itemNode(_ id: Int64, _ index: Int32) -> ViewNodeObject? { BridgeRuntime.current?.itemNode(id, Int(index)) diff --git a/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt b/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt index 06ab576..4af85b2 100644 --- a/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt +++ b/composeui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt @@ -1,22 +1,12 @@ package com.pureswift.swiftui -// The entire Kotlin→Swift bridge surface: five externals dispatching event -// callback ids into the Swift registry. The JNI symbol for each derives from -// the SWIFT @JavaImplementation signature — these declarations and the Swift -// counterparts in SwiftCallbackSink.swift must stay exactly in sync, and this -// class must never grow per-view methods. -class SwiftCallbackSink : CallbackSink { - - external override fun invokeVoid(id: Long) - - external override fun invokeBool(id: Long, value: Boolean) - - external override fun invokeDouble(id: Long, value: Double) - - external override fun invokeInt(id: Long, value: Int) - - external override fun invokeString(id: Long, value: String) - - // A lazy row query: returns the materialized row subtree, or null. - external override fun itemNode(id: Long, index: Int): ViewNode? +// The one remaining hand-matched Kotlin→Swift external: a lazy-row query that +// returns a materialized `ViewNode` subtree. jextract can't express it (an +// exported Swift function can't return a JavaKit-wrapped type), so this stays a +// hand-written external whose JNI symbol matches the Swift @JavaImplementation +// in SwiftCallbackSink.swift. The five scalar event callbacks moved to the +// generated `BridgeExport` bindings — see JextractCallbackSink. +class SwiftCallbackSink { + + external fun itemNode(id: Long, index: Int): ViewNode? } From 73dba7ec08f8a3c1d0cdbc3acadbb20912f9530d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 20:19:26 -0400 Subject: [PATCH 08/13] Route the main-thread scheduler through generated callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../AndroidSwiftUI/JavaRetainedValue.swift | 88 ------------------- Sources/AndroidSwiftUI/Runnable.swift | 42 --------- Sources/AndroidSwiftUI/SwiftUIHostView.swift | 21 +++-- Sources/BridgeExport/BridgeExport.swift | 66 ++++++++++++-- Sources/BridgeExport/swift-java.config | 1 + androidbridge/build.gradle.kts | 5 ++ .../swiftandroid/AndroidBridgeHost.kt | 19 ++++ .../com/pureswift/swiftandroid/Runnable.kt | 9 -- .../com/pureswift/swiftandroid/SwiftObject.kt | 17 ---- composeui/build.gradle.kts | 2 + .../com/pureswift/swiftui/SwiftUIHostView.kt | 5 ++ swiftbridge/build.gradle.kts | 15 +++- 12 files changed, 114 insertions(+), 176 deletions(-) delete mode 100644 Sources/AndroidSwiftUI/JavaRetainedValue.swift delete mode 100644 Sources/AndroidSwiftUI/Runnable.swift create mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/AndroidBridgeHost.kt delete mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/Runnable.kt delete mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt diff --git a/Sources/AndroidSwiftUI/JavaRetainedValue.swift b/Sources/AndroidSwiftUI/JavaRetainedValue.swift deleted file mode 100644 index e12a9c7..0000000 --- a/Sources/AndroidSwiftUI/JavaRetainedValue.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// JavaRetainedValue.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import SwiftJava - -/// Java class that retains a Swift value for the duration of its lifetime. -@JavaClass("com.pureswift.swiftandroid.SwiftObject") -open class SwiftObject: JavaObject { - - @JavaMethod - @_nonoverride public convenience init(swiftObject: Int64, type: String, environment: JNIEnvironment? = nil) - - @JavaMethod - open func getSwiftObject() -> Int64 - - @JavaMethod - open func getType() -> String -} - -@JavaImplementation("com.pureswift.swiftandroid.SwiftObject") -extension SwiftObject { - - @JavaMethod - public func toStringSwift() -> String { - "\(valueObject().value)" - } - - @JavaMethod - public func finalizeSwift() { - // release owned swift value - release() - } -} - -extension SwiftObject { - - convenience init(_ value: T, environment: JNIEnvironment? = nil) { - let box = JavaRetainedValue(value) - let type = box.type - self.init(swiftObject: box.id, type: type, environment: environment) - // retain value - retain(box) - } - - func valueObject() -> JavaRetainedValue { - let id = getSwiftObject() - guard let object = Self.retained[id] else { - fatalError() - } - return object - } -} - -private extension SwiftObject { - - static var retained = [JavaRetainedValue.ID: JavaRetainedValue]() - - func retain(_ value: JavaRetainedValue) { - Self.retained[value.id] = value - } - - func release() { - let id = getSwiftObject() - Self.retained[id] = nil - } -} - -/// Swift Object retained until released by Java object. -final class JavaRetainedValue: Identifiable { - - var value: Any - - var type: String { - String(describing: Swift.type(of: value)) - } - - var id: Int64 { - Int64(ObjectIdentifier(self).hashValue) - } - - init(_ value: T) { - self.value = value - } -} diff --git a/Sources/AndroidSwiftUI/Runnable.swift b/Sources/AndroidSwiftUI/Runnable.swift deleted file mode 100644 index a589fc9..0000000 --- a/Sources/AndroidSwiftUI/Runnable.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Runnable.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import AndroidKit - -/// Java `Runnable` backed by a Swift closure. -@JavaClass("com.pureswift.swiftandroid.Runnable") -open class Runnable: JavaObject { - - public typealias Block = () -> () - - @JavaMethod - @_nonoverride public convenience init(block: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - public func getBlock() -> SwiftObject? -} - -@JavaImplementation("com.pureswift.swiftandroid.Runnable") -extension Runnable { - - @JavaMethod - public func run() { - guard let block = getBlock()?.valueObject().value as? Block else { - assertionFailure("Missing block") - return - } - block() - } -} - -public extension Runnable { - - convenience init(_ block: @escaping Block, environment: JNIEnvironment? = nil) { - let object = SwiftObject(block, environment: environment) - self.init(block: object, environment: environment) - } -} diff --git a/Sources/AndroidSwiftUI/SwiftUIHostView.swift b/Sources/AndroidSwiftUI/SwiftUIHostView.swift index b123ecf..89f3272 100644 --- a/Sources/AndroidSwiftUI/SwiftUIHostView.swift +++ b/Sources/AndroidSwiftUI/SwiftUIHostView.swift @@ -6,7 +6,7 @@ // import AndroidKit -import JavaLang +import BridgeExport import ComposeUI import SwiftUICore @@ -37,18 +37,17 @@ public enum AndroidSwiftUIApp { assertionFailure("host view has no tree store") return } - // Re-renders post to the main looper through a JVM `Runnable`, NOT - // `DispatchQueue.main`. Rendering makes JNI calls (materializing the - // `ViewNode` tree), and JNI `FindClass` resolves against the class loader - // of the Java frame on the stack. A `Runnable.run()` invocation carries - // the app's class loader; the dispatch main-queue drain runs in a native - // context whose fallback boot class loader can't see the app's classes, - // so JNI aborts with `NoClassDefFoundError`. `AndroidMainActor`/ + // Re-renders post to the main looper through the generated `BridgeHost` + // (a Kotlin `Handler.post` frame), NOT `DispatchQueue.main`. Rendering + // makes JNI calls (materializing the `ViewNode` tree), and JNI + // `FindClass` resolves against the class loader of the Java frame on + // the stack. A `Handler.post` invocation carries the app's class + // loader; the dispatch main-queue drain runs in a native context whose + // fallback boot class loader can't see the app's classes, so JNI + // aborts with `NoClassDefFoundError`. `AndroidMainActor`/ // `DispatchQueue.main` (bound at launch) remain correct for non-JNI work. - let handler = AndroidOS.Handler(try! JavaClass().getMainLooper()) let runtime = BridgeRuntime(root: root, store: store) { block in - let runnable = Runnable { block() } - _ = handler.post(runnable.as(JavaLang.Runnable.self)) + bridgeScheduleMain(block) } Self.runtime = runtime runtime.start() diff --git a/Sources/BridgeExport/BridgeExport.swift b/Sources/BridgeExport/BridgeExport.swift index 4cbe416..96ee787 100644 --- a/Sources/BridgeExport/BridgeExport.swift +++ b/Sources/BridgeExport/BridgeExport.swift @@ -11,16 +11,70 @@ // `overrideStaticBlockLibraryLoading: []`): the app's boot already loaded the // single image these thunks resolve against. // -// Only the event-dispatch surface lives here for now: these are plain -// Java→Swift calls (no `enableJavaCallbacks` needed), replacing the five -// hand-matched scalar `SwiftCallbackSink` externals. `itemNode` stays a -// hand-matched external (it returns a JavaKit-wrapped `ViewNode`, which -// jextract cannot express), and the main-thread scheduler / closure boxing -// move in a later phase (they need the Swift→Java callback machinery). +// The event-dispatch functions below are plain Java→Swift calls. `BridgeHost` +// and `SwiftTask` are the opposite direction (Swift calling back into +// Kotlin) and need `enableJavaCallbacks: true`: jextract turns the protocol +// into a Java interface Kotlin implements, and generates the Swift-side box +// that lets Swift call through to it. `itemNode` stays a hand-matched +// external (it returns a JavaKit-wrapped `ViewNode`, which jextract cannot +// express). // import ComposeUI +/// Kotlin-implemented main-thread scheduler, installed once at startup +/// (`bridgeSetHost`). Replaces the hand-matched `Runnable`/`SwiftObject` JNI +/// pair: Swift calls `postToMain` instead of building a `Runnable` by hand, +/// so a signature drift fails to compile instead of reading garbage at the +/// JNI boundary. +public protocol BridgeHost { + /// Runs `task` on the platform main thread/looper. On Android this must + /// land on a `Handler.post` frame, not a native dispatch-queue drain — see + /// the invariant documented at the `AndroidSwiftUIApp.run` call site. + func postToMain(_ task: SwiftTask) +} + +/// A boxed `() -> Void` Kotlin holds and runs later from +/// `BridgeHost.postToMain`, replacing `SwiftObject`'s hand-rolled retain map. +/// jextract's `SwiftArena` owns the instance's lifetime on the Java side. +public final class SwiftTask { + + private let block: () -> Void + + // Not public: only Swift ever constructs a `SwiftTask` (in + // `bridgeScheduleMain`); Kotlin only ever receives instances handed to + // `postToMain` and calls `run()`. A public init with an escaping-closure + // parameter trips a jextract wrap-java bug — the generated Java nested + // type for the closure is named `SwiftTask$init$block`, and translating + // that back to Swift tries to declare a type literally named `init`, + // which is a reserved keyword. + init(_ block: @escaping () -> Void) { + self.block = block + } + + public func run() { + block() + } +} + +private var currentHost: (any BridgeHost)? + +/// Installs the process-wide `BridgeHost`. Called once at startup, before +/// `AndroidSwiftUIApp.run`. +public func bridgeSetHost(_ host: some BridgeHost) { + currentHost = host +} + +/// Schedules `block` on the main thread through the installed host. +public func bridgeScheduleMain(_ block: @escaping () -> Void) { + guard let currentHost else { + assertionFailure("bridgeSetHost was never called") + block() + return + } + currentHost.postToMain(SwiftTask(block)) +} + /// Dispatches a `() -> Void` handler by id into the active runtime. public func bridgeInvokeVoid(_ id: Int64) { BridgeRuntime.current?.invokeVoid(id) diff --git a/Sources/BridgeExport/swift-java.config b/Sources/BridgeExport/swift-java.config index f702506..a540864 100644 --- a/Sources/BridgeExport/swift-java.config +++ b/Sources/BridgeExport/swift-java.config @@ -1,5 +1,6 @@ { "javaPackage": "com.pureswift.bridge", "mode": "jni", + "enableJavaCallbacks": true, "overrideStaticBlockLibraryLoading": [] } diff --git a/androidbridge/build.gradle.kts b/androidbridge/build.gradle.kts index c3751eb..f5da56e 100644 --- a/androidbridge/build.gradle.kts +++ b/androidbridge/build.gradle.kts @@ -25,3 +25,8 @@ android { jvmTarget = "11" } } + +dependencies { + // `BridgeHost`/`SwiftTask`: the generated types `AndroidBridgeHost` implements. + api(project(":swiftbridge")) +} diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/AndroidBridgeHost.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/AndroidBridgeHost.kt new file mode 100644 index 0000000..c53e89a --- /dev/null +++ b/androidbridge/src/main/java/com/pureswift/swiftandroid/AndroidBridgeHost.kt @@ -0,0 +1,19 @@ +package com.pureswift.swiftandroid + +import android.os.Handler +import android.os.Looper +import com.pureswift.bridge.BridgeHost +import com.pureswift.bridge.SwiftTask + +/// The generated `BridgeHost`: replaces the hand-matched `Runnable`/ +/// `SwiftObject` JNI pair. Posting through `Handler.post` (not a coroutine +/// dispatcher) keeps the JVM frame that resolves JNI classes as the app's +/// class loader — see the invariant documented at `AndroidSwiftUIApp.run`. +class AndroidBridgeHost : BridgeHost { + + private val handler = Handler(Looper.getMainLooper()) + + override fun postToMain(task: SwiftTask) { + handler.post { task.run() } + } +} diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/Runnable.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/Runnable.kt deleted file mode 100644 index eca0adc..0000000 --- a/androidbridge/src/main/java/com/pureswift/swiftandroid/Runnable.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.pureswift.swiftandroid - -// `java.lang.Runnable` backed by a Swift closure. -class Runnable(private val block: SwiftObject?) : java.lang.Runnable { - - fun getBlock(): SwiftObject? = block - - external override fun run() -} diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt deleted file mode 100644 index 94ff2db..0000000 --- a/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.pureswift.swiftandroid - -/// Swift object retained by JVM -class SwiftObject(val swiftObject: Long, val type: String) { - - override fun toString(): String { - return toStringSwift() - } - - external fun toStringSwift(): String - - fun finalize() { - finalizeSwift() - } - - external fun finalizeSwift() -} \ No newline at end of file diff --git a/composeui/build.gradle.kts b/composeui/build.gradle.kts index 6e41b2a..f40e627 100644 --- a/composeui/build.gradle.kts +++ b/composeui/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { // remote streams, so the Android player is Media3. implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") + // AndroidBridgeHost: the generated BridgeHost's main-thread scheduler. + implementation(project(":androidbridge")) } // `external fun` is JVM-only; both targets are JVM, so the bridge's // Swift-implemented classes live in a source set they share — as does diff --git a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt index 46aa79e..f4bb355 100644 --- a/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt +++ b/composeui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt @@ -8,6 +8,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import com.pureswift.bridge.BridgeExport +import com.pureswift.swiftandroid.AndroidBridgeHost // The Android host: one Compose island rendering the whole Swift-evaluated // tree. Swift constructs this, hands its store to the bridge runtime, and @@ -18,6 +20,9 @@ class SwiftUIHostView(context: Context) : FrameLayout(context) { init { SwiftBridge.sink = JextractCallbackSink() + // Installed before Swift's `AndroidSwiftUIApp.run` builds its + // scheduler closure, since that runs later in this same construction. + BridgeExport.bridgeSetHost(AndroidBridgeHost()) val composeView = ComposeView(context) composeView.setContent { MaterialTheme { diff --git a/swiftbridge/build.gradle.kts b/swiftbridge/build.gradle.kts index 5e55b3f..58b25c5 100644 --- a/swiftbridge/build.gradle.kts +++ b/swiftbridge/build.gradle.kts @@ -13,8 +13,11 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + // VERSION_11 doesn't parse: generated wrapper types (e.g. `SwiftTask`) + // implement `JNISwiftInstance`, whose `equals()` jextract generates using + // pattern-matching `instanceof`, a Java 16+ syntax feature. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } // The pinned swift-java checkout SwiftPM resolved into `.build/checkouts`. @@ -31,7 +34,13 @@ val jextract by tasks.registering(Exec::class) { workingDir = rootDir // The plugin invokes `javac`, so it needs a JDK; reuse the one running Gradle. environment("JAVA_HOME", System.getProperty("java.home")) - commandLine("swift", "build", "--target", "BridgeExport") + // `BridgeExport`'s config has `enableJavaCallbacks: true` (needed for + // `BridgeHost`), which makes the JExtractSwiftPlugin shell out to + // swift-java's own Gradle to build SwiftKitCore. SwiftPM sandboxes build + // plugins (no network) by default, which blocks that download — the + // outer Gradle process here isn't sandboxed, so without this flag the + // inner `swift build` fails where a bare Gradle invocation wouldn't. + commandLine("swift", "build", "--target", "BridgeExport", "--disable-sandbox") outputs.dir(jextractGenerated) } From bc43d195cba2d6fe6804a69320c4de83c4f31a8b Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 20:19:37 -0400 Subject: [PATCH 09/13] Document every .so the Android demo needs staged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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". --- README.md | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23a7074..cdc1705 100644 --- a/README.md +++ b/README.md @@ -65,13 +65,40 @@ cd Demo/swift JAVA_HOME="…/Android Studio.app/Contents/jbr/Contents/Home" \ swift build --swift-sdk aarch64-unknown-linux-android28 -# 2. stage the .so, then assemble & install (gradle root is the repo root) -cp .build/aarch64-unknown-linux-android28/debug/libSwiftAndroidApp.so \ - ../app/src/main/jniLibs/arm64-v8a/ +# 2. stage every .so the app dlopens. `jniLibs` is gitignored, so a fresh +# clone starts empty and needs all of these, not just the app library. +JNI=../app/src/main/jniLibs/arm64-v8a +BUILD=.build/aarch64-unknown-linux-android28/debug +SDK=$(echo ~/Library/org.swift.swiftpm/swift-sdks/*_android.artifactbundle/swift-android) +NDK=$(echo "$ANDROID_HOME"/ndk/*/toolchains/llvm/prebuilt/*/sysroot) + +mkdir -p "$JNI" +cp "$BUILD"/libSwiftAndroidApp.so "$BUILD"/libSwiftJava.so "$JNI"/ +# the Swift runtime, from the Android SDK bundle, minus the test-only libraries +cp "$SDK"/swift-resources/usr/lib/swift-aarch64/android/*.so "$JNI"/ +rm -f "$JNI"/libXCTest.so "$JNI"/libTesting.so \ + "$JNI"/lib_TestingInterop.so "$JNI"/lib_Testing_Foundation.so +# and the NDK's C++ runtime +cp "$NDK"/usr/lib/aarch64-linux-android/libc++_shared.so "$JNI"/ + +# 3. assemble & install (gradle root is the repo root) cd ../.. JAVA_HOME="…/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :demo-app:assembleDebug ``` +Miss a runtime library and the app dies at `Application.onCreate` with +`UnsatisfiedLinkError: No implementation found for … onCreateSwift`. That error is +misleading — the JNI symbol *is* in the `.so`; it's the `dlopen` that failed. The +real cause is the line above it in logcat: `NativeLibrary: Unable to load native +libraries: … library "libFoo.so" not found`. + +If `swift build` instead fails with *"compiled module was created by an older +version of the compiler"*, the host toolchain doesn't match the one the Android SDK +bundle was built with — Xcode's bundled Swift and a same-numbered swift.org release +are different builds. Select the swift.org toolchain explicitly: +`export TOOLCHAINS=$(plutil -extract CFBundleIdentifier raw \ +~/Library/Developer/Toolchains/swift--RELEASE.xctoolchain/Info.plist)`. + ## Running the desktop rig (macOS) The whole pipeline — Swift `.dylib` → JNI → Kotlin interpreter → a Compose From af85c00cf3c867c7418fb021f21648672c8fb878 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 21:40:28 -0400 Subject: [PATCH 10/13] Move the Android lifecycle onto reusable base classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- Demo/App.swiftpm/Sources/App.swift | 2 +- .../com/pureswift/swiftandroid/Application.kt | 23 +---- .../pureswift/swiftandroid/MainActivity.kt | 50 ++-------- Package.swift | 2 +- .../AndroidSwiftUI/AndroidApplication.swift | 61 ++++++------ Sources/AndroidSwiftUI/MainActivity.swift | 73 --------------- Sources/AndroidSwiftUI/SwiftUIActivity.swift | 92 +++++++++++++++++++ Sources/AndroidSwiftUI/SwiftUIHostView.swift | 4 +- Sources/BridgeExport/BridgeExport.swift | 49 ++++++++++ .../AndroidLifecycleStubs.swift | 25 +++++ androidbridge/build.gradle.kts | 4 + .../com/pureswift/swiftandroid/HostContext.kt | 30 ++++++ .../pureswift/swiftandroid/SwiftUIActivity.kt | 54 +++++++++++ .../swiftandroid/SwiftUIApplication.kt | 26 ++++++ gradle/libs.versions.toml | 1 + 15 files changed, 324 insertions(+), 172 deletions(-) delete mode 100644 Sources/AndroidSwiftUI/MainActivity.swift create mode 100644 Sources/AndroidSwiftUI/SwiftUIActivity.swift create mode 100644 Sources/SwiftUIDesktopDemo/AndroidLifecycleStubs.swift create mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/HostContext.kt create mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIActivity.kt create mode 100644 androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIApplication.kt diff --git a/Demo/App.swiftpm/Sources/App.swift b/Demo/App.swiftpm/Sources/App.swift index 463bf80..d7642c5 100644 --- a/Demo/App.swiftpm/Sources/App.swift +++ b/Demo/App.swiftpm/Sources/App.swift @@ -8,7 +8,7 @@ import SwiftUI #if canImport(AndroidSwiftUI) -/// App launch point, called from `MainActivity`. +/// App launch point, called from `SwiftUIActivity`. @_silgen_name("AndroidSwiftUIMain") func AndroidSwiftUIMain() { AndroidSwiftUILog("Starting SwiftUI App") diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt index 4e398e6..beadd86 100644 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt @@ -1,22 +1,5 @@ package com.pureswift.swiftandroid -class Application: android.app.Application() { - - init { - NativeLibrary.shared() - } - - override fun onCreate() { - super.onCreate() - onCreateSwift() - } - - private external fun onCreateSwift() - - override fun onTerminate() { - super.onTerminate() - onTerminateSwift() - } - - private external fun onTerminateSwift() -} \ No newline at end of file +// A pure consumer: the lifecycle bridge lives in `SwiftUIApplication` +// (`:androidbridge`). Named in the manifest as `.Application`. +class Application : SwiftUIApplication() diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt index f975c83..d82b1c6 100644 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt @@ -1,55 +1,19 @@ package com.pureswift.swiftandroid -import android.content.Intent -import android.os.Bundle -import android.util.Log -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.Spinner -import android.widget.TextView -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.fragment.app.FragmentActivity -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.viewinterop.AndroidView import com.pureswift.swiftandroid.ui.theme.SwiftAndroidTheme -// Extends `FragmentActivity` so both framework and AndroidX fragments can be hosted. -class MainActivity : FragmentActivity() { +// A pure consumer: hosting, the JNI lifecycle and `setRootView` all live in +// `SwiftUIActivity` (`:androidbridge`). All this app adds is its own +// composable registry. +class MainActivity : SwiftUIActivity() { - init { - NativeLibrary.shared() + override fun onRegisterComposables() { + registerDemoComposables() } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - registerDemoComposables() // custom composables must be registered before the first render - onCreateSwift(savedInstanceState) - enableEdgeToEdge() - } - - external fun onCreateSwift(savedInstanceState: Bundle?) - - fun setRootView(view: View) { - Log.v("MainActivity", "AndroidSwiftUI.MainActivity.setRootView(_:)") - setContentView(view) - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - onActivityResultSwift(requestCode, resultCode, data) - } - - external fun onActivityResultSwift(requestCode: Int, resultCode: Int, data: Intent?) } @Composable @@ -66,4 +30,4 @@ fun GreetingPreview() { SwiftAndroidTheme { Greeting("Android") } -} \ No newline at end of file +} diff --git a/Package.swift b/Package.swift index 10a16fc..7e2b959 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,7 @@ let package = Package( ], targets: [ // The Android umbrella: re-exports SwiftUICore + ComposeUI and adds the - // android.view bridging (MainActivity, Application, host view). + // android.view bridging (SwiftUIActivity, SwiftUIApplication, host view). .target( name: "AndroidSwiftUI", dependencies: [ diff --git a/Sources/AndroidSwiftUI/AndroidApplication.swift b/Sources/AndroidSwiftUI/AndroidApplication.swift index 21fd8ce..9aabc60 100644 --- a/Sources/AndroidSwiftUI/AndroidApplication.swift +++ b/Sources/AndroidSwiftUI/AndroidApplication.swift @@ -6,46 +6,43 @@ // import AndroidKit +import BridgeExport #if canImport(AndroidLooper) import AndroidLooper #endif -@JavaClass("com.pureswift.swiftandroid.Application") -open class Application: AndroidApp.Application { - - public internal(set) static var shared: Application! +/// The reusable host application, implemented in `:androidbridge`. A host app +/// names the Kotlin `SwiftUIApplication` (or a subclass) in its manifest. +@JavaClass("com.pureswift.swiftandroid.SwiftUIApplication") +open class SwiftUIApplication: AndroidApp.Application {} + +/// `Application.onCreate`, handed off from the generated `BridgeExport` entry +/// point (which can't reach this module — see the note at its declaration). +@_cdecl("swiftui_applicationCreated") +func swiftui_applicationCreated() { + SwiftUIApplication.log("\(#function)") + + // Bind the Android main looper to `AndroidMainActor` at process launch. + // `Application.onCreate` runs on the main thread — the required call site + // — so `@MainActor` and `DispatchQueue.main` dispatch correctly from here + // on, without hand-draining `RunLoop.main`. + #if canImport(AndroidLooper) + let boundMainLooper = AndroidMainActor.setupMainLooper() + SwiftUIApplication.log("AndroidMainActor.setupMainLooper() -> \(boundMainLooper)") + #endif } -@JavaImplementation("com.pureswift.swiftandroid.Application") -extension Application { - - @JavaMethod - func onCreateSwift() { - log("\(self).\(#function)") - Application.shared = self - - // Bind the Android main looper to `AndroidMainActor` at process launch. - // `Application.onCreate` runs on the main thread — the required call site - // — so `@MainActor` and `DispatchQueue.main` dispatch correctly from here - // on, without hand-draining `RunLoop.main`. - #if canImport(AndroidLooper) - let boundMainLooper = AndroidMainActor.setupMainLooper() - log("AndroidMainActor.setupMainLooper() -> \(boundMainLooper)") - #endif - } - - @JavaMethod - func onTerminateSwift() { - log("\(self).\(#function)") - Application.shared = nil - } +/// `Application.onTerminate`. +@_cdecl("swiftui_applicationTerminated") +func swiftui_applicationTerminated() { + SwiftUIApplication.log("\(#function)") } -extension Application { - - static var logTag: String { "Application" } - - func log(_ string: String) { +extension SwiftUIApplication { + + static var logTag: String { "SwiftUIApplication" } + + static func log(_ string: String) { let log = try! JavaClass() _ = log.v(Self.logTag, string) } diff --git a/Sources/AndroidSwiftUI/MainActivity.swift b/Sources/AndroidSwiftUI/MainActivity.swift deleted file mode 100644 index f8613dd..0000000 --- a/Sources/AndroidSwiftUI/MainActivity.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// Activity.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/8/25. -// - -import Foundation -import AndroidKit -import JavaLang - -@JavaClass("com.pureswift.swiftandroid.MainActivity") -open class MainActivity: AndroidApp.Activity { - - public internal(set) static var shared: MainActivity! - - @JavaMethod - open func setRootView(_ view: AndroidView.View?) -} - -@JavaImplementation("com.pureswift.swiftandroid.MainActivity") -extension MainActivity { - - @JavaMethod - public func onCreateSwift(_ savedInstanceState: BaseBundle?) { - log("\(self).\(#function)") - MainActivity.shared = self - - // Point @AppStorage at a file in the app's private storage before any - // view is built, so the first evaluation already reads saved values. - // The path comes through the existing Context binding — no new bridge. - if let directory = (self as AndroidContent.Context).getFilesDir()?.getAbsolutePath() { - AppStorageStore.backend = FileAppStorage(directory: directory) - } else { - log("MainActivity: no files directory; @AppStorage stays in memory") - } - - // start app - AndroidSwiftUIMain() - } - - @JavaMethod - public func onActivityResultSwift(_ requestCode: Int32, _ resultCode: Int32, _ data: AndroidContent.Intent?) { - log("\(self).\(#function) requestCode \(requestCode) resultCode \(resultCode)") - } -} - -extension MainActivity { - - static var logTag: String { "MainActivity" } - - static let log = try! JavaClass() - - static func log(_ string: String) { - _ = Self.log.d(Self.logTag, string) - } - - static func logInfo(_ string: String) { - _ = Self.log.i(Self.logTag, string) - } - - static func logError(_ string: String) { - _ = Self.log.e(Self.logTag, string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} diff --git a/Sources/AndroidSwiftUI/SwiftUIActivity.swift b/Sources/AndroidSwiftUI/SwiftUIActivity.swift new file mode 100644 index 0000000..b5f0f0d --- /dev/null +++ b/Sources/AndroidSwiftUI/SwiftUIActivity.swift @@ -0,0 +1,92 @@ +// +// SwiftUIActivity.swift +// AndroidSwiftUI +// +// Created by Alsey Coleman Miller on 6/8/25. +// + +import Foundation +import AndroidKit +import JavaLang +import BridgeExport + +/// The reusable host activity, implemented in `:androidbridge`. A host app +/// subclasses the Kotlin `SwiftUIActivity`; nothing on either side declares a +/// JNI symbol by hand. +@JavaClass("com.pureswift.swiftandroid.SwiftUIActivity") +open class SwiftUIActivity: AndroidApp.Activity { + + public internal(set) static var shared: SwiftUIActivity! + + @JavaMethod + open func setRootView(_ view: AndroidView.View?) +} + +/// Kotlin's holder for the Java objects the generated bridge can't carry. +/// Reading it by name lookup is the safe direction across the boundary. +@JavaClass("com.pureswift.swiftandroid.HostContext") +open class HostContext: JavaObject {} + +extension JavaClass { + + @JavaStaticMethod + public func getActivity() -> SwiftUIActivity? +} + +/// `Activity.onCreate`, handed off from the generated `BridgeExport` entry +/// point (which can't reach this module — see the note at its declaration). +@_cdecl("swiftui_activityCreated") +func swiftui_activityCreated() { + guard let activity = try! JavaClass().getActivity() else { + SwiftUIActivity.logError("activityCreated: HostContext has no activity") + return + } + SwiftUIActivity.log("\(activity).\(#function)") + SwiftUIActivity.shared = activity + + // Point @AppStorage at a file in the app's private storage before any + // view is built, so the first evaluation already reads saved values. + // The path comes through the existing Context binding — no new bridge. + if let directory = (activity as AndroidContent.Context).getFilesDir()?.getAbsolutePath() { + AppStorageStore.backend = FileAppStorage(directory: directory) + } else { + SwiftUIActivity.log("no files directory; @AppStorage stays in memory") + } + + // start app + AndroidSwiftUIMain() +} + +/// `Activity.onActivityResult`. The `Intent` stays in `HostContext`; only the +/// primitive codes cross the generated bridge. +@_cdecl("swiftui_activityResult") +func swiftui_activityResult(_ requestCode: Int32, _ resultCode: Int32) { + SwiftUIActivity.log("\(#function) requestCode \(requestCode) resultCode \(resultCode)") +} + +extension SwiftUIActivity { + + static var logTag: String { "SwiftUIActivity" } + + static let log = try! JavaClass() + + static func log(_ string: String) { + _ = Self.log.d(Self.logTag, string) + } + + static func logInfo(_ string: String) { + _ = Self.log.i(Self.logTag, string) + } + + static func logError(_ string: String) { + _ = Self.log.e(Self.logTag, string) + } + + func log(_ string: String) { + Self.log(string) + } + + func logError(_ string: String) { + Self.logError(string) + } +} diff --git a/Sources/AndroidSwiftUI/SwiftUIHostView.swift b/Sources/AndroidSwiftUI/SwiftUIHostView.swift index 89f3272..cdd7a66 100644 --- a/Sources/AndroidSwiftUI/SwiftUIHostView.swift +++ b/Sources/AndroidSwiftUI/SwiftUIHostView.swift @@ -28,8 +28,8 @@ public enum AndroidSwiftUIApp { private static var runtime: BridgeRuntime? public static func run(_ root: any SwiftUICore.View) { - guard let activity = MainActivity.shared else { - assertionFailure("MainActivity not created yet") + guard let activity = SwiftUIActivity.shared else { + assertionFailure("SwiftUIActivity not created yet") return } let host = SwiftUIHostView(activity as AndroidContent.Context) diff --git a/Sources/BridgeExport/BridgeExport.swift b/Sources/BridgeExport/BridgeExport.swift index 96ee787..451c761 100644 --- a/Sources/BridgeExport/BridgeExport.swift +++ b/Sources/BridgeExport/BridgeExport.swift @@ -75,6 +75,55 @@ public func bridgeScheduleMain(_ block: @escaping () -> Void) { currentHost.postToMain(SwiftTask(block)) } +// MARK: - Android lifecycle +// +// The Swift side of these lives in `AndroidSwiftUI`: it needs `AndroidKit`, +// which this target can't depend on (the desktop demo links `BridgeExport` +// too), and `AndroidSwiftUI` already depends on *this* target, so importing +// it back would be a cycle. The call is therefore handed off through fixed +// symbols — the same trick `AndroidSwiftUIMain` already uses. Unlike a JNI +// name match, a missing or misspelled definition fails at link time rather +// than reading garbage at runtime. +// +// Every product linking `BridgeExport` must define all four. `AndroidSwiftUI` +// does for Android hosts; the desktop rig defines no-ops. + +@_silgen_name("swiftui_applicationCreated") +func swiftui_applicationCreated() + +@_silgen_name("swiftui_applicationTerminated") +func swiftui_applicationTerminated() + +@_silgen_name("swiftui_activityCreated") +func swiftui_activityCreated() + +@_silgen_name("swiftui_activityResult") +func swiftui_activityResult(_ requestCode: Int32, _ resultCode: Int32) + +/// `Application.onCreate`. Binds the main looper before anything renders. +public func bridgeApplicationCreated() { + swiftui_applicationCreated() +} + +/// `Application.onTerminate`. +public func bridgeApplicationTerminated() { + swiftui_applicationTerminated() +} + +/// `Activity.onCreate`. Reads the activity back out of `HostContext` and +/// starts the app; the activity itself can't come through as an argument. +public func bridgeActivityCreated() { + swiftui_activityCreated() +} + +/// `Activity.onActivityResult`. The `Intent` stays in `HostContext` — only +/// the primitive codes cross here. +public func bridgeActivityResult(_ requestCode: Int32, _ resultCode: Int32) { + swiftui_activityResult(requestCode, resultCode) +} + +// MARK: - Event dispatch + /// Dispatches a `() -> Void` handler by id into the active runtime. public func bridgeInvokeVoid(_ id: Int64) { BridgeRuntime.current?.invokeVoid(id) diff --git a/Sources/SwiftUIDesktopDemo/AndroidLifecycleStubs.swift b/Sources/SwiftUIDesktopDemo/AndroidLifecycleStubs.swift new file mode 100644 index 0000000..7d29577 --- /dev/null +++ b/Sources/SwiftUIDesktopDemo/AndroidLifecycleStubs.swift @@ -0,0 +1,25 @@ +// +// AndroidLifecycleStubs.swift +// SwiftUIDesktopDemo +// +// `BridgeExport` declares the four Android lifecycle entry points as fixed +// symbols, since it sits below `AndroidSwiftUI` and can't call into it (see +// the note at their declaration). Every product that links `BridgeExport` +// therefore has to define them, and the desktop rig links it for the event +// dispatch. There is no Android lifecycle on a desktop JVM — the rig's +// Compose window drives everything from `SwiftRuntime.start` — so these are +// deliberately empty. The linker, not a runtime JNI lookup, is what proves +// they exist. +// + +@_cdecl("swiftui_applicationCreated") +func swiftui_applicationCreated() {} + +@_cdecl("swiftui_applicationTerminated") +func swiftui_applicationTerminated() {} + +@_cdecl("swiftui_activityCreated") +func swiftui_activityCreated() {} + +@_cdecl("swiftui_activityResult") +func swiftui_activityResult(_ requestCode: Int32, _ resultCode: Int32) {} diff --git a/androidbridge/build.gradle.kts b/androidbridge/build.gradle.kts index f5da56e..2c5dd50 100644 --- a/androidbridge/build.gradle.kts +++ b/androidbridge/build.gradle.kts @@ -29,4 +29,8 @@ android { dependencies { // `BridgeHost`/`SwiftTask`: the generated types `AndroidBridgeHost` implements. api(project(":swiftbridge")) + // `SwiftUIActivity` extends `FragmentActivity` and calls `enableEdgeToEdge`; + // `api` so host apps subclassing it get the base type on their compile path. + api(libs.androidx.fragment) + api(libs.androidx.activity) } diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/HostContext.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/HostContext.kt new file mode 100644 index 0000000..04042b1 --- /dev/null +++ b/androidbridge/src/main/java/com/pureswift/swiftandroid/HostContext.kt @@ -0,0 +1,30 @@ +package com.pureswift.swiftandroid + +import android.app.Application +import android.content.Intent +import android.os.Bundle + +/// Where the host parks the Java objects Swift needs to read. +/// +/// The generated bridge carries only primitives, Strings and closures — a +/// wrapped Java type like `Activity` can't be an argument to it. So those +/// cross in the other, safe direction: Kotlin publishes them here and Swift +/// reads them through its classic `@JavaClass` name-lookup bindings, leaving +/// the generated lifecycle calls to carry nothing but primitives. +/// +/// `savedState` and `activityResultData` are published for hosts that need +/// them; the Swift bridge itself currently only reads `activity`. +object HostContext { + + @JvmStatic + var activity: SwiftUIActivity? = null + + @JvmStatic + var application: Application? = null + + @JvmStatic + var savedState: Bundle? = null + + @JvmStatic + var activityResultData: Intent? = null +} diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIActivity.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIActivity.kt new file mode 100644 index 0000000..f05087d --- /dev/null +++ b/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIActivity.kt @@ -0,0 +1,54 @@ +package com.pureswift.swiftandroid + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity +import com.pureswift.bridge.BridgeExport + +/// Base `Activity` for a SwiftUI-on-Android host. Subclassing this is the +/// whole contract: no `external fun`, no JNI symbol to match by hand. +/// +/// Extends `FragmentActivity` so both framework and AndroidX fragments can +/// be hosted. +open class SwiftUIActivity : FragmentActivity() { + + init { + NativeLibrary.shared() + } + + /// Runs after the activity is published to [HostContext] but before Swift + /// evaluates the first tree. Override to register custom composables — + /// the registry is read during that first render, so registering later + /// is too late. + open fun onRegisterComposables() {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + HostContext.activity = this + HostContext.savedState = savedInstanceState + onRegisterComposables() + BridgeExport.bridgeActivityCreated() + enableEdgeToEdge() + } + + override fun onDestroy() { + if (HostContext.activity === this) { + HostContext.activity = null + } + super.onDestroy() + } + + /// Swift installs the rendered tree here. + fun setRootView(view: View) { + setContentView(view) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + HostContext.activityResultData = data + BridgeExport.bridgeActivityResult(requestCode, resultCode) + } +} diff --git a/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIApplication.kt b/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIApplication.kt new file mode 100644 index 0000000..e180866 --- /dev/null +++ b/androidbridge/src/main/java/com/pureswift/swiftandroid/SwiftUIApplication.kt @@ -0,0 +1,26 @@ +package com.pureswift.swiftandroid + +import com.pureswift.bridge.BridgeExport + +/// Base `Application` for a SwiftUI-on-Android host: loads the Swift library +/// and drives the Swift side's process lifecycle through the generated +/// bridge. Name it (or a subclass) in the manifest instead of hand-writing +/// `external fun` — the JNI symbols are generated and typed. +open class SwiftUIApplication : android.app.Application() { + + init { + NativeLibrary.shared() + } + + override fun onCreate() { + super.onCreate() + HostContext.application = this + BridgeExport.bridgeApplicationCreated() + } + + override fun onTerminate() { + super.onTerminate() + BridgeExport.bridgeApplicationTerminated() + HostContext.application = null + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bc3bf14..e97b81c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activityCompose" } androidx-fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } From 768b080591c6806ee626bda013c1f58e93ceacff Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 22:20:32 -0400 Subject: [PATCH 11/13] Ship R8 keep rules and turn on the minified release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- Demo/app/build.gradle.kts | 8 +++++++- androidbridge/build.gradle.kts | 1 + androidbridge/consumer-rules.pro | 5 +++++ composeui/build.gradle.kts | 1 + composeui/consumer-rules.pro | 12 +++++++++++ swiftbridge/build.gradle.kts | 6 ++++++ .../META-INF/proguard/swiftbridge.pro | 20 +++++++++++++++++++ 7 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 androidbridge/consumer-rules.pro create mode 100644 composeui/consumer-rules.pro create mode 100644 swiftbridge/src/main/resources/META-INF/proguard/swiftbridge.pro diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index e1c5552..135fdb3 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -23,7 +23,13 @@ android { buildTypes { release { - isMinifyEnabled = false + // On: a release build is the only thing that exercises the keep + // rules the bridge modules ship. The JNI surface is invisible to + // R8 — a missing rule fails at runtime, not at build time. + isMinifyEnabled = true + // Signed with the debug key so the minified build is installable + // and can actually be run; this demo ships no release keystore. + signingConfig = signingConfigs.getByName("debug") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/androidbridge/build.gradle.kts b/androidbridge/build.gradle.kts index 2c5dd50..160e66b 100644 --- a/androidbridge/build.gradle.kts +++ b/androidbridge/build.gradle.kts @@ -16,6 +16,7 @@ android { compileSdk = 35 defaultConfig { minSdk = 24 + consumerProguardFiles("consumer-rules.pro") } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/androidbridge/consumer-rules.pro b/androidbridge/consumer-rules.pro new file mode 100644 index 0000000..1e6d7a5 --- /dev/null +++ b/androidbridge/consumer-rules.pro @@ -0,0 +1,5 @@ +# Swift binds these by name — `@JavaClass("com.pureswift.swiftandroid.…")` — +# and calls their methods over JNI, so R8 sees no caller for either the +# classes or their members. The package is bridge glue only; keeping it +# whole costs nothing an app would otherwise shrink. +-keep class com.pureswift.swiftandroid.** { *; } diff --git a/composeui/build.gradle.kts b/composeui/build.gradle.kts index f40e627..6c879e5 100644 --- a/composeui/build.gradle.kts +++ b/composeui/build.gradle.kts @@ -65,6 +65,7 @@ android { compileSdk = 35 defaultConfig { minSdk = 24 + consumerProguardFiles("consumer-rules.pro") } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/composeui/consumer-rules.pro b/composeui/consumer-rules.pro new file mode 100644 index 0000000..6a77a15 --- /dev/null +++ b/composeui/consumer-rules.pro @@ -0,0 +1,12 @@ +# The types Swift binds by name (`@JavaClass("com.pureswift.swiftui.…")`) +# and drives over JNI: the tree store it pushes into, the node objects it +# constructs slot by slot, the host view, and the one remaining hand-matched +# callback. R8 sees no Java caller for their members. +# +# Deliberately not a whole-package keep: the interpreter itself (Render.kt, +# the composable registry) is only ever called from Kotlin and should stay +# shrinkable. +-keep class com.pureswift.swiftui.TreeStore { *; } +-keep class com.pureswift.swiftui.ViewNode { *; } +-keep class com.pureswift.swiftui.SwiftUIHostView { *; } +-keep class com.pureswift.swiftui.SwiftCallbackSink { *; } diff --git a/swiftbridge/build.gradle.kts b/swiftbridge/build.gradle.kts index 58b25c5..8182a06 100644 --- a/swiftbridge/build.gradle.kts +++ b/swiftbridge/build.gradle.kts @@ -50,5 +50,11 @@ sourceSets { srcDir(swiftKitCoreSrc) srcDir(jextract) } + // SwiftKitCore ships its own `-keep org.swift.swiftkit.**` rules under + // META-INF/proguard. We compile it from source rather than consuming its + // jar, so without this its rules never reach a minifying consumer. + resources { + srcDir(rootDir.resolve(".build/checkouts/swift-java/SwiftKitCore/src/main/resources")) + } } } diff --git a/swiftbridge/src/main/resources/META-INF/proguard/swiftbridge.pro b/swiftbridge/src/main/resources/META-INF/proguard/swiftbridge.pro new file mode 100644 index 0000000..653fd5c --- /dev/null +++ b/swiftbridge/src/main/resources/META-INF/proguard/swiftbridge.pro @@ -0,0 +1,20 @@ +# Consumer R8/ProGuard rules for the jextract-generated JNI bindings. +# Packaged under META-INF/proguard so any app consuming this jar picks them +# up automatically. +# +# Nothing here may be renamed or removed. Swift reaches these by name at +# runtime and R8 cannot see the callers: +# - the `@_cdecl` thunks are named after the class and method, e.g. +# Java_com_pureswift_bridge_BridgeExport__00024bridgeInvokeVoid__J; +# - `BridgeHostBox` looks `BridgeHost` methods up from native code, so the +# interface and its implementors have no Java-visible caller; +# - `SwiftTask.run` is invoked only from the Kotlin scheduler via that +# same generated box. +-keep class com.pureswift.bridge.** { *; } +-keep interface com.pureswift.bridge.** { *; } + +# SwiftKitCore's `@ThreadSafe`/`@Unsigned` annotations are declared with JFR +# meta-annotations (`jdk.jfr.Label`, `jdk.jfr.Description`), which exist on +# the JDK but not on Android. They are documentation only and nothing reads +# them at runtime, so let R8 proceed rather than fail the release build. +-dontwarn jdk.jfr.** From f893d73bd66dc1b76d18f6a006faf0541638abf6 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 23:23:59 -0400 Subject: [PATCH 12/13] Fix CI: enableJavaCallbacks needs --disable-sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .github/workflows/swift.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 96278ed..3e2d19d 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -14,8 +14,21 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v4 - - name: "Build Swift Package for Android" + # `BridgeExport`'s swift-java.config has `enableJavaCallbacks: true`, which + # makes the JExtractSwiftPlugin shell out to swift-java's own Gradle to + # build SwiftKitCore during `swift build` — needs a JDK for javac and to + # run that Gradle wrapper itself. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: "Install Swift Android SDK" run: | brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip) skip android sdk install --version ${{ matrix.swift }} - ANDROID_NDK_ROOT="" ANDROID_SDK_VERSION=${{ matrix.sdk }} skip android build --swift-version ${{ matrix.swift }} --arch ${{ matrix.arch }} --android-api-level ${{ matrix.sdk }} \ No newline at end of file + - name: "Build Swift Package for Android" + run: | + "$HOME/Library/Developer/Toolchains/swift-${{ matrix.swift }}-RELEASE.xctoolchain/usr/bin/swift" build \ + --swift-sdk ${{ matrix.arch }}-unknown-linux-android${{ matrix.sdk }} \ + --disable-sandbox \ + -Xswiftc -DSKIP_BRIDGE -Xswiftc -DTARGET_OS_ANDROID \ No newline at end of file From 4d5591ea5fc05fa36ab7a33712d7a134affdc87a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Fri, 24 Jul 2026 23:35:30 -0400 Subject: [PATCH 13/13] Fix CI: select the toolchain by identifier, not by absolute path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/swift.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3e2d19d..103b56e 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -28,7 +28,14 @@ jobs: skip android sdk install --version ${{ matrix.swift }} - name: "Build Swift Package for Android" run: | - "$HOME/Library/Developer/Toolchains/swift-${{ matrix.swift }}-RELEASE.xctoolchain/usr/bin/swift" build \ + # Select the toolchain by identifier (not by invoking its `swift` + # binary at an absolute path): a build plugin here shells out to its + # own nested `swift`/`swiftc`, which resolves through `xcrun`/PATH + # and would otherwise land on the runner's default Xcode toolchain + # instead of the one the SDK bundle was built against. + export TOOLCHAINS=$(plutil -extract CFBundleIdentifier raw \ + "$HOME/Library/Developer/Toolchains/swift-${{ matrix.swift }}-RELEASE.xctoolchain/Info.plist") + swift build \ --swift-sdk ${{ matrix.arch }}-unknown-linux-android${{ matrix.sdk }} \ --disable-sandbox \ -Xswiftc -DSKIP_BRIDGE -Xswiftc -DTARGET_OS_ANDROID \ No newline at end of file