diff --git a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt index d86b75b6..f3c767ba 100644 --- a/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt +++ b/android/src/main/kotlin/com/navideck/universal_ble/UniversalBlePlugin.kt @@ -42,6 +42,12 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), private val advertisePermissionRequestCode = 2342616 private var permissionHandler: PermissionHandler? = null private var callbackChannel: UniversalBleCallbackChannel? = null + + // Read on Bluetooth binder threads (from GATT callbacks) but assigned on the + // main thread in onAttachedToEngine/onDetachedFromEngine; @Volatile makes + // those writes visible so a binder thread never sees a stale null and drops + // a completion. + @Volatile private var mainThreadHandler: Handler? = null private lateinit var context: Context private var activity: Activity? = null @@ -724,8 +730,12 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), callback ) - // Wait for the result - writeResultFutureList.add(writeFuture) + // Wait for the result. The list is mutated both here (main thread) + // and in onCharacteristicWrite / cleanUpConnection (Bluetooth + // binder threads), so every access must hold the lock. + synchronized(writeResultFutureList) { + writeResultFutureList.add(writeFuture) + } val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(gattCharacteristic, value, writeType) @@ -739,7 +749,9 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), } if (result != BluetoothGatt.GATT_SUCCESS) { - writeResultFutureList.remove(writeFuture) + synchronized(writeResultFutureList) { + writeResultFutureList.remove(writeFuture) + } callback( Result.failure( createFlutterError( @@ -755,35 +767,59 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), } } + // Deliver on the main looper. Platform-channel replies must be sent there, + // and because matching futures are removed from their list before delivery, + // a completion must never be dropped just because the cached handler was + // cleared (a GATT callback racing onDetachedFromEngine) — that would leak + // the pending Dart await. Fall back to a fresh main-looper handler so the + // completion is always attempted. + private fun postToMainLooper(action: () -> Unit) { + (mainThreadHandler ?: Handler(Looper.getMainLooper())).post(action) + } + override fun onCharacteristicWrite( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic, status: Int, ) { - writeResultFutureList.removeAll { - if (it.deviceId == gatt?.device?.address && - it.characteristicId == characteristic.uuid.toString() && - it.serviceId == characteristic.service.uuid.toString() - ) { - if (status == BluetoothGatt.GATT_SUCCESS) { - it.result(Result.success(Unit)) - } else { - UniversalBleLogger.logError( - "WRITE_FAILED <- ${gatt?.device?.address} ${characteristic.uuid} status=$status" - ) - it.result( - Result.failure( - createFlutterError( - gattStatusToUniversalBleErrorCode(status), - "Failed to write", - status.toString() + // Runs on a binder thread: snapshot and remove matches under the lock, + // then complete each future on the main thread (platform-channel + // replies must be sent there). Completing outside the removal loop, + // with each completion individually contained, means one bad callback + // can neither corrupt the list nor block later completions. + val matches: List + synchronized(writeResultFutureList) { + matches = writeResultFutureList.filter { + it.deviceId == gatt?.device?.address && + it.characteristicId == characteristic.uuid.toString() && + it.serviceId == characteristic.service?.uuid?.toString() + } + writeResultFutureList.removeAll(matches) + } + if (status != BluetoothGatt.GATT_SUCCESS) { + UniversalBleLogger.logError( + "WRITE_FAILED <- ${gatt?.device?.address} ${characteristic.uuid} status=$status" + ) + } + for (future in matches) { + postToMainLooper { + try { + if (status == BluetoothGatt.GATT_SUCCESS) { + future.result(Result.success(Unit)) + } else { + future.result( + Result.failure( + createFlutterError( + gattStatusToUniversalBleErrorCode(status), + "Failed to write", + status.toString() + ) ) ) - ) + } + } catch (e: Exception) { + UniversalBleLogger.logError("Write completion delivery failed: $e") } - true - } else { - false } } } @@ -1104,12 +1140,18 @@ class UniversalBlePlugin : UniversalBlePlatformChannel, BluetoothGattCallback(), false } } - writeResultFutureList.removeAll { - if (it.deviceId == deviceId) { - it.result(Result.failure(deviceDisconnectedError)) - true - } else { - false + val pendingWrites: List + synchronized(writeResultFutureList) { + pendingWrites = writeResultFutureList.filter { it.deviceId == deviceId } + writeResultFutureList.removeAll(pendingWrites) + } + for (future in pendingWrites) { + postToMainLooper { + try { + future.result(Result.failure(deviceDisconnectedError)) + } catch (e: Exception) { + UniversalBleLogger.logError("Write completion delivery failed: $e") + } } } subscriptionResultFutureList.removeAll {