Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,28 @@ 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 }}
- name: "Build Swift Package for Android"
run: |
# 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
2 changes: 1 addition & 1 deletion Demo/App.swiftpm/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 6 additions & 3 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 += [
Expand Down
8 changes: 7 additions & 1 deletion Demo/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 3 additions & 20 deletions Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt
Original file line number Diff line number Diff line change
@@ -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()
}
// A pure consumer: the lifecycle bridge lives in `SwiftUIApplication`
// (`:androidbridge`). Named in the manifest as `.Application`.
class Application : SwiftUIApplication()
50 changes: 7 additions & 43 deletions Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -66,4 +30,4 @@ fun GreetingPreview() {
SwiftAndroidTheme {
Greeting("Android")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
28 changes: 26 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ 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: [
"ComposeUI",
"BridgeExport",
.product(
name: "SwiftUICore",
package: "SwiftUICore"
Expand Down Expand Up @@ -90,10 +91,30 @@ 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: [
"ComposeUI",
"BridgeExport",
.product(name: "SwiftUICore", package: "SwiftUICore")
],
// `Playgrounds` symlinks the Android demo's shared sources; the rig
Expand All @@ -109,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")
]
)
]
Expand Down
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>-RELEASE.xctoolchain/Info.plist)`.

## Running the desktop rig (macOS)

The whole pipeline — Swift `.dylib` → JNI → Kotlin interpreter → a Compose
Expand Down
61 changes: 29 additions & 32 deletions Sources/AndroidSwiftUI/AndroidApplication.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AndroidUtil.Log>()
_ = log.v(Self.logTag, string)
}
Expand Down
Loading
Loading