package com.margelo.nitro.co.zyke.ble import android.Manifest import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCallback import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothGattService import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager import android.os.Build import android.os.ParcelUuid import android.provider.Settings import androidx.core.content.ContextCompat import com.margelo.nitro.core.* import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** * Android implementation of the BLE Nitro Module * This class provides the actual BLE functionality for Android devices */ class BleNitroBleManager : HybridNativeBleNitroSpec() { // iOS-specific property (not used on Android) override var restoreStateIdentifier: String? = null private var bluetoothAdapter: BluetoothAdapter? = null private var stateCallback: ((state: BLEState) -> Unit)? = null private var bluetoothStateReceiver: BroadcastReceiver? = null private var restoreStateCallback: ((devices: List) -> Unit)? = null // BLE Scanning private var bleScanner: BluetoothLeScanner? = null private var isCurrentlyScanning = false private var scanCallback: ScanCallback? = null private var deviceFoundCallback: ((device: Variant_NullType_BLEDevice?, error: Variant_NullType_String?) -> Unit)? = null private val discoveredDevicesInCurrentScan = mutableSetOf() // Device connections private val connectedDevices = ConcurrentHashMap() private val deviceCallbacks = ConcurrentHashMap() // Read callback storage for proper response handling (key: deviceId:characteristicId) private val readCallbacks = ConcurrentHashMap Unit>() // Write callback storage for proper response handling (key: deviceId:characteristicId) private val writeCallbacks = ConcurrentHashMap Unit>() // RSSI callback storage (key: deviceId) private val rssiCallbacks = ConcurrentHashMap Unit>() // GATT operation queue for sequential descriptor writes (Android BLE requirement) private val gattOperationQueue = java.util.concurrent.LinkedBlockingQueue() private var isGattOperationInProgress = false private val gattOperationLock = Any() // Descriptor write callback storage (key: deviceId:descriptorUuid) private val descriptorWriteCallbacks = ConcurrentHashMap Unit>() // Android permits only one outstanding ATT operation per GATT connection. // Defer service discovery while an MTU request is in flight so the two // procedures cannot overlap when JS calls them back-to-back. // All three maps below are guarded by mtuGateLock, so plain HashMaps suffice. private val mtuInFlight = HashMap() private val pendingDiscovery = HashMap Unit>() private val mtuFallbackRunnables = HashMap() private val mtuHandler = android.os.Handler(android.os.Looper.getMainLooper()) private val mtuGateLock = Any() private val mtuSettleFallbackMs = 1000L // Helper class to store device callbacks private data class DeviceCallbacks( var connectCallback: ((success: Boolean, deviceId: String, error: String) -> Unit)? = null, var disconnectCallback: ((deviceId: String, interrupted: Boolean, error: String) -> Unit)? = null, var serviceDiscoveryCallback: ((success: Boolean, error: String) -> Unit)? = null, // Key: "serviceId:characteristicId" for correct scoping when the same // characteristic UUID exists under multiple services. var characteristicSubscriptions: MutableMap Unit> = ConcurrentHashMap() ) // Sealed class for GATT operations that need to be queued private sealed class GattOperation { data class WriteDescriptor( val gatt: BluetoothGatt, val descriptor: BluetoothGattDescriptor, val value: ByteArray, val deviceId: String, val callback: (Boolean, String) -> Unit ) : GattOperation() } init { // Try to get context from React Native application context tryToGetContextFromReactNative() } companion object { private var appContext: Context? = null fun setContext(context: Context) { appContext = context.applicationContext } fun getContext(): Context? = appContext } private fun tryToGetContextFromReactNative() { if (appContext == null) { try { // Try to get Application context using reflection val activityThread = Class.forName("android.app.ActivityThread") val currentApplicationMethod = activityThread.getMethod("currentApplication") val application = currentApplicationMethod.invoke(null) as? android.app.Application if (application != null) { setContext(application) } } catch (e: Exception) { // Context will be set by package initialization if reflection fails } } } private fun initializeBluetoothIfNeeded() { if (bluetoothAdapter == null) { try { val context = appContext ?: return val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager bluetoothAdapter = bluetoothManager?.adapter } catch (e: Exception) { // Handle initialization error silently } } } private fun hasBluetoothPermissions(): Boolean { val context = appContext ?: return false return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ (API 31+) - check new Bluetooth permissions ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED } else { // Android < 12 - check legacy permissions ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED } } private fun getMissingPermissions(): List { val context = appContext ?: return emptyList() val missing = mutableListOf() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ permissions if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.BLUETOOTH_CONNECT) } if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.BLUETOOTH_SCAN) } } else { // Legacy permissions if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.BLUETOOTH) } if (ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.BLUETOOTH_ADMIN) } } // Location permissions for BLE scanning if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.ACCESS_FINE_LOCATION) } return missing } private fun bluetoothStateToBlEState(bluetoothState: Int): BLEState { return when (bluetoothState) { BluetoothAdapter.STATE_OFF -> BLEState.POWEREDOFF BluetoothAdapter.STATE_ON -> BLEState.POWEREDON BluetoothAdapter.STATE_TURNING_ON -> BLEState.RESETTING BluetoothAdapter.STATE_TURNING_OFF -> BLEState.RESETTING else -> BLEState.UNKNOWN } } private fun createBluetoothStateReceiver(): BroadcastReceiver { return object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) { val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) val bleState = bluetoothStateToBlEState(state) stateCallback?.invoke(bleState) } } } } private fun createBLEDeviceFromScanResult(scanResult: ScanResult): BLEDevice { val device = scanResult.device val scanRecord = scanResult.scanRecord // Extract manufacturer data val manufacturerData = scanRecord?.manufacturerSpecificData?.let { sparseArray -> val entries = mutableListOf() for (i in 0 until sparseArray.size()) { val key = sparseArray.keyAt(i) val value = sparseArray.get(key) // Create direct ByteBuffer as required by ArrayBuffer.wrap() val directBuffer = java.nio.ByteBuffer.allocateDirect(value.size) directBuffer.put(value) directBuffer.flip() entries.add(ManufacturerDataEntry( id = key.toString(), data = ArrayBuffer.wrap(directBuffer) )) } ManufacturerData(companyIdentifiers = entries.toTypedArray()) } ?: ManufacturerData(companyIdentifiers = emptyArray()) // Extract service data val serviceData = scanRecord?.serviceData?.let { dataMap -> val entries = mutableListOf() for ((uuid, value) in dataMap) { // Create direct ByteBuffer as required by ArrayBuffer.wrap() val directBuffer = java.nio.ByteBuffer.allocateDirect(value.size) directBuffer.put(value) directBuffer.flip() entries.add(ServiceDataEntry( uuid = uuid.toString(), data = ArrayBuffer.wrap(directBuffer) )) } ServiceData(services = entries.toTypedArray()) } ?: ServiceData(services = emptyArray()) // Extract service UUIDs val serviceUUIDs = scanRecord?.serviceUuids?.map { it.toString() }?.toTypedArray() ?: emptyArray() return BLEDevice( id = device.address, name = device.name ?: "", rssi = scanResult.rssi.toDouble(), manufacturerData = manufacturerData, serviceData = serviceData, serviceUUIDs = serviceUUIDs, isConnectable = true, // Assume scannable devices are connectable isConnected = false // Scanned devices are not yet connected ) } private fun createAndroidScanFilters(filter: com.margelo.nitro.co.zyke.ble.ScanFilter): List { val filters = mutableListOf() // Add service UUID filters filter.serviceUUIDs.forEach { serviceId -> try { val builder = android.bluetooth.le.ScanFilter.Builder() val uuid = UUID.fromString(serviceId) builder.setServiceUuid(ParcelUuid(uuid)) filters.add(builder.build()) } catch (e: Exception) { // Invalid UUID, skip } } // If no specific filters, add empty filter to scan all devices if (filters.isEmpty()) { val builder = android.bluetooth.le.ScanFilter.Builder() filters.add(builder.build()) } return filters } private fun createGattCallback(deviceId: String): BluetoothGattCallback { return object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { when (newState) { BluetoothProfile.STATE_CONNECTED -> { clearMtuGateState(deviceId) var connectCb: ((Boolean, String, String) -> Unit)? = null deviceCallbacks.compute(deviceId) { _, callbacks -> connectCb = callbacks?.connectCallback callbacks?.copy(connectCallback = null) } connectCb?.invoke(true, deviceId, "") } BluetoothProfile.STATE_DISCONNECTED -> { // Clean up clearMtuGateState(deviceId) connectedDevices.remove(deviceId) val interrupted = status != BluetoothGatt.GATT_SUCCESS val cb = deviceCallbacks.remove(deviceId) // If a discoverServices() call was still in flight (or deferred // behind an MTU request), it will never receive onServicesDiscovered // now. Fail it explicitly so the JS promise does not hang forever. cb?.serviceDiscoveryCallback?.invoke(false, "Disconnected before service discovery completed") cb?.disconnectCallback?.invoke(deviceId, interrupted, if (interrupted) "Connection lost" else "") gatt.close() } } } override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { // Use the deviceId captured for this callback so the gate key always // matches the one markMtuInFlight() recorded under. settleMtu(deviceId) } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { val callbacks = deviceCallbacks[deviceId] val serviceDiscoveryCallback = callbacks?.serviceDiscoveryCallback if (status == BluetoothGatt.GATT_SUCCESS) { serviceDiscoveryCallback?.invoke(true, "") } else { serviceDiscoveryCallback?.invoke(false, "Service discovery failed with status: $status") } // Clear the service discovery callback as it's one-time use callbacks?.serviceDiscoveryCallback = null } override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { // Handle characteristic read result val deviceId = gatt.device.address val characteristicId = characteristic.uuid.toString() val callbackKey = "$deviceId:$characteristicId" readCallbacks.remove(callbackKey)?.let { callback -> if (status == BluetoothGatt.GATT_SUCCESS) { val value = characteristic.value ?: byteArrayOf() // Create direct ByteBuffer as required by ArrayBuffer.wrap() val directBuffer = java.nio.ByteBuffer.allocateDirect(value.size) directBuffer.put(value) directBuffer.flip() val data = ArrayBuffer.wrap(directBuffer) callback(true, data, "") } else { callback(false, ArrayBuffer.allocate(0), "Read failed with status: $status") } } } override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { // Handle characteristic write result val deviceId = gatt.device.address val characteristicId = characteristic.uuid.toString() val callbackKey = "$deviceId:$characteristicId" writeCallbacks.remove(callbackKey)?.let { callback -> if (status == BluetoothGatt.GATT_SUCCESS) { // Get response data from characteristic value (may be null/empty for acknowledgments) val responseData = characteristic.value ?: byteArrayOf() val directBuffer = java.nio.ByteBuffer.allocateDirect(responseData.size) directBuffer.put(responseData) directBuffer.flip() val arrayBuffer = ArrayBuffer.wrap(directBuffer) callback(true, arrayBuffer, "") } else { callback(false, ArrayBuffer.allocate(0), "Write failed with status: $status") } } } override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) { val deviceId = gatt.device.address rssiCallbacks.remove(deviceId)?.let { callback -> if (status == BluetoothGatt.GATT_SUCCESS) { callback(true, rssi.toDouble(), "") } else { callback(false, 0.0, "RSSI read failed with status: $status") } } } override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { // Handle characteristic notifications val characteristicId = characteristic.uuid.toString() val serviceId = characteristic.service?.uuid?.toString() if (serviceId == null) { android.util.Log.w("BleNitro", "onCharacteristicChanged: characteristic.service is null for $characteristicId") return } val subscriptionKey = "$serviceId:$characteristicId" val value = characteristic.value ?: byteArrayOf() // Create direct ByteBuffer as required by ArrayBuffer.wrap() val directBuffer = java.nio.ByteBuffer.allocateDirect(value.size) directBuffer.put(value) directBuffer.flip() val data = ArrayBuffer.wrap(directBuffer) val callbacks = deviceCallbacks[deviceId] callbacks?.characteristicSubscriptions?.get(subscriptionKey)?.invoke(characteristicId, data) } override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { // Handle descriptor write (for enabling/disabling notifications) val deviceId = gatt.device.address val descriptorUuid = descriptor.uuid.toString() val callbackKey = "$deviceId:$descriptorUuid" // Get and invoke the stored callback descriptorWriteCallbacks.remove(callbackKey)?.let { callback -> if (status == BluetoothGatt.GATT_SUCCESS) { callback(true, "") } else { callback(false, "Descriptor write failed with status: $status") } } // Process next queued operation processNextGattOperation() } } } private fun runOrDeferDiscovery(deviceId: String, startDiscovery: () -> Unit) { val shouldStartNow = synchronized(mtuGateLock) { if (mtuInFlight[deviceId] == true) { pendingDiscovery[deviceId] = startDiscovery false } else { true } } if (shouldStartNow) { startDiscovery() } } private fun markMtuInFlight(deviceId: String) { synchronized(mtuGateLock) { mtuInFlight[deviceId] = true mtuFallbackRunnables.remove(deviceId)?.let { mtuHandler.removeCallbacks(it) } val runnable = Runnable { settleMtu(deviceId) } mtuFallbackRunnables[deviceId] = runnable mtuHandler.postDelayed(runnable, mtuSettleFallbackMs) } } private fun settleMtu(deviceId: String) { val discoveryToStart = synchronized(mtuGateLock) { mtuInFlight.remove(deviceId) mtuFallbackRunnables.remove(deviceId)?.let { mtuHandler.removeCallbacks(it) } pendingDiscovery.remove(deviceId) } // settleMtu can be invoked from the binder thread (onMtuChanged) or the main // thread (fallback). Start the deferred discovery on the main looper so GATT // operations are always issued from a single, consistent thread. discoveryToStart?.let { mtuHandler.post(it) } } private fun clearMtuGateState(deviceId: String) { synchronized(mtuGateLock) { mtuInFlight.remove(deviceId) pendingDiscovery.remove(deviceId) mtuFallbackRunnables.remove(deviceId)?.let { mtuHandler.removeCallbacks(it) } } } private fun mapAndroidConnectionPriority(androidConnectionPriority: AndroidConnectionPriority): Int { return when (androidConnectionPriority) { AndroidConnectionPriority.HIGH -> BluetoothGatt.CONNECTION_PRIORITY_HIGH AndroidConnectionPriority.LOWPOWER -> BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER AndroidConnectionPriority.BALANCED -> BluetoothGatt.CONNECTION_PRIORITY_BALANCED } } /** * Enqueue a GATT operation to be executed sequentially. * Android BLE requires that only one GATT operation runs at a time. */ private fun enqueueGattOperation(operation: GattOperation) { gattOperationQueue.add(operation) processNextGattOperation() } /** * Process the next GATT operation in the queue if none is currently running. */ private fun processNextGattOperation() { synchronized(gattOperationLock) { if (isGattOperationInProgress) { return } val operation = gattOperationQueue.poll() ?: return isGattOperationInProgress = true when (operation) { is GattOperation.WriteDescriptor -> { val callbackKey = "${operation.deviceId}:${operation.descriptor.uuid}" descriptorWriteCallbacks[callbackKey] = { success, error -> synchronized(gattOperationLock) { isGattOperationInProgress = false } operation.callback(success, error) } operation.descriptor.value = operation.value val success = operation.gatt.writeDescriptor(operation.descriptor) if (!success) { descriptorWriteCallbacks.remove(callbackKey) synchronized(gattOperationLock) { isGattOperationInProgress = false } operation.callback(false, "Failed to initiate descriptor write") // Try next operation processNextGattOperation() } } } } } override fun setRestoreStateCallback(callback: (restoredPeripherals: Array) -> Unit) { restoreStateCallback = { devices -> callback(devices.toTypedArray()) } return } // iOS-only method, no-op on Android override fun iosLazyInit() { } // Scanning operations override fun startScan(filter: com.margelo.nitro.co.zyke.ble.ScanFilter, callback: (device: Variant_NullType_BLEDevice?, error: Variant_NullType_String?) -> Unit) { try { initializeBluetoothIfNeeded() val adapter = bluetoothAdapter ?: return if (!adapter.isEnabled) { return } if (isCurrentlyScanning) { return } // Clear discovered devices for fresh scan session discoveredDevicesInCurrentScan.clear() // Initialize scanner bleScanner = adapter.bluetoothLeScanner ?: return deviceFoundCallback = callback // Create scan callback scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = createBLEDeviceFromScanResult(result) // Apply RSSI threshold filtering if (device.rssi < filter.rssiThreshold) { return } // Apply application-level duplicate filtering if needed if (!filter.allowDuplicates) { if (discoveredDevicesInCurrentScan.contains(device.id)) { return // Skip duplicate } discoveredDevicesInCurrentScan.add(device.id) } callback(Variant_NullType_BLEDevice.create(device), null) } override fun onBatchScanResults(results: MutableList) { results.forEach { result -> val device = createBLEDeviceFromScanResult(result) // Apply RSSI threshold filtering if (device.rssi < filter.rssiThreshold) { return@forEach } // Apply application-level duplicate filtering if needed if (!filter.allowDuplicates) { if (discoveredDevicesInCurrentScan.contains(device.id)) { return@forEach // Skip duplicate } discoveredDevicesInCurrentScan.add(device.id) } callback(Variant_NullType_BLEDevice.create(device), null) } } override fun onScanFailed(errorCode: Int) { val errorMessage = when (errorCode) { ScanCallback.SCAN_FAILED_ALREADY_STARTED -> "Scan already started" ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed" ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported" ScanCallback.SCAN_FAILED_INTERNAL_ERROR -> "Internal error" ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> "Out of hardware resources" ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> "Scanning too frequently" else -> "Scan failed with error code: $errorCode" } callback(null, Variant_NullType_String.create(errorMessage)) stopScan() } } // Create scan filters and settings val scanFilters = createAndroidScanFilters(filter) val scanMode = when (filter.androidScanMode) { AndroidScanMode.LOWLATENCY -> ScanSettings.SCAN_MODE_LOW_LATENCY AndroidScanMode.LOWPOWER -> ScanSettings.SCAN_MODE_LOW_POWER AndroidScanMode.BALANCED -> ScanSettings.SCAN_MODE_BALANCED AndroidScanMode.OPPORTUNISTIC -> ScanSettings.SCAN_MODE_OPPORTUNISTIC } val scanSettingsBuilder = ScanSettings.Builder() .setScanMode(scanMode) .setReportDelay(0) // Report each advertisement individually // Always use CALLBACK_TYPE_ALL_MATCHES for application-level duplicate filtering if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { scanSettingsBuilder.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) } val scanSettings = scanSettingsBuilder.build() // Start scanning bleScanner?.startScan(scanFilters, scanSettings, scanCallback) isCurrentlyScanning = true } catch (e: SecurityException) { isCurrentlyScanning = false } catch (e: Exception) { isCurrentlyScanning = false } } override fun stopScan(): Boolean { return try { if (scanCallback != null && isCurrentlyScanning) { bleScanner?.stopScan(scanCallback) } isCurrentlyScanning = false scanCallback = null deviceFoundCallback = null bleScanner = null discoveredDevicesInCurrentScan.clear() // Clear discovered devices for next scan session true } catch (e: Exception) { isCurrentlyScanning = false scanCallback = null deviceFoundCallback = null bleScanner = null discoveredDevicesInCurrentScan.clear() false } } override fun isScanning(): Boolean { return isCurrentlyScanning } // Device discovery override fun getConnectedDevices(services: Array): Array { return try { val bluetoothManager = appContext?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager val connectedDevices = bluetoothManager?.getConnectedDevices(BluetoothProfile.GATT) ?: emptyList() connectedDevices.map { device -> BLEDevice( id = device.address, name = device.name ?: "", rssi = 0.0, // RSSI not available for already connected devices manufacturerData = ManufacturerData(companyIdentifiers = emptyArray()), serviceData = ServiceData(services = emptyArray()), serviceUUIDs = emptyArray(), // Service UUIDs not available without service discovery isConnectable = true, isConnected = true ) }.toTypedArray() } catch (e: Exception) { emptyArray() } } // Connection management override fun connect( deviceId: String, callback: (success: Boolean, deviceId: String, error: String) -> Unit, disconnectCallback: ((deviceId: String, interrupted: Boolean, error: String) -> Unit)?, autoConnectAndroid: Boolean? ) { try { initializeBluetoothIfNeeded() val adapter = bluetoothAdapter if (adapter == null) { callback(false, deviceId, "Bluetooth not available") return } val device = adapter.getRemoteDevice(deviceId) if (device == null) { callback(false, deviceId, "Device not found") return } // Store callbacks for this device deviceCallbacks[deviceId] = DeviceCallbacks( connectCallback = callback, disconnectCallback = disconnectCallback ) // Create GATT callback val gattCallback = createGattCallback(deviceId) // Connect to device val context = appContext if (context != null) { val autoConnect = autoConnectAndroid ?: false val gatt = device.connectGatt(context, autoConnect, gattCallback) connectedDevices[deviceId] = gatt } else { callback(false, deviceId, "Context not available") } } catch (e: SecurityException) { callback(false, deviceId, "Permission denied") } catch (e: Exception) { callback(false, deviceId, "Connection error: ${e.message}") } } override fun disconnect(deviceId: String, callback: (success: Boolean, error: String) -> Unit) { try { val gatt = connectedDevices[deviceId] if (gatt != null) { var pendingConnect: ((Boolean, String, String) -> Unit)? = null deviceCallbacks.compute(deviceId) { _, callbacks -> pendingConnect = callbacks?.connectCallback callbacks?.copy(connectCallback = null) } pendingConnect?.invoke(false, deviceId, "Connection cancelled") gatt.disconnect() callback(true, "") } else { callback(false, "Device not connected") } } catch (e: Exception) { callback(false, "Disconnect error: ${e.message}") } } override fun isConnected(deviceId: String): Boolean { val gatt = connectedDevices[deviceId] ?: return false val bluetoothManager = appContext?.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager ?: return false val connectionState = bluetoothManager.getConnectionState(gatt.device, BluetoothProfile.GATT) return connectionState == BluetoothProfile.STATE_CONNECTED } override fun requestMTU(deviceId: String, mtu: Double): Double { return try { val gatt = connectedDevices[deviceId] if (gatt != null) { val success = gatt.requestMtu(mtu.toInt()) if (success) { markMtuInFlight(deviceId) } if (success) mtu else 0.0 } else { 0.0 } } catch (e: Exception) { 0.0 } } override fun requestConnectionPriority(deviceId: String, androidConnectionPriority: AndroidConnectionPriority): Boolean { return try { val gatt = connectedDevices[deviceId] ?: return false gatt.requestConnectionPriority(mapAndroidConnectionPriority(androidConnectionPriority)) } catch (_: Exception) { false } } override fun readRSSI(deviceId: String, callback: (success: Boolean, rssi: Double, error: String) -> Unit) { try { val gatt = connectedDevices[deviceId] if (gatt == null) { callback(false, 0.0, "Device not connected") return } // Store callback for when RSSI is read rssiCallbacks[deviceId] = callback // Initiate RSSI read val success = gatt.readRemoteRssi() if (!success) { rssiCallbacks.remove(deviceId) callback(false, 0.0, "Failed to initiate RSSI read") } } catch (e: Exception) { rssiCallbacks.remove(deviceId) callback(false, 0.0, "RSSI read error: ${e.message}") } } // Service discovery override fun discoverServices(deviceId: String, callback: (success: Boolean, error: String) -> Unit) { try { val gatt = connectedDevices[deviceId] if (gatt != null) { val callbacks = deviceCallbacks[deviceId] if (callbacks != null) { // Store the callback for when service discovery completes callbacks.serviceDiscoveryCallback = callback runOrDeferDiscovery(deviceId) { // Start service discovery val success = gatt.discoverServices() if (!success) { // Clear callback and report failure immediately callbacks.serviceDiscoveryCallback = null callback(false, "Failed to start service discovery") } // If success, the callback will be invoked in onServicesDiscovered } } else { callback(false, "Device callback not found") } } else { callback(false, "Device not connected") } } catch (e: Exception) { callback(false, "Service discovery error: ${e.message}") } } override fun discoverServicesWithCharacteristics(deviceId: String, callback: (success: Boolean, error: String) -> Unit) { // On Android, discoverServices() already discovers characteristics automatically. // Delegate directly to discoverServices. discoverServices(deviceId, callback) } override fun getServices(deviceId: String): Array { return try { val gatt = connectedDevices[deviceId] gatt?.services?.map { service -> service.uuid.toString() }?.toTypedArray() ?: emptyArray() } catch (e: Exception) { emptyArray() } } override fun getCharacteristics(deviceId: String, serviceId: String): Array { return try { val gatt = connectedDevices[deviceId] val service = gatt?.getService(UUID.fromString(serviceId)) service?.characteristics?.map { characteristic -> characteristic.uuid.toString() }?.toTypedArray() ?: emptyArray() } catch (e: Exception) { emptyArray() } } // Characteristic operations override fun readCharacteristic( deviceId: String, serviceId: String, characteristicId: String, callback: (success: Boolean, data: ArrayBuffer, error: String) -> Unit ) { try { val gatt = connectedDevices[deviceId] if (gatt == null) { callback(false, ArrayBuffer.allocate(0), "Device not connected") return } val service = gatt.getService(UUID.fromString(serviceId)) if (service == null) { callback(false, ArrayBuffer.allocate(0), "Service not found") return } val characteristic = service.getCharacteristic(UUID.fromString(characteristicId)) if (characteristic == null) { callback(false, ArrayBuffer.allocate(0), "Characteristic not found") return } // Store callback for when read completes in onCharacteristicRead val callbackKey = "$deviceId:$characteristicId" readCallbacks[callbackKey] = callback // Initiate the read operation val success = gatt.readCharacteristic(characteristic) if (!success) { // Remove callback and report failure immediately readCallbacks.remove(callbackKey) callback(false, ArrayBuffer.allocate(0), "Failed to initiate read operation") } // If success, wait for onCharacteristicRead callback } catch (e: Exception) { callback(false, ArrayBuffer.allocate(0), "Read error: ${e.message}") } } override fun writeCharacteristic( deviceId: String, serviceId: String, characteristicId: String, data: ArrayBuffer, withResponse: Boolean, callback: (success: Boolean, responseData: ArrayBuffer, error: String) -> Unit ) { try { val gatt = connectedDevices[deviceId] if (gatt == null) { callback(false, ArrayBuffer.allocate(0), "Device not connected") return } val service = gatt.getService(UUID.fromString(serviceId)) if (service == null) { callback(false, ArrayBuffer.allocate(0), "Service not found") return } val characteristic = service.getCharacteristic(UUID.fromString(characteristicId)) if (characteristic == null) { callback(false, ArrayBuffer.allocate(0), "Characteristic not found") return } // Convert ArrayBuffer to byte array using proper Nitro API val byteBuffer = data.getBuffer(copyIfNeeded = true) val bytes = ByteArray(byteBuffer.remaining()) byteBuffer.get(bytes) characteristic.value = bytes characteristic.writeType = if (withResponse) { BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT } else { BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE } if (withResponse) { // Store callback for when response comes back val callbackKey = "$deviceId:$characteristicId" writeCallbacks[callbackKey] = callback } val success = gatt.writeCharacteristic(characteristic) if (!withResponse) { // For no response, call callback immediately callback(success, ArrayBuffer.allocate(0), if (success) "" else "Write operation failed") } else if (!success) { // If write initiation failed, remove callback and notify val callbackKey = "$deviceId:$characteristicId" writeCallbacks.remove(callbackKey) callback(false, ArrayBuffer.allocate(0), "Write operation failed to initiate") } // If withResponse and success, wait for onCharacteristicWrite } catch (e: Exception) { callback(false, ArrayBuffer.allocate(0), "Write error: ${e.message}") } } override fun subscribeToCharacteristic( deviceId: String, serviceId: String, characteristicId: String, updateCallback: (characteristicId: String, data: ArrayBuffer) -> Unit, completionCallback: (success: Boolean, error: String) -> Unit ) { try { val gatt = connectedDevices[deviceId] if (gatt == null) { completionCallback(false, "Device not connected") return } val service = gatt.getService(UUID.fromString(serviceId)) if (service == null) { completionCallback(false, "Service not found") return } val characteristic = service.getCharacteristic(UUID.fromString(characteristicId)) if (characteristic == null) { completionCallback(false, "Characteristic not found") return } // Enable notifications locally val success = gatt.setCharacteristicNotification(characteristic, true) if (!success) { completionCallback(false, "Failed to enable notifications") return } val subscriptionKey = "$serviceId:$characteristicId" val cccdValue = when (cccdSubscriptionMode(characteristic.properties)) { CccdSubscriptionMode.INDICATE -> BluetoothGattDescriptor.ENABLE_INDICATION_VALUE CccdSubscriptionMode.NOTIFY -> BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE CccdSubscriptionMode.UNSUPPORTED -> { completionCallback(false, "Characteristic does not support notify or indicate") return } } // Write to the CCCD descriptor to enable notifications or indications on the remote device. // The update callback is stored only after the descriptor write succeeds // so that isSubscribedToCharacteristic reflects actual BLE state. val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) if (descriptor != null) { enqueueGattOperation( GattOperation.WriteDescriptor( gatt = gatt, descriptor = descriptor, value = cccdValue, deviceId = deviceId, callback = { success, error -> if (success) { val callbacks = deviceCallbacks[deviceId] callbacks?.characteristicSubscriptions?.set(subscriptionKey, updateCallback) } completionCallback(success, error) } ) ) } else { // No CCCD descriptor - store callback and report success val callbacks = deviceCallbacks[deviceId] callbacks?.characteristicSubscriptions?.set(subscriptionKey, updateCallback) completionCallback(true, "") } } catch (e: Exception) { completionCallback(false, "Subscription error: ${e.message}") } } override fun unsubscribeFromCharacteristic( deviceId: String, serviceId: String, characteristicId: String, callback: (success: Boolean, error: String) -> Unit ) { try { val gatt = connectedDevices[deviceId] if (gatt == null) { callback(false, "Device not connected") return } val service = gatt.getService(UUID.fromString(serviceId)) if (service == null) { callback(false, "Service not found") return } val characteristic = service.getCharacteristic(UUID.fromString(characteristicId)) if (characteristic == null) { callback(false, "Characteristic not found") return } // Disable notifications locally val success = gatt.setCharacteristicNotification(characteristic, false) if (!success) { callback(false, "Failed to disable notifications") return } val subscriptionKey = "$serviceId:$characteristicId" // Write to the CCCD descriptor to disable notifications on the remote device. // The subscription entry is removed only after the descriptor write succeeds, // mirroring the subscribe pattern to avoid a stale-entry race when // subscribe and unsubscribe are called in rapid succession. val descriptor = characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) if (descriptor != null) { enqueueGattOperation( GattOperation.WriteDescriptor( gatt = gatt, descriptor = descriptor, value = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE, deviceId = deviceId, callback = { success, error -> if (success) { val callbacks = deviceCallbacks[deviceId] callbacks?.characteristicSubscriptions?.remove(subscriptionKey) } callback(success, error) } ) ) } else { // No CCCD descriptor - remove immediately val callbacks = deviceCallbacks[deviceId] callbacks?.characteristicSubscriptions?.remove(subscriptionKey) callback(true, "") } } catch (e: Exception) { callback(false, "Unsubscription error: ${e.message}") } } override fun isSubscribedToCharacteristic( deviceId: String, serviceId: String, characteristicId: String ): Boolean { val callbacks = deviceCallbacks[deviceId] ?: return false val subscriptionKey = "$serviceId:$characteristicId" return callbacks.characteristicSubscriptions.containsKey(subscriptionKey) } // Bluetooth state management override fun requestBluetoothEnable(callback: (success: Boolean, error: String) -> Unit) { try { initializeBluetoothIfNeeded() val adapter = bluetoothAdapter if (adapter == null) { callback(false, "Bluetooth not supported on this device") return } if (adapter.isEnabled) { callback(true, "") return } // Request user to enable Bluetooth try { val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) appContext?.let { ctx -> enableBtIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ctx.startActivity(enableBtIntent) callback(true, "Bluetooth enable request sent") } ?: callback(false, "Context not available") } catch (securityException: SecurityException) { callback(false, "Permission denied: Cannot request Bluetooth enable. Please check app permissions.") } } catch (e: Exception) { callback(false, "Error requesting Bluetooth enable: ${e.message}") } } override fun state(): BLEState { // Check permissions first if (!hasBluetoothPermissions()) { return BLEState.UNAUTHORIZED } initializeBluetoothIfNeeded() val adapter = bluetoothAdapter ?: return BLEState.UNSUPPORTED return try { bluetoothStateToBlEState(adapter.state) } catch (securityException: SecurityException) { BLEState.UNAUTHORIZED } } override fun subscribeToStateChange(stateCallback: (state: BLEState) -> Unit): OperationResult { try { val context = appContext ?: return OperationResult(success = false, error = "Context not available") // Unsubscribe from any existing subscription unsubscribeFromStateChange() // Store the callback this.stateCallback = stateCallback // Create and register broadcast receiver bluetoothStateReceiver = createBluetoothStateReceiver() val intentFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.registerReceiver(bluetoothStateReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED) } else { context.registerReceiver(bluetoothStateReceiver, intentFilter) } return OperationResult(success = true, error = null) } catch (e: Exception) { return OperationResult(success = false, error = "Error subscribing to state changes: ${e.message}") } } override fun unsubscribeFromStateChange(): OperationResult { try { // Clear the callback this.stateCallback = null // Unregister broadcast receiver if it exists bluetoothStateReceiver?.let { receiver -> val context = appContext if (context != null) { try { context.unregisterReceiver(receiver) } catch (e: IllegalArgumentException) { // Receiver was not registered, ignore } } bluetoothStateReceiver = null } return OperationResult(success = true, error = null) } catch (e: Exception) { return OperationResult(success = false, error = "Error unsubscribing from state changes: ${e.message}") } } override fun openSettings(): Promise { val promise = Promise() try { val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS) appContext?.let { ctx -> intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ctx.startActivity(intent) promise.resolve(Unit) } ?: promise.reject(Exception("Context not available")) } catch (e: Exception) { promise.reject(Exception("Error opening Bluetooth settings: ${e.message}")) } return promise } }