Your users complain about intermittent BLE failures. Devices connect, then mysteriously stop responding. Writes fail, reads time out, and notifications are sporadic. You've checked the peripheral firmware, meticulously reviewed your byte arrays, and still, the flakiness persists. Sound familiar? If you've been building serious Android BLE applications, you've almost certainly encountered the dreaded GATT race condition. It's a silent killer of reliability, often manifesting as non-deterministic failures that are incredibly hard to debug.
This article cuts through the noise. We'll diagnose the root cause of these race conditions in Android's BluetoothGatt API and then engineer a robust, production-grade solution using Kotlin Coroutines to enforce reliable, sequential GATT operations. No more guessing, no more random disconnections – just predictable, rock-solid BLE communication.
Core Concepts: The Silent Killer – Android's GATT Race Condition
At the heart of the problem lies the fundamental mismatch between Android's BluetoothGatt API design and the inherently sequential nature of GATT operations.
When you interact with a BLE peripheral, you're performing a series of operations: discover services, read characteristics, write characteristic values, enable notifications, and so on. Each of these operations is a "transaction" that the peripheral (and your Android device's Bluetooth stack) must process in order.
Consider the typical flow for writing a characteristic:
- Call
bluetoothGatt.writeCharacteristic(characteristic). - Wait for the
onCharacteristicWritecallback inBluetoothGattCallback. - Process the result.
The critical detail here is that bluetoothGatt.writeCharacteristic() (and readCharacteristic(), setCharacteristicNotification(), requestMtu(), etc.) are non-blocking methods. They return immediately. The actual operation is then queued internally by the Android Bluetooth stack and executed asynchronously. The result arrives much later via the BluetoothGattCallback.
The Race: The problem arises when you call multiple BluetoothGatt methods in rapid succession without waiting for the previous operation's completion callback. For example:
// DANGER: Race condition highly probable!
bluetoothGatt.writeCharacteristic(char1) // Call 1
bluetoothGatt.readCharacteristic(char2) // Call 2
bluetoothGatt.setCharacteristicNotification(char3, true) // Call 3
What happens here? All three calls are immediately dispatched to the Android Bluetooth stack. The internal state machine of the BluetoothGatt object is not designed to handle multiple concurrent operations. If you call writeCharacteristic while a previous readCharacteristic is still pending (i.e., onCharacteristicRead hasn't fired yet), the stack might:
- Drop the new operation: Simply ignore your second call.
- Corrupt its internal state: Lead to incorrect callback responses for unrelated operations.
- Return
false: Indicating the operation could not be enqueued. This is a clear signal of an overloaded or busy GATT queue. - Trigger a disconnect: The most frustrating outcome, as the stack gets into an unrecoverable state.
This effectively creates a race condition: which operation finishes first? Will the BluetoothGatt object be ready for the next command when you issue it? The answer is often "no," leading to the intermittent, hard-to-debug issues you've likely faced.
The Solution: A Synchronized Operation Queue
To eliminate this race, you must impose strict sequential execution. Only one GATT operation should be active at any given time. We achieve this by:
- Defining Discrete Operations: Encapsulating each GATT command (read, write, notify, MTU request) into a distinct, identifiable object.
- Queuing Operations: Submitting these operations to a single, ordered queue.
- Serial Processing: Consuming operations from the queue one at a time, ensuring the previous operation's
BluetoothGattCallbackcompletes before initiating the next. - Asynchronous Awaiting: Allowing the calling code to
suspenduntil its requested GATT operation has completed or timed out.
Here's an ASCII diagram illustrating this reliable flow:
+----------------+ +-------------------+ +---------------------+
| Application | | GATT Operation | | Android Bluetooth |
| (Coroutines) | | Queue | | Stack |
+----------------+ +-------------------+ +---------------------+
| | |
| 1. Request Op A | |
|----------------------->| |
| | 2. Dequeue Op A |
| |---------------------------->|
| | | 3. Execute Op A
| | | (e.g., writeCharacteristic)
| Suspend | |
|<-----------------------| |
| | | 4. Op A Result
| | |<---------------------+
| | | (e.g., onCharacteristicWrite)
| | |
| | 5. Mark Op A complete |
| |<----------------------------|
| | |
| Resume (Op A done) | |
|----------------------->| |
| 6. Request Op B | |
|----------------------->| |
| | 7. Dequeue Op B |
| |---------------------------->|
| | | 8. Execute Op B
| | |
... and so on ...
Kotlin Coroutines, particularly Channel and suspendCancellableCoroutine, provide the perfect primitives to build this robust system.
Implementation: Building the GATT Operation Processor
We'll build a GattClient class that encapsulates all BluetoothGatt interactions, manages the operation queue, and bridges the callback-based API to a suspendable Coroutine API.
Requirements & Setup
- Android API Level: 21+ (for BLE)
- Kotlin Coroutines:
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.x(or latest stable) -
Permissions (Android Manifest):
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />(Note:
ACCESS_FINE_LOCATIONis required for BLE scanning on Android 11 and below.BLUETOOTH_SCANandBLUETOOTH_CONNECTare for Android 12+.)
The Core Components
-
GattOperationsealed class: Defines the types of operations we support. -
BluetoothGattManager(orGattClient): The central class managing theBluetoothGattinstance, its callback, and the operation queue. -
Channelfor the queue: AChannelis an excellent choice for sending a stream of operations. -
suspendCancellableCoroutine: The crucial piece to convert callback-based GATT operations into suspendable functions. - Timeouts: Every GATT operation needs a timeout to prevent indefinite waiting.
Gotchas and Considerations
-
BluetoothGattCallbackThread: TheBluetoothGattCallbackmethods are typically invoked on the main application thread ifBluetoothDevice.connectGatt()is called without specifying aHandler. However, if you pass aHandlertoconnectGatt, callbacks will be on thatHandler's thread. Regardless, avoid blocking this thread. Always dispatch heavy processing or UI updates to appropriate dispatchers (Dispatchers.Default,Dispatchers.Main). - Error Propagation: You need a mechanism to propagate GATT errors (e.g.,
GATT_FAILUREstatus codes) from theBluetoothGattCallbackback to the awaitingsuspendfunction. - Connection State: The operation queue should only process commands when the GATT connection is established and stable. Operations submitted during a disconnected state should either be queued for later, rejected, or trigger a connection attempt.
Code Examples
Let's build out the GattClient class.
Snippet 1: Defining Operations and the GattClient Skeleton
First, we define our GattOperation types. These encapsulate the characteristic, value, and any other data required for the operation.
// GattOperation.kt
package com.example.ble.client
import android.bluetooth.BluetoothGattCharacteristic
import java.util.UUID
/**
* Represents a single, discrete GATT operation to be performed.
* This sealed class helps ensure all possible operations are handled explicitly.
*/
sealed class GattOperation {
data class ReadCharacteristic(val characteristic: BluetoothGattCharacteristic) : GattOperation()
data class WriteCharacteristic(val characteristic: BluetoothGattCharacteristic, val value: ByteArray) : GattOperation() {
// Essential for proper array comparison in data classes
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as WriteCharacteristic
if (characteristic != other.characteristic) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result = characteristic.hashCode()
result = 31 * result + value.contentHashCode()
return result
}
}
data class SetCharacteristicNotification(val characteristic: BluetoothGattCharacteristic, val enable: Boolean) : GattOperation()
data class RequestMtu(val mtu: Int) : GattOperation()
// Add other operations as needed, e.g., ReadDescriptor, WriteDescriptor
}
Now, the GattClient skeleton, which includes the BluetoothGattCallback and the Channel for our operation queue.
// GattClient.kt
package com.example.ble.client
import android.bluetooth.*
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeoutException
class GattClient(
private val context: Context,
private val device: BluetoothDevice,
private val scope: CoroutineScope // Parent scope for GATT operations
) {
private val TAG = "GattClient"
private var bluetoothGatt: BluetoothGatt? = null
private val operationChannel = Channel<GattOperation>(Channel.UNLIMITED) // Unbuffered channel for operations
private val operationCompletions = ConcurrentHashMap<GattOperation, CompletableDeferred<Unit>>() // To link operations to their completion
// State flow for connection status
private val _connectionState = MutableStateFlow(BluetoothProfile.STATE_DISCONNECTED)
val connectionState: StateFlow<Int> = _connectionState.asStateFlow()
// Listener for characteristic notifications
private val _characteristicChanges = MutableSharedFlow<Pair<BluetoothGattCharacteristic, ByteArray>>()
val characteristicChanges: SharedFlow<Pair<BluetoothGattCharacteristic, ByteArray>> = _characteristicChanges.asSharedFlow()
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
Log.d(TAG, "onConnectionStateChange: status=$status newState=$newState")
if (status == BluetoothGatt.GATT_SUCCESS) {
_connectionState.value = newState
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Start service discovery only after successful connection
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
// Clean up on disconnect
closeGatt()
}
} else {
Log.e(TAG, "Connection failed with status $status, closing GATT.")
_connectionState.value = newState // Update to DISCONNECTED
closeGatt()
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
Log.d(TAG, "onServicesDiscovered: status=$status")
if (status == BluetoothGatt.GATT_SUCCESS) {
// Services discovered. We can now proceed with operations.
// This doesn't directly complete a specific operation,
// but signals readiness for the queue processor.
// You might have a Deferred specifically for service discovery if needed.
} else {
Log.e(TAG, "Service discovery failed with status $status")
}
}
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
Log.d(TAG, "onCharacteristicRead for ${characteristic.uuid}: status=$status")
val operation = GattOperation.ReadCharacteristic(characteristic)
completeOperation(operation, status) {
// Here, you'd typically pass the value to the caller
// For simplicity, we just complete the operation.
// A more advanced design would have `CompletableDeferred<ByteArray>`
// tied to the operation.
}
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
Log.d(TAG, "onCharacteristicWrite for ${characteristic.uuid}: status=$status")
// Note: The value written is not available here, so we must rely on our stored operation.
// This is why we created a data class for WriteCharacteristic with the value.
// However, for completing the deferred, the value itself isn't needed, only the characteristic.
// We need a way to link back to the exact operation that was initiated.
// A more robust solution might pass an 'id' with each operation.
// For this example, we'll try to match by characteristic, which works IF only one write per char is pending.
val operation = GattOperation.WriteCharacteristic(characteristic, byteArrayOf()) // Dummy value for lookup, problematic if multiple writes to same char
completeOperation(operation, status) { /* value not returned by callback */ }
}
// Newer Android 13+ API for characteristic writes with response
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
Log.d(TAG, "onCharacteristicWrite (API 33) for ${characteristic.uuid}: status=$status")
val operation = GattOperation.WriteCharacteristic(characteristic, value) // Value is now available!
completeOperation(operation, status) { /* value handled implicitly */ }
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
Log.d(TAG, "onCharacteristicChanged for ${characteristic.uuid}: value=${value.toHexString()}")
scope.launch {
// Dispatch characteristic changes asynchronously
_characteristicChanges.emit(characteristic to value)
}
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) {
Log.d(TAG, "onCharacteristicChanged (API 33) for ${characteristic.uuid}: value=${value.toHexString()} status=$status")
scope.launch {
if (status == BluetoothGatt.GATT_SUCCESS) {
_characteristicChanges.emit(characteristic to value)
} else {
Log.e(TAG, "Characteristic change failed with status $status for ${characteristic.uuid}")
}
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.d(TAG, "onMtuChanged: mtu=$mtu status=$status")
val operation = GattOperation.RequestMtu(mtu)
completeOperation(operation, status) { /* MTU changed */ }
}
// Add other relevant callbacks like onDescriptorRead, onDescriptorWrite, onPhyUpdate, etc.
}
init {
// Start the operation processor coroutine
scope.launch {
processGattOperations()
}
}
private suspend fun processGattOperations() {
for (operation in operationChannel) {
// Ensure GATT is connected before processing operations
while (connectionState.value != BluetoothProfile.STATE_CONNECTED || bluetoothGatt == null) {
Log.w(TAG, "Gatt not connected, waiting for connection before processing operation: $operation")
// Wait for connection state to change, or implement a timeout/retry
// For simplicity, we just delay. A real app might re-queue or fail.
delay(1000)
}
Log.d(TAG, "Processing operation: $operation")
val gatt = bluetoothGatt ?: run {
Log.e(TAG, "BluetoothGatt is null while processing operation. Skipping.")
// If GATT is null, the operation cannot be performed.
// We should complete the associated Deferred with an error.
operationCompletions[operation]?.completeExceptionally(IllegalStateException("BluetoothGatt not initialized"))
operationCompletions.remove(operation)
continue
}
val success = when (operation) {
is GattOperation.ReadCharacteristic -> gatt.readCharacteristic(operation.characteristic)
is GattOperation.WriteCharacteristic -> {
operation.characteristic.value = operation.value
gatt.writeCharacteristic(operation.characteristic)
}
is GattOperation.SetCharacteristicNotification -> {
val descriptor = operation.characteristic.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")) // Client Characteristic Configuration
?: run {
Log.e(TAG, "CCC descriptor not found for ${operation.characteristic.uuid}")
false
}
val result = gatt.setCharacteristicNotification(operation.characteristic, operation.enable)
if (result) {
descriptor.value = if (operation.enable) {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
} else {
BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
}
gatt.writeDescriptor(descriptor) // Writing the descriptor is also an async op!
// For simplicity, we are not queueing descriptor write as a separate op here.
// A truly robust solution would queue descriptor writes as well.
true
} else {
false
}
}
is GattOperation.RequestMtu -> gatt.requestMtu(operation.mtu)
}
if (!success) {
Log.e(TAG, "Failed to enqueue GATT operation: $operation")
// If `bluetoothGatt` method returns false, it means the operation was not even accepted by the stack.
// We should immediately complete the deferred with an error.
operationCompletions[operation]?.completeExceptionally(IllegalStateException("Failed to enqueue GATT operation with Bluetooth stack"))
operationCompletions.remove(operation)
}
// The operation will be completed by the gattCallback
// We now wait for the callback to complete the associated Deferred
}
}
/**
* Completes a deferred GATT operation based on the callback status.
* Must be called from the `BluetoothGattCallback` thread.
*/
private fun completeOperation(operation: GattOperation, status: Int, onGattSuccess: () -> Unit) {
val deferred = operationCompletions[operation]
if (deferred == null) {
Log.w(TAG, "No deferred found for completed operation: $operation. Likely an unsolicited callback or already handled.")
return
}
if (status == BluetoothGatt.GATT_SUCCESS) {
onGattSuccess()
deferred.complete(Unit)
} else {
deferred.completeExceptionally(GattOperationException("GATT operation failed with status: $status", status))
}
operationCompletions.remove(operation) // Remove once completed
}
/**
* Connects to the GATT server.
*/
fun connect() {
if (_connectionState.value == BluetoothProfile.STATE_CONNECTED) {
Log.w(TAG, "Already connected, skipping connect()")
return
}
Log.d(TAG, "Attempting to connect to GATT server for ${device.address}")
bluetoothGatt = device.connectGatt(context, false, gattCallback)
// AutoConnect=false is generally recommended for explicit control.
// If true, the device will try to reconnect automatically after disconnect.
}
/**
* Disconnects from the GATT server and closes resources.
*/
fun disconnect() {
Log.d(TAG, "Disconnecting GATT for ${device.address}")
bluetoothGatt?.disconnect() // Will trigger onConnectionStateChange
}
/**
* Closes the GATT client and releases resources.
* Called after disconnect or on connection failure.
*/
private fun closeGatt() {
Log.d(TAG, "Closing GATT client for ${device.address}")
bluetoothGatt?.close()
bluetoothGatt = null
// Fail any pending operations
operationCompletions.forEach { (_, deferred) ->
deferred.completeExceptionally(IllegalStateException("GATT connection closed unexpectedly"))
}
operationCompletions.clear()
_connectionState.value = BluetoothProfile.STATE_DISCONNECTED
}
/**
* Suspends until a characteristic read operation completes or times out.
*/
suspend fun readCharacteristic(characteristic: BluetoothGattCharacteristic): ByteArray = withContext(Dispatchers.IO) {
val operation = GattOperation.ReadCharacteristic(characteristic)
// Use suspendCancellableCoroutine to bridge callback-based API with coroutines
val result = suspendCancellableCoroutine<ByteArray> { continuation ->
// Store the deferred completion for this specific operation
val deferred = CompletableDeferred<Unit>().also { operationCompletions[operation] = it }
scope.launch {
try {
// Send the operation to the queue
operationChannel.send(operation)
// Wait for the operation to complete via callback or timeout
withTimeout(GATT_OPERATION_TIMEOUT_MS) {
deferred.await() // Await the completion of the operation
}
// For a read, we need the actual value.
// This is a simplification; a real solution would store the read value
// in the `CompletableDeferred` or make `operationCompletions` map
// to `CompletableDeferred<ByteArray>` for read operations.
// For now, we simulate success and return a dummy.
continuation.resume(characteristic.value ?: byteArrayOf()) // Replace with actual read value
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Read characteristic timeout for ${characteristic.uuid}", e)
// Remove the pending operation from map if it timed out
operationCompletions.remove(operation)
continuation.resumeWithException(e)
} catch (e: Exception) {
Log.e(TAG, "Error during read characteristic for ${characteristic.uuid}", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
}
}
}
result
}
/**
* Suspends until a characteristic write operation completes or times out.
*/
suspend fun writeCharacteristic(characteristic: BluetoothGattCharacteristic, value: ByteArray): Unit = withContext(Dispatchers.IO) {
val operation = GattOperation.WriteCharacteristic(characteristic, value)
suspendCancellableCoroutine { continuation ->
val deferred = CompletableDeferred<Unit>().also { operationCompletions[operation] = it }
scope.launch {
try {
operationChannel.send(operation)
withTimeout(GATT_OPERATION_TIMEOUT_MS) {
deferred.await()
}
continuation.resume(Unit)
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Write characteristic timeout for ${characteristic.uuid}", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
} catch (e: Exception) {
Log.e(TAG, "Error during write characteristic for ${characteristic.uuid}", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
}
}
}
}
/**
* Suspends until a notification enable/disable operation completes or times out.
*/
suspend fun setCharacteristicNotification(characteristic: BluetoothGattCharacteristic, enable: Boolean): Unit = withContext(Dispatchers.IO) {
val operation = GattOperation.SetCharacteristicNotification(characteristic, enable)
suspendCancellableCoroutine { continuation ->
val deferred = CompletableDeferred<Unit>().also { operationCompletions[operation] = it }
scope.launch {
try {
operationChannel.send(operation)
withTimeout(GATT_OPERATION_TIMEOUT_MS) {
deferred.await()
}
continuation.resume(Unit)
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "Set notification timeout for ${characteristic.uuid}", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
} catch (e: Exception) {
Log.e(TAG, "Error during set notification for ${characteristic.uuid}", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
}
}
}
}
/**
* Suspends until an MTU request operation completes or times out.
*/
suspend fun requestMtu(mtu: Int): Unit = withContext(Dispatchers.IO) {
val operation = GattOperation.RequestMtu(mtu)
suspendCancellableCoroutine { continuation ->
val deferred = CompletableDeferred<Unit>().also { operationCompletions[operation] = it }
scope.launch {
try {
operationChannel.send(operation)
withTimeout(GATT_OPERATION_TIMEOUT_MS) {
deferred.await()
}
continuation.resume(Unit)
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "MTU request timeout for $mtu", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
} catch (e: Exception) {
Log.e(TAG, "Error during MTU request for $mtu", e)
operationCompletions.remove(operation)
continuation.resumeWithException(e)
}
}
}
}
// Custom Exception for GATT operations
class GattOperationException(message: String, val statusCode: Int) : Exception(message)
companion object {
private const val GATT_OPERATION_TIMEOUT_MS = 10000L // 10 seconds for GATT operations
}
}
// Extension function for easier hex string conversion of byte arrays
fun ByteArray.toHexString(): String =
joinToString(separator = " ", prefix = "[", postfix = "]") { String.format("%02X", it) }
Explanation of Key Parts:
-
GattOperation: Defines distinct types of BLE operations. We usedata classto enable easy comparison. ForWriteCharacteristic,contentEqualsinequalsandcontentHashCodeinhashCodeare critical for correct comparison of byte arrays. -
GattClient(..., scope: CoroutineScope): The main class. It takes aCoroutineScopeto launch its internal processing coroutine andFlowemissions. -
operationChannel: Channel<GattOperation>: An unlimitedChannelacts as our queue. Operations are sent here and processed serially. -
operationCompletions: ConcurrentHashMap<GattOperation, CompletableDeferred<Unit>>: This map is the crucial link. When asuspendfunction initiates an operation, it creates aCompletableDeferredand stores it here, keyed by theGattOperationinstance. When theBluetoothGattCallbackfires for that operation, it retrieves theDeferredand completes it. -
processGattOperations(): Thisfor (operation in operationChannel)loop runs indefinitely within its own coroutine. It dequeues one operation at a time, ensures the GATT connection is active, and then calls the correspondingbluetoothGattmethod. It then implicitly waits for theBluetoothGattCallbackto trigger. -
suspend fun readCharacteristic(...),writeCharacteristic(...), etc.: These public functions are what your application code calls. They wrap the entire process:- Create a
GattOperationinstance. - Create a
CompletableDeferred<Unit>and map it to thisGattOperation. -
sendthe operation tooperationChannel. -
await()theDeferredwith awithTimeout. If the callback doesn't fire in time,TimeoutCancellationExceptionis thrown. -
resumetheContinuationwhendeferred.await()completes.
- Create a
-
completeOperation(...): Called from within theBluetoothGattCallbackmethods. It retrieves theDeferredassociated with the completed operation and callscomplete(Unit)for success orcompleteExceptionally(...)for failure. -
onCharacteristicWrite(..., value: ByteArray, ...): Note the API 33+ version of this callback which provides the written value. This makes linking to the correctWriteCharacteristicoperation more reliable if multiple writes to the same characteristic are pending (though ouroperationCompletionsmap still relies on exactGattOperationobject identity). -
GATT_OPERATION_TIMEOUT_MS: Essential for robust operation. Without it, your app could hang indefinitely if a peripheral becomes unresponsive or the Android Bluetooth stack fails to issue a callback.
Using the GattClient
Here's how you might use this GattClient from your ViewModel or other application logic:
// In your ViewModel or Service
class MyBleViewModel(application: Application) : AndroidViewModel(application) {
private val TAG = "MyBleViewModel"
private val clientScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Dedicated scope for BLE
private val bluetoothAdapter: BluetoothAdapter? by lazy {
val bluetoothManager = application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private var gattClient: GattClient? = null
init {
// Observe connection state
clientScope.launch {
gattClient?.connectionState?.collect { state ->
Log.d(TAG, "Connection state changed: $state")
// Update UI or perform actions based on connection state
}
}
// Observe characteristic changes
clientScope.launch {
gattClient?.characteristicChanges?.collect { (characteristic, value) ->
Log.d(TAG, "Characteristic ${characteristic.uuid} changed: ${value.toHexString()}")
// Process incoming data
}
}
}
fun connectToDevice(address: String) {
val device = bluetoothAdapter?.getRemoteDevice(address) ?: run {
Log.e(TAG, "Device with address $address not found.")
return
}
gattClient?.disconnect() // Disconnect any previous client
gattClient?.closeGatt() // Close resources
gattClient = GattClient(getApplication(), device, clientScope)
gattClient?.connect()
}
fun disconnectDevice() {
gattClient?.disconnect()
}
fun performBleOperations() = clientScope.launch {
// Wait for connection to be established and services discovered
gattClient?.connectionState?.filter { it == BluetoothProfile.STATE_CONNECTED }?.first()
delay(500) // Small delay to ensure services are fully discovered after state change
val gatt = gattClient?.bluetoothGatt ?: run {
Log.e(TAG, "GattClient not ready for operations.")
return@launch
}
try {
// Assume you have service and characteristic UUIDs
val serviceUuid = UUID.fromString("0000180A-0000-1000-8000-00805f9b34fb") // Example: Device Information Service
val manufacturerCharUuid = UUID.fromString("00002A29-0000-1000-8000-00805f9b34fb") // Manufacturer Name String
val service = gatt.getService(serviceUuid) ?: run {
Log.e(TAG, "Service $serviceUuid not found.")
return@launch
}
val manufacturerChar = service.getCharacteristic(manufacturerCharUuid) ?: run {
Log.e(TAG, "Characteristic $manufacturerCharUuid not found.")
return@launch
}
// --- Sequential Operations ---
Log.d(TAG, "Attempting MTU request...")
gattClient?.requestMtu(250) // Request a larger MTU
Log.d(TAG, "MTU requested successfully.")
Log.d(TAG, "Attempting to read Manufacturer Name...")
val manufacturerNameBytes = gattClient?.readCharacteristic(manufacturerChar)
val manufacturerName = manufacturerNameBytes?.let { String(it) }
Log.d(TAG, "Read Manufacturer Name: $manufacturerName")
Log.d(TAG, "Attempting to write custom value...")
val customCharUuid = UUID.fromString("YOUR_CUSTOM_CHARACTERISTIC_UUID") // Replace with actual
val customServiceUuid = UUID.fromString("YOUR_CUSTOM_SERVICE_UUID") // Replace with actual
val customService = gatt.getService(customServiceUuid) ?: throw IllegalStateException("Custom service not found")
val customChar = customService.getCharacteristic(customCharUuid) ?: throw IllegalStateException("Custom characteristic not found")
val valueToWrite = "Hello BLE".toByteArray()
gattClient?.writeCharacteristic(customChar, valueToWrite)
Log.d(TAG, "Custom value written successfully.")
// Enable notifications for a characteristic
val notificationCharUuid = UUID.fromString("YOUR_NOTIFICATION_CHARACTERISTIC_UUID") // Replace with actual
val notificationChar = customService.getCharacteristic(notificationCharUuid) ?: throw IllegalStateException("Notification characteristic not found")
gattClient?.setCharacteristicNotification(notificationChar, true)
Log.d(TAG, "Notifications enabled for $notificationCharUuid.")
} catch (e: TimeoutCancellationException) {
Log.e(TAG, "BLE operation timed out: ${e.message}", e)
} catch (e: GattClient.GattOperationException) {
Log.e(TAG, "GATT operation failed with status ${e.statusCode}: ${e.message}", e)
} catch (e: Exception) {
Log.e(TAG, "General error during BLE operations: ${e.message}", e)
}
}
override fun onCleared() {
super.onCleared()
clientScope.cancel() // Cancel all coroutines in this scope
gattClient?.disconnect()
gattClient?.closeGatt()
}
}
Notice how performBleOperations() now reads like synchronous code, despite all underlying BLE operations being asynchronous. This is the power of Kotlin Coroutines. Each call to readCharacteristic, writeCharacteristic, etc., will suspend until the operation is truly complete (or timed out), ensuring strict sequential execution.
Best Practices for Robust BLE
Implementing the queue is a massive step, but to truly make your BLE app production-grade, consider these additional best practices:
-
Always Implement Timeouts for Every Operation:
- Pitfall: Relying solely on
BluetoothGattCallbackto signal completion can lead to indefinitely hanging operations if the peripheral becomes unresponsive, goes out of range, or the Android Bluetooth stack glitches. This blocks your queue and halts all subsequent communication. - Fix: Wrap every
CompletableDeferred.await()withwithTimeoutOrNullorwithTimeout. A typical timeout for a single GATT operation is between 5 to 15 seconds. When a timeout occurs, ensure the pendingDeferredis completed exceptionally (e.g.,TimeoutCancellationException), and remove the operation fromoperationCompletionsto prevent memory leaks or incorrect future callbacks. This allows the queue to move to the next operation or signal failure to the caller.
- Pitfall: Relying solely on
-
Properly Manage GATT Resources and Connection State:
- Pitfall: Neglecting to call
bluetoothGatt.disconnect()andbluetoothGatt.close()at the appropriate times. Android's Bluetooth stack has a finite number of GATT client slots. LeakingBluetoothGattinstances can lead to futureconnectGattcalls failing silently or with obscureGATT_CONN_FAILerrors, even after a device is seemingly disconnected. - Fix:
- Always call
bluetoothGatt.disconnect()when you explicitly want to end a session. - Always call
bluetoothGatt.close()afterdisconnect()has completed (usually inonConnectionStateChangewithnewState == STATE_DISCONNECTED) or if a connection fails. This frees up resources. - Nullify your
bluetoothGattreference afterclose()to prevent accidental reuse. - Handle
BluetoothProfile.STATE_DISCONNECTEDinonConnectionStateChangeto clean up and potentially trigger reconnect logic ifautoConnectwas set totrue. - Fail any pending operations in your
operationCompletionsmap when the connection drops, as they can no longer complete.
- Always call
- Pitfall: Neglecting to call
-
Handle Characteristic Notifications (
onCharacteristicChanged) Separately:- Pitfall: Confusing characteristic notifications with requested read/write operations.
onCharacteristicChangedis an unsolicited event from the peripheral, not a response to a specific command you issued through theBluetoothGattobject (except for the initialsetCharacteristicNotificationwhich enables it). Trying to queue these or make them block your operation queue is fundamentally incorrect. - Fix: Handle
onCharacteristicChangedcallbacks as a separate stream of data. Use KotlinSharedFloworChannel(as demonstrated inGattClient) to emit these changes. Your application can then collect from this flow independently, allowing real-time data processing without blocking the command queue. Remember thatsetCharacteristicNotification(the act of enabling or disabling notifications) is a GATT operation and should be queued.
- Pitfall: Confusing characteristic notifications with requested read/write operations.
-
Consider a Unique Identifier for Each Operation:
- Pitfall: Using
GattOperationdata classes directly as keys inoperationCompletionscan be problematic if multiple identical operations (e.g., writing the same value to the same characteristic) are enqueued. Theequalsmethod might conflate them, or you might struggle to match the exact pending operation to a callback. - Fix: Augment your
GattOperationsealed class with a uniqueid(e.g., aUUIDor an atomic counter) that is generated for each new operation instance. StoreCompletableDeferred<Unit>keyed by thisid. When a GATT callback fires, identify the operation by characteristic UUID/handle, then iterate through pending operations inoperationCompletionsto find the one matching both the characteristic and the expected state (e.g., "pending write"). For writes, especially if value isn't returned in callback (pre-API 33), the unique ID is vital.
- Pitfall: Using
Conclusion
The Android BLE GATT race condition is a significant challenge for any developer striving for robust Bluetooth communication. By understanding the asynchronous nature of BluetoothGatt operations and the need for strict serialization, you can leverage Kotlin Coroutines to build a highly reliable system. The Channel for queuing, suspendCancellableCoroutine for bridging callbacks, and diligent timeout management are your key tools. This pattern transforms flaky, non-deterministic BLE interactions into predictable, sequential operations, making your application significantly more stable and easier to maintain.
Your next step: Refactor your existing BLE client code using this queue-based, Coroutine-driven approach. You'll immediately notice a dramatic improvement in reliability and a cleaner, more readable codebase.
Top comments (0)