diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index ea175a3..1aa049a 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -7,13 +7,20 @@ jobs: strategy: fail-fast: false matrix: - swift: ['6.2.3', 'nightly-6.3'] + # Package manifests require swift-tools 6.3. + swift: ['6.3.3'] arch: ['aarch64', 'x86_64', 'armv7'] sdk: ['28', '29', '31', '33'] runs-on: macos-15 timeout-minutes: 30 steps: - uses: actions/checkout@v4 + # Java is required by android-commandlinetools (skip android sdk install) + # and by the swift-java jextract plugin. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' - name: "Build Swift Package for Android" run: | brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip) diff --git a/.gitignore b/.gitignore index 57b84ea..51f6e89 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,10 @@ Package.resolved # Android *.so .gradle -.idea \ No newline at end of file +.idea +# GenerateBluetoothDefinitions plugin artifacts leaked into cwd by --disable-sandbox builds +*-2.d +*-2.dia +*-2.swiftdeps +*-2.swiftmodule +.kotlin/ diff --git a/Demo/Package.swift b/Demo/Package.swift index ab35f25..3ffe524 100644 --- a/Demo/Package.swift +++ b/Demo/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library( name: "SwiftAndroidApp", type: .dynamic, - targets: ["SwiftAndroidApp"] + targets: ["BluetoothDemoBridge"] ), ], dependencies: [ @@ -21,23 +21,68 @@ let package = Package( url: "https://github.com/PureSwift/Android.git", branch: "master" ), + .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" + ), + .package( + url: "https://github.com/PureSwift/GATT.git", + from: "4.0.0" + ), + .package( + url: "https://github.com/PureSwift/Bluetooth.git", + from: "8.0.0" + ) ], targets: [ .target( - name: "SwiftAndroidApp", + name: "BluetoothDemoBridge", dependencies: [ .product( name: "AndroidBluetooth", package: "AndroidBluetooth" ), + .product( + name: "AndroidBluetoothBridge", + package: "AndroidBluetooth" + ), + .product( + name: "GATT", + package: "GATT" + ), + .product( + name: "Bluetooth", + package: "Bluetooth" + ), + .product( + name: "SwiftJava", + package: "swift-java" + ), .product( name: "AndroidKit", package: "Android" + ), + .product( + name: "AndroidContext", + package: "swift-android-native" ) ], - path: "./app/src/main/swift", + path: "./app/src/main/swift-bridge/BluetoothDemoBridge", + 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 4bed26e..28590d7 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -4,6 +4,111 @@ plugins { alias(libs.plugins.kotlin.compose) } +// --------------------------------------------------------------------------- +// swift-java (jextract, JNI mode) integration +// +// The Swift package graph (AndroidBluetooth + AndroidBluetoothBridge + +// BluetoothDemoBridge) 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///...`. +// Both bridge targets generate Java: the AndroidBluetooth library's callback +// bridge and the demo's UI-facing bridge. +val generatedLibraryJavaDir = File(swiftPackageRoot, ".build/plugins/outputs/androidbluetooth/AndroidBluetoothBridge/destination/JExtractSwiftPlugin/src/generated/java") +val generatedDemoJavaDir = File(swiftPackageRoot, ".build/plugins/outputs/demo/BluetoothDemoBridge/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 swiftAndroidSysroot = File( + (findProperty("swiftAndroidSysroot") as String?) + ?: "$userHome/Library/org.swift.swiftpm/swift-sdks/swift-${swiftToolchainVersion}-RELEASE_android.artifactbundle/swift-android/ndk-sysroot" +) + +// 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(generatedLibraryJavaDir) + outputs.dir(generatedDemoJavaDir) + outputs.file(File(swiftBuildDir, "libSwiftAndroidApp.so")) + // `swift build` is incremental itself; let it decide what is stale. + outputs.upToDateWhen { false } +} + +// SwiftKitCore is consumed as source (swift-java is not published to Maven), +// minus two annotations that pull in `jdk.jfr` (Java Flight Recorder), which +// is unavailable on Android. `Unsigned` (which the generated bindings DO +// reference) is replaced by an Android-compatible copy in `src/main/java`. +val patchedSwiftKitCoreDir = layout.buildDirectory.dir("swiftkitcore-java") +val stageSwiftKitCore = tasks.register("stageSwiftKitCore") { + dependsOn(jextract) + from(swiftKitCoreDir) + exclude( + "org/swift/swiftkit/core/annotations/ThreadSafe.java", + "org/swift/swiftkit/core/annotations/Unsigned.java" + ) + into(patchedSwiftKitCoreDir) +} + +// 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(swiftAndroidSysroot, "usr/lib/aarch64-linux-android")) { + include("libc++_shared.so") + } +} + android { namespace = "com.pureswift.swiftandroid" compileSdk = 35 @@ -16,7 +121,7 @@ android { versionName = "1.0" ndk { //noinspection ChromeOsAbiSupport - abiFilters += listOf("arm64-v8a") + abiFilters += listOf(swiftAbi) } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -31,15 +136,21 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } buildFeatures { compose = true } + + // Generated JNI bindings + the (patched) SwiftKitCore Java runtime. + sourceSets["main"].java.srcDir(generatedLibraryJavaDir) + sourceSets["main"].java.srcDir(generatedDemoJavaDir) + sourceSets["main"].java.srcDir(patchedSwiftKitCoreDir) + packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") @@ -54,16 +165,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, stageSwiftKitCore) +} +tasks.withType().configureEach { + dependsOn(jextract, stageSwiftKitCore) } - tasks.named("preBuild") { - dependsOn(buildSwift) + dependsOn(stageJniLibs) } dependencies { @@ -75,8 +186,6 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.recyclerview) - implementation(libs.androidx.navigation.runtime) implementation(libs.androidx.material3) implementation(libs.material) testImplementation(libs.junit) 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 0000000..212539d --- /dev/null +++ b/Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +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. + *

+ * Android-compatible copy of SwiftKitCore's {@code Unsigned} annotation: the + * upstream source carries {@code jdk.jfr} annotations that are unavailable on + * Android, so the original is excluded from the source set and replaced by + * this one. + */ +@Documented +@Target({TYPE_USE, PARAMETER, FIELD, METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Unsigned { +} diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Application.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Application.kt index 4075c97..2848765 100644 --- a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Application.kt +++ b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Application.kt @@ -1,29 +1,8 @@ package com.pureswift.swiftandroid -import com.example.swift.HelloSubclass - -class Application: android.app.Application() { +class Application : android.app.Application() { init { NativeLibrary.shared() } - - override fun onCreate() { - super.onCreate() - onCreateSwift() - } - - private external fun onCreateSwift() - - override fun onTerminate() { - super.onTerminate() - onTerminateSwift() - } - - private external fun onTerminateSwift() - - fun sayHello() { - val result = HelloSubclass("Swift").sayHello(17, 25) - println("sayHello(17, 25) = $result") - } -} \ No newline at end of file +} diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt deleted file mode 100644 index 5b20617..0000000 --- a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.pureswift.swiftandroid - -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.material3.Text -import androidx.compose.material3.Button -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat - -@Preview(showBackground = true) -@Composable -fun EmbeddedAndroidViewDemo() { - Column { - val state = remember { mutableIntStateOf(0) } - - //widget.ImageView - AndroidView(factory = { ctx -> - ImageView(ctx).apply { - val drawable = ContextCompat.getDrawable(ctx, R.drawable.ic_launcher_foreground) - setImageDrawable(drawable) - } - }) - - //Compose Button - Button(onClick = { state.value++ }) { - Text("MyComposeButton") - } - - //widget.Button - AndroidView(factory = { ctx -> - //Here you can construct your View - android.widget.Button(ctx).apply { - text = "MyAndroidButton" - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - setOnClickListener { - state.value++ - } - } - }, modifier = Modifier.padding(8.dp)) - - //widget.TextView - AndroidView(factory = { ctx -> - //Here you can construct your View - TextView(ctx).apply { - layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - } - }, update = { - it.text = "You have clicked the buttons: " + state.value.toString() + " times" - }) - } -} \ No newline at end of file diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Fragment.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Fragment.kt deleted file mode 100644 index 26a6c39..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/ListViewAdapter.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/ListViewAdapter.kt deleted file mode 100644 index 722b2b0..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/MainActivity.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/MainActivity.kt index 2f3c2eb..0b38dde 100644 --- a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/MainActivity.kt +++ b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/MainActivity.kt @@ -2,27 +2,40 @@ package com.pureswift.swiftandroid import android.Manifest import android.os.Bundle -import android.util.Log -import android.view.View +import android.os.Handler +import android.os.Looper import androidx.activity.ComponentActivity 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.Row 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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +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.material3.TopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api 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.mutableStateMapOf 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 androidx.core.app.ActivityCompat -import java.util.Date +import com.pureswift.swiftandroid.bridge.BluetoothDemoBridge +import com.pureswift.swiftandroid.ui.theme.SwiftAndroidTheme class MainActivity : ComponentActivity() { @@ -30,54 +43,141 @@ class MainActivity : ComponentActivity() { NativeLibrary.shared() } - val emitter = UnitEmitter() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - onCreateSwift(savedInstanceState) - //enableEdgeToEdge() - + // Request permissions on startup. - val permissions = listOf( + val permissions = arrayOf( Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.BLUETOOTH_ADVERTISE, - Manifest.permission.INTERNET + Manifest.permission.BLUETOOTH_CONNECT ) - val requestTag = 1 - ActivityCompat.requestPermissions(this, permissions.toTypedArray(), requestTag) - } + ActivityCompat.requestPermissions(this, permissions, 1) - external fun onCreateSwift(savedInstanceState: Bundle?) - - fun setRootView(view: View) { - Log.d("MainActivity", "AndroidSwiftUI.MainActivity.setRootView(_:)") - setContentView(view) + setContent { + SwiftAndroidTheme { + ScannerScreen() + } + } } } -@Composable -fun EventReceiver(emitter: UnitEmitter) { +data class Device( + val address: String, + val name: String, + val rssi: Long +) - val tick by emitter.flow.collectAsState(initial = Unit) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScannerScreen() { + // Bridge callbacks arrive on Swift background threads; hop to main before touching state. + val mainHandler = remember { Handler(Looper.getMainLooper()) } - var date by remember { mutableStateOf(Date()) } + var scanning by remember { mutableStateOf(false) } + val devices = remember { mutableStateMapOf() } + var selected by remember { mutableStateOf(null) } + var status by remember { mutableStateOf("") } + val services = remember { mutableStateListOf() } - LaunchedEffect(Unit) { - emitter.flow.collect { - date = Date() + fun startScan() { + devices.clear() + scanning = true + BluetoothDemoBridge.startScan { address, name, rssi -> + mainHandler.post { devices[address] = Device(address, name, rssi) } } } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { + fun stopScan() { + BluetoothDemoBridge.stopScan() + scanning = false + } + + fun connect(device: Device) { + stopScan() + selected = device + status = "Connecting…" + services.clear() + BluetoothDemoBridge.connect( + device.address, + { mainHandler.post { status = "Connected" } }, + { uuid -> mainHandler.post { services.add(uuid) } }, + { message -> mainHandler.post { status = "Error: $message" } } + ) + } + + fun disconnect() { + selected?.let { BluetoothDemoBridge.disconnect(it.address) } + selected = null + services.clear() + status = "" + } + + Scaffold( + topBar = { TopAppBar(title = { Text("Bluetooth LE") }) } + ) { padding -> Column( - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text("Hello Swift!") - Text(date.toString()) + val device = selected + if (device == null) { + Button(onClick = { if (scanning) stopScan() else startScan() }) { + Text(if (scanning) "Stop Scan" else "Start Scan") + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(devices.values.sortedByDescending { it.rssi }) { item -> + DeviceRow(item) { connect(item) } + } + } + } else { + Text( + text = device.name.ifEmpty { device.address }, + style = MaterialTheme.typography.titleLarge + ) + Text(status, style = MaterialTheme.typography.bodyMedium) + HorizontalDivider() + Text("Services", style = MaterialTheme.typography.titleMedium) + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(services) { uuid -> + Text(uuid, style = MaterialTheme.typography.bodySmall) + } + } + Button(onClick = { disconnect() }) { + Text("Disconnect") + } + } + } + } +} + +@Composable +fun DeviceRow(device: Device, onClick: () -> Unit) { + Card(modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Row(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name.ifEmpty { "(unknown)" }, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = device.address, + style = MaterialTheme.typography.bodySmall + ) + } + Spacer(modifier = Modifier.weight(0.1f)) + Text( + text = "${device.rssi} dBm", + style = MaterialTheme.typography.bodyMedium + ) } } } diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NativeLibrary.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NativeLibrary.kt index 871a45c..7dc7632 100644 --- a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NativeLibrary.kt +++ b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NativeLibrary.kt @@ -25,9 +25,8 @@ class NativeLibrary private constructor() { private fun loadNativeLibrary() { try { System.loadLibrary("SwiftAndroidApp") - System.loadLibrary("SwiftJava") } catch (error: UnsatisfiedLinkError) { - Log.e("NativeLibrary", "Unable to load native libraries: $error") + Log.e("NativeLibrary", "Unable to load native library: $error") return } Log.d("NativeLibrary", "Loaded Swift library") diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt deleted file mode 100644 index cf8014f..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/RecyclerViewAdapter.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/RecyclerViewAdapter.kt deleted file mode 100644 index d11648d..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/Runnable.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Runnable.kt deleted file mode 100644 index 08a0987..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/SwiftObject.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/SwiftObject.kt deleted file mode 100644 index 94ff2db..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/com/pureswift/swiftandroid/UnitEmitter.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/UnitEmitter.kt deleted file mode 100644 index 93af004..0000000 --- a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/UnitEmitter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.pureswift.swiftandroid - -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow - -class UnitEmitter() { - - private val _flow = MutableSharedFlow(extraBufferCapacity = 64) - val flow: SharedFlow get() = _flow - - fun emit() { - //println("Emit") - _flow.tryEmit(Unit) - } -} \ No newline at end of file diff --git a/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/ViewOnClickListener.kt b/Demo/app/src/main/kotlin/com/pureswift/swiftandroid/ViewOnClickListener.kt deleted file mode 100644 index 39b5ce3..0000000 --- a/Demo/app/src/main/kotlin/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/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt b/Demo/app/src/main/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt index 2073505..5f27a97 100644 --- a/Demo/app/src/main/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt +++ b/Demo/app/src/main/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt @@ -4,162 +4,110 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback as AndroidGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor -import android.util.Log +import org.pureswift.bluetooth.bridge.AndroidBluetoothBridge +import org.pureswift.bluetooth.bridge.BluetoothGattEventSink +import org.swift.swiftkit.core.SwiftArena /** - * Bluetooth GATT Callback for AndroidBluetooth Swift package. + * Bluetooth GATT Callback for the AndroidBluetooth Swift package. * - * This class is referenced by AndroidBluetooth's GattCallback - * via the @JavaClass("org.pureswift.bluetooth.BluetoothGattCallback") annotation. - * It extends Android's BluetoothGattCallback and provides the bridge between - * Android's Bluetooth GATT and the Swift AndroidBluetooth package. + * This class is instantiated by AndroidBluetooth's `GattCallback` + * via the `@JavaClass("org.pureswift.bluetooth.BluetoothGattCallback")` annotation. + * It extends Android's `BluetoothGattCallback` and forwards each event to a + * [BluetoothGattEventSink] — a Swift-implemented sink surfaced through the + * jextract-generated class, created for a specific central and peripheral. + * The no-argument constructor obtains the sink for the connection currently + * under construction. */ open class BluetoothGattCallback( - private var swiftPeer: Long = 0L + private val sink: BluetoothGattEventSink ) : AndroidGattCallback() { - fun setSwiftPeer(swiftPeer: Long) { - this.swiftPeer = swiftPeer - } - - fun getSwiftPeer(): Long { - return swiftPeer - } - - fun finalize() { - swiftGattRelease(swiftPeer) - swiftPeer = 0L - } - - private external fun swiftGattRelease(swiftPeer: Long) - - companion object { - private const val TAG = "PureSwift.GattCallback" - } + constructor() : this(AndroidBluetoothBridge.takePendingGattEventSink(SwiftArena.ofAuto())) override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) - if (swiftPeer != 0L) { - swiftOnConnectionStateChange(swiftPeer, gatt, status, newState) - } else { - Log.d(TAG, "onConnectionStateChange: status=$status newState=$newState") - } + sink.onConnectionStateChange(status, newState) } - private external fun swiftOnConnectionStateChange(swiftPeer: Long, gatt: BluetoothGatt?, status: Int, newState: Int) override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) - if (swiftPeer != 0L) { - swiftOnServicesDiscovered(swiftPeer, gatt, status) - } else { - Log.d(TAG, "onServicesDiscovered: status=$status") - } + sink.onServicesDiscovered(status) } - private external fun swiftOnServicesDiscovered(swiftPeer: Long, gatt: BluetoothGatt?, status: Int) @Deprecated("Deprecated in Java") override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { @Suppress("DEPRECATION") super.onCharacteristicChanged(gatt, characteristic) - if (swiftPeer != 0L) { - swiftOnCharacteristicChanged(swiftPeer, gatt, characteristic) - } else { - Log.d(TAG, "onCharacteristicChanged: ${characteristic.uuid}") - } + sink.onCharacteristicChanged( + characteristic.uuid.toString(), + characteristic.instanceId, + @Suppress("DEPRECATION") + characteristic.value ?: ByteArray(0) + ) } - private external fun swiftOnCharacteristicChanged(swiftPeer: Long, gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) @Deprecated("Deprecated in Java") override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { @Suppress("DEPRECATION") super.onCharacteristicRead(gatt, characteristic, status) - if (swiftPeer != 0L) { - swiftOnCharacteristicRead(swiftPeer, gatt, characteristic, status) - } else { - Log.d(TAG, "onCharacteristicRead: ${characteristic.uuid} status=$status") - } + sink.onCharacteristicRead( + characteristic.uuid.toString(), + characteristic.instanceId, + @Suppress("DEPRECATION") + characteristic.value ?: ByteArray(0), + status + ) } - private external fun swiftOnCharacteristicRead(swiftPeer: Long, gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) override fun onCharacteristicWrite(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { super.onCharacteristicWrite(gatt, characteristic, status) - if (swiftPeer != 0L) { - swiftOnCharacteristicWrite(swiftPeer, gatt, characteristic, status) - } else { - Log.d(TAG, "onCharacteristicWrite: ${characteristic?.uuid} status=$status") - } + sink.onCharacteristicWrite( + characteristic?.uuid?.toString() ?: "", + characteristic?.instanceId ?: 0, + status + ) } - private external fun swiftOnCharacteristicWrite(swiftPeer: Long, gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) @Deprecated("Deprecated in Java") override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { @Suppress("DEPRECATION") super.onDescriptorRead(gatt, descriptor, status) - if (swiftPeer != 0L) { - swiftOnDescriptorRead(swiftPeer, gatt, descriptor, status) - } else { - Log.d(TAG, "onDescriptorRead: ${descriptor.uuid} status=$status") - } + sink.onDescriptorRead( + descriptor.uuid.toString(), + @Suppress("DEPRECATION") + descriptor.value ?: ByteArray(0), + status + ) } - private external fun swiftOnDescriptorRead(swiftPeer: Long, gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) { super.onDescriptorWrite(gatt, descriptor, status) - if (swiftPeer != 0L) { - swiftOnDescriptorWrite(swiftPeer, gatt, descriptor, status) - } else { - Log.d(TAG, "onDescriptorWrite: ${descriptor?.uuid} status=$status") - } + sink.onDescriptorWrite(descriptor?.uuid?.toString() ?: "", status) } - private external fun swiftOnDescriptorWrite(swiftPeer: Long, gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { super.onMtuChanged(gatt, mtu, status) - if (swiftPeer != 0L) { - swiftOnMtuChanged(swiftPeer, gatt, mtu, status) - } else { - Log.d(TAG, "onMtuChanged: mtu=$mtu status=$status") - } + sink.onMtuChanged(mtu, status) } - private external fun swiftOnMtuChanged(swiftPeer: Long, gatt: BluetoothGatt?, mtu: Int, status: Int) override fun onPhyRead(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) { super.onPhyRead(gatt, txPhy, rxPhy, status) - if (swiftPeer != 0L) { - swiftOnPhyRead(swiftPeer, gatt, txPhy, rxPhy, status) - } else { - Log.d(TAG, "onPhyRead: txPhy=$txPhy rxPhy=$rxPhy status=$status") - } + sink.onPhyRead(txPhy, rxPhy, status) } - private external fun swiftOnPhyRead(swiftPeer: Long, gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) override fun onPhyUpdate(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) { super.onPhyUpdate(gatt, txPhy, rxPhy, status) - if (swiftPeer != 0L) { - swiftOnPhyUpdate(swiftPeer, gatt, txPhy, rxPhy, status) - } else { - Log.d(TAG, "onPhyUpdate: txPhy=$txPhy rxPhy=$rxPhy status=$status") - } + sink.onPhyUpdate(txPhy, rxPhy, status) } - private external fun swiftOnPhyUpdate(swiftPeer: Long, gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) { super.onReadRemoteRssi(gatt, rssi, status) - if (swiftPeer != 0L) { - swiftOnReadRemoteRssi(swiftPeer, gatt, rssi, status) - } else { - Log.d(TAG, "onReadRemoteRssi: rssi=$rssi status=$status") - } + sink.onReadRemoteRssi(rssi, status) } - private external fun swiftOnReadRemoteRssi(swiftPeer: Long, gatt: BluetoothGatt?, rssi: Int, status: Int) override fun onReliableWriteCompleted(gatt: BluetoothGatt?, status: Int) { super.onReliableWriteCompleted(gatt, status) - if (swiftPeer != 0L) { - swiftOnReliableWriteCompleted(swiftPeer, gatt, status) - } else { - Log.d(TAG, "onReliableWriteCompleted: status=$status") - } + sink.onReliableWriteCompleted(status) } - private external fun swiftOnReliableWriteCompleted(swiftPeer: Long, gatt: BluetoothGatt?, status: Int) } diff --git a/Demo/app/src/main/kotlin/org/pureswift/bluetooth/le/ScanCallback.kt b/Demo/app/src/main/kotlin/org/pureswift/bluetooth/le/ScanCallback.kt index 28c0bcc..295cbcd 100644 --- a/Demo/app/src/main/kotlin/org/pureswift/bluetooth/le/ScanCallback.kt +++ b/Demo/app/src/main/kotlin/org/pureswift/bluetooth/le/ScanCallback.kt @@ -2,34 +2,28 @@ package org.pureswift.bluetooth.le import android.bluetooth.le.ScanCallback as AndroidScanCallback import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.os.Build import android.util.Log +import org.pureswift.bluetooth.bridge.AndroidBluetoothBridge +import org.pureswift.bluetooth.bridge.BluetoothScanEventSink +import org.swift.swiftkit.core.SwiftArena /** - * Bluetooth LE Scan Callback for AndroidBluetooth Swift package. + * Bluetooth LE Scan Callback for the AndroidBluetooth Swift package. * - * This class is referenced by AndroidBluetooth's LowEnergyScanCallback - * via the @JavaClass("org.pureswift.bluetooth.le.ScanCallback") annotation. - * It extends Android's ScanCallback and provides the bridge between - * Android's Bluetooth LE scanning and the Swift AndroidBluetooth package. + * This class is instantiated by AndroidBluetooth's `LowEnergyScanCallback` + * via the `@JavaClass("org.pureswift.bluetooth.le.ScanCallback")` annotation. + * It extends Android's `ScanCallback` and forwards each event to a + * [BluetoothScanEventSink] — a Swift-implemented sink surfaced through the + * jextract-generated class. The no-argument constructor obtains the sink for + * the Swift central currently under construction. */ open class ScanCallback( - private var swiftPeer: Long = 0L + private val sink: BluetoothScanEventSink ) : AndroidScanCallback() { - fun setSwiftPeer(swiftPeer: Long) { - this.swiftPeer = swiftPeer - } - - fun getSwiftPeer(): Long { - return swiftPeer - } - - fun finalize() { - swiftScanRelease(swiftPeer) - swiftPeer = 0L - } - - private external fun swiftScanRelease(swiftPeer: Long) + constructor() : this(AndroidBluetoothBridge.takePendingScanEventSink(SwiftArena.ofAuto())) companion object { private const val TAG = "PureSwift.ScanCallback" @@ -43,15 +37,9 @@ open class ScanCallback( */ override fun onScanResult(callbackType: Int, result: ScanResult?) { super.onScanResult(callbackType, result) - swiftOnScanResult(swiftPeer, callbackType, result) + result?.let { forward(callbackType, it) } } - external fun swiftOnScanResult( - swiftPeer: Long, - callbackType: Int, - result: ScanResult? - ) - /** * Callback when batch results are delivered. * @@ -59,16 +47,8 @@ open class ScanCallback( */ override fun onBatchScanResults(results: MutableList?) { super.onBatchScanResults(results) - if (swiftPeer != 0L) { - swiftOnBatchScanResults(swiftPeer, results) - } else { - Log.d(TAG, "onBatchScanResults: ${results?.size ?: 0} results") - } + results?.forEach { forward(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, it) } } - private external fun swiftOnBatchScanResults( - swiftPeer: Long, - results: MutableList? - ) /** * Callback when scan could not be started. @@ -77,11 +57,22 @@ open class ScanCallback( */ override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) - if (swiftPeer != 0L) { - swiftOnScanFailed(swiftPeer, errorCode) + Log.e(TAG, "onScanFailed: errorCode=$errorCode") + sink.onScanFailed(errorCode) + } + + private fun forward(callbackType: Int, result: ScanResult) { + val isConnectable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + result.isConnectable } else { - Log.e(TAG, "onScanFailed: errorCode=$errorCode") + true } + sink.onScanResult( + callbackType, + result.device.address, + result.rssi, + isConnectable, + result.scanRecord?.bytes ?: ByteArray(0) + ) } - private external fun swiftOnScanFailed(swiftPeer: Long, errorCode: Int) } diff --git a/Demo/app/src/main/res/layout/list_item.xml b/Demo/app/src/main/res/layout/list_item.xml deleted file mode 100644 index eaf5504..0000000 --- a/Demo/app/src/main/res/layout/list_item.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/BluetoothDemoBridge.swift b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/BluetoothDemoBridge.swift new file mode 100644 index 0000000..f63ee0f --- /dev/null +++ b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/BluetoothDemoBridge.swift @@ -0,0 +1,58 @@ +// +// BluetoothDemoBridge.swift +// SwiftAndroidApp +// +// Created by Alsey Coleman Miller on 7/25/26. +// +// Public jextract surface for the demo app's Compose UI. +// +// This target is processed by the swift-java `JExtractSwiftPlugin` (JNI mode with +// Java callbacks, see `swift-java.config`), which generates the +// `com.pureswift.swiftandroid.bridge.BluetoothDemoBridge` Java class. Kotlin calls +// the static methods below, passing lambdas for the closure parameters; Swift invokes +// those lambdas as Bluetooth events arrive. Only jextract-supported types +// (primitives, String, closures) may appear in public declarations here; the +// implementation lives in `DemoCentral.swift` using internal visibility. + +/// Start scanning for BLE peripherals. +/// +/// - Parameter onScanResult: Invoked for every advertisement received, with the +/// peripheral's address, advertised name (empty if none), and RSSI. +/// Called from a background thread. +public func startScan( + onScanResult: @escaping (String, String, Int64) -> Void +) { + DemoCentral.shared.startScan(onScanResult: onScanResult) +} + +/// Stop an ongoing scan. +public func stopScan() { + DemoCentral.shared.stopScan() +} + +/// Connect to a previously scanned peripheral and discover its services. +/// +/// - Parameters: +/// - address: The peripheral's Bluetooth address (from ``startScan``). +/// - onConnected: Invoked when the connection is established. +/// - onServiceDiscovered: Invoked once per discovered service with its UUID string. +/// - onError: Invoked with a description if connecting or discovery fails. +/// All callbacks are invoked from a background thread. +public func connect( + address: String, + onConnected: @escaping () -> Void, + onServiceDiscovered: @escaping (String) -> Void, + onError: @escaping (String) -> Void +) { + DemoCentral.shared.connect( + address: address, + onConnected: onConnected, + onServiceDiscovered: onServiceDiscovered, + onError: onError + ) +} + +/// Disconnect from a peripheral. +public func disconnect(address: String) { + DemoCentral.shared.disconnect(address: address) +} diff --git a/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/DemoCentral.swift b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/DemoCentral.swift new file mode 100644 index 0000000..6606cd5 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/DemoCentral.swift @@ -0,0 +1,133 @@ +// +// DemoCentral.swift +// SwiftAndroidApp +// +// Created by Alsey Coleman Miller on 7/25/26. +// +// Internal implementation behind the jextract surface in `BluetoothDemoBridge.swift`. +// Owns the `AndroidCentral` instance, bootstrapping it from the application context +// provided by the AndroidContext module. + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import SwiftJava +import AndroidKit +import AndroidBluetooth +import AndroidContext +import Bluetooth +import GATT + +internal final class DemoCentral: @unchecked Sendable { + + static let shared = DemoCentral() + + private let central: AndroidCentral? + + private var scanTask: Task? + + private init() { + guard let application = try? AndroidContext.application, + let environment = try? JavaVirtualMachine.shared().environment(), + let hostController = try? JavaClass().getDefaultAdapter() else { + Self.log("Unable to initialize Bluetooth central") + self.central = nil + return + } + // The global application context, bootstrapped through + // `ActivityThread.currentApplication()` by the AndroidContext module and + // wrapped as a SwiftJava `Context` for use with the SDK wrappers. + let context = AndroidContent.Context( + javaHolder: JavaObjectHolder( + object: application.pointer, + environment: environment + ) + ) + let central = AndroidCentral( + hostController: hostController, + context: context + ) + central.log = { message in + Self.log(message) + } + self.central = central + } + + func startScan(onScanResult: @escaping (String, String, Int64) -> Void) { + stopScan() + guard let central else { + Self.log("Bluetooth unavailable") + return + } + scanTask = Task { + do { + Self.log("Start scan") + let stream = try await central.scan() + for try await scanData in stream { + let name = scanData.advertisementData.localName ?? "" + onScanResult(scanData.peripheral.id.rawValue, name, Int64(scanData.rssi)) + } + } + catch is CancellationError { + Self.log("Scan stopped") + } + catch { + Self.log("Scan error: \(error)") + } + } + } + + func stopScan() { + scanTask?.cancel() + scanTask = nil + } + + func connect( + address: String, + onConnected: @escaping () -> Void, + onServiceDiscovered: @escaping (String) -> Void, + onError: @escaping (String) -> Void + ) { + guard let central, let peripheral = peripheral(for: address) else { + onError("Invalid peripheral \(address)") + return + } + Task { + do { + Self.log("Connecting to \(address)") + try await central.connect(to: peripheral) + onConnected() + let services = try await central.discoverServices([], for: peripheral) + for service in services { + onServiceDiscovered(service.uuid.description) + } + } + catch { + Self.log("Connection error: \(error)") + onError("\(error)") + } + } + } + + func disconnect(address: String) { + guard let central, let peripheral = peripheral(for: address) else { + return + } + Task { + await central.disconnect(peripheral) + } + } + + private func peripheral(for address: String) -> Peripheral? { + guard let address = BluetoothAddress(rawValue: address) else { + return nil + } + return Peripheral(id: address) + } + + private static func log(_ message: String) { + try? AndroidLogger(tag: "BluetoothDemo", priority: .debug).log(message) + } +} diff --git a/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/swift-java.config b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/swift-java.config new file mode 100644 index 0000000..b9e2d40 --- /dev/null +++ b/Demo/app/src/main/swift-bridge/BluetoothDemoBridge/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/Application.swift b/Demo/app/src/main/swift/Application.swift deleted file mode 100644 index b2b4d78..0000000 --- a/Demo/app/src/main/swift/Application.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// SwiftApp.swift -// SwiftAndroidApp -// -// Created by Alsey Coleman Miller on 6/8/25. -// - -import AndroidKit - -@JavaClass("com.pureswift.swiftandroid.Application") -public class Application: AndroidApp.Application { - - @JavaMethod - public func sayHello() -} - -@JavaImplementation("com.pureswift.swiftandroid.Application") -public extension Application { - - @JavaMethod - func onCreateSwift() { - log("\(self).\(#function)") - - printAPIVersion() - sayHello() - } - - @JavaMethod - func onTerminateSwift() { - log("\(self).\(#function)") - } -} - -private extension Application { - - func printAPIVersion() { - - do { - let api = try AndroidOS.AndroidAPI.current - Self.logInfo("Running on Android API \(api)") - } - catch { - Self.logError("\(error)") - } - } -} - -extension Application { - - static var logTag: LogTag { "Application" } - - 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/Fragment.swift b/Demo/app/src/main/swift/Fragment.swift deleted file mode 100644 index a058472..0000000 --- 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/HelloSubclass.swift b/Demo/app/src/main/swift/HelloSubclass.swift deleted file mode 100644 index 8434d77..0000000 --- a/Demo/app/src/main/swift/HelloSubclass.swift +++ /dev/null @@ -1,16 +0,0 @@ -// Auto-generated by Java-to-Swift wrapper generator. -import SwiftJava -import CSwiftJavaJNI - -@JavaClass("com.example.swift.HelloSubclass") -open class HelloSubclass: HelloSwift { - @JavaMethod - @_nonoverride public convenience init(_ greeting: String, environment: JNIEnvironment? = nil) - - @JavaMethod - open func greetMe() -} -extension JavaClass { - @JavaStaticField(isFinal: false) - public var initialValue: Double -} diff --git a/Demo/app/src/main/swift/HelloSwift.swift b/Demo/app/src/main/swift/HelloSwift.swift deleted file mode 100644 index abd7f54..0000000 --- a/Demo/app/src/main/swift/HelloSwift.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Auto-generated by Java-to-Swift wrapper generator. -import SwiftJava -import JavaUtilFunction -import CSwiftJavaJNI - -@JavaClass("com.example.swift.HelloSwift") -open class HelloSwift: JavaObject { - @JavaField(isFinal: false) - public var value: Double - - @JavaField(isFinal: false) - public var name: String - - @JavaMethod - @_nonoverride public convenience init(environment: JNIEnvironment? = nil) - - @JavaMethod - open func greet(_ name: String) - - @JavaMethod - open func sayHelloBack(_ i: Int32) -> Double - - @JavaMethod - open func lessThanTen() -> JavaPredicate! - - @JavaMethod - open func doublesToStrings(_ doubles: [Double]) -> [String] - - @JavaMethod - open func throwMessage(_ message: String) throws -} -extension JavaClass { - @JavaStaticField(isFinal: false) - public var initialValue: Double -} -/// Describes the Java `native` methods for ``HelloSwift``. -/// -/// To implement all of the `native` methods for HelloSwift in Swift, -/// extend HelloSwift to conform to this protocol and mark -/// each implementation of the protocol requirement with -/// `@JavaMethod`. -protocol HelloSwiftNativeMethods { - func throwMessageFromSwift(_ message: String) throws -> String - - func sayHello(_ x: Int32, _ y: Int32) -> Int32 -} diff --git a/Demo/app/src/main/swift/JavaKitExample.swift b/Demo/app/src/main/swift/JavaKitExample.swift deleted file mode 100644 index 1ca3b3d..0000000 --- a/Demo/app/src/main/swift/JavaKitExample.swift +++ /dev/null @@ -1,115 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2024 Apple Inc. and the Swift.org project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift.org project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import SwiftJava -import JavaUtilFunction -import AndroidUtil -import AndroidLogging - -enum SwiftWrappedError: Error { - case message(String) -} - -@JavaImplementation("com.example.swift.HelloSwift") -extension HelloSwift: HelloSwiftNativeMethods { - @JavaMethod - func sayHello(_ i: Int32, _ j: Int32) -> Int32 { - print("Hello from Swift!") - let answer = self.sayHelloBack(i + j) - print("Swift got back \(answer) from Java") - - print("We expect the above value to be the initial value, \(self.javaClass.initialValue)") - - print("Updating Java field value to something different") - self.value = 2.71828 - - let newAnswer = self.sayHelloBack(17) - print("Swift got back updated \(newAnswer) from Java") - - let newHello = HelloSwift(environment: javaEnvironment) - print("Swift created a new Java instance with the value \(newHello.value)") - - let name = newHello.name - print("Hello to \(name)") - newHello.greet("Swift 👋🏽 How's it going") - - self.name = "a 🗑️-collected language" - _ = self.sayHelloBack(42) - - let predicate: JavaPredicate = self.lessThanTen()! - let value = predicate.test(JavaInteger(3).as(JavaObject.self)) - print("Running a JavaPredicate from swift 3 < 10 = \(value)") - - let strings = doublesToStrings([3.14159, 2.71828]) - print("Converting doubles to strings: \(strings)") - - // Try downcasting - if let helloSub = self.as(HelloSubclass.self) { - print("Hello from the subclass!") - helloSub.greetMe() - - assert(helloSub.value == 2.71828) - } else { - fatalError("Expected subclass here") - } - - // Check "is" behavior - assert(newHello.is(HelloSwift.self)) - assert(!newHello.is(HelloSubclass.self)) - - // Create a new instance. - let helloSubFromSwift = HelloSubclass("Hello from Swift", environment: javaEnvironment) - helloSubFromSwift.greetMe() - - do { - try throwMessage("I am an error") - } catch { - print("Caught Java error: \(error)") - } - - // Make sure that the thread safe class is sendable - let helper = ThreadSafeHelperClass(environment: javaEnvironment) - let threadSafe: Sendable = helper - - checkOptionals(helper: helper) - - return i * j - } - - func checkOptionals(helper: ThreadSafeHelperClass) { - let text: JavaString? = helper.textOptional - let value: String? = helper.getValueOptional(Optional.none) - let textFunc: JavaString? = helper.getTextOptional() - let doubleOpt: Double? = helper.valOptional - let longOpt: Int64? = helper.fromOptional(21 as Int32?) - print("Optional text = \(text.debugDescription)") - print("Optional string value = \(value.debugDescription)") - print("Optional text function returned \(textFunc.debugDescription)") - print("Optional double function returned \(doubleOpt.debugDescription)") - print("Optional long function returned \(longOpt.debugDescription)") - } - - @JavaMethod - func throwMessageFromSwift(_ message: String) throws -> String { - throw SwiftWrappedError.message(message) - } -} - -internal extension HelloSwift { - - func print(_ string: String) { - try? AndroidLogger(tag: "HelloSwift", priority: .verbose) - .log(string) - } -} diff --git a/Demo/app/src/main/swift/JavaRetainedValue.swift b/Demo/app/src/main/swift/JavaRetainedValue.swift deleted file mode 100644 index 4228e6e..0000000 --- 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 bce0934..0000000 --- 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 deleted file mode 100644 index 1a06c8c..0000000 --- a/Demo/app/src/main/swift/MainActivity.swift +++ /dev/null @@ -1,409 +0,0 @@ -// -// Activity.swift -// AndroidSwiftUI -// -// Created by Alsey Coleman Miller on 6/8/25. -// - -import Foundation -import AndroidKit -import AndroidBluetooth -import Bluetooth -import GATT -import JavaLang -#if canImport(Binder) -import Binder -#endif - -@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() - - var central: AndroidCentral? - - var results = [AndroidCentral.Peripheral.ID: GATT.ScanData]() -} - -@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 create views - setRootView() - - // start scanning - let hostController = try! JavaClass().getDefaultAdapter()! - let context = self.as(AndroidContent.Context.self)! - let central = AndroidCentral( - hostController: hostController, - context: context - ) - self.central = central - - Task { - do { - log("Start Scan") - let scanStream = try await central.scan() - for try await result in scanStream { - log("Found: \(result)") - results[result.id] = result - } - } - catch { - log("Error: \(error.localizedDescription)") - } - } - - #if canImport(Binder) - Task { - printBinderVersion() - } - #endif - } - - func runAsync() { - RunLoop.main.run(until: Date() + 0.1) - } - - func startMainRunLoop() { - #if os(Android) - guard AndroidMainActor.setupMainLooper() else { - fatalError("Unable to setup main loop") - } - #endif - } - - func updateListView() { - let results = results.keys.sorted().map { $0.description } - setListView(results) - } - - func setRootView() { - setTextView() - } - - func setTextView() { - let linearLayout = LinearLayout(self) - linearLayout.orientation = .vertical - linearLayout.gravity = .center - linearLayout.addView(textView) - 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 - while let self { - await emit() - try? await Task.sleep(for: .seconds(1)) - } - } - } - - @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(_ items: [String]) { - 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)") - let results = results.keys.sorted().map { $0.description } - var text = "Hello Swift!\n\(Date().formatted(date: .numeric, time: .complete))" - for result in results { - text += "\n\(result)" - } - textView.text = text - } - - 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 - do { - let version = try BinderVersion.current - logInfo("Binder Version: \(version)") - } - catch { - logError("Unable to read binder: \(error)") - } - } - #endif -} - -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 96670b6..0000000 --- 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 c57c8fe..0000000 --- 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 4bd25e2..0000000 --- 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 946a138..0000000 --- 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/app/src/main/swift/ThreadSafeHelperClass.swift b/Demo/app/src/main/swift/ThreadSafeHelperClass.swift deleted file mode 100644 index 82efbb5..0000000 --- a/Demo/app/src/main/swift/ThreadSafeHelperClass.swift +++ /dev/null @@ -1,54 +0,0 @@ -// Auto-generated by Java-to-Swift wrapper generator. -import SwiftJava -import CSwiftJavaJNI - -@JavaClass("com.example.swift.ThreadSafeHelperClass") -open class ThreadSafeHelperClass: JavaObject { - @JavaField(isFinal: false) - public var text: JavaOptional! - - - public var textOptional: JavaString? { - get { - Optional(javaOptional: text) - } - set { - text = newValue.toJavaOptional() - } - } - - @JavaField(isFinal: true) - public var val: JavaOptionalDouble! - - - public var valOptional: Double? { - get { - Optional(javaOptional: val) - } - } - - @JavaMethod - @_nonoverride public convenience init(environment: JNIEnvironment? = nil) - - @JavaMethod - open func getValue(_ name: JavaOptional?) -> String - - open func getValueOptional(_ name: JavaString?) -> String { - getValue(name.toJavaOptional()) - } - - @JavaMethod - open func from(_ value: JavaOptionalInt?) -> JavaOptionalLong! - - open func fromOptional(_ value: Int32?) -> Int64? { - Optional(javaOptional: from(value.toJavaOptional())) - } - - @JavaMethod - open func getText() -> JavaOptional! - - open func getTextOptional() -> JavaString? { - Optional(javaOptional: getText()) - } -} -extension ThreadSafeHelperClass: @unchecked Swift.Sendable { } diff --git a/Demo/app/src/main/swift/UnitEmitter.swift b/Demo/app/src/main/swift/UnitEmitter.swift deleted file mode 100644 index 972b906..0000000 --- a/Demo/app/src/main/swift/UnitEmitter.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// UnitEmitter.swift -// SwiftAndroidApp -// -// Created by Alsey Coleman Miller on 7/13/25. -// - -import SwiftJava - -/// Bridge from Swift to Kotlin Coroutines -@JavaClass("com.pureswift.swiftandroid.UnitEmitter") -open class UnitEmitter: JavaObject { - - @JavaMethod - @_nonoverride public convenience init(environment: JNIEnvironment? = nil) - - @JavaMethod - func emit() -} diff --git a/Demo/build-swift.sh b/Demo/build-swift.sh deleted file mode 100755 index c66f135..0000000 --- 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/setup.sh b/Demo/setup.sh deleted file mode 100755 index 90b1824..0000000 --- a/Demo/setup.sh +++ /dev/null @@ -1,52 +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" - -# Install macOS dependencies -if [[ $OSTYPE == 'darwin'* ]]; then - echo "Install macOS build dependencies" - brew install skiptools/skip/skip - skip android sdk install - brew update - HOMEBREW_NO_AUTO_UPDATE=1 brew install wget cmake ninja android-ndk -fi - -# Copy Swift libraries -rm -rf "$JNI_LIBS_DIR/" -mkdir -p "$JNI_LIBS_DIR/" - -copied_swift_libs=0 -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/" - copied_swift_libs=1 - done - shopt -u nullglob -fi - -# Fallback for newer Skip/Swift SDK layouts where runtime libs are emitted into `.build`. -if [[ $copied_swift_libs -eq 0 && -d "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug" ]]; then - shopt -s nullglob - for so in "$SWIFT_PACKAGE_SRC/.build/$SWIFT_TARGET_NAME/debug"/libSwift*.so; do - cp -f "$so" "$JNI_LIBS_DIR/" - copied_swift_libs=1 - done - shopt -u nullglob -fi - -if [[ $copied_swift_libs -eq 0 ]]; then - echo "Warning: No Swift runtime libraries found to copy." -fi - -# Copy C stdlib -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/" -else - echo "Warning: libc++_shared.so not found at $SWIFT_ANDROID_SYSROOT/usr/lib/$ANDROID_LIB/libc++_shared.so" -fi -echo "Copied Swift libraries" diff --git a/Demo/swift-define b/Demo/swift-define deleted file mode 100644 index 43004e4..0000000 --- a/Demo/swift-define +++ /dev/null @@ -1,30 +0,0 @@ -# Configurable -SWIFT_DEFINE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SRC_ROOT="${SRC_ROOT:=$SWIFT_DEFINE_DIR}" -SWIFT_TARGET_ARCH="${SWIFT_TARGET_ARCH:=aarch64}" -ANDROID_ARCH="${ANDROID_ARCH:=arm64-v8a}" -ANDROID_LIB="${ANDROID_LIB:=aarch64-linux-android}" -SWIFT_COMPILATION_MODE="${SWIFT_COMPILATION_MODE:=debug}" - -# Version -ANDROID_SDK_VERSION=28 -SWIFT_VERSION_SHORT=6.2.3 -SWIFT_VERSION=swift-$SWIFT_VERSION_SHORT-RELEASE -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 - SWIFT_SDKS_ROOT="$HOME/.swiftpm/swift-sdks" -fi - -# Paths -SWIFT_SDK=swift-$SWIFT_VERSION_SHORT-release-android-$ANDROID_SDK_VERSION-sdk -SWIFT_ANDROID_SYSROOT="$SWIFT_SDKS_ROOT/$SWIFT_ARTIFACT_BUNDLE/swift-android/ndk-sysroot" -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}" diff --git a/Package.swift b/Package.swift index e54bbbb..8efb4ee 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,9 @@ let package = Package( .library( name: "AndroidBluetooth", targets: ["AndroidBluetooth"]), + .library( + name: "AndroidBluetoothBridge", + targets: ["AndroidBluetoothBridge"]), ], dependencies: [ .package( @@ -47,6 +50,10 @@ let package = Package( .package( url: "https://github.com/swift-android-sdk/swift-android-native.git", from: "2.1.0" + ), + .package( + url: "https://github.com/swiftlang/swift-java.git", + branch: "main" ) ], targets: [ @@ -95,6 +102,23 @@ let package = Package( plugins: [ //.plugin(name: "SwiftJavaPlugin", package: "swift-java") ] + ), + .target( + name: "AndroidBluetoothBridge", + dependencies: [ + "AndroidBluetooth", + .product( + name: "SwiftJava", + package: "swift-java" + ) + ], + exclude: ["swift-java.config"], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] ) ] ) diff --git a/Sources/AndroidBluetooth/AndroidCentral.swift b/Sources/AndroidBluetooth/AndroidCentral.swift index 985906e..6ad82b1 100644 --- a/Sources/AndroidBluetooth/AndroidCentral.swift +++ b/Sources/AndroidBluetooth/AndroidCentral.swift @@ -79,16 +79,16 @@ public final class AndroidCentral: CentralManager { } public let options: Options - + internal let storage = Storage() - + // MARK: - Intialization - + public init( hostController: BluetoothAdapter, context: AndroidContent.Context, options: AndroidCentral.Options = Options()) { - + self.hostController = hostController self.context = context self.options = options @@ -124,7 +124,7 @@ public final class AndroidCentral: CentralManager { $0.scan.peripherals.removeAll() $0.scan.continuation = continuation } - let scanCallBack = LowEnergyScanCallback(central: self) + let scanCallBack = LowEnergyScanCallback.create(central: self) do { try scanner.startScan(scanCallBack) await storage.update { @@ -159,9 +159,9 @@ public final class AndroidCentral: CentralManager { guard hostController.isEnabled() else { throw AndroidCentralError.bluetoothDisabled } - guard let scanDevice = await storage.state.scan.peripherals[peripheral] + guard await storage.state.scan.peripherals[peripheral] != nil else { throw CentralError.unknownPeripheral } - + // wait for connection continuation do { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -170,20 +170,21 @@ public final class AndroidCentral: CentralManager { await storage.update { [unowned self] state in // store continuation - let callback = GattCallback(central: self) + let callback = GattCallback.create(central: self, peripheral: peripheral) + let device = try! self.hostController.getRemoteDevice(peripheral.address)! let gatt: BluetoothGatt - + // call the correct method for connecting let sdkInt = try! JavaClass().SDK_INT let lollipopMr1 = try! JavaClass().LOLLIPOP_MR1 if sdkInt <= lollipopMr1 { - gatt = try! scanDevice.scanResult.getDevice().connectGatt( + gatt = try! device.connectGatt( context: self.context, autoConnect: autoConnect, callback: callback ) } else { - gatt = try! scanDevice.scanResult.getDevice().connectGatt( + gatt = try! device.connectGatt( context: self.context, autoConnect: autoConnect, callback: callback, diff --git a/Sources/AndroidBluetooth/AndroidCentralCallback.swift b/Sources/AndroidBluetooth/AndroidCentralCallback.swift index eba1771..58d04ec 100644 --- a/Sources/AndroidBluetooth/AndroidCentralCallback.swift +++ b/Sources/AndroidBluetooth/AndroidCentralCallback.swift @@ -16,126 +16,30 @@ import JavaLangUtil import Bluetooth import GATT +// MARK: - Callback Adapters +// +// The Kotlin adapter classes extend the Android framework callback classes and forward +// each event to a Swift-implemented event handler (`BluetoothScanEventHandler` / +// `BluetoothGattEventHandler`, jextract-generated Java interfaces from the +// AndroidBluetoothBridge target). The adapters obtain their handler in their +// no-argument constructor, which runs inside `AndroidCentralHandoff.withPending`, +// so construction must go through the `create` factories below. + extension AndroidCentral { - + @JavaClass("org.pureswift.bluetooth.le.ScanCallback") internal class LowEnergyScanCallback: AndroidBluetooth.ScanCallback { - - @JavaMethod - @_nonoverride convenience init(swiftPeer: Int64, environment: JNIEnvironment? = nil) - - convenience init(central: AndroidCentral, environment: JNIEnvironment? = nil) { - // Get Swift pointer to AndroidCentral - let swiftPeer = Int64(bitPattern: UInt64(UInt(bitPattern: Unmanaged.passUnretained(central).toOpaque()))) - self.init(swiftPeer: swiftPeer, environment: environment) - assert(getSwiftPeer() == swiftPeer) - } - - @JavaMethod - func setSwiftPeer(_ swiftPeer: Int64) - - @JavaMethod - func getSwiftPeer() -> Int64 - - @JavaMethod - override func finalize() - - - } -} -private extension AndroidCentral.LowEnergyScanCallback { - - func central(_ swiftPeer: Int64) -> AndroidCentral? { - // Get the Swift peer pointer from Java/Kotlin side - guard swiftPeer != 0 else { - return nil - } - // Convert back to AndroidCentral reference - let pointer = UnsafeRawPointer(bitPattern: Int(truncatingIfNeeded: swiftPeer)) - guard let pointer else { - return nil - } - return Unmanaged.fromOpaque(pointer).takeUnretainedValue() + @JavaMethod + @_nonoverride convenience init(environment: JNIEnvironment? = nil) } } -@JavaImplementation("org.pureswift.bluetooth.le.ScanCallback") extension AndroidCentral.LowEnergyScanCallback { - - @JavaMethod - func swiftScanRelease(_ swiftPeer: Int64) { - setSwiftPeer(0) - } - @JavaMethod - func swiftOnScanResult( - _ swiftPeer: Int64, - error: Int32, - result: AndroidBluetooth.ScanResult? - ) { - guard let central = central(swiftPeer), - let result, - let scanData = try? ScanData(result) else { - assertionFailure() - return - } - // - central.log?("\(type(of: self)): \(#function) name: \((try? result.getDevice().getName()) ?? "") address: \(result.getDevice().getAddress())") - - Task { - await central.storage.update { state in - state.scan.continuation?.yield(scanData) - state.scan.peripherals[scanData.peripheral] = AndroidCentral.InternalState.Scan.Device( - scanData: scanData, - scanResult: result - ) - } - } - } - - @JavaMethod - func swiftOnBatchScanResults(_ swiftPeer: Int64, results: [AndroidBluetooth.ScanResult?]) { - guard let central = central(swiftPeer) else { - return - } - central.log?("\(type(of: self)): \(#function)") - for result in results { - guard let result, let scanData = try? ScanData(result) else { - assertionFailure() - return - } - Task { - await central.storage.update { state in - state.scan.continuation?.yield(scanData) - state.scan.peripherals[scanData.peripheral] = AndroidCentral.InternalState.Scan.Device( - scanData: scanData, - scanResult: result - ) - } - } - } - } - - @JavaMethod - func swiftOnScanFailed(_ swiftPeer: Int64, error: Int32) { - let central = central(swiftPeer) - central?.log?("\(type(of: self)): \(#function)") - - // TODO: Map error codes - let error = AndroidCentralError.scanFailed(error) - - /* - static var SCAN_FAILED_ALREADY_STARTED - static var SCAN_FAILED_APPLICATION_REGISTRATION_FAILED - static var SCAN_FAILED_FEATURE_UNSUPPORTED - static var SCAN_FAILED_INTERNAL_ERROR - */ - - Task { - await central?.storage.update { state in - state.scan.continuation?.finish(throwing: error) - } + static func create(central: AndroidCentral) -> AndroidCentral.LowEnergyScanCallback { + AndroidCentralHandoff.withPending(central: central) { + AndroidCentral.LowEnergyScanCallback() } } } @@ -146,397 +50,15 @@ extension AndroidCentral { class GattCallback: AndroidBluetooth.BluetoothGattCallback { @JavaMethod - @_nonoverride convenience init(swiftPeer: Int64, environment: JNIEnvironment? = nil) - - convenience init(central: AndroidCentral, environment: JNIEnvironment? = nil) { - let swiftPeer = Int64(bitPattern: UInt64(UInt(bitPattern: Unmanaged.passUnretained(central).toOpaque()))) - self.init(swiftPeer: swiftPeer, environment: environment) - assert(getSwiftPeer() == swiftPeer) - } - - @JavaMethod - func setSwiftPeer(_ swiftPeer: Int64) - - @JavaMethod - func getSwiftPeer() -> Int64 - - @JavaMethod - override func finalize() - } -} - -private extension AndroidCentral.GattCallback { - - func central(_ swiftPeer: Int64) -> AndroidCentral? { - guard swiftPeer != 0 else { - return nil - } - let pointer = UnsafeRawPointer(bitPattern: Int(truncatingIfNeeded: swiftPeer)) - guard let pointer else { - return nil - } - return Unmanaged.fromOpaque(pointer).takeUnretainedValue() + @_nonoverride convenience init(environment: JNIEnvironment? = nil) } } -@JavaImplementation("org.pureswift.bluetooth.BluetoothGattCallback") extension AndroidCentral.GattCallback { - @JavaMethod - func swiftGattRelease(_ swiftPeer: Int64) { - setSwiftPeer(0) - } - - /** - Callback indicating when GATT client has connected/disconnected to/from a remote GATT server. - - Parameters - - gatt BluetoothGatt: GATT client - - status int: Status of the connect or disconnect operation. BluetoothGatt.GATT_SUCCESS if the operation succeeds. - - newState int: Returns the new connection state. Can be one of BluetoothProfile.STATE_DISCONNECTED or BluetoothProfile.STATE_CONNECTED - */ - @JavaMethod - func swiftOnConnectionStateChange( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - status: Int32, - newState: Int32 - ) { - let central = central(swiftPeer) - let log = central?.log - let status = BluetoothGatt.Status(rawValue: status) - guard let central, - let gatt, - let newState = BluetoothConnectionState(rawValue: newState) else { - assertionFailure() - return - } - log?("\(type(of: self)): \(#function) \(status) \(newState)") - let peripheral = Peripheral(gatt) - Task { - await central.storage.update { state in - switch (status, newState) { - case (.success, .connected): - log?("\(peripheral) Connected") - // if we are expecting a new connection - if state.cache[peripheral]?.continuation.connect != nil { - state.cache[peripheral]?.continuation.connect?.resume() - state.cache[peripheral]?.continuation.connect = nil - } - case (.success, .disconnected): - log?("\(peripheral) Disconnected") - state.cache[peripheral] = nil - default: - log?("\(peripheral) Status Error") - state.cache[peripheral]?.continuation.connect?.resume(throwing: AndroidCentralError.gattStatus(status)) - state.cache[peripheral]?.continuation.connect = nil - } - } - } - } - - @JavaMethod - func swiftOnServicesDiscovered( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt else { - assertionFailure() - return - } - let log = central.log - let peripheral = Peripheral(gatt) - let status = BluetoothGatt.Status(rawValue: status) - log?("\(type(of: self)): \(#function) Status: \(status)") - - Task { - await central.storage.update { state in - // success discovering - switch status { - case .success: - guard let javaServices = gatt.getServices()?.toArray().map({ $0!.as(BluetoothGattService.self)! }), - let services = state.cache[peripheral]?.update(javaServices) else { - assertionFailure() - return - } - state.cache[peripheral]?.continuation.discoverServices?.resume(returning: services) - default: - state.cache[peripheral]?.continuation.discoverServices?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.discoverServices = nil - } - } - } - - @JavaMethod - func swiftOnCharacteristicChanged( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic? - ) { - let central = central(swiftPeer) - guard let central, let gatt, let characteristic else { - assertionFailure() - return - } - let log = central.log - log?("\(type(of: self)): \(#function)") - - let peripheral = Peripheral(gatt) - - Task { - await central.storage.update { state in - - guard let uuid = characteristic.getUuid()?.toString() else { - assertionFailure() - return - } - - guard let cache = state.cache[peripheral] else { - assertionFailure("Invalid cache for \(uuid)") - return - } - - let id = cache.identifier(for: characteristic) - - let bytes = characteristic.getValue() - - // TODO: Replace usage of Foundation.Data with byte array to prevent copying - let data = Data(unsafeBitCast(bytes, to: [UInt8].self)) - - guard let characteristicCache = cache.characteristics.values[id] else { - assertionFailure("Invalid identifier for \(uuid)") - return - } - - guard let notification = characteristicCache.notification else { - assertionFailure("Unexpected notification for \(uuid)") - return - } - - notification.yield(data) - } - } - } - - @JavaMethod - func swiftOnCharacteristicRead( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic?, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt, let characteristic else { - assertionFailure() - return - } - let log = central.log - let peripheral = Peripheral(gatt) - let status = BluetoothGatt.Status(rawValue: status) - log?("\(type(of: self)): \(#function) \(peripheral) Status: \(status)") - - Task { - await central.storage.update { state in - - switch status { - case .success: - let bytes = characteristic.getValue() - let data = Data(unsafeBitCast(bytes, to: [UInt8].self)) - state.cache[peripheral]?.continuation.readCharacteristic?.resume(returning: data) - default: - state.cache[peripheral]?.continuation.readCharacteristic?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.readCharacteristic = nil - } - } - } - - @JavaMethod - func swiftOnCharacteristicWrite( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - characteristic: BluetoothGattCharacteristic?, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt else { - assertionFailure() - return - } - let status = BluetoothGatt.Status(rawValue: status) - central.log?("\(type(of: self)): \(#function)") - let peripheral = Peripheral(gatt) - - Task { - await central.storage.update { state in - switch status { - case .success: - state.cache[peripheral]?.continuation.writeCharacteristic?.resume() - default: - state.cache[peripheral]?.continuation.writeCharacteristic?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.writeCharacteristic = nil - } - } - } - - @JavaMethod - func swiftOnDescriptorRead( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - descriptor: BluetoothGattDescriptor?, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt, let descriptor else { - assertionFailure() - return - } - let status = BluetoothGatt.Status(rawValue: status) - let peripheral = Peripheral(gatt) - - guard let uuid = descriptor.getUuid()?.toString() else { - assertionFailure() - return - } - - central.log?(" \(type(of: self)): \(#function) \(uuid)") - - Task { - await central.storage.update { state in - - switch status { - case .success: - let bytes = descriptor.getValue() - let data = Data(unsafeBitCast(bytes, to: [UInt8].self)) - state.cache[peripheral]?.continuation.readDescriptor?.resume(returning: data) - default: - state.cache[peripheral]?.continuation.readDescriptor?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.readDescriptor = nil - } - } - } - - @JavaMethod - func swiftOnDescriptorWrite( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - descriptor: BluetoothGattDescriptor?, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt, let descriptor else { - assertionFailure() - return - } - let status = BluetoothGatt.Status(rawValue: status) - let peripheral = Peripheral(gatt) - - guard let uuid = descriptor.getUuid()?.toString() else { - assertionFailure() - return - } - - central.log?(" \(type(of: self)): \(#function) \(uuid)") - - Task { - await central.storage.update { state in - switch status { - case .success: - state.cache[peripheral]?.continuation.writeDescriptor?.resume() - default: - state.cache[peripheral]?.continuation.writeDescriptor?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.writeDescriptor = nil - } - } - } - - @JavaMethod - func swiftOnMtuChanged( - _ swiftPeer: Int64, - gatt: BluetoothGatt?, - mtu: Int32, - status: Int32 - ) { - let central = central(swiftPeer) - guard let central, let gatt else { - assertionFailure() - return + static func create(central: AndroidCentral, peripheral: Peripheral) -> AndroidCentral.GattCallback { + AndroidCentralHandoff.withPending(central: central, peripheral: peripheral) { + AndroidCentral.GattCallback() } - let status = BluetoothGatt.Status(rawValue: status) - central.log?("\(type(of: self)): \(#function) Peripheral \(Peripheral(gatt)) MTU \(mtu) Status \(status)") - - let peripheral = Peripheral(gatt) - - let oldMTU = central.options.maximumTransmissionUnit - - Task { - await central.storage.update { state in - - // get new MTU value - guard let newMTU = MaximumTransmissionUnit(rawValue: UInt16(mtu)) else { - assertionFailure("Invalid MTU \(mtu)") - return - } - - assert(newMTU <= oldMTU, "Invalid MTU: \(newMTU) > \(oldMTU)") - - // cache new MTU value - state.cache[peripheral]?.maximumTransmissionUnit = newMTU - - // pending MTU exchange - state.cache[peripheral]?.continuation.exchangeMTU?.resume(returning: newMTU) - state.cache[peripheral]?.continuation.exchangeMTU = nil - return - } - } - } - - @JavaMethod - func swiftOnPhyRead(_ swiftPeer: Int64, gatt: BluetoothGatt?, txPhy: Int32, rxPhy: Int32, status: Int32) { - let status = BluetoothGatt.Status(rawValue: status) - central(swiftPeer)?.log?("\(type(of: self)): \(#function) \(status)") - } - - @JavaMethod - func swiftOnPhyUpdate(_ swiftPeer: Int64, gatt: BluetoothGatt?, txPhy: Int32, rxPhy: Int32, status: Int32) { - let status = BluetoothGatt.Status(rawValue: status) - central(swiftPeer)?.log?("\(type(of: self)): \(#function) \(status)") - } - - @JavaMethod - func swiftOnReadRemoteRssi(_ swiftPeer: Int64, gatt: BluetoothGatt?, rssi: Int32, status: Int32) { - let central = central(swiftPeer) - guard let central, let gatt else { - assertionFailure() - return - } - let status = BluetoothGatt.Status(rawValue: status) - central.log?("\(type(of: self)): \(#function) \(rssi) \(status)") - - let peripheral = Peripheral(gatt) - - Task { - await central.storage.update { state in - switch status { - case .success: - state.cache[peripheral]?.continuation.readRemoteRSSI?.resume(returning: Int(rssi)) - default: - state.cache[peripheral]?.continuation.readRemoteRSSI?.resume(throwing: AndroidCentralError.gattStatus(status)) - } - state.cache[peripheral]?.continuation.readRemoteRSSI = nil - } - } - } - - @JavaMethod - func swiftOnReliableWriteCompleted(_ swiftPeer: Int64, gatt: BluetoothGatt?, status: Int32) { - let status = BluetoothGatt.Status(rawValue: status) - central(swiftPeer)?.log?("\(type(of: self)): \(#function) \(status)") } } diff --git a/Sources/AndroidBluetooth/AndroidCentralEvents.swift b/Sources/AndroidBluetooth/AndroidCentralEvents.swift new file mode 100644 index 0000000..15a2dde --- /dev/null +++ b/Sources/AndroidBluetooth/AndroidCentralEvents.swift @@ -0,0 +1,386 @@ +// +// AndroidCentralEvents.swift +// AndroidBluetooth +// +// Created by Alsey Coleman Miller on 7/25/26. +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import SwiftJava +import JavaUtil +import JavaLangUtil +import Bluetooth +import GATT + +// MARK: - Callback Events + +/// Entry points for Bluetooth callback events crossing the JNI bridge. +/// +/// The Kotlin callback adapters (`org.pureswift.bluetooth.le.ScanCallback` and +/// `org.pureswift.bluetooth.BluetoothGattCallback`) extract primitive values from the +/// Android callback objects and forward them through the generated `AndroidBluetoothBridge` +/// Java class, which calls these methods after resolving the central via ``AndroidCentralRegistry``. +@available(Android 18, *) +package extension AndroidCentral { + + // MARK: Scan Events + + func handleScanResult( + callbackType: Int32, + address: String, + rssi: Int32, + isConnectable: Bool, + advertisementData: Data + ) { + log?("\(type(of: self)): \(#function) address: \(address) rssi: \(rssi)") + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let scanData = ScanData( + peripheral: peripheral, + date: Date(), + rssi: Double(rssi), + advertisementData: AndroidLowEnergyAdvertisementData(data: advertisementData), + isConnectable: isConnectable + ) + Task { + await storage.update { state in + state.scan.continuation?.yield(scanData) + state.scan.peripherals[scanData.peripheral] = InternalState.Scan.Device( + scanData: scanData + ) + } + } + } + + func handleScanFailed(errorCode: Int32) { + log?("\(type(of: self)): \(#function) error: \(errorCode)") + + // TODO: Map error codes + let error = AndroidCentralError.scanFailed(errorCode) + + /* + static var SCAN_FAILED_ALREADY_STARTED + static var SCAN_FAILED_APPLICATION_REGISTRATION_FAILED + static var SCAN_FAILED_FEATURE_UNSUPPORTED + static var SCAN_FAILED_INTERNAL_ERROR + */ + + Task { + await storage.update { state in + state.scan.continuation?.finish(throwing: error) + } + } + } + + // MARK: GATT Events + + func handleConnectionStateChange( + address: String, + status: Int32, + newState: Int32 + ) { + let log = self.log + let status = BluetoothGatt.Status(rawValue: status) + guard let newState = BluetoothConnectionState(rawValue: newState) else { + assertionFailure() + return + } + log?("\(type(of: self)): \(#function) \(status) \(newState)") + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + Task { + await storage.update { state in + switch (status, newState) { + case (.success, .connected): + log?("\(peripheral) Connected") + // if we are expecting a new connection + if state.cache[peripheral]?.continuation.connect != nil { + state.cache[peripheral]?.continuation.connect?.resume() + state.cache[peripheral]?.continuation.connect = nil + } + case (.success, .disconnected): + log?("\(peripheral) Disconnected") + state.cache[peripheral] = nil + default: + log?("\(peripheral) Status Error") + state.cache[peripheral]?.continuation.connect?.resume(throwing: AndroidCentralError.gattStatus(status)) + state.cache[peripheral]?.continuation.connect = nil + } + } + } + } + + func handleServicesDiscovered( + address: String, + status: Int32 + ) { + let log = self.log + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) Status: \(status)") + + Task { + await storage.update { state in + // success discovering + switch status { + case .success: + guard let cache = state.cache[peripheral], + let javaServices = cache.gatt.getServices()?.toArray().map({ $0!.as(BluetoothGattService.self)! }), + let services = state.cache[peripheral]?.update(javaServices) else { + assertionFailure() + return + } + state.cache[peripheral]?.continuation.discoverServices?.resume(returning: services) + default: + state.cache[peripheral]?.continuation.discoverServices?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.discoverServices = nil + } + } + } + + func handleCharacteristicChanged( + address: String, + uuid: String, + instanceID: Int32, + value: Data + ) { + log?("\(type(of: self)): \(#function) \(uuid)") + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + + Task { + await storage.update { state in + + guard let cache = state.cache[peripheral] else { + assertionFailure("Invalid cache for \(uuid)") + return + } + + let id = Cache.identifier( + peripheral: peripheral, + type: .characteristic, + instanceID: instanceID, + uuid: uuid + ) + + guard let characteristicCache = cache.characteristics.values[id] else { + assertionFailure("Invalid identifier for \(uuid)") + return + } + + guard let notification = characteristicCache.notification else { + assertionFailure("Unexpected notification for \(uuid)") + return + } + + notification.yield(value) + } + } + } + + func handleCharacteristicRead( + address: String, + uuid: String, + instanceID: Int32, + value: Data, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(peripheral) Status: \(status)") + + Task { + await storage.update { state in + switch status { + case .success: + state.cache[peripheral]?.continuation.readCharacteristic?.resume(returning: value) + default: + state.cache[peripheral]?.continuation.readCharacteristic?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.readCharacteristic = nil + } + } + } + + func handleCharacteristicWrite( + address: String, + uuid: String, + instanceID: Int32, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(uuid) Status: \(status)") + + Task { + await storage.update { state in + switch status { + case .success: + state.cache[peripheral]?.continuation.writeCharacteristic?.resume() + default: + state.cache[peripheral]?.continuation.writeCharacteristic?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.writeCharacteristic = nil + } + } + } + + func handleDescriptorRead( + address: String, + uuid: String, + value: Data, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(uuid) Status: \(status)") + + Task { + await storage.update { state in + switch status { + case .success: + state.cache[peripheral]?.continuation.readDescriptor?.resume(returning: value) + default: + state.cache[peripheral]?.continuation.readDescriptor?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.readDescriptor = nil + } + } + } + + func handleDescriptorWrite( + address: String, + uuid: String, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(uuid) Status: \(status)") + + Task { + await storage.update { state in + switch status { + case .success: + state.cache[peripheral]?.continuation.writeDescriptor?.resume() + default: + state.cache[peripheral]?.continuation.writeDescriptor?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.writeDescriptor = nil + } + } + } + + func handleMtuChanged( + address: String, + mtu: Int32, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) Peripheral \(peripheral) MTU \(mtu) Status \(status)") + + let oldMTU = options.maximumTransmissionUnit + + Task { + await storage.update { state in + + // get new MTU value + guard let newMTU = MaximumTransmissionUnit(rawValue: UInt16(mtu)) else { + assertionFailure("Invalid MTU \(mtu)") + return + } + + assert(newMTU <= oldMTU, "Invalid MTU: \(newMTU) > \(oldMTU)") + + // cache new MTU value + state.cache[peripheral]?.maximumTransmissionUnit = newMTU + + // pending MTU exchange + state.cache[peripheral]?.continuation.exchangeMTU?.resume(returning: newMTU) + state.cache[peripheral]?.continuation.exchangeMTU = nil + } + } + } + + func handlePhyRead( + address: String, + txPhy: Int32, + rxPhy: Int32, + status: Int32 + ) { + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(status)") + } + + func handlePhyUpdate( + address: String, + txPhy: Int32, + rxPhy: Int32, + status: Int32 + ) { + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(status)") + } + + func handleReadRemoteRssi( + address: String, + rssi: Int32, + status: Int32 + ) { + guard let peripheral = Peripheral(address: address) else { + assertionFailure("Invalid address \(address)") + return + } + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(rssi) \(status)") + + Task { + await storage.update { state in + switch status { + case .success: + state.cache[peripheral]?.continuation.readRemoteRSSI?.resume(returning: Int(rssi)) + default: + state.cache[peripheral]?.continuation.readRemoteRSSI?.resume(throwing: AndroidCentralError.gattStatus(status)) + } + state.cache[peripheral]?.continuation.readRemoteRSSI = nil + } + } + } + + func handleReliableWriteCompleted( + address: String, + status: Int32 + ) { + let status = BluetoothGatt.Status(rawValue: status) + log?("\(type(of: self)): \(#function) \(status)") + } +} diff --git a/Sources/AndroidBluetooth/AndroidCentralHandoff.swift b/Sources/AndroidBluetooth/AndroidCentralHandoff.swift new file mode 100644 index 0000000..4691ebf --- /dev/null +++ b/Sources/AndroidBluetooth/AndroidCentralHandoff.swift @@ -0,0 +1,60 @@ +// +// AndroidCentralHandoff.swift +// AndroidBluetooth +// +// Created by Alsey Coleman Miller on 7/25/26. +// + +import Synchronization +import Bluetooth +import GATT + +/// Hands an ``AndroidCentral`` (and target peripheral) to the bridge target while a +/// Kotlin callback adapter is being constructed. +/// +/// The Kotlin adapters obtain their Swift event handler in their constructor by calling +/// a jextract-generated static, which runs on the same thread inside the adapter's +/// Swift `init` call. This type carries the central across that synchronous window: +/// ``withPending(central:peripheral:_:)`` publishes the pending value, runs the +/// constructor, and clears the slot — serialized so concurrent constructions can't +/// observe each other's value. Nothing is retained beyond the construction call. +@available(Android 18, *) +package enum AndroidCentralHandoff { + + private struct Pending { + weak var central: AndroidCentral? + var peripheral: Peripheral? + } + + private static let slot = Mutex(nil) + + /// Serializes set-construct-take sequences. Separate from `slot` because the + /// take happens via JNI re-entrancy on the same thread while this lock is held. + private static let constructionLock = Mutex(()) + + /// Publish `central` (and optionally the target peripheral) for the duration of + /// `construct`, which must synchronously trigger the bridge's take call. + package static func withPending( + central: AndroidCentral, + peripheral: Peripheral? = nil, + _ construct: () -> T + ) -> T { + constructionLock.withLock { _ in + slot.withLock { $0 = Pending(central: central, peripheral: peripheral) } + defer { slot.withLock { $0 = nil } } + return construct() + } + } + + /// Consume the pending central. Called (indirectly, over JNI) from the Kotlin + /// adapter constructor running inside ``withPending(central:peripheral:_:)``. + package static func takePending() -> (central: AndroidCentral, peripheral: Peripheral?)? { + slot.withLock { pending in + defer { pending = nil } + guard let value = pending, let central = value.central else { + return nil + } + return (central, value.peripheral) + } + } +} diff --git a/Sources/AndroidBluetooth/AndroidCentralState.swift b/Sources/AndroidBluetooth/AndroidCentralState.swift index 144beea..74da3c5 100644 --- a/Sources/AndroidBluetooth/AndroidCentralState.swift +++ b/Sources/AndroidBluetooth/AndroidCentralState.swift @@ -44,10 +44,8 @@ internal extension AndroidCentral { var callback: ScanCallback? struct Device { - + let scanData: ScanData - - let scanResult: AndroidBluetooth.ScanResult } } } @@ -77,6 +75,15 @@ internal extension AndroidCentral { var continuation = PeripheralContinuation() + static func identifier( + peripheral: Peripheral, + type: AndroidCentralAttributeType, + instanceID: Int32, + uuid: String + ) -> AndroidCentral.AttributeID { + return "\(peripheral.id)/\(type)/\(instanceID)/\(uuid)" + } + func identifier(for attribute: T) -> AndroidCentral.AttributeID where T: AndroidCentralAttribute { let peripheral = Peripheral(gatt) let instanceID = attribute.getInstanceId() @@ -84,7 +91,7 @@ internal extension AndroidCentral { assertionFailure() return instanceID.description } - return "\(peripheral.id)/\(T.attributeType)/\(instanceID)/\(uuid)" + return Self.identifier(peripheral: peripheral, type: T.attributeType, instanceID: instanceID, uuid: uuid) } func identifier( diff --git a/Sources/AndroidBluetooth/Extensions/Peripheral.swift b/Sources/AndroidBluetooth/Extensions/Peripheral.swift index 568dfd6..003e871 100644 --- a/Sources/AndroidBluetooth/Extensions/Peripheral.swift +++ b/Sources/AndroidBluetooth/Extensions/Peripheral.swift @@ -8,11 +8,24 @@ import Bluetooth import GATT -internal extension Peripheral { - +package extension Peripheral { + init(_ device: AndroidBluetooth.BluetoothDevice) { self.init(id: device.address) } + + /// Initialize from a Bluetooth address string (e.g. `"00:11:22:AA:BB:CC"`). + init?(address: String) { + guard let address = BluetoothAddress(rawValue: address) else { + return nil + } + self.init(id: address) + } + + /// The peripheral's address formatted for the Android APIs. + var address: String { + id.rawValue + } init(_ gatt: AndroidBluetooth.BluetoothGatt) { self.init(gatt.getDevice()) diff --git a/Sources/AndroidBluetooth/Extensions/ScanData.swift b/Sources/AndroidBluetooth/Extensions/ScanData.swift deleted file mode 100644 index e4a34bb..0000000 --- a/Sources/AndroidBluetooth/Extensions/ScanData.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// ScanData.swift -// AndroidBluetooth -// -// Created by Alsey Coleman Miller on 7/13/25. -// - -#if canImport(FoundationEssentials) -import FoundationEssentials -#elseif canImport(Foundation) -import Foundation -#endif -import SwiftJava -import AndroidOS -import Bluetooth -import GATT - -internal extension ScanData where Peripheral == AndroidCentral.Peripheral, Advertisement == AndroidCentral.Advertisement { - - init(_ result: ScanResult) throws { - - let peripheral = Peripheral(result.getDevice()) - let record = result.getScanRecord()! - let advertisement = AndroidLowEnergyAdvertisementData(data: Data(record.bytes)) - let isConnectable: Bool - let sdkInt = try JavaClass().SDK_INT - let oVersion = try JavaClass().O - if sdkInt >= oVersion { - isConnectable = result.isConnectable() - } else { - isConnectable = true - } - self.init( - peripheral: peripheral, - date: Date(), - rssi: Double(result.getRssi()), - advertisementData: advertisement, - isConnectable: isConnectable - ) - } -} diff --git a/Sources/AndroidBluetoothBridge/BluetoothEventSink.swift b/Sources/AndroidBluetoothBridge/BluetoothEventSink.swift new file mode 100644 index 0000000..9f6baba --- /dev/null +++ b/Sources/AndroidBluetoothBridge/BluetoothEventSink.swift @@ -0,0 +1,144 @@ +// +// BluetoothEventSink.swift +// AndroidBluetooth +// +// Created by Alsey Coleman Miller on 7/25/26. +// +// Swift classes receiving the Bluetooth callback events. jextract exposes each as a +// Java class with native instance methods, so the Kotlin callback adapters hold and +// invoke a Swift instance directly — no identifier registry needed. Instances are +// created through the `takePending*` factories below, which run inside +// `AndroidCentralHandoff.withPending` during adapter construction. + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import AndroidBluetooth + +// MARK: - Factories + +/// Create the scan event sink for the ``AndroidCentral`` currently under construction. +/// Called by the Kotlin `ScanCallback` constructor, which runs inside +/// `AndroidCentralHandoff.withPending` on the Swift side. +public func takePendingScanEventSink() -> BluetoothScanEventSink { + guard let pending = AndroidCentralHandoff.takePending() else { + fatalError("ScanCallback constructed outside of AndroidCentralHandoff.withPending") + } + return BluetoothScanEventSink(central: pending.central) +} + +/// Create the GATT event sink for the ``AndroidCentral`` and peripheral currently +/// under construction. Called by the Kotlin `BluetoothGattCallback` constructor, which +/// runs inside `AndroidCentralHandoff.withPending` on the Swift side. +public func takePendingGattEventSink() -> BluetoothGattEventSink { + guard let pending = AndroidCentralHandoff.takePending(), let peripheral = pending.peripheral else { + fatalError("BluetoothGattCallback constructed outside of AndroidCentralHandoff.withPending") + } + return BluetoothGattEventSink(central: pending.central, address: peripheral.address) +} + +// MARK: - Scan Events + +/// Receives Bluetooth LE scan events for an ``AndroidCentral``. +/// +/// jextract surfaces this as the `org.pureswift.bluetooth.bridge.BluetoothScanEventSink` +/// Java class; the Kotlin `ScanCallback` adapter holds one and forwards every Android +/// scan callback to it. +public final class BluetoothScanEventSink { + + private weak var central: AndroidCentral? + + internal init(central: AndroidCentral?) { + self.central = central + } + + public func onScanResult( + callbackType: Int32, + address: String, + rssi: Int32, + isConnectable: Bool, + advertisementData: [UInt8] + ) { + central?.handleScanResult( + callbackType: callbackType, + address: address, + rssi: rssi, + isConnectable: isConnectable, + advertisementData: Data(advertisementData) + ) + } + + public func onScanFailed(errorCode: Int32) { + central?.handleScanFailed(errorCode: errorCode) + } +} + +// MARK: - GATT Events + +/// Receives GATT events for a single peripheral connection of an ``AndroidCentral``. +/// +/// jextract surfaces this as the `org.pureswift.bluetooth.bridge.BluetoothGattEventSink` +/// Java class; the Kotlin `BluetoothGattCallback` adapter holds one and forwards every +/// Android GATT callback to it. The sink is created for a specific peripheral, so +/// events carry no address. +public final class BluetoothGattEventSink { + + private weak var central: AndroidCentral? + + private let address: String + + internal init(central: AndroidCentral?, address: String) { + self.central = central + self.address = address + } + + public func onConnectionStateChange(status: Int32, newState: Int32) { + central?.handleConnectionStateChange(address: address, status: status, newState: newState) + } + + public func onServicesDiscovered(status: Int32) { + central?.handleServicesDiscovered(address: address, status: status) + } + + public func onCharacteristicChanged(uuid: String, instanceId: Int32, value: [UInt8]) { + central?.handleCharacteristicChanged(address: address, uuid: uuid, instanceID: instanceId, value: Data(value)) + } + + public func onCharacteristicRead(uuid: String, instanceId: Int32, value: [UInt8], status: Int32) { + central?.handleCharacteristicRead(address: address, uuid: uuid, instanceID: instanceId, value: Data(value), status: status) + } + + public func onCharacteristicWrite(uuid: String, instanceId: Int32, status: Int32) { + central?.handleCharacteristicWrite(address: address, uuid: uuid, instanceID: instanceId, status: status) + } + + public func onDescriptorRead(uuid: String, value: [UInt8], status: Int32) { + central?.handleDescriptorRead(address: address, uuid: uuid, value: Data(value), status: status) + } + + public func onDescriptorWrite(uuid: String, status: Int32) { + central?.handleDescriptorWrite(address: address, uuid: uuid, status: status) + } + + public func onMtuChanged(mtu: Int32, status: Int32) { + central?.handleMtuChanged(address: address, mtu: mtu, status: status) + } + + public func onPhyRead(txPhy: Int32, rxPhy: Int32, status: Int32) { + central?.handlePhyRead(address: address, txPhy: txPhy, rxPhy: rxPhy, status: status) + } + + public func onPhyUpdate(txPhy: Int32, rxPhy: Int32, status: Int32) { + central?.handlePhyUpdate(address: address, txPhy: txPhy, rxPhy: rxPhy, status: status) + } + + public func onReadRemoteRssi(rssi: Int32, status: Int32) { + central?.handleReadRemoteRssi(address: address, rssi: rssi, status: status) + } + + public func onReliableWriteCompleted(status: Int32) { + central?.handleReliableWriteCompleted(address: address, status: status) + } +} diff --git a/Sources/AndroidBluetoothBridge/swift-java.config b/Sources/AndroidBluetoothBridge/swift-java.config new file mode 100644 index 0000000..50043c8 --- /dev/null +++ b/Sources/AndroidBluetoothBridge/swift-java.config @@ -0,0 +1,6 @@ +{ + "javaPackage": "org.pureswift.bluetooth.bridge", + "mode": "jni", + "nativeLibraryName": "SwiftAndroidApp", + "logLevel": "info" +}