From 7c3296c293143d24f2d56d3fb8a1feb6dbc13b4f Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 12:12:54 -0400 Subject: [PATCH 1/4] Migrate Demo to official Swift Android SDK and add jextract JNI bridge - Replace `skip android build` with direct `swift build --swift-sdk` (official Swift 6.3.3 Android SDK); --disable-sandbox enables the swift-java jextract plugin's nested Java callbacks build - Add CatalogBridge target using jextract JNI mode with enableJavaCallbacks for Swift<->Java closures/Runnable blocks - Stage SwiftKitCore + generated Java bindings into the app build, vendoring Android-compatible copies of jdk.jfr-based annotations - Smoke test in Application: Kotlin->Swift downcall and Swift->Kotlin closure callback, verified on emulator --- Demo/Package.swift | 34 ++++++++++++++++++- Demo/app/build.gradle.kts | 32 ++++++++++++++++- .../com/pureswift/swiftandroid/Application.kt | 13 +++++++ .../swiftkit/core/annotations/ThreadSafe.java | 28 +++++++++++++++ .../swiftkit/core/annotations/Unsigned.java | 30 ++++++++++++++++ .../CatalogBridge/CatalogBridge.swift | 9 +++++ .../CatalogBridge/swift-java.config | 6 ++++ Demo/build-swift.sh | 28 ++++++++++----- Demo/swift-define | 18 +++++++--- 9 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java create mode 100644 Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java create mode 100644 Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift create mode 100644 Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config diff --git a/Demo/Package.swift b/Demo/Package.swift index c0340257..679a1159 100644 --- a/Demo/Package.swift +++ b/Demo/Package.swift @@ -12,10 +12,19 @@ let package = Package( type: .dynamic, targets: ["SwiftAndroidApp"] ), + .library( + name: "CatalogBridge", + type: .dynamic, + targets: ["CatalogBridge"] + ), ], dependencies: [ .package( path: "../" + ), + .package( + url: "https://github.com/swiftlang/swift-java.git", + branch: "main" ) ], targets: [ @@ -25,12 +34,35 @@ let package = Package( .product( name: "AndroidKit", package: "Android" - ) + ), + "CatalogBridge" ], path: "./app/src/main/swift", swiftSettings: [ .swiftLanguageMode(.v5) ] + ), + .target( + name: "CatalogBridge", + dependencies: [ + .product( + name: "SwiftJava", + package: "swift-java" + ) + ], + path: "./app/src/main/swift-bridge/CatalogBridge", + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin( + name: "JExtractSwiftPlugin", + package: "swift-java" + ) + ] ) ] ) diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index 4bed26e7..1b20a543 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -40,6 +40,16 @@ android { buildFeatures { compose = true } + // swift-java jextract (JNI mode) generated Java bindings + the SwiftKitCore + // runtime they depend on. Generated by `build-swift.sh` into the SwiftPM + // `.build` tree, staged (with Android-incompatible files filtered out) by + // the `stageSwiftJavaSources` task below. + sourceSets { + getByName("main") { + java.srcDir(layout.buildDirectory.dir("swift-java-staged/swiftkit-core")) + java.srcDir(layout.buildDirectory.dir("swift-java-staged/jextract")) + } + } packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") @@ -54,7 +64,7 @@ android { } } -// Compile native Swift code for the demo app with `skip android build`. +// Compile native Swift code for the demo app with the official Swift Android SDK. val buildSwift by tasks.registering(Exec::class) { group = "build" description = "Build native Swift sources for Android" @@ -62,8 +72,28 @@ val buildSwift by tasks.registering(Exec::class) { commandLine("bash", "build-swift.sh") } +// Stage swift-java Java sources into the build dir, excluding files that use +// `jdk.jfr` (unavailable on Android). Android-compatible replacements for the +// excluded annotations live in the app's own java source dir. +val stageSwiftJavaSources by tasks.registering(Sync::class) { + group = "build" + description = "Stage SwiftKitCore + jextract-generated Java sources for Android" + dependsOn(buildSwift) + into(layout.buildDirectory.dir("swift-java-staged")) + from(rootProject.file(".build/checkouts/swift-java/SwiftKitCore/src/main/java")) { + into("swiftkit-core") + exclude("org/swift/swiftkit/core/annotations/Unsigned.java") + exclude("org/swift/swiftkit/core/annotations/ThreadSafe.java") + } + from(rootProject.file(".build/plugins/outputs/demo/CatalogBridge/destination/JExtractSwiftPlugin/src/generated/java")) { + into("jextract") + exclude("jextract-generated-sources.txt") + } +} + tasks.named("preBuild") { dependsOn(buildSwift) + dependsOn(stageSwiftJavaSources) } dependencies { 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 4075c97e..f54b17f0 100644 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/Application.kt @@ -1,6 +1,8 @@ package com.pureswift.swiftandroid +import android.util.Log import com.example.swift.HelloSubclass +import com.pureswift.swiftandroid.bridge.CatalogBridge class Application: android.app.Application() { @@ -11,6 +13,17 @@ class Application: android.app.Application() { override fun onCreate() { super.onCreate() onCreateSwift() + testCatalogBridge() + } + + /// Smoke test for the jextract JNI bridge: a Java->Swift downcall and a + /// Swift->Java callback (closure/Runnable block). + private fun testCatalogBridge() { + val sum = CatalogBridge.addOne(41) + Log.d("CatalogBridge", "addOne(41) = $sum") + CatalogBridge.runBlock { + Log.d("CatalogBridge", "runBlock callback invoked from Swift") + } } private external fun onCreateSwift() diff --git a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java new file mode 100644 index 00000000..9976d04a --- /dev/null +++ b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java @@ -0,0 +1,28 @@ +// Android-compatible copy of SwiftKitCore's ThreadSafe annotation. +// The upstream file uses `jdk.jfr` meta-annotations which are unavailable +// on Android, so it is excluded from the imported source set and replaced +// by this equivalent declaration. + +package org.swift.swiftkit.core.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; + +/** + * Used to mark a type as thread-safe, i.e. no additional synchronization is necessary when accessing it + * from multiple threads. + * + *

In SwiftJava specifically, this attribute is applied when an extracted Swift type conforms to the Swift + * {@code Sendable} protocol, which is a compiler enforced mechanism to enforce thread-safety in Swift. + * + * @see Swift Sendable API documentation. + */ +@Documented +@Target({TYPE_USE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ThreadSafe { +} diff --git a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java new file mode 100644 index 00000000..92d95470 --- /dev/null +++ b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java @@ -0,0 +1,30 @@ +// Android-compatible copy of SwiftKitCore's Unsigned annotation. +// The upstream file uses `jdk.jfr` meta-annotations which are unavailable +// on Android, so it is excluded from the imported source set and replaced +// by this equivalent declaration. + +package org.swift.swiftkit.core.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; + +/** + * Value is of an unsigned numeric type. + *

+ * This annotation is used to annotate java integer primitives when their + * corresponding Swift type was actually unsigned, e.g. an {@code @Unsigned long} + * in a method signature corresponds to a Swift {@code UInt64} type, and therefore + * negative values reported by the signed {@code long} should instead be interpreted positive values, + * larger than {@code Long.MAX_VALUE} that are just not representable using a signed {@code long}. + *

+ * If this annotation is used on a method, it refers to the return type using an unsigned integer. + */ +@Documented +@Target({TYPE_USE, PARAMETER, FIELD, METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Unsigned { +} diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift new file mode 100644 index 00000000..4d6a4765 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift @@ -0,0 +1,9 @@ +// Minimal jextract JNI spike: a closure/Runnable callback exposed to Java. + +public func runBlock(block: () -> Void) { + block() +} + +public func addOne(input: Int64) -> Int64 { + input + 1 +} diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config new file mode 100644 index 00000000..fa494aab --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config @@ -0,0 +1,6 @@ +{ + "javaPackage": "com.pureswift.swiftandroid.bridge", + "mode": "jni", + "enableJavaCallbacks": true, + "logLevel": "info" +} diff --git a/Demo/build-swift.sh b/Demo/build-swift.sh index c66f1359..5275deeb 100755 --- a/Demo/build-swift.sh +++ b/Demo/build-swift.sh @@ -5,12 +5,27 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/swift-define" JNI_LIBS_DIR="$SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH" -# Build with SwiftPM -ANDROID_NDK_ROOT="" ANDROID_SDK_VERSION="$ANDROID_SDK_VERSION" skip android build --arch "$SWIFT_TARGET_ARCH" --android-api-level "$ANDROID_SDK_VERSION" +# Build with SwiftPM against the official Swift Android SDK. +# +# `--disable-sandbox` is required so the swift-java jextract build-tool plugin +# (JNI mode with `enableJavaCallbacks`) can run its nested Gradle build to +# compile the Java callback wrappers. This replaces the previous `skip +# android build` driver. +swift build \ + --package-path "$SWIFT_PACKAGE_SRC" \ + --swift-sdk "$SWIFT_TARGET_NAME" \ + --configuration "$SWIFT_COMPILATION_MODE" \ + --disable-sandbox -# Copy compiled Swift package +BUILD_BIN="$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/$SWIFT_COMPILATION_MODE" + +# Copy compiled Swift product shared objects (app + any jextract bridge modules). mkdir -p "$JNI_LIBS_DIR/" -cp -f "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftAndroidApp.so" "$JNI_LIBS_DIR/" +shopt -s nullglob +for so in "$BUILD_BIN"/lib*.so; do + cp -f "$so" "$JNI_LIBS_DIR/" +done +shopt -u nullglob # Copy Swift runtime shared libraries required by libSwiftAndroidApp.so. if [[ -d "$SWIFT_ANDROID_RUNTIME_LIBS" ]]; then @@ -21,11 +36,6 @@ if [[ -d "$SWIFT_ANDROID_RUNTIME_LIBS" ]]; then shopt -u nullglob fi -# Copy SwiftJava helper library when available. -if [[ -f "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftJava.so" ]]; then - cp -f "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftJava.so" "$JNI_LIBS_DIR/" -fi - # Copy C++ runtime from Android sysroot. if [[ -f "$SWIFT_ANDROID_SYSROOT/usr/lib/$ANDROID_LIB/libc++_shared.so" ]]; then cp -f "$SWIFT_ANDROID_SYSROOT/usr/lib/$ANDROID_LIB/libc++_shared.so" "$JNI_LIBS_DIR/" diff --git a/Demo/swift-define b/Demo/swift-define index 43004e4d..a13ded22 100644 --- a/Demo/swift-define +++ b/Demo/swift-define @@ -8,10 +8,10 @@ SWIFT_COMPILATION_MODE="${SWIFT_COMPILATION_MODE:=debug}" # Version ANDROID_SDK_VERSION=28 -SWIFT_VERSION_SHORT=6.2.3 +SWIFT_VERSION_SHORT=6.3.3 SWIFT_VERSION=swift-$SWIFT_VERSION_SHORT-RELEASE +# Swift SDK target triple used by `swift build --swift-sdk`. SWIFT_TARGET_NAME=$SWIFT_TARGET_ARCH-unknown-linux-android$ANDROID_SDK_VERSION -XCTOOLCHAIN=/Library/Developer/Toolchains/$SWIFT_VERSION.xctoolchain SWIFT_ARTIFACT_BUNDLE="${SWIFT_ARTIFACT_BUNDLE:=swift-$SWIFT_VERSION_SHORT-RELEASE_android.artifactbundle}" SWIFT_SDKS_ROOT="${SWIFT_SDKS_ROOT:=$HOME/Library/org.swift.swiftpm/swift-sdks}" if [[ ! -d "$SWIFT_SDKS_ROOT/$SWIFT_ARTIFACT_BUNDLE" ]]; then @@ -24,7 +24,15 @@ SWIFT_ANDROID_SYSROOT="$SWIFT_SDKS_ROOT/$SWIFT_ARTIFACT_BUNDLE/swift-android/ndk SWIFT_ANDROID_LIBS="$SWIFT_SDKS_ROOT/$SWIFT_ARTIFACT_BUNDLE/swift-android/swift-resources/usr/lib/swift-$SWIFT_TARGET_ARCH" SWIFT_ANDROID_RUNTIME_LIBS="$SWIFT_ANDROID_LIBS/android" SWIFT_PACKAGE_SRC=$SRC_ROOT -JAVA_HOME=$SWIFT_ANDROID_SYSROOT/usr -# Configurable -SWIFT_NATIVE_PATH="${SWIFT_NATIVE_PATH:=$XCTOOLCHAIN/usr/bin}" +# JDK for the swift-java plugin's nested Gradle/javac invocations and the +# Android Gradle build (Kotlin compiler rejects JDK 25+). Prefer Android +# Studio's bundled JBR (21) when no explicit JAVA_HOME is set. +if [[ -z "${JAVA_HOME:-}" || ! -x "${JAVA_HOME:-}/bin/java" ]]; then + if [[ -x "/Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java" ]]; then + export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" + fi +fi + +# Host Swift toolchain (defaults to the active Xcode toolchain). +SWIFT_NATIVE_PATH="${SWIFT_NATIVE_PATH:=$(dirname "$(xcrun --find swift 2>/dev/null)")}" From b5a88e84374d89c3e027c06f0d63a34a65488f18 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 12:19:42 -0400 Subject: [PATCH 2/4] Drive Swift cross-compilation and jniLibs staging from Gradle Follow the trucksmart-ios integration pattern: an `jextract` Exec task runs `swift build` with the swift.org toolchain, a `stageJniLibs` Copy task stages product/runtime/NDK libraries, and the generated JNI Java + SwiftKitCore sources join the source set via srcDir + filter.exclude. Replaces build-swift.sh, the source-staging Sync task, and the vendored jdk.jfr-free annotation copies. --- Demo/app/build.gradle.kts | 142 +++++++++++++----- .../swiftkit/core/annotations/ThreadSafe.java | 28 ---- .../swiftkit/core/annotations/Unsigned.java | 30 ---- Demo/build-swift.sh | 42 ------ 4 files changed, 103 insertions(+), 139 deletions(-) delete mode 100644 Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java delete mode 100644 Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java delete mode 100755 Demo/build-swift.sh diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index 1b20a543..f3139f7d 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -4,6 +4,92 @@ plugins { alias(libs.plugins.kotlin.compose) } +// --------------------------------------------------------------------------- +// swift-java (jextract, JNI mode) integration +// +// The Swift package (SwiftAndroidApp + CatalogBridge) is cross-compiled to +// native `.so`s with the official Swift Android SDK; the `JExtractSwiftPlugin` +// emits the Java JNI bindings as a side effect of `swift build`. Gradle then +// adds the generated Java + SwiftKitCore runtime sources to the source set and +// stages every required `.so` into `jniLibs`. +// --------------------------------------------------------------------------- + +val swiftPackageRoot: File = rootProject.projectDir +val androidTriple = "aarch64-unknown-linux-android28" +val swiftAbi = "arm64-v8a" +val userHome: String = System.getProperty("user.home") + +// Configuration the Swift package is cross-compiled with, independent of the +// Android build type. Override with `-PswiftBuildConfig=release`. +val swiftBuildConfig: String = (findProperty("swiftBuildConfig") as String?) ?: "debug" + +// Locates both the toolchain and the matching Android SDK artifactbundle. +val swiftToolchainVersion: String = (findProperty("swiftToolchainVersion") as String?) ?: "6.3.3" + +// The Android SDK's prebuilt Swift modules require the matching swift.org +// toolchain. Override with `-PswiftBin=/path/to/swift` if installed elsewhere. +val swiftBin: String = (findProperty("swiftBin") as String?) + ?: "$userHome/Library/Developer/Toolchains/swift-$swiftToolchainVersion-RELEASE.xctoolchain/usr/bin/swift" + +// swift-java's `enableJavaCallbacks` feature runs its own internal Gradle +// sub-build to compile the generated Java callback interfaces, requiring a +// modern JDK — independent of whatever JDK runs *this* Gradle build. +// Override with `-PcallbacksJdkHome=/path/to/jdk`. +val callbacksJdkHome: String = (findProperty("callbacksJdkHome") as String?) + ?: "/opt/homebrew/opt/openjdk@25" + +// SwiftPM plugin output convention: `outputs///...`. +val generatedJavaDir = File(swiftPackageRoot, ".build/plugins/outputs/demo/CatalogBridge/destination/JExtractSwiftPlugin/src/generated/java") +val swiftKitCoreDir = File(swiftPackageRoot, ".build/checkouts/swift-java/SwiftKitCore/src/main/java") +val swiftBuildDir = File(swiftPackageRoot, ".build/$androidTriple/$swiftBuildConfig") +val swiftAndroidRuntimeDir = File( + (findProperty("swiftAndroidRuntimeDir") as String?) + ?: "$userHome/Library/org.swift.swiftpm/swift-sdks/swift-${swiftToolchainVersion}-RELEASE_android.artifactbundle/swift-android/swift-resources/usr/lib/swift-aarch64/android" +) + +// Supplies libc++_shared.so, which every Swift Android binary links against. +val ndkRoot = File( + (findProperty("ndkRoot") as String?) + ?: System.getenv("ANDROID_NDK_HOME") + ?: System.getenv("ANDROID_NDK_ROOT") + ?: "$userHome/Library/Android/sdk/ndk/27.2.12479018" +) + +// Cross-compile the Swift package + generate the JNI Java bindings. +val jextract = tasks.register("jextract") { + workingDir = swiftPackageRoot + environment("JAVA_HOME", callbacksJdkHome) + commandLine( + swiftBin, "build", + "--swift-sdk", androidTriple, + "-c", swiftBuildConfig, + "--disable-sandbox" + ) + outputs.dir(generatedJavaDir) + outputs.file(File(swiftBuildDir, "libSwiftAndroidApp.so")) + outputs.file(File(swiftBuildDir, "libCatalogBridge.so")) +} + +// Stage the cross-compiled libraries, the swift-java runtime, the Swift +// Android runtime and libc++_shared into jniLibs so they end up in the APK. +val stageJniLibs = tasks.register("stageJniLibs") { + dependsOn(jextract) + into(layout.projectDirectory.dir("src/main/jniLibs/$swiftAbi")) + from(swiftBuildDir) { + // Every dynamic library product built by the package graph — anything + // less leaves a dangling `dlopen` at runtime. + include("*.so") + } + from(swiftAndroidRuntimeDir) { + include("*.so") + // Test-only runtime libraries are not needed by the app. + exclude("*Testing*", "libXCTest.so") + } + from(File(ndkRoot, "toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android")) { + include("libc++_shared.so") + } +} + android { namespace = "com.pureswift.swiftandroid" compileSdk = 35 @@ -16,7 +102,7 @@ android { versionName = "1.0" ndk { //noinspection ChromeOsAbiSupport - abiFilters += listOf("arm64-v8a") + abiFilters += listOf(swiftAbi) } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -40,16 +126,18 @@ android { buildFeatures { compose = true } - // swift-java jextract (JNI mode) generated Java bindings + the SwiftKitCore - // runtime they depend on. Generated by `build-swift.sh` into the SwiftPM - // `.build` tree, staged (with Android-incompatible files filtered out) by - // the `stageSwiftJavaSources` task below. - sourceSets { - getByName("main") { - java.srcDir(layout.buildDirectory.dir("swift-java-staged/swiftkit-core")) - java.srcDir(layout.buildDirectory.dir("swift-java-staged/jextract")) - } - } + + // Generated JNI bindings + the SwiftKitCore Java runtime (consumed as + // source, since swift-java is not published to Maven). + sourceSets["main"].java.srcDir(generatedJavaDir) + sourceSets["main"].java.srcDir(swiftKitCoreDir) + // These two annotations pull in `jdk.jfr` (Java Flight Recorder), which is + // unavailable on Android; the JNI bindings don't reference them. + sourceSets["main"].java.filter.exclude( + "org/swift/swiftkit/core/annotations/ThreadSafe.java", + "org/swift/swiftkit/core/annotations/Unsigned.java" + ) + packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") @@ -64,36 +152,12 @@ android { } } -// Compile native Swift code for the demo app with the official Swift Android SDK. -val buildSwift by tasks.registering(Exec::class) { - group = "build" - description = "Build native Swift sources for Android" - workingDir(rootProject.projectDir) - commandLine("bash", "build-swift.sh") -} - -// Stage swift-java Java sources into the build dir, excluding files that use -// `jdk.jfr` (unavailable on Android). Android-compatible replacements for the -// excluded annotations live in the app's own java source dir. -val stageSwiftJavaSources by tasks.registering(Sync::class) { - group = "build" - description = "Stage SwiftKitCore + jextract-generated Java sources for Android" - dependsOn(buildSwift) - into(layout.buildDirectory.dir("swift-java-staged")) - from(rootProject.file(".build/checkouts/swift-java/SwiftKitCore/src/main/java")) { - into("swiftkit-core") - exclude("org/swift/swiftkit/core/annotations/Unsigned.java") - exclude("org/swift/swiftkit/core/annotations/ThreadSafe.java") - } - from(rootProject.file(".build/plugins/outputs/demo/CatalogBridge/destination/JExtractSwiftPlugin/src/generated/java")) { - into("jextract") - exclude("jextract-generated-sources.txt") - } +// Ensure Swift is built + bindings generated + libs staged before Java compiles. +tasks.withType().configureEach { + dependsOn(jextract) } - tasks.named("preBuild") { - dependsOn(buildSwift) - dependsOn(stageSwiftJavaSources) + dependsOn(stageJniLibs) } dependencies { diff --git a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java deleted file mode 100644 index 9976d04a..00000000 --- a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/ThreadSafe.java +++ /dev/null @@ -1,28 +0,0 @@ -// Android-compatible copy of SwiftKitCore's ThreadSafe annotation. -// The upstream file uses `jdk.jfr` meta-annotations which are unavailable -// on Android, so it is excluded from the imported source set and replaced -// by this equivalent declaration. - -package org.swift.swiftkit.core.annotations; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.*; - -/** - * Used to mark a type as thread-safe, i.e. no additional synchronization is necessary when accessing it - * from multiple threads. - * - *

In SwiftJava specifically, this attribute is applied when an extracted Swift type conforms to the Swift - * {@code Sendable} protocol, which is a compiler enforced mechanism to enforce thread-safety in Swift. - * - * @see Swift Sendable API documentation. - */ -@Documented -@Target({TYPE_USE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ThreadSafe { -} diff --git a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java deleted file mode 100644 index 92d95470..00000000 --- a/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java +++ /dev/null @@ -1,30 +0,0 @@ -// Android-compatible copy of SwiftKitCore's Unsigned annotation. -// The upstream file uses `jdk.jfr` meta-annotations which are unavailable -// on Android, so it is excluded from the imported source set and replaced -// by this equivalent declaration. - -package org.swift.swiftkit.core.annotations; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.*; - -/** - * Value is of an unsigned numeric type. - *

- * This annotation is used to annotate java integer primitives when their - * corresponding Swift type was actually unsigned, e.g. an {@code @Unsigned long} - * in a method signature corresponds to a Swift {@code UInt64} type, and therefore - * negative values reported by the signed {@code long} should instead be interpreted positive values, - * larger than {@code Long.MAX_VALUE} that are just not representable using a signed {@code long}. - *

- * If this annotation is used on a method, it refers to the return type using an unsigned integer. - */ -@Documented -@Target({TYPE_USE, PARAMETER, FIELD, METHOD}) -@Retention(RetentionPolicy.RUNTIME) -public @interface Unsigned { -} diff --git a/Demo/build-swift.sh b/Demo/build-swift.sh deleted file mode 100755 index 5275deeb..00000000 --- a/Demo/build-swift.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/swift-define" -JNI_LIBS_DIR="$SRC_ROOT/app/src/main/jniLibs/$ANDROID_ARCH" - -# Build with SwiftPM against the official Swift Android SDK. -# -# `--disable-sandbox` is required so the swift-java jextract build-tool plugin -# (JNI mode with `enableJavaCallbacks`) can run its nested Gradle build to -# compile the Java callback wrappers. This replaces the previous `skip -# android build` driver. -swift build \ - --package-path "$SWIFT_PACKAGE_SRC" \ - --swift-sdk "$SWIFT_TARGET_NAME" \ - --configuration "$SWIFT_COMPILATION_MODE" \ - --disable-sandbox - -BUILD_BIN="$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/$SWIFT_COMPILATION_MODE" - -# Copy compiled Swift product shared objects (app + any jextract bridge modules). -mkdir -p "$JNI_LIBS_DIR/" -shopt -s nullglob -for so in "$BUILD_BIN"/lib*.so; do - cp -f "$so" "$JNI_LIBS_DIR/" -done -shopt -u nullglob - -# Copy Swift runtime shared libraries required by libSwiftAndroidApp.so. -if [[ -d "$SWIFT_ANDROID_RUNTIME_LIBS" ]]; then - shopt -s nullglob - for so in "$SWIFT_ANDROID_RUNTIME_LIBS"/*.so; do - cp -f "$so" "$JNI_LIBS_DIR/" - done - shopt -u nullglob -fi - -# Copy C++ runtime from Android sysroot. -if [[ -f "$SWIFT_ANDROID_SYSROOT/usr/lib/$ANDROID_LIB/libc++_shared.so" ]]; then - cp -f "$SWIFT_ANDROID_SYSROOT/usr/lib/$ANDROID_LIB/libc++_shared.so" "$JNI_LIBS_DIR/" -fi From 752d2b236318368c621747a3988df66440d3dbef Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 12:47:15 -0400 Subject: [PATCH 3/4] Add per-library catalog with jextract JNI callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CatalogBridge exposes catalogEntryCount/Name/Summary and runCatalogDemo(index, onLine, onCompleted) — demo output and completion are delivered from Swift to Kotlin through jextract JNI closures/runnable blocks - Curated demos exercising the SDK wrappers: AndroidOS (Build, SystemClock), AndroidContent (package/app info), AndroidLocation (providers), AndroidMedia (volumes, ringer), AndroidNet (connectivity, Wi-Fi), AndroidNFC (adapter) - Demos acquire the application Context via AndroidContext (ActivityThread bootstrap), wrapped once as a JNI global ref - Compose catalog UI: list of libraries with the Swift-driven clock header, per-entry demo screens - Remove the legacy SwiftObject/external-method callback shims (Runnable, ViewOnClickListener, adapters, Fragment) superseded by the jextract bridge - Add ACCESS_NETWORK_STATE, ACCESS_WIFI_STATE and NFC permissions required by the demos All six demos verified on the emulator (arm64-v8a, API 36). --- Demo/Package.swift | 12 + Demo/app/build.gradle.kts | 8 +- Demo/app/src/main/AndroidManifest.xml | 3 + .../com/pureswift/swiftandroid/Application.kt | 4 +- .../com/pureswift/swiftandroid/Fragment.kt | 24 -- .../pureswift/swiftandroid/ListViewAdapter.kt | 14 - .../pureswift/swiftandroid/MainActivity.kt | 147 +++++++-- ...NavigationBarViewOnItemSelectedListener.kt | 9 - .../swiftandroid/RecyclerViewAdapter.kt | 40 --- .../com/pureswift/swiftandroid/Runnable.kt | 6 - .../com/pureswift/swiftandroid/SwiftObject.kt | 17 -- .../swiftandroid/ViewOnClickListener.kt | 8 - .../swift-bridge/CatalogBridge/Catalog.swift | 82 +++++ .../CatalogBridge/CatalogBridge.swift | 50 ++- .../CatalogBridge/CatalogDemos.swift | 169 ++++++++++ Demo/app/src/main/swift/Fragment.swift | 86 ------ .../src/main/swift/JavaRetainedValue.swift | 89 ------ Demo/app/src/main/swift/ListViewAdapter.swift | 87 ------ Demo/app/src/main/swift/MainActivity.swift | 288 ++---------------- ...igationBarViewOnItemSelectedListener.swift | 76 ----- Demo/app/src/main/swift/OnClickListener.swift | 74 ----- Demo/app/src/main/swift/RecyclerView.swift | 107 ------- Demo/app/src/main/swift/Runnable.swift | 50 --- 23 files changed, 472 insertions(+), 978 deletions(-) delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt delete mode 100644 Demo/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt create mode 100644 Demo/app/src/main/swift-bridge/CatalogBridge/Catalog.swift create mode 100644 Demo/app/src/main/swift-bridge/CatalogBridge/CatalogDemos.swift delete mode 100644 Demo/app/src/main/swift/Fragment.swift delete mode 100644 Demo/app/src/main/swift/JavaRetainedValue.swift delete mode 100644 Demo/app/src/main/swift/ListViewAdapter.swift delete mode 100644 Demo/app/src/main/swift/NavigationBarViewOnItemSelectedListener.swift delete mode 100644 Demo/app/src/main/swift/OnClickListener.swift delete mode 100644 Demo/app/src/main/swift/RecyclerView.swift delete mode 100644 Demo/app/src/main/swift/Runnable.swift diff --git a/Demo/Package.swift b/Demo/Package.swift index 679a1159..4d67e4a5 100644 --- a/Demo/Package.swift +++ b/Demo/Package.swift @@ -25,6 +25,10 @@ let package = Package( .package( url: "https://github.com/swiftlang/swift-java.git", branch: "main" + ), + .package( + url: "https://github.com/swift-android-sdk/swift-android-native.git", + from: "2.1.0" ) ], targets: [ @@ -48,6 +52,14 @@ let package = Package( .product( name: "SwiftJava", package: "swift-java" + ), + .product( + name: "AndroidKit", + package: "Android" + ), + .product( + name: "AndroidContext", + package: "swift-android-native" ) ], path: "./app/src/main/swift-bridge/CatalogBridge", diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index f3139f7d..fed5bdae 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -68,6 +68,8 @@ val jextract = tasks.register("jextract") { outputs.dir(generatedJavaDir) outputs.file(File(swiftBuildDir, "libSwiftAndroidApp.so")) outputs.file(File(swiftBuildDir, "libCatalogBridge.so")) + // `swift build` is incremental itself; let it decide what is stale. + outputs.upToDateWhen { false } } // Stage the cross-compiled libraries, the swift-java runtime, the Swift @@ -152,10 +154,14 @@ android { } } -// Ensure Swift is built + bindings generated + libs staged before Java compiles. +// Ensure Swift is built + bindings generated + libs staged before Java or +// Kotlin compiles (Kotlin also consumes the generated Java bindings). tasks.withType().configureEach { dependsOn(jextract) } +tasks.withType().configureEach { + dependsOn(jextract) +} tasks.named("preBuild") { dependsOn(stageJniLibs) } diff --git a/Demo/app/src/main/AndroidManifest.xml b/Demo/app/src/main/AndroidManifest.xml index b3615777..f944d649 100644 --- a/Demo/app/src/main/AndroidManifest.xml +++ b/Demo/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + Swift downcall and a /// Swift->Java callback (closure/Runnable block). private fun testCatalogBridge() { - val sum = CatalogBridge.addOne(41) - Log.d("CatalogBridge", "addOne(41) = $sum") + val count = CatalogBridge.catalogEntryCount() + Log.d("CatalogBridge", "catalogEntryCount() = $count") CatalogBridge.runBlock { Log.d("CatalogBridge", "runBlock callback invoked from Swift") } diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt deleted file mode 100644 index 26a6c393..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/Fragment.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.pureswift.swiftandroid - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout - -class Fragment(val swiftObject: SwiftObject): android.app.Fragment() { - - @Deprecated("Deprecated in Java") - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val context = this.context - checkNotNull(context) - val linearLayout = LinearLayout(context) - return linearLayout - } - - external override fun onViewCreated(view: View, savedInstanceState: Bundle?) -} \ No newline at end of file diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt deleted file mode 100644 index 722b2b0b..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/ListViewAdapter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.pureswift.swiftandroid - -import android.R -import android.content.Context -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import androidx.recyclerview.widget.RecyclerView - -class ListViewAdapter(context: Context, val swiftObject: SwiftObject, val objects: ArrayList) : - ArrayAdapter(context, 0, objects) { - - external override fun getView(position: Int, convertView: View?, parent: ViewGroup): View -} 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 249104b5..4e32753d 100644 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt +++ b/Demo/app/src/main/java/com/pureswift/swiftandroid/MainActivity.kt @@ -4,22 +4,33 @@ import android.os.Bundle import android.util.Log import android.view.View import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pureswift.swiftandroid.bridge.CatalogBridge import java.util.Date class MainActivity : ComponentActivity() { @@ -33,11 +44,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) onCreateSwift(savedInstanceState) - //enableEdgeToEdge() - // set composable root view setContent { - EventReceiver(emitter = emitter) + MaterialTheme { + CatalogApp(emitter) + } } } @@ -49,11 +60,69 @@ class MainActivity : ComponentActivity() { } } +/// One entry of the Swift library catalog, loaded over the jextract JNI bridge. +data class CatalogEntry(val index: Long, val name: String, val summary: String) + +private fun loadCatalog(): List = + (0 until CatalogBridge.catalogEntryCount()).map { index -> + CatalogEntry( + index = index, + name = CatalogBridge.catalogEntryName(index), + summary = CatalogBridge.catalogEntrySummary(index) + ) + } + @Composable -fun EventReceiver(emitter: UnitEmitter) { +fun CatalogApp(emitter: UnitEmitter) { + val entries = remember { loadCatalog() } + var selected by remember { mutableStateOf(null) } + + val current = selected + if (current == null) { + CatalogListScreen(emitter, entries) { selected = it } + } else { + DemoScreen(entry = current) { selected = null } + } +} - val tick by emitter.flow.collectAsState(initial = Unit) +@Composable +fun CatalogListScreen( + emitter: UnitEmitter, + entries: List, + onSelect: (CatalogEntry) -> Unit +) { + Scaffold { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + SwiftClockHeader(emitter) + } + items(entries) { entry -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(entry) } + ) { + Column(Modifier.padding(16.dp)) { + Text(entry.name, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text(entry.summary, style = MaterialTheme.typography.bodyMedium) + } + } + } + item { Spacer(Modifier.height(16.dp)) } + } + } +} +/// Header driven by the Swift-side timer emitting through [UnitEmitter]. +@Composable +fun SwiftClockHeader(emitter: UnitEmitter) { var date by remember { mutableStateOf(Date()) } LaunchedEffect(Unit) { @@ -62,15 +131,59 @@ fun EventReceiver(emitter: UnitEmitter) { } } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + Column(Modifier.padding(vertical = 16.dp)) { + Text("Swift Library Catalog", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(4.dp)) + Text(date.toString(), style = MaterialTheme.typography.bodySmall) + } +} + +/// Runs the selected demo in Swift; output lines and completion arrive through +/// jextract JNI callbacks (closures/runnable blocks) invoked from Swift. +@Composable +fun DemoScreen(entry: CatalogEntry, onBack: () -> Unit) { + val lines = remember(entry.index) { mutableStateListOf() } + var completed by remember(entry.index) { mutableStateOf(false) } + + BackHandler(onBack = onBack) + + LaunchedEffect(entry.index) { + lines.clear() + completed = false + CatalogBridge.runCatalogDemo( + entry.index, + { line -> lines.add(line) }, + { completed = true } + ) + } + + Scaffold { padding -> Column( - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) ) { - Text("Hello Swift!") - Text(date.toString()) + Text(entry.name, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(4.dp)) + Text(entry.summary, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(lines.toList()) { line -> + Text(line, style = MaterialTheme.typography.bodyMedium) + } + item { + if (completed) { + Spacer(Modifier.height(8.dp)) + Text( + "✓ Demo completed (Swift → Kotlin callback)", + style = MaterialTheme.typography.labelMedium + ) + } + } + } } } -} \ No newline at end of file +} diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt deleted file mode 100644 index cf8014fe..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.pureswift.swiftandroid - -import android.view.MenuItem -import com.google.android.material.navigation.NavigationBarView - -class NavigationBarViewOnItemSelectedListener(val action: SwiftObject): NavigationBarView.OnItemSelectedListener { - - external override fun onNavigationItemSelected(menuItem: MenuItem): Boolean -} \ No newline at end of file diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt deleted file mode 100644 index d11648d1..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/RecyclerViewAdapter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.pureswift.swiftandroid - -import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ViewHolder - -class RecyclerViewAdapter(val swiftObject: SwiftObject) : - RecyclerView.Adapter() { - - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - } - - // Create new views (invoked by the layout manager) - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerViewAdapter.ViewHolder { - Log.d("RecyclerViewAdapter", "SwiftAndroidApp.RecyclerViewAdapter.onCreateViewHolderSwift(_:_:) $viewType") - val view = LinearLayout(parent.context) - val viewHolder = ViewHolder(view) - checkNotNull(viewHolder) - checkNotNull(viewHolder.itemView) - return viewHolder - } - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - onBindViewHolderSwift(holder as RecyclerViewAdapter.ViewHolder, position) - } - - external fun onBindViewHolderSwift(holder: RecyclerViewAdapter.ViewHolder, position: Int) - - // Return the size of your dataset (invoked by the layout manager) - override fun getItemCount(): Int { - return getItemCountSwift() - } - - external fun getItemCountSwift(): Int -} \ No newline at end of file diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt deleted file mode 100644 index 08a09879..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/Runnable.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.pureswift.swiftandroid - -class Runnable(val block: SwiftObject): java.lang.Runnable { - - external override fun run() -} diff --git a/Demo/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/SwiftObject.kt deleted file mode 100644 index 94ff2db6..00000000 --- a/Demo/app/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/Demo/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt b/Demo/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt deleted file mode 100644 index 39b5ce3c..00000000 --- a/Demo/app/src/main/java/com/pureswift/swiftandroid/ViewOnClickListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.pureswift.swiftandroid - -import android.view.View - -class ViewOnClickListener(val action: SwiftObject): View.OnClickListener { - - external override fun onClick(view: View) -} \ No newline at end of file diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/Catalog.swift b/Demo/app/src/main/swift-bridge/CatalogBridge/Catalog.swift new file mode 100644 index 00000000..4cffdff5 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/Catalog.swift @@ -0,0 +1,82 @@ +// +// Catalog.swift +// SwiftAndroidApp +// +// Internal registry backing the public CatalogBridge API. Nothing in this +// file is extracted by jextract (internal/package visibility only). +// + +import AndroidKit +import AndroidContext + +/// A single library demo in the catalog. +struct CatalogEntry { + let name: String + let summary: String + let demo: ((String) -> Void) -> Void +} + +package enum Catalog { + + /// The global application context, bootstrapped through + /// `ActivityThread.currentApplication()` by the AndroidContext module and + /// wrapped as a SwiftJava `Context` for use with the SDK wrappers. + /// + /// Wrapped exactly once: `JavaObjectHolder` promotes the incoming + /// reference to a JNI global ref and deletes the original local ref, so + /// re-wrapping `AndroidContext`'s cached pointer on a later JNI call + /// would use a stale reference and crash. + static var context: AndroidContent.Context? { cachedContext } + + private nonisolated(unsafe) static let cachedContext: AndroidContent.Context? = { + guard let application = try? AndroidContext.application, + let environment = try? JavaVirtualMachine.shared().environment() else { + return nil + } + return AndroidContent.Context( + javaHolder: JavaObjectHolder( + object: application.pointer, + environment: environment + ) + ) + }() + + static func entry(at index: Int64) -> CatalogEntry? { + let index = Int(index) + guard entries.indices.contains(index) else { return nil } + return entries[index] + } + + static let entries: [CatalogEntry] = [ + CatalogEntry( + name: "AndroidOS", + summary: "Build info, SDK level and system clocks (android.os)", + demo: CatalogDemos.androidOS + ), + CatalogEntry( + name: "AndroidContent", + summary: "Package and application info via Context (android.content)", + demo: CatalogDemos.androidContent + ), + CatalogEntry( + name: "AndroidLocation", + summary: "Location providers and their state (android.location)", + demo: CatalogDemos.androidLocation + ), + CatalogEntry( + name: "AndroidMedia", + summary: "Audio streams, volumes and ringer mode (android.media)", + demo: CatalogDemos.androidMedia + ), + CatalogEntry( + name: "AndroidNet", + summary: "Connectivity and Wi-Fi state (android.net)", + demo: CatalogDemos.androidNet + ), + CatalogEntry( + name: "AndroidNFC", + summary: "NFC adapter availability (android.nfc)", + demo: CatalogDemos.androidNFC + ), + ] +} diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift index 4d6a4765..84cb9e83 100644 --- a/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift @@ -1,9 +1,49 @@ -// Minimal jextract JNI spike: a closure/Runnable callback exposed to Java. +// +// CatalogBridge.swift +// SwiftAndroidApp +// +// Public jextract (JNI mode) surface of the library catalog. +// +// Only jextract-supported types (primitives, String, closures) may appear in +// public declarations here; the catalog implementation lives in Catalog.swift +// using internal visibility. +// -public func runBlock(block: () -> Void) { - block() +/// Number of entries in the library catalog. +public func catalogEntryCount() -> Int64 { + Int64(Catalog.entries.count) +} + +/// Display name of the catalog entry at `index` (e.g. "AndroidOS"). +public func catalogEntryName(index: Int64) -> String { + Catalog.entry(at: index)?.name ?? "" +} + +/// One-line summary of what the catalog entry demonstrates. +public func catalogEntrySummary(index: Int64) -> String { + Catalog.entry(at: index)?.summary ?? "" +} + +/// Runs the demo for the catalog entry at `index`. +/// +/// Output lines are delivered through the `onLine` callback as they are +/// produced, and `onCompleted` fires exactly once when the demo finishes — +/// demonstrating Swift invoking Java closures/runnable blocks through the +/// jextract JNI bridge. +public func runCatalogDemo( + index: Int64, + onLine: (String) -> Void, + onCompleted: () -> Void +) { + defer { onCompleted() } + guard let entry = Catalog.entry(at: index) else { + onLine("Invalid catalog index \(index)") + return + } + entry.demo(onLine) } -public func addOne(input: Int64) -> Int64 { - input + 1 +/// Runs a Java block on the calling thread (Runnable-style round trip). +public func runBlock(block: () -> Void) { + block() } diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogDemos.swift b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogDemos.swift new file mode 100644 index 00000000..28148023 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogDemos.swift @@ -0,0 +1,169 @@ +// +// CatalogDemos.swift +// SwiftAndroidApp +// +// Per-library demo implementations, exercising the PureSwift Android SDK +// wrappers. Internal only — jextract never sees this file's declarations. +// + +import AndroidKit +import AndroidLocation +import AndroidMedia +import AndroidNet +import AndroidNFC + +enum CatalogDemos { + + // MARK: - AndroidOS + + static func androidOS(_ out: (String) -> Void) { + do { + let build = try JavaClass() + out("Manufacturer: \(build.MANUFACTURER)") + out("Model: \(build.MODEL)") + out("Device: \(build.DEVICE)") + let version = try JavaClass() + out("Android version: \(version.RELEASE) (API \(version.SDK_INT))") + let clock = try JavaClass() + out("Uptime: \(clock.uptimeMillis() / 1000)s") + out("Elapsed realtime: \(clock.elapsedRealtime() / 1000)s") + } catch { + out("Error: \(error)") + } + } + + // MARK: - AndroidContent + + static func androidContent(_ out: (String) -> Void) { + guard let context = Catalog.context else { + out("No Android Context available") + return + } + out("Package: \(context.getPackageName())") + if let info = context.getApplicationInfo() { + out("Target SDK: \(info.targetSdkVersion)") + out("Min SDK: \(info.minSdkVersion)") + out("Data dir: \(info.dataDir)") + } + } + + // MARK: - AndroidLocation + + static func androidLocation(_ out: (String) -> Void) { + guard let context = Catalog.context else { + out("No Android Context available") + return + } + do { + let serviceName = try JavaClass().LOCATION_SERVICE + guard let manager = context.getSystemService(serviceName)? + .as(AndroidLocation.LocationManager.self) else { + out("LocationManager unavailable") + return + } + guard let providers = manager.getAllProviders() else { + out("No providers") + return + } + out("Providers: \(providers.size())") + for i in 0.. Void) { + guard let context = Catalog.context else { + out("No Android Context available") + return + } + do { + let serviceName = try JavaClass().AUDIO_SERVICE + guard let audio = context.getSystemService(serviceName)? + .as(AndroidMedia.AudioManager.self) else { + out("AudioManager unavailable") + return + } + let audioClass = try JavaClass() + let streams: [(String, Int32)] = [ + ("Music", audioClass.STREAM_MUSIC), + ("Ring", audioClass.STREAM_RING), + ("Alarm", audioClass.STREAM_ALARM), + ("Notification", audioClass.STREAM_NOTIFICATION), + ] + for (label, stream) in streams { + let volume = audio.getStreamVolume(stream) + let max = audio.getStreamMaxVolume(stream) + out("\(label) volume: \(volume)/\(max)") + } + let ringer = audio.getRingerMode() + let ringerName: String + switch ringer { + case audioClass.RINGER_MODE_SILENT: ringerName = "silent" + case audioClass.RINGER_MODE_VIBRATE: ringerName = "vibrate" + default: ringerName = "normal" + } + out("Ringer mode: \(ringerName)") + } catch { + out("Error: \(error)") + } + } + + // MARK: - AndroidNet + + static func androidNet(_ out: (String) -> Void) { + guard let context = Catalog.context else { + out("No Android Context available") + return + } + do { + let contextClass = try JavaClass() + if let connectivity = context.getSystemService(contextClass.CONNECTIVITY_SERVICE)? + .as(AndroidNet.ConnectivityManager.self) { + if let info = connectivity.getActiveNetworkInfo() { + out("Active network: \(info.getTypeName())") + out("Connected: \(info.isConnected())") + } else { + out("No active network") + } + } else { + out("ConnectivityManager unavailable") + } + if let wifi = context.getSystemService(contextClass.WIFI_SERVICE)? + .as(AndroidNet.WifiManager.self) { + out("Wi-Fi enabled: \(wifi.isWifiEnabled())") + } else { + out("WifiManager unavailable") + } + } catch { + out("Error: \(error)") + } + } + + // MARK: - AndroidNFC + + static func androidNFC(_ out: (String) -> Void) { + guard let context = Catalog.context else { + out("No Android Context available") + return + } + do { + let adapterClass = try JavaClass() + if let adapter = adapterClass.getDefaultAdapter(context) { + out("NFC adapter present") + out("Enabled: \(adapter.isEnabled())") + } else { + out("No NFC adapter on this device") + } + } catch { + out("Error: \(error)") + } + } +} diff --git a/Demo/app/src/main/swift/Fragment.swift b/Demo/app/src/main/swift/Fragment.swift deleted file mode 100644 index a058472c..00000000 --- a/Demo/app/src/main/swift/Fragment.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Fragment.swift -// SwiftAndroidApp -// -// Created by Alsey Coleman Miller on 6/22/25. -// - -import AndroidKit - -@JavaClass("com.pureswift.swiftandroid.Fragment") -open class Fragment: AndroidApp.Fragment { - - @JavaMethod - @_nonoverride public convenience init(swiftObject: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - public func getSwiftObject() -> SwiftObject? -} - -public extension Fragment { - - struct Callback { - - var onViewCreated: ((AndroidView.View, AndroidOS.Bundle?) -> ())? - } -} - -@JavaImplementation("com.pureswift.swiftandroid.Fragment") -extension Fragment { - - @JavaMethod - func onViewCreated( - view: AndroidView.View?, - savedInstanceState: AndroidOS.Bundle? - ) { - log("\(self).\(#function)") - guard let onViewCreated = callback.onViewCreated else { - return - } - guard let view else { - assertionFailure("Missing view") - return - } - onViewCreated(view, savedInstanceState) - } -} - -public extension Fragment { - - convenience init(callback: Callback, environment: JNIEnvironment? = nil) { - let object = SwiftObject(callback, environment: environment) - self.init(swiftObject: object, environment: environment) - } - - var callback: Callback { - getSwiftObject()!.valueObject().value as! Callback - } -} - -extension Fragment { - - static var logTag: LogTag { "Fragment" } - - static func log(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .debug) - .log(string) - } - - static func logInfo(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .info) - .log(string) - } - - static func logError(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .error) - .log(string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} diff --git a/Demo/app/src/main/swift/JavaRetainedValue.swift b/Demo/app/src/main/swift/JavaRetainedValue.swift deleted file mode 100644 index 4228e6ec..00000000 --- a/Demo/app/src/main/swift/JavaRetainedValue.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// JavaRetainedValue.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import SwiftJava -import CSwiftJavaJNI - -/// 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/Demo/app/src/main/swift/ListViewAdapter.swift b/Demo/app/src/main/swift/ListViewAdapter.swift deleted file mode 100644 index bce09344..00000000 --- a/Demo/app/src/main/swift/ListViewAdapter.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ListViewAdapter.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import Foundation -import SwiftJava -import JavaUtil -import AndroidKit - -@JavaClass("com.pureswift.swiftandroid.ListViewAdapter", extends: ListAdapter.self) -open class ListViewAdapter: JavaObject { - - @JavaMethod - @_nonoverride public convenience init( - context: AndroidContent.Context?, - swiftObject: SwiftObject?, - objects: ArrayList?, - environment: JNIEnvironment? = nil - ) - - @JavaMethod - func getSwiftObject() -> SwiftObject! -} - -@JavaImplementation("com.pureswift.swiftandroid.ListViewAdapter") -extension ListViewAdapter { - - @JavaMethod - func getView(position: Int32, convertView: AndroidView.View?, parent: ViewGroup?) -> AndroidView.View? { - log("\(self).\(#function) \(position)") - return getView(position, convertView, parent) - } -} - -public extension ListViewAdapter { - - typealias GetView = (Int32, AndroidView.View?, ViewGroup?) -> AndroidView.View? - - var getView: GetView { - get { - getSwiftObject().valueObject().value as! GetView - } - set { - getSwiftObject().valueObject().value = newValue - } - } - - convenience init( - context: AndroidContent.Context, - getView: @escaping (Int32, AndroidView.View?, ViewGroup?) -> AndroidView.View?, - objects: ArrayList, - environment: JNIEnvironment? = nil - ) { - self.init(context: context, swiftObject: SwiftObject(getView), objects: objects, environment: environment) - } -} - -extension ListViewAdapter { - - static var logTag: LogTag { "ListViewAdapter" } - - static func log(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .debug) - .log(string) - } - - static func logInfo(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .info) - .log(string) - } - - static func logError(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .error) - .log(string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} diff --git a/Demo/app/src/main/swift/MainActivity.swift b/Demo/app/src/main/swift/MainActivity.swift index 4626f36e..1aed3ebc 100644 --- a/Demo/app/src/main/swift/MainActivity.swift +++ b/Demo/app/src/main/swift/MainActivity.swift @@ -14,65 +14,53 @@ import Binder @JavaClass("com.pureswift.swiftandroid.MainActivity") open class MainActivity: AndroidApp.Activity { - + @JavaMethod open func setRootView(_ view: AndroidView.View?) - + @JavaMethod open func getEmitter() -> UnitEmitter! - + static private(set) var shared: MainActivity! - - lazy var textView = TextView(self) - - lazy var listView = ListView(self) - - lazy var recyclerView = RecyclerView(self) - - lazy var button = AndroidWidget.Button(self) lazy var emitter = getEmitter()! - - lazy var rootViewID: Int32 = try! JavaClass().generateViewId() } @JavaImplementation("com.pureswift.swiftandroid.MainActivity") extension MainActivity { - + @JavaMethod public func onCreateSwift(_ savedInstanceState: BaseBundle?) { log("\(self).\(#function)") - + _onCreate(savedInstanceState) } } private extension MainActivity { - + #if os(Android) typealias MainActor = AndroidMainActor #endif - + func _onCreate(_ savedInstanceState: BaseBundle?) { - + // setup singletons if savedInstanceState == nil, MainActivity.shared == nil { MainActivity.shared = self startMainRunLoop() runAsync() } - - // need to recreate views - //setRootView() + startEmitterTimer() - + #if canImport(Binder) Task { printBinderVersion() } #endif } - + func runAsync() { RunLoop.main.run(until: Date() + 0.1) DispatchQueue.main.async { @@ -85,7 +73,7 @@ private extension MainActivity { Self.log("\(self).\(#function) Task Started") } } - + func startMainRunLoop() { #if os(Android) guard AndroidMainActor.setupMainLooper() else { @@ -93,28 +81,7 @@ private extension MainActivity { } #endif } - - func setRootView() { - setTextView() - } - - func setTextView() { - let linearLayout = LinearLayout(self) - linearLayout.orientation = .vertical - linearLayout.gravity = .center - linearLayout.addView(textView) - configureButton() - linearLayout.addView(button) - setRootView(linearLayout) - // update view on timer - Task { [weak self] in - while let self { - await self.updateTextView() - try? await Task.sleep(for: .seconds(1)) - } - } - } - + func startEmitterTimer() { // update view on timer Task { [weak self] in @@ -124,224 +91,13 @@ private extension MainActivity { } } } - + @MainActor func emit() { Self.log("\(self).\(#function)") emitter.emit() } - - func setupNavigationStack() { - - let fragmentContainer = FrameLayout(self) - fragmentContainer.setId(rootViewID) - let matchParent = try! JavaClass().MATCH_PARENT - fragmentContainer.setLayoutParams(ViewGroup.LayoutParams(matchParent, matchParent)) - - let homeFragment = Fragment(callback: .init(onViewCreated: { view, bundle in - let context = self - - let linearLayout = LinearLayout(self) - linearLayout.setLayoutParams(ViewGroup.LayoutParams(matchParent, matchParent)) - linearLayout.orientation = .vertical - linearLayout.gravity = .center - - let label = TextView(context) - label.text = "Home View" - label.gravity = .center - linearLayout.addView(label) - - let button = Button(context) - button.text = "Push" - label.gravity = .center - let listener = ViewOnClickListener { - self.didPushButton() - } - button.setOnClickListener(listener.as(View.OnClickListener.self)) - linearLayout.addView(button) - - view.as(ViewGroup.self)!.addView(linearLayout) - })) - - // setup initial fragment - _ = getFragmentManager() - .beginTransaction() - .replace(rootViewID, homeFragment) - .commit() - - // Set as the content view - setRootView(fragmentContainer) - } - - func configureButton() { - button.text = "Push" - let listener = ViewOnClickListener { - self.didPushButton() - } - button.setOnClickListener(listener.as(View.OnClickListener.self)) - } - - func didPushButton() { - - let counter = getFragmentManager().getBackStackEntryCount() + 1 - - let detailFragment = Fragment(callback: .init(onViewCreated: { view, bundle in - let context = self - - let matchParent = try! JavaClass().MATCH_PARENT - - let linearLayout = LinearLayout(self) - linearLayout.setLayoutParams(ViewGroup.LayoutParams(matchParent, matchParent)) - linearLayout.orientation = .vertical - linearLayout.gravity = .center - - let label = TextView(context) - label.text = "Detail View \(counter)" - label.gravity = .center - linearLayout.addView(label) - - let button = Button(context) - button.text = "Push" - button.gravity = .center - let listener = ViewOnClickListener { - self.didPushButton() - } - button.setOnClickListener(listener.as(View.OnClickListener.self)) - linearLayout.addView(button) - - view.as(ViewGroup.self)!.addView(linearLayout) - })) - - push(detailFragment, name: "Detail \(counter)") - } - - func push(_ fragment: AndroidApp.Fragment, name: String) { - log("\(self).\(#function) \(name)") - _ = getFragmentManager() - .beginTransaction() - .replace(rootViewID, fragment) - .addToBackStack(name) - .commit() - } - - func setListView() { - let items = [ - "Row 1", - "Row 2", - "Row 3", - "Row 4", - "Row 5" - ] - let layout = try! JavaClass() - let resource = layout.simple_list_item_1 - assert(resource != 0) - let objects: [JavaObject?] = items.map { JavaString($0) } - let adapter = ArrayAdapter( - context: self, - resource: resource, - objects: objects - ) - listView.setAdapter(adapter.as(Adapter.self)) - - setRootView(listView) - } - - func setRecyclerView() { - let items = [ - "Row 1", - "Row 2", - "Row 3", - "Row 4", - "Row 5" - ] - let callback = RecyclerViewAdapter.Callback( - onBindViewHolder: { (holder, position) in - guard let viewHolder = holder.as(RecyclerViewAdapter.ViewHolder.self) else { - return - } - // get view - let linearLayout = viewHolder.itemView.as(LinearLayout.self)! - let textView: TextView - if linearLayout.getChildCount() == 0 { - textView = TextView(self) - linearLayout.addView(textView) - } else { - textView = linearLayout.getChildAt(0).as(TextView.self)! - } - // set data - let data = items[Int(position)] - textView.text = data - }, - getItemCount: { - Int32(items.count) - } - ) - let adapter = RecyclerViewAdapter(callback) - recyclerView.setLayoutManager(LinearLayoutManager(self)) - recyclerView.setAdapter(adapter) - setRootView(recyclerView) - } - - @MainActor - func updateTextView() { - log("\(self).\(#function)") - textView.text = "Hello Swift!\n\(Date().formatted(date: .numeric, time: .complete))" - } - - func setTabBar() { - let layout = LinearLayout(self) - layout.orientation = .vertical - - let container = FrameLayout(self) - container.setId(2001) - - let bottomNav = BottomNavigationView(self) - _ = bottomNav.getMenu().add(0, 1, 0, JavaString("Home").as(CharSequence.self)).setIcon(17301543) - _ = bottomNav.getMenu().add(0, 2, 1, JavaString("Profile").as(CharSequence.self)).setIcon(17301659) - - let homeFragment = Fragment(callback: .init(onViewCreated: { view, bundle in - let context = self - let label = TextView(context) - label.text = "Home View" - label.gravity = .center - view.as(ViewGroup.self)!.addView(label) - })) - - let profileFragment = Fragment(callback: .init(onViewCreated: { view, bundle in - let context = self - let label = TextView(context) - label.text = "Profile" - label.gravity = .center - view.as(ViewGroup.self)!.addView(label) - })) - - let fragment1 = homeFragment - let fragment2 = profileFragment - - let listener = NavigationBarViewOnItemSelectedListener { item in - guard let item else { return false } - let fragment: AndroidApp.Fragment = (item.getItemId() == 1) ? fragment1 : fragment2 - _ = self.getFragmentManager().beginTransaction() - .replace(2001, fragment) - .commit() - return true - } - bottomNav.setOnItemSelectedListener(listener.as(NavigationBarView.OnItemSelectedListener.self)) - - let matchParent = try! JavaClass().MATCH_PARENT - let wrapContent = try! JavaClass().WRAP_CONTENT - - layout.addView(container as AndroidView.View, ViewGroup.LayoutParams(matchParent, 1)) - layout.addView(bottomNav as AndroidView.View, ViewGroup.LayoutParams(matchParent, wrapContent)) - - self.setRootView(layout) - - // Default to Home - _ = self.getFragmentManager().beginTransaction() - .add(2001, fragment1) - .commit() - } - + #if canImport(Binder) private func printBinderVersion() { // Print Binder version @@ -357,32 +113,32 @@ private extension MainActivity { } extension MainActivity { - + static var logTag: LogTag { "MainActivity" } - + static func log(_ string: String) { try? AndroidLogger(tag: logTag, priority: .debug) .log(string) } - + static func logInfo(_ string: String) { try? AndroidLogger(tag: logTag, priority: .info) .log(string) } - + static func logError(_ string: String) { try? AndroidLogger(tag: logTag, priority: .error) .log(string) } - + func log(_ string: String) { Self.log(string) } - + func logError(_ string: String) { Self.logError(string) } - + func logInfo(_ string: String) { Self.logInfo(string) } diff --git a/Demo/app/src/main/swift/NavigationBarViewOnItemSelectedListener.swift b/Demo/app/src/main/swift/NavigationBarViewOnItemSelectedListener.swift deleted file mode 100644 index 96670b61..00000000 --- a/Demo/app/src/main/swift/NavigationBarViewOnItemSelectedListener.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// OnItemSelectedListener.swift -// SwiftAndroidApp -// -// Created by Alsey Coleman Miller on 6/21/25. -// - -import Foundation -import AndroidKit -import AndroidMaterial - -@JavaClass("com.pureswift.swiftandroid.NavigationBarViewOnItemSelectedListener", extends: AndroidMaterial.NavigationView.OnNavigationItemSelectedListener.self) -open class NavigationBarViewOnItemSelectedListener: JavaObject { - - public typealias Action = (MenuItem?) -> (Bool) - - @JavaMethod - @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - public func getAction() -> SwiftObject? -} - -@JavaImplementation("com.pureswift.swiftandroid.NavigationBarViewOnItemSelectedListener") -extension NavigationBarViewOnItemSelectedListener { - - @JavaMethod - func onNavigationItemSelected(menuItem: MenuItem?) -> Bool { - log("\(self).\(#function)") - // drain queue - RunLoop.main.run(until: Date() + 0.01) - let result = action(menuItem) - RunLoop.main.run(until: Date() + 0.01) - return result - } -} - -public extension NavigationBarViewOnItemSelectedListener { - - convenience init(action: @escaping Action, environment: JNIEnvironment? = nil) { - let object = SwiftObject(action, environment: environment) - self.init(action: object, environment: environment) - } - - var action: Action { - getAction()!.valueObject().value as! Action - } -} - -extension NavigationBarViewOnItemSelectedListener { - - static var logTag: LogTag { "NavigationBarViewOnItemSelectedListener" } - - static func log(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .debug) - .log(string) - } - - static func logInfo(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .info) - .log(string) - } - - static func logError(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .error) - .log(string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} diff --git a/Demo/app/src/main/swift/OnClickListener.swift b/Demo/app/src/main/swift/OnClickListener.swift deleted file mode 100644 index c57c8fea..00000000 --- a/Demo/app/src/main/swift/OnClickListener.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// OnClickListener.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import Foundation -import AndroidKit - -@JavaClass("com.pureswift.swiftandroid.ViewOnClickListener", extends: AndroidView.View.OnClickListener.self) -open class ViewOnClickListener: JavaObject { - - public typealias Action = () -> () - - @JavaMethod - @_nonoverride public convenience init(action: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - public func getAction() -> SwiftObject? -} - -@JavaImplementation("com.pureswift.swiftandroid.ViewOnClickListener") -extension ViewOnClickListener { - - @JavaMethod - func onClick() { - log("\(self).\(#function)") - // drain queue - RunLoop.main.run(until: Date() + 0.01) - action() - RunLoop.main.run(until: Date() + 0.01) - } -} - -public extension ViewOnClickListener { - - convenience init(action: @escaping () -> (), environment: JNIEnvironment? = nil) { - let object = SwiftObject(action, environment: environment) - self.init(action: object, environment: environment) - } - - var action: (() -> ()) { - getAction()!.valueObject().value as! Action - } -} - -extension ViewOnClickListener { - - static var logTag: LogTag { "ViewOnClickListener" } - - static func log(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .debug) - .log(string) - } - - static func logInfo(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .info) - .log(string) - } - - static func logError(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .error) - .log(string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} diff --git a/Demo/app/src/main/swift/RecyclerView.swift b/Demo/app/src/main/swift/RecyclerView.swift deleted file mode 100644 index 4bd25e25..00000000 --- a/Demo/app/src/main/swift/RecyclerView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// RecyclerView.swift -// SwiftAndroidApp -// -// Created by Alsey Coleman Miller on 6/13/25. -// - -import AndroidKit - -@JavaClass("com.pureswift.swiftandroid.RecyclerViewAdapter") -open class RecyclerViewAdapter: RecyclerView.Adapter { - - @JavaMethod - @_nonoverride public convenience init(swiftObject: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - func getSwiftObject() -> SwiftObject! -} - -@JavaImplementation("com.pureswift.swiftandroid.RecyclerViewAdapter") -extension RecyclerViewAdapter { - - @JavaMethod - public func onBindViewHolderSwift(_ viewHolder: RecyclerViewAdapter.ViewHolder?, _ position: Int32) { - log("\(self).\(#function) \(position)") - callback.onBindViewHolder(viewHolder!, position) - } - - @JavaMethod - public func getItemCountSwift() -> Int32 { - log("\(self).\(#function)") - return callback.getItemCount() - } -} - -extension RecyclerViewAdapter { - - static var logTag: LogTag { "RecyclerViewAdapter" } - - static func log(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .debug) - .log(string) - } - - static func logInfo(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .info) - .log(string) - } - - static func logError(_ string: String) { - try? AndroidLogger(tag: logTag, priority: .error) - .log(string) - } - - func log(_ string: String) { - Self.log(string) - } - - func logError(_ string: String) { - Self.logError(string) - } -} - -public extension RecyclerViewAdapter { - - struct Callback { - - var onBindViewHolder: ((RecyclerViewAdapter.ViewHolder, Int32) -> ()) - - var getItemCount: () -> Int32 - - public init( - onBindViewHolder: @escaping ((RecyclerViewAdapter.ViewHolder, Int32) -> Void), - getItemCount: @escaping () -> Int32 = { return 0 } - ) { - self.onBindViewHolder = onBindViewHolder - self.getItemCount = getItemCount - } - } -} - -public extension RecyclerViewAdapter { - - convenience init(_ callback: Callback, environment: JNIEnvironment? = nil) { - let swiftObject = SwiftObject(callback, environment: environment) - self.init(swiftObject: swiftObject, environment: environment) - } - - var callback: Callback { - get { - getSwiftObject().valueObject().value as! Callback - } - set { - getSwiftObject().valueObject().value = newValue - } - } -} - -extension RecyclerViewAdapter { - - @JavaClass("com.pureswift.swiftandroid.RecyclerViewAdapter$ViewHolder") - open class ViewHolder: RecyclerView.ViewHolder { - - @JavaMethod - @_nonoverride public convenience init(view: AndroidView.View?, environment: JNIEnvironment? = nil) - } -} diff --git a/Demo/app/src/main/swift/Runnable.swift b/Demo/app/src/main/swift/Runnable.swift deleted file mode 100644 index 946a138f..00000000 --- a/Demo/app/src/main/swift/Runnable.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Runnable.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/9/25. -// - -import SwiftJava -import CSwiftJavaJNI -import AndroidKit -import JavaLang - -@JavaClass("com.pureswift.swiftandroid.Runnable", extends: JavaLang.Runnable.self) -open class Runnable: JavaObject { - - public typealias Block = () -> () - - @JavaMethod - @_nonoverride public convenience init(block: SwiftObject?, environment: JNIEnvironment? = nil) - - @JavaMethod - public func getBlock() -> SwiftObject? -} - -public extension Runnable { - - convenience init(_ block: @escaping () -> Void, environment: JNIEnvironment? = nil) { - let object = SwiftObject(block, environment: environment) - self.init(block: object, environment: environment) - } -} - -@JavaImplementation("com.pureswift.swiftandroid.Runnable") -extension Runnable { - - @JavaMethod - func run() { - block() - } -} - -private extension Runnable { - - var block: Block { - guard let block = getBlock()?.valueObject().value as? Block else { - fatalError() - } - return block - } -} From 5dbf43423118dbafd14f092954fe11d6afa19a39 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 13:19:41 -0400 Subject: [PATCH 4/4] Link CatalogBridge statically into libSwiftAndroidApp.so Drop the separate CatalogBridge dynamic product: it duplicated the module (static-linked into the app library and as its own .so), splitting module statics between two copies. The jextract config's nativeLibraryName now points the generated Java bindings at SwiftAndroidApp, whose exported Java_* thunk symbols resolve the native methods. --- Demo/Package.swift | 5 ----- Demo/app/build.gradle.kts | 1 - .../src/main/swift-bridge/CatalogBridge/swift-java.config | 1 + 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Demo/Package.swift b/Demo/Package.swift index 4d67e4a5..a44030c6 100644 --- a/Demo/Package.swift +++ b/Demo/Package.swift @@ -12,11 +12,6 @@ let package = Package( type: .dynamic, targets: ["SwiftAndroidApp"] ), - .library( - name: "CatalogBridge", - type: .dynamic, - targets: ["CatalogBridge"] - ), ], dependencies: [ .package( diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index fed5bdae..13a31d07 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -67,7 +67,6 @@ val jextract = tasks.register("jextract") { ) outputs.dir(generatedJavaDir) outputs.file(File(swiftBuildDir, "libSwiftAndroidApp.so")) - outputs.file(File(swiftBuildDir, "libCatalogBridge.so")) // `swift build` is incremental itself; let it decide what is stale. outputs.upToDateWhen { false } } diff --git a/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config index fa494aab..b9e2d408 100644 --- a/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config @@ -2,5 +2,6 @@ "javaPackage": "com.pureswift.swiftandroid.bridge", "mode": "jni", "enableJavaCallbacks": true, + "nativeLibraryName": "SwiftAndroidApp", "logLevel": "info" }