From b7a8cb5fd0a415fd952537bd04cd88a0fef45643 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:07:02 -0400 Subject: [PATCH 1/8] Replace @JavaImplementation callbacks with jextract JNI bridge Add AndroidBluetoothBridge target (JExtractSwiftPlugin, JNI mode) exposing one function per callback event, an AndroidCentralRegistry mapping Int64 identifiers to centrals (replacing the unsafe swiftPeer raw pointer), and handle* event methods on AndroidCentral taking primitive values. Peripherals are resolved by address via getRemoteDevice and characteristics matched by UUID and instance id, so callback events no longer need Java object identity. --- Package.swift | 24 + Sources/AndroidBluetooth/AndroidCentral.swift | 30 +- .../AndroidCentralCallback.swift | 521 +----------------- .../AndroidCentralEvents.swift | 386 +++++++++++++ .../AndroidCentralRegistry.swift | 53 ++ .../AndroidCentralState.swift | 15 +- .../Extensions/Peripheral.swift | 15 +- .../Extensions/ScanData.swift | 41 -- .../AndroidBluetoothBridge.swift | 232 ++++++++ .../AndroidBluetoothBridge/swift-java.config | 6 + 10 files changed, 762 insertions(+), 561 deletions(-) create mode 100644 Sources/AndroidBluetooth/AndroidCentralEvents.swift create mode 100644 Sources/AndroidBluetooth/AndroidCentralRegistry.swift delete mode 100644 Sources/AndroidBluetooth/Extensions/ScanData.swift create mode 100644 Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift create mode 100644 Sources/AndroidBluetoothBridge/swift-java.config 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..0747bb3 100644 --- a/Sources/AndroidBluetooth/AndroidCentral.swift +++ b/Sources/AndroidBluetooth/AndroidCentral.swift @@ -79,19 +79,28 @@ public final class AndroidCentral: CentralManager { } public let options: Options - + + /// Identifier used by the Kotlin callback adapters to route events back to this instance. + package let identifier: Int64 + internal let storage = Storage() - + // MARK: - Intialization - + + deinit { + AndroidCentralRegistry.unregister(identifier) + } + public init( hostController: BluetoothAdapter, context: AndroidContent.Context, options: AndroidCentral.Options = Options()) { - + self.hostController = hostController self.context = context self.options = options + self.identifier = AndroidCentralRegistry.reserveIdentifier() + AndroidCentralRegistry.register(self, for: identifier) } // MARK: - Methods @@ -159,9 +168,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 +179,21 @@ public final class AndroidCentral: CentralManager { await storage.update { [unowned self] state in // store continuation - let callback = GattCallback(central: self) + let callback = GattCallback(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..b9fa8af 100644 --- a/Sources/AndroidBluetooth/AndroidCentralCallback.swift +++ b/Sources/AndroidBluetooth/AndroidCentralCallback.swift @@ -16,126 +16,24 @@ import JavaLangUtil import Bluetooth import GATT +// MARK: - Callback Adapters +// +// The Kotlin adapter classes extend the Android framework callback classes and forward +// each event as primitive values to the jextract-generated `AndroidBluetoothBridge` +// Java class, which resolves the central by its registry identifier and calls the +// `handle*` event methods in `AndroidCentralEvents.swift`. These wrappers exist only +// so Swift can construct the adapter instances to hand to the Android APIs. + 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() - } -} -@JavaImplementation("org.pureswift.bluetooth.le.ScanCallback") -extension AndroidCentral.LowEnergyScanCallback { - - @JavaMethod - func swiftScanRelease(_ swiftPeer: Int64) { - setSwiftPeer(0) - } + @JavaMethod + @_nonoverride convenience init(centralId: Int64, environment: JNIEnvironment? = nil) - @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) - } + convenience init(central: AndroidCentral, environment: JNIEnvironment? = nil) { + self.init(centralId: central.identifier, environment: environment) } } } @@ -146,397 +44,10 @@ extension AndroidCentral { class GattCallback: AndroidBluetooth.BluetoothGattCallback { @JavaMethod - @_nonoverride convenience init(swiftPeer: Int64, environment: JNIEnvironment? = nil) + @_nonoverride convenience init(centralId: Int64, peripheralAddress: String, 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) + convenience init(central: AndroidCentral, peripheral: Peripheral, environment: JNIEnvironment? = nil) { + self.init(centralId: central.identifier, peripheralAddress: peripheral.address, environment: environment) } - - @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() - } -} - -@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 - } - 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/AndroidCentralRegistry.swift b/Sources/AndroidBluetooth/AndroidCentralRegistry.swift new file mode 100644 index 0000000..45f9bfa --- /dev/null +++ b/Sources/AndroidBluetooth/AndroidCentralRegistry.swift @@ -0,0 +1,53 @@ +// +// AndroidCentralRegistry.swift +// AndroidBluetooth +// +// Created by Alsey Coleman Miller on 7/25/26. +// + +import Synchronization + +/// Maps stable `Int64` identifiers to live ``AndroidCentral`` instances. +/// +/// Kotlin callback adapters are constructed with a central's identifier and pass it +/// back through the JNI bridge with every event; the bridge resolves it here. +/// Entries are weak so the registry never extends a central's lifetime. +@available(Android 18, *) +package enum AndroidCentralRegistry { + + private struct WeakBox { + weak var central: AndroidCentral? + } + + private static let storage = Mutex<(nextID: Int64, values: [Int64: WeakBox])>((nextID: 1, values: [:])) + + /// Reserve a unique identifier for a central that is being initialized. + package static func reserveIdentifier() -> Int64 { + storage.withLock { state in + let id = state.nextID + state.nextID += 1 + return id + } + } + + /// Register a central under a previously reserved identifier. + package static func register(_ central: AndroidCentral, for id: Int64) { + storage.withLock { state in + state.values[id] = WeakBox(central: central) + } + } + + /// Remove a central from the registry. + package static func unregister(_ id: Int64) { + storage.withLock { state in + state.values[id] = nil + } + } + + /// Resolve a central from an identifier received over JNI. + package static func central(for id: Int64) -> AndroidCentral? { + storage.withLock { state in + state.values[id]?.central + } + } +} 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..1cabf9a 100644 --- a/Sources/AndroidBluetooth/Extensions/Peripheral.swift +++ b/Sources/AndroidBluetooth/Extensions/Peripheral.swift @@ -9,10 +9,23 @@ import Bluetooth import GATT internal 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/AndroidBluetoothBridge.swift b/Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift new file mode 100644 index 0000000..17f70e6 --- /dev/null +++ b/Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift @@ -0,0 +1,232 @@ +// +// AndroidBluetoothBridge.swift +// AndroidBluetooth +// +// Created by Alsey Coleman Miller on 7/25/26. +// +// JNI entry points for the Kotlin Bluetooth callback adapters. +// +// This target is processed by the swift-java `JExtractSwiftPlugin` (JNI mode, see +// `swift-java.config`), which generates the `org.pureswift.bluetooth.bridge.AndroidBluetoothBridge` +// Java class with a static method per public function below. The Kotlin adapter classes +// (`org.pureswift.bluetooth.le.ScanCallback` and `org.pureswift.bluetooth.BluetoothGattCallback`) +// extend the Android framework callback classes and forward each event here as primitive +// values, keyed by the central's registry identifier (and peripheral address for GATT). +// +// Only jextract-supported types (primitives, String, primitive arrays) may appear in public +// declarations in this target. + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import AndroidBluetooth + +// MARK: - Scan Callback Events + +public func scanCallbackOnScanResult( + centralId: Int64, + callbackType: Int32, + address: String, + rssi: Int32, + isConnectable: Bool, + advertisementData: [UInt8] +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleScanResult( + callbackType: callbackType, + address: address, + rssi: rssi, + isConnectable: isConnectable, + advertisementData: Data(advertisementData) + ) +} + +public func scanCallbackOnScanFailed( + centralId: Int64, + errorCode: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleScanFailed(errorCode: errorCode) +} + +// MARK: - GATT Callback Events + +public func gattCallbackOnConnectionStateChange( + centralId: Int64, + peripheralAddress: String, + status: Int32, + newState: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleConnectionStateChange( + address: peripheralAddress, + status: status, + newState: newState + ) +} + +public func gattCallbackOnServicesDiscovered( + centralId: Int64, + peripheralAddress: String, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleServicesDiscovered( + address: peripheralAddress, + status: status + ) +} + +public func gattCallbackOnCharacteristicChanged( + centralId: Int64, + peripheralAddress: String, + characteristicUuid: String, + characteristicInstanceId: Int32, + value: [UInt8] +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleCharacteristicChanged( + address: peripheralAddress, + uuid: characteristicUuid, + instanceID: characteristicInstanceId, + value: Data(value) + ) +} + +public func gattCallbackOnCharacteristicRead( + centralId: Int64, + peripheralAddress: String, + characteristicUuid: String, + characteristicInstanceId: Int32, + value: [UInt8], + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleCharacteristicRead( + address: peripheralAddress, + uuid: characteristicUuid, + instanceID: characteristicInstanceId, + value: Data(value), + status: status + ) +} + +public func gattCallbackOnCharacteristicWrite( + centralId: Int64, + peripheralAddress: String, + characteristicUuid: String, + characteristicInstanceId: Int32, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleCharacteristicWrite( + address: peripheralAddress, + uuid: characteristicUuid, + instanceID: characteristicInstanceId, + status: status + ) +} + +public func gattCallbackOnDescriptorRead( + centralId: Int64, + peripheralAddress: String, + descriptorUuid: String, + value: [UInt8], + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleDescriptorRead( + address: peripheralAddress, + uuid: descriptorUuid, + value: Data(value), + status: status + ) +} + +public func gattCallbackOnDescriptorWrite( + centralId: Int64, + peripheralAddress: String, + descriptorUuid: String, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleDescriptorWrite( + address: peripheralAddress, + uuid: descriptorUuid, + status: status + ) +} + +public func gattCallbackOnMtuChanged( + centralId: Int64, + peripheralAddress: String, + mtu: Int32, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleMtuChanged( + address: peripheralAddress, + mtu: mtu, + status: status + ) +} + +public func gattCallbackOnPhyRead( + centralId: Int64, + peripheralAddress: String, + txPhy: Int32, + rxPhy: Int32, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handlePhyRead( + address: peripheralAddress, + txPhy: txPhy, + rxPhy: rxPhy, + status: status + ) +} + +public func gattCallbackOnPhyUpdate( + centralId: Int64, + peripheralAddress: String, + txPhy: Int32, + rxPhy: Int32, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handlePhyUpdate( + address: peripheralAddress, + txPhy: txPhy, + rxPhy: rxPhy, + status: status + ) +} + +public func gattCallbackOnReadRemoteRssi( + centralId: Int64, + peripheralAddress: String, + rssi: Int32, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleReadRemoteRssi( + address: peripheralAddress, + rssi: rssi, + status: status + ) +} + +public func gattCallbackOnReliableWriteCompleted( + centralId: Int64, + peripheralAddress: String, + status: Int32 +) { + guard let central = AndroidCentralRegistry.central(for: centralId) else { return } + central.handleReliableWriteCompleted( + address: peripheralAddress, + 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" +} From d7b722a2bacf69b963b7af07b8009e9245568cb3 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:07:02 -0400 Subject: [PATCH 2/8] Rewrite Kotlin callback adapters as bridge data-forwarders ScanCallback and BluetoothGattCallback no longer declare external JNI functions or hold a swiftPeer pointer; they extract primitive values from the Android callback objects and forward them to the generated AndroidBluetoothBridge statics, keyed by central identifier and address. --- .../bluetooth/BluetoothGattCallback.kt | 152 +++++++----------- .../pureswift/bluetooth/le/ScanCallback.kt | 69 ++++---- 2 files changed, 82 insertions(+), 139 deletions(-) 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..28b7a40 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,118 @@ 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 /** - * 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 as primitive + * values to the jextract-generated [AndroidBluetoothBridge], keyed by the Swift + * central's registry identifier and the peripheral's address. */ open class BluetoothGattCallback( - private var swiftPeer: Long = 0L + private val centralId: Long, + private val peripheralAddress: String ) : 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" - } - 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") - } + AndroidBluetoothBridge.gattCallbackOnConnectionStateChange(centralId, peripheralAddress, 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") - } + AndroidBluetoothBridge.gattCallbackOnServicesDiscovered(centralId, peripheralAddress, 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}") - } + AndroidBluetoothBridge.gattCallbackOnCharacteristicChanged( + centralId, + peripheralAddress, + 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") - } + AndroidBluetoothBridge.gattCallbackOnCharacteristicRead( + centralId, + peripheralAddress, + 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") - } + AndroidBluetoothBridge.gattCallbackOnCharacteristicWrite( + centralId, + peripheralAddress, + 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") - } + AndroidBluetoothBridge.gattCallbackOnDescriptorRead( + centralId, + peripheralAddress, + 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") - } + AndroidBluetoothBridge.gattCallbackOnDescriptorWrite( + centralId, + peripheralAddress, + 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") - } + AndroidBluetoothBridge.gattCallbackOnMtuChanged(centralId, peripheralAddress, 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") - } + AndroidBluetoothBridge.gattCallbackOnPhyRead(centralId, peripheralAddress, 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") - } + AndroidBluetoothBridge.gattCallbackOnPhyUpdate(centralId, peripheralAddress, 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") - } + AndroidBluetoothBridge.gattCallbackOnReadRemoteRssi(centralId, peripheralAddress, 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") - } + AndroidBluetoothBridge.gattCallbackOnReliableWriteCompleted(centralId, peripheralAddress, 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..937392e 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,35 +2,24 @@ 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 /** - * 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 as primitive values + * to the jextract-generated [AndroidBluetoothBridge], keyed by the Swift central's + * registry identifier. */ open class ScanCallback( - private var swiftPeer: Long = 0L + private val centralId: Long ) : 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) - companion object { private const val TAG = "PureSwift.ScanCallback" } @@ -43,15 +32,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 +42,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 +52,23 @@ open class ScanCallback( */ override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) - if (swiftPeer != 0L) { - swiftOnScanFailed(swiftPeer, errorCode) + Log.e(TAG, "onScanFailed: errorCode=$errorCode") + AndroidBluetoothBridge.scanCallbackOnScanFailed(centralId, 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 } + AndroidBluetoothBridge.scanCallbackOnScanResult( + centralId, + callbackType, + result.device.address, + result.rssi, + isConnectable, + result.scanRecord?.bytes ?: ByteArray(0) + ) } - private external fun swiftOnScanFailed(swiftPeer: Long, errorCode: Int) } From cdcd15950a7dc77a53ee2d1d57bfef0ce14d5a97 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:07:02 -0400 Subject: [PATCH 3/8] Move demo Swift code into a jextract bridge target The SwiftAndroidApp dynamic library is now built from BluetoothDemoBridge, a jextract JNI target exposing startScan/stopScan/connect/disconnect with closure parameters that surface as Java functional interfaces. DemoCentral owns the AndroidCentral instance, bootstrapped from the application context via AndroidContext. The Swift-driven View UI, example sources and shell build scripts are removed. --- Demo/Package.swift | 51 ++- .../BluetoothDemoBridge.swift | 58 +++ .../BluetoothDemoBridge/DemoCentral.swift | 133 ++++++ .../BluetoothDemoBridge/swift-java.config | 7 + Demo/app/src/main/swift/Application.swift | 74 ---- Demo/app/src/main/swift/Fragment.swift | 86 ---- Demo/app/src/main/swift/HelloSubclass.swift | 16 - Demo/app/src/main/swift/HelloSwift.swift | 46 -- Demo/app/src/main/swift/JavaKitExample.swift | 115 ----- .../src/main/swift/JavaRetainedValue.swift | 89 ---- Demo/app/src/main/swift/ListViewAdapter.swift | 87 ---- Demo/app/src/main/swift/MainActivity.swift | 409 ------------------ ...igationBarViewOnItemSelectedListener.swift | 76 ---- Demo/app/src/main/swift/OnClickListener.swift | 74 ---- Demo/app/src/main/swift/RecyclerView.swift | 107 ----- Demo/app/src/main/swift/Runnable.swift | 50 --- .../main/swift/ThreadSafeHelperClass.swift | 54 --- Demo/app/src/main/swift/UnitEmitter.swift | 19 - Demo/build-swift.sh | 32 -- Demo/setup.sh | 52 --- Demo/swift-define | 30 -- 21 files changed, 246 insertions(+), 1419 deletions(-) create mode 100644 Demo/app/src/main/swift-bridge/BluetoothDemoBridge/BluetoothDemoBridge.swift create mode 100644 Demo/app/src/main/swift-bridge/BluetoothDemoBridge/DemoCentral.swift create mode 100644 Demo/app/src/main/swift-bridge/BluetoothDemoBridge/swift-java.config delete mode 100644 Demo/app/src/main/swift/Application.swift delete mode 100644 Demo/app/src/main/swift/Fragment.swift delete mode 100644 Demo/app/src/main/swift/HelloSubclass.swift delete mode 100644 Demo/app/src/main/swift/HelloSwift.swift delete mode 100644 Demo/app/src/main/swift/JavaKitExample.swift delete mode 100644 Demo/app/src/main/swift/JavaRetainedValue.swift delete mode 100644 Demo/app/src/main/swift/ListViewAdapter.swift delete mode 100644 Demo/app/src/main/swift/MainActivity.swift delete mode 100644 Demo/app/src/main/swift/NavigationBarViewOnItemSelectedListener.swift delete mode 100644 Demo/app/src/main/swift/OnClickListener.swift delete mode 100644 Demo/app/src/main/swift/RecyclerView.swift delete mode 100644 Demo/app/src/main/swift/Runnable.swift delete mode 100644 Demo/app/src/main/swift/ThreadSafeHelperClass.swift delete mode 100644 Demo/app/src/main/swift/UnitEmitter.swift delete mode 100755 Demo/build-swift.sh delete mode 100755 Demo/setup.sh delete mode 100644 Demo/swift-define 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/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}" From f800bfe2a9b032c6b4fab198345fbe2cf7d2a6df Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:07:02 -0400 Subject: [PATCH 4/8] Rewrite demo UI with Jetpack Compose MainActivity renders a Compose scanner screen backed by the generated BluetoothDemoBridge bindings: scan toggle, device list sorted by RSSI, and tap-to-connect with discovered service UUIDs. The SwiftObject/external-fun Kotlin scaffolding and the unused View-based demo classes are removed. --- .../com/pureswift/swiftandroid/Application.kt | 25 +-- .../swiftandroid/EmbeddedAndroidViewDemo.kt | 63 ------- .../com/pureswift/swiftandroid/Fragment.kt | 24 --- .../pureswift/swiftandroid/ListViewAdapter.kt | 14 -- .../pureswift/swiftandroid/MainActivity.kt | 178 ++++++++++++++---- .../pureswift/swiftandroid/NativeLibrary.kt | 3 +- ...NavigationBarViewOnItemSelectedListener.kt | 9 - .../swiftandroid/RecyclerViewAdapter.kt | 40 ---- .../com/pureswift/swiftandroid/Runnable.kt | 6 - .../com/pureswift/swiftandroid/SwiftObject.kt | 17 -- .../com/pureswift/swiftandroid/UnitEmitter.kt | 15 -- .../swiftandroid/ViewOnClickListener.kt | 8 - Demo/app/src/main/res/layout/list_item.xml | 6 - 13 files changed, 142 insertions(+), 266 deletions(-) delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/EmbeddedAndroidViewDemo.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Fragment.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/ListViewAdapter.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/NavigationBarViewOnItemSelectedListener.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/RecyclerViewAdapter.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Runnable.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/SwiftObject.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/UnitEmitter.kt delete mode 100644 Demo/app/src/main/kotlin/com/pureswift/swiftandroid/ViewOnClickListener.kt delete mode 100644 Demo/app/src/main/res/layout/list_item.xml 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/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 From cef1ced60a2003afe0c46e7ce86a6ffa93e9e130 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:07:02 -0400 Subject: [PATCH 5/8] Drive Swift cross-compilation and jniLibs staging from Gradle The jextract task cross-compiles the package graph with the Swift Android SDK (--disable-sandbox so the swift-java callbacks sub-build can run Gradle), generated Java bindings and a patched SwiftKitCore source tree are added to the source set, and all required .so files are staged into jniLibs. The Unsigned annotation is replaced with an Android-compatible copy since the upstream source depends on jdk.jfr. --- Demo/app/build.gradle.kts | 131 ++++++++++++++++-- .../swiftkit/core/annotations/Unsigned.java | 36 +++++ 2 files changed, 156 insertions(+), 11 deletions(-) create mode 100644 Demo/app/src/main/java/org/swift/swiftkit/core/annotations/Unsigned.java diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index 4bed26e..63eab89 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" } @@ -40,6 +145,12 @@ android { 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 { +} From 805b9a651add6406a33da3ac161535663361192a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 16:15:46 -0400 Subject: [PATCH 6/8] Update GitHub CI Install a Temurin JDK before the Android build (required by android-commandlinetools and the swift-java jextract plugin) and build with Swift 6.3.3, matching the swift-tools 6.3 requirement of the manifests. --- .github/workflows/swift.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 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) From fe352f22a60d6fd2e9d03d8a2216d0cba4c6667d Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 18:04:23 -0400 Subject: [PATCH 7/8] Replace the central registry with exposed event-sink classes The AndroidBluetoothBridge target now exposes BluetoothScanEventSink and BluetoothGattEventSink as public Swift classes; jextract surfaces each as a Java class with native instance methods, so the Kotlin adapters hold and invoke a Swift instance directly instead of routing events through Int64 identifiers and a global registry. Each sink weakly references its central, preserving the no-dangling-events behavior. Sink identity crosses the JNI boundary once, during adapter construction, via the transient AndroidCentralHandoff window; lifetime is managed by the swift-java arena. --- .gitignore | 8 +- Sources/AndroidBluetooth/AndroidCentral.swift | 13 +- .../AndroidCentralCallback.swift | 31 ++- .../AndroidCentralHandoff.swift | 60 +++++ .../AndroidCentralRegistry.swift | 53 ---- .../Extensions/Peripheral.swift | 2 +- .../AndroidBluetoothBridge.swift | 232 ------------------ .../BluetoothEventSink.swift | 144 +++++++++++ 8 files changed, 235 insertions(+), 308 deletions(-) create mode 100644 Sources/AndroidBluetooth/AndroidCentralHandoff.swift delete mode 100644 Sources/AndroidBluetooth/AndroidCentralRegistry.swift delete mode 100644 Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift create mode 100644 Sources/AndroidBluetoothBridge/BluetoothEventSink.swift 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/Sources/AndroidBluetooth/AndroidCentral.swift b/Sources/AndroidBluetooth/AndroidCentral.swift index 0747bb3..6ad82b1 100644 --- a/Sources/AndroidBluetooth/AndroidCentral.swift +++ b/Sources/AndroidBluetooth/AndroidCentral.swift @@ -80,17 +80,10 @@ public final class AndroidCentral: CentralManager { public let options: Options - /// Identifier used by the Kotlin callback adapters to route events back to this instance. - package let identifier: Int64 - internal let storage = Storage() // MARK: - Intialization - deinit { - AndroidCentralRegistry.unregister(identifier) - } - public init( hostController: BluetoothAdapter, context: AndroidContent.Context, @@ -99,8 +92,6 @@ public final class AndroidCentral: CentralManager { self.hostController = hostController self.context = context self.options = options - self.identifier = AndroidCentralRegistry.reserveIdentifier() - AndroidCentralRegistry.register(self, for: identifier) } // MARK: - Methods @@ -133,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 { @@ -179,7 +170,7 @@ public final class AndroidCentral: CentralManager { await storage.update { [unowned self] state in // store continuation - let callback = GattCallback(central: self, peripheral: peripheral) + let callback = GattCallback.create(central: self, peripheral: peripheral) let device = try! self.hostController.getRemoteDevice(peripheral.address)! let gatt: BluetoothGatt diff --git a/Sources/AndroidBluetooth/AndroidCentralCallback.swift b/Sources/AndroidBluetooth/AndroidCentralCallback.swift index b9fa8af..58d04ec 100644 --- a/Sources/AndroidBluetooth/AndroidCentralCallback.swift +++ b/Sources/AndroidBluetooth/AndroidCentralCallback.swift @@ -19,10 +19,11 @@ import GATT // MARK: - Callback Adapters // // The Kotlin adapter classes extend the Android framework callback classes and forward -// each event as primitive values to the jextract-generated `AndroidBluetoothBridge` -// Java class, which resolves the central by its registry identifier and calls the -// `handle*` event methods in `AndroidCentralEvents.swift`. These wrappers exist only -// so Swift can construct the adapter instances to hand to the Android APIs. +// 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 { @@ -30,10 +31,15 @@ extension AndroidCentral { internal class LowEnergyScanCallback: AndroidBluetooth.ScanCallback { @JavaMethod - @_nonoverride convenience init(centralId: Int64, environment: JNIEnvironment? = nil) + @_nonoverride convenience init(environment: JNIEnvironment? = nil) + } +} + +extension AndroidCentral.LowEnergyScanCallback { - convenience init(central: AndroidCentral, environment: JNIEnvironment? = nil) { - self.init(centralId: central.identifier, environment: environment) + static func create(central: AndroidCentral) -> AndroidCentral.LowEnergyScanCallback { + AndroidCentralHandoff.withPending(central: central) { + AndroidCentral.LowEnergyScanCallback() } } } @@ -44,10 +50,15 @@ extension AndroidCentral { class GattCallback: AndroidBluetooth.BluetoothGattCallback { @JavaMethod - @_nonoverride convenience init(centralId: Int64, peripheralAddress: String, environment: JNIEnvironment? = nil) + @_nonoverride convenience init(environment: JNIEnvironment? = nil) + } +} + +extension AndroidCentral.GattCallback { - convenience init(central: AndroidCentral, peripheral: Peripheral, environment: JNIEnvironment? = nil) { - self.init(centralId: central.identifier, peripheralAddress: peripheral.address, environment: environment) + static func create(central: AndroidCentral, peripheral: Peripheral) -> AndroidCentral.GattCallback { + AndroidCentralHandoff.withPending(central: central, peripheral: peripheral) { + AndroidCentral.GattCallback() } } } 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/AndroidCentralRegistry.swift b/Sources/AndroidBluetooth/AndroidCentralRegistry.swift deleted file mode 100644 index 45f9bfa..0000000 --- a/Sources/AndroidBluetooth/AndroidCentralRegistry.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// AndroidCentralRegistry.swift -// AndroidBluetooth -// -// Created by Alsey Coleman Miller on 7/25/26. -// - -import Synchronization - -/// Maps stable `Int64` identifiers to live ``AndroidCentral`` instances. -/// -/// Kotlin callback adapters are constructed with a central's identifier and pass it -/// back through the JNI bridge with every event; the bridge resolves it here. -/// Entries are weak so the registry never extends a central's lifetime. -@available(Android 18, *) -package enum AndroidCentralRegistry { - - private struct WeakBox { - weak var central: AndroidCentral? - } - - private static let storage = Mutex<(nextID: Int64, values: [Int64: WeakBox])>((nextID: 1, values: [:])) - - /// Reserve a unique identifier for a central that is being initialized. - package static func reserveIdentifier() -> Int64 { - storage.withLock { state in - let id = state.nextID - state.nextID += 1 - return id - } - } - - /// Register a central under a previously reserved identifier. - package static func register(_ central: AndroidCentral, for id: Int64) { - storage.withLock { state in - state.values[id] = WeakBox(central: central) - } - } - - /// Remove a central from the registry. - package static func unregister(_ id: Int64) { - storage.withLock { state in - state.values[id] = nil - } - } - - /// Resolve a central from an identifier received over JNI. - package static func central(for id: Int64) -> AndroidCentral? { - storage.withLock { state in - state.values[id]?.central - } - } -} diff --git a/Sources/AndroidBluetooth/Extensions/Peripheral.swift b/Sources/AndroidBluetooth/Extensions/Peripheral.swift index 1cabf9a..003e871 100644 --- a/Sources/AndroidBluetooth/Extensions/Peripheral.swift +++ b/Sources/AndroidBluetooth/Extensions/Peripheral.swift @@ -8,7 +8,7 @@ import Bluetooth import GATT -internal extension Peripheral { +package extension Peripheral { init(_ device: AndroidBluetooth.BluetoothDevice) { self.init(id: device.address) diff --git a/Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift b/Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift deleted file mode 100644 index 17f70e6..0000000 --- a/Sources/AndroidBluetoothBridge/AndroidBluetoothBridge.swift +++ /dev/null @@ -1,232 +0,0 @@ -// -// AndroidBluetoothBridge.swift -// AndroidBluetooth -// -// Created by Alsey Coleman Miller on 7/25/26. -// -// JNI entry points for the Kotlin Bluetooth callback adapters. -// -// This target is processed by the swift-java `JExtractSwiftPlugin` (JNI mode, see -// `swift-java.config`), which generates the `org.pureswift.bluetooth.bridge.AndroidBluetoothBridge` -// Java class with a static method per public function below. The Kotlin adapter classes -// (`org.pureswift.bluetooth.le.ScanCallback` and `org.pureswift.bluetooth.BluetoothGattCallback`) -// extend the Android framework callback classes and forward each event here as primitive -// values, keyed by the central's registry identifier (and peripheral address for GATT). -// -// Only jextract-supported types (primitives, String, primitive arrays) may appear in public -// declarations in this target. - -#if canImport(FoundationEssentials) -import FoundationEssentials -#elseif canImport(Foundation) -import Foundation -#endif -import AndroidBluetooth - -// MARK: - Scan Callback Events - -public func scanCallbackOnScanResult( - centralId: Int64, - callbackType: Int32, - address: String, - rssi: Int32, - isConnectable: Bool, - advertisementData: [UInt8] -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleScanResult( - callbackType: callbackType, - address: address, - rssi: rssi, - isConnectable: isConnectable, - advertisementData: Data(advertisementData) - ) -} - -public func scanCallbackOnScanFailed( - centralId: Int64, - errorCode: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleScanFailed(errorCode: errorCode) -} - -// MARK: - GATT Callback Events - -public func gattCallbackOnConnectionStateChange( - centralId: Int64, - peripheralAddress: String, - status: Int32, - newState: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleConnectionStateChange( - address: peripheralAddress, - status: status, - newState: newState - ) -} - -public func gattCallbackOnServicesDiscovered( - centralId: Int64, - peripheralAddress: String, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleServicesDiscovered( - address: peripheralAddress, - status: status - ) -} - -public func gattCallbackOnCharacteristicChanged( - centralId: Int64, - peripheralAddress: String, - characteristicUuid: String, - characteristicInstanceId: Int32, - value: [UInt8] -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleCharacteristicChanged( - address: peripheralAddress, - uuid: characteristicUuid, - instanceID: characteristicInstanceId, - value: Data(value) - ) -} - -public func gattCallbackOnCharacteristicRead( - centralId: Int64, - peripheralAddress: String, - characteristicUuid: String, - characteristicInstanceId: Int32, - value: [UInt8], - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleCharacteristicRead( - address: peripheralAddress, - uuid: characteristicUuid, - instanceID: characteristicInstanceId, - value: Data(value), - status: status - ) -} - -public func gattCallbackOnCharacteristicWrite( - centralId: Int64, - peripheralAddress: String, - characteristicUuid: String, - characteristicInstanceId: Int32, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleCharacteristicWrite( - address: peripheralAddress, - uuid: characteristicUuid, - instanceID: characteristicInstanceId, - status: status - ) -} - -public func gattCallbackOnDescriptorRead( - centralId: Int64, - peripheralAddress: String, - descriptorUuid: String, - value: [UInt8], - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleDescriptorRead( - address: peripheralAddress, - uuid: descriptorUuid, - value: Data(value), - status: status - ) -} - -public func gattCallbackOnDescriptorWrite( - centralId: Int64, - peripheralAddress: String, - descriptorUuid: String, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleDescriptorWrite( - address: peripheralAddress, - uuid: descriptorUuid, - status: status - ) -} - -public func gattCallbackOnMtuChanged( - centralId: Int64, - peripheralAddress: String, - mtu: Int32, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleMtuChanged( - address: peripheralAddress, - mtu: mtu, - status: status - ) -} - -public func gattCallbackOnPhyRead( - centralId: Int64, - peripheralAddress: String, - txPhy: Int32, - rxPhy: Int32, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handlePhyRead( - address: peripheralAddress, - txPhy: txPhy, - rxPhy: rxPhy, - status: status - ) -} - -public func gattCallbackOnPhyUpdate( - centralId: Int64, - peripheralAddress: String, - txPhy: Int32, - rxPhy: Int32, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handlePhyUpdate( - address: peripheralAddress, - txPhy: txPhy, - rxPhy: rxPhy, - status: status - ) -} - -public func gattCallbackOnReadRemoteRssi( - centralId: Int64, - peripheralAddress: String, - rssi: Int32, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleReadRemoteRssi( - address: peripheralAddress, - rssi: rssi, - status: status - ) -} - -public func gattCallbackOnReliableWriteCompleted( - centralId: Int64, - peripheralAddress: String, - status: Int32 -) { - guard let central = AndroidCentralRegistry.central(for: centralId) else { return } - central.handleReliableWriteCompleted( - address: peripheralAddress, - status: status - ) -} 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) + } +} From 9ef650fe09012e48ade40c809dbe5a5eae074bd9 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Sat, 25 Jul 2026 18:04:23 -0400 Subject: [PATCH 8/8] Adopt event-sink classes in the Kotlin adapters ScanCallback and BluetoothGattCallback now hold a jextract-generated BluetoothScanEventSink/BluetoothGattEventSink and forward events to it; the no-argument constructors obtain the Swift sink for the connection under construction via AndroidBluetoothBridge.takePending*EventSink, and the primary constructors accept any sink instance. Java compatibility moves to 17, which the generated bindings require. --- Demo/app/build.gradle.kts | 6 +-- .../bluetooth/BluetoothGattCallback.kt | 52 ++++++++----------- .../pureswift/bluetooth/le/ScanCallback.kt | 18 ++++--- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/Demo/app/build.gradle.kts b/Demo/app/build.gradle.kts index 63eab89..28590d7 100644 --- a/Demo/app/build.gradle.kts +++ b/Demo/app/build.gradle.kts @@ -136,11 +136,11 @@ 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 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 28b7a40..5f27a97 100644 --- a/Demo/app/src/main/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt +++ b/Demo/app/src/main/kotlin/org/pureswift/bluetooth/BluetoothGattCallback.kt @@ -5,38 +5,41 @@ import android.bluetooth.BluetoothGattCallback as AndroidGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import org.pureswift.bluetooth.bridge.AndroidBluetoothBridge +import org.pureswift.bluetooth.bridge.BluetoothGattEventSink +import org.swift.swiftkit.core.SwiftArena /** * Bluetooth GATT Callback for the AndroidBluetooth Swift 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 as primitive - * values to the jextract-generated [AndroidBluetoothBridge], keyed by the Swift - * central's registry identifier and the peripheral's address. + * 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 val centralId: Long, - private val peripheralAddress: String + private val sink: BluetoothGattEventSink ) : AndroidGattCallback() { + constructor() : this(AndroidBluetoothBridge.takePendingGattEventSink(SwiftArena.ofAuto())) + override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) { super.onConnectionStateChange(gatt, status, newState) - AndroidBluetoothBridge.gattCallbackOnConnectionStateChange(centralId, peripheralAddress, status, newState) + sink.onConnectionStateChange(status, newState) } override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) { super.onServicesDiscovered(gatt, status) - AndroidBluetoothBridge.gattCallbackOnServicesDiscovered(centralId, peripheralAddress, status) + sink.onServicesDiscovered(status) } @Deprecated("Deprecated in Java") override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { @Suppress("DEPRECATION") super.onCharacteristicChanged(gatt, characteristic) - AndroidBluetoothBridge.gattCallbackOnCharacteristicChanged( - centralId, - peripheralAddress, + sink.onCharacteristicChanged( characteristic.uuid.toString(), characteristic.instanceId, @Suppress("DEPRECATION") @@ -48,9 +51,7 @@ open class BluetoothGattCallback( override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { @Suppress("DEPRECATION") super.onCharacteristicRead(gatt, characteristic, status) - AndroidBluetoothBridge.gattCallbackOnCharacteristicRead( - centralId, - peripheralAddress, + sink.onCharacteristicRead( characteristic.uuid.toString(), characteristic.instanceId, @Suppress("DEPRECATION") @@ -61,9 +62,7 @@ open class BluetoothGattCallback( override fun onCharacteristicWrite(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { super.onCharacteristicWrite(gatt, characteristic, status) - AndroidBluetoothBridge.gattCallbackOnCharacteristicWrite( - centralId, - peripheralAddress, + sink.onCharacteristicWrite( characteristic?.uuid?.toString() ?: "", characteristic?.instanceId ?: 0, status @@ -74,9 +73,7 @@ open class BluetoothGattCallback( override fun onDescriptorRead(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { @Suppress("DEPRECATION") super.onDescriptorRead(gatt, descriptor, status) - AndroidBluetoothBridge.gattCallbackOnDescriptorRead( - centralId, - peripheralAddress, + sink.onDescriptorRead( descriptor.uuid.toString(), @Suppress("DEPRECATION") descriptor.value ?: ByteArray(0), @@ -86,36 +83,31 @@ open class BluetoothGattCallback( override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) { super.onDescriptorWrite(gatt, descriptor, status) - AndroidBluetoothBridge.gattCallbackOnDescriptorWrite( - centralId, - peripheralAddress, - descriptor?.uuid?.toString() ?: "", - status - ) + sink.onDescriptorWrite(descriptor?.uuid?.toString() ?: "", status) } override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { super.onMtuChanged(gatt, mtu, status) - AndroidBluetoothBridge.gattCallbackOnMtuChanged(centralId, peripheralAddress, mtu, status) + sink.onMtuChanged(mtu, status) } override fun onPhyRead(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) { super.onPhyRead(gatt, txPhy, rxPhy, status) - AndroidBluetoothBridge.gattCallbackOnPhyRead(centralId, peripheralAddress, txPhy, rxPhy, status) + sink.onPhyRead(txPhy, rxPhy, status) } override fun onPhyUpdate(gatt: BluetoothGatt?, txPhy: Int, rxPhy: Int, status: Int) { super.onPhyUpdate(gatt, txPhy, rxPhy, status) - AndroidBluetoothBridge.gattCallbackOnPhyUpdate(centralId, peripheralAddress, txPhy, rxPhy, status) + sink.onPhyUpdate(txPhy, rxPhy, status) } override fun onReadRemoteRssi(gatt: BluetoothGatt?, rssi: Int, status: Int) { super.onReadRemoteRssi(gatt, rssi, status) - AndroidBluetoothBridge.gattCallbackOnReadRemoteRssi(centralId, peripheralAddress, rssi, status) + sink.onReadRemoteRssi(rssi, status) } override fun onReliableWriteCompleted(gatt: BluetoothGatt?, status: Int) { super.onReliableWriteCompleted(gatt, status) - AndroidBluetoothBridge.gattCallbackOnReliableWriteCompleted(centralId, peripheralAddress, status) + sink.onReliableWriteCompleted(status) } } 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 937392e..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 @@ -6,20 +6,25 @@ 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 the AndroidBluetooth Swift 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 as primitive values - * to the jextract-generated [AndroidBluetoothBridge], keyed by the Swift central's - * registry identifier. + * 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 val centralId: Long + private val sink: BluetoothScanEventSink ) : AndroidScanCallback() { + constructor() : this(AndroidBluetoothBridge.takePendingScanEventSink(SwiftArena.ofAuto())) + companion object { private const val TAG = "PureSwift.ScanCallback" } @@ -53,7 +58,7 @@ open class ScanCallback( override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.e(TAG, "onScanFailed: errorCode=$errorCode") - AndroidBluetoothBridge.scanCallbackOnScanFailed(centralId, errorCode) + sink.onScanFailed(errorCode) } private fun forward(callbackType: Int, result: ScanResult) { @@ -62,8 +67,7 @@ open class ScanCallback( } else { true } - AndroidBluetoothBridge.scanCallbackOnScanResult( - centralId, + sink.onScanResult( callbackType, result.device.address, result.rssi,