diff --git a/Demo/Package.swift b/Demo/Package.swift index c0340257..a44030c6 100644 --- a/Demo/Package.swift +++ b/Demo/Package.swift @@ -16,6 +16,14 @@ let package = Package( dependencies: [ .package( path: "../" + ), + .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: [ @@ -25,12 +33,43 @@ 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" + ), + .product( + name: "AndroidKit", + package: "Android" + ), + .product( + name: "AndroidContext", + package: "swift-android-native" + ) + ], + 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..13a31d07 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -4,6 +4,93 @@ 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")) + // `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 +// 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 +103,7 @@ android { versionName = "1.0" ndk { //noinspection ChromeOsAbiSupport - abiFilters += listOf("arm64-v8a") + abiFilters += listOf(swiftAbi) } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -40,6 +127,18 @@ android { buildFeatures { compose = true } + + // 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}") @@ -54,16 +153,16 @@ android { } } -// Compile native Swift code for the demo app with `skip android build`. -val buildSwift by tasks.registering(Exec::class) { - group = "build" - description = "Build native Swift sources for Android" - workingDir(rootProject.projectDir) - commandLine("bash", "build-swift.sh") +// 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(buildSwift) + dependsOn(stageJniLibs) } dependencies { 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 count = CatalogBridge.catalogEntryCount() + Log.d("CatalogBridge", "catalogEntryCount() = $count") + CatalogBridge.runBlock { + Log.d("CatalogBridge", "runBlock callback invoked from Swift") + } } private external fun onCreateSwift() 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 new file mode 100644 index 00000000..84cb9e83 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/CatalogBridge.swift @@ -0,0 +1,49 @@ +// +// 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. +// + +/// 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) +} + +/// 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-bridge/CatalogBridge/swift-java.config b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config new file mode 100644 index 00000000..b9e2d408 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/CatalogBridge/swift-java.config @@ -0,0 +1,7 @@ +{ + "javaPackage": "com.pureswift.swiftandroid.bridge", + "mode": "jni", + "enableJavaCallbacks": true, + "nativeLibraryName": "SwiftAndroidApp", + "logLevel": "info" +} 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 - } -} diff --git a/Demo/build-swift.sh b/Demo/build-swift.sh deleted file mode 100755 index c66f1359..00000000 --- a/Demo/build-swift.sh +++ /dev/null @@ -1,32 +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 -ANDROID_NDK_ROOT="" ANDROID_SDK_VERSION="$ANDROID_SDK_VERSION" skip android build --arch "$SWIFT_TARGET_ARCH" --android-api-level "$ANDROID_SDK_VERSION" - -# Copy compiled Swift package -mkdir -p "$JNI_LIBS_DIR/" -cp -f "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug/libSwiftAndroidApp.so" "$JNI_LIBS_DIR/" - -# 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 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/" -fi 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)")}"