Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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<WriteResultFuture>
synchronized(writeResultFutureList) {
matches = writeResultFutureList.filter {
it.deviceId == gatt?.device?.address &&
it.characteristicId == characteristic.uuid.toString() &&
it.serviceId == characteristic.service?.uuid?.toString()
}
writeResultFutureList.removeAll(matches)
}
Comment thread
fotiDim marked this conversation as resolved.
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))
Comment thread
fotiDim marked this conversation as resolved.
} 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
}
}
}
Expand Down Expand Up @@ -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<WriteResultFuture>
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The subscriptionResultFutureList (and similarly readResultFutureList, mtuResultFutureList, discoverServicesFutureList, and rssiResultFutureList) suffers from the exact same thread-safety and callback-delivery issues that were just fixed for writeResultFutureList:

  1. It is mutated concurrently from the main thread (in setNotifiable) and Bluetooth binder threads (in updateSubscriptionState and cleanUpConnection) without synchronization.
  2. Pigeon/platform-channel replies are invoked directly on the binder thread instead of the main thread.
  3. removeAll invokes completion callbacks inside its predicate, which can throw and abort the loop mid-iteration, leaving entries in the list and permanently blocking later completions.

To prevent similar race conditions, timeouts, and latched failures, these lists should be refactored using the same synchronized snapshot-and-remove pattern, with completions posted to the main thread.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — readResultFutureList, subscriptionResultFutureList, mtuResultFutureList, discoverServicesFutureList, and rssiResultFutureList do share the same race, off-thread reply, and callbacks-inside-removeAll pattern. I've kept this PR scoped to the write path since that's the one we could tie to a concrete field failure with a verifiable device test.

It's probably better to create other PRs to fix other lists with some on-device tests.

Expand Down
Loading