DEV Community

Ble Advertiser
Ble Advertiser

Posted on

Solving the Android BLE GATT Race Condition: Reliable Sequential Operations with Kotlin Coroutines

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:

  1. Call bluetoothGatt.writeCharacteristic(characteristic).
  2. Wait for the onCharacteristicWrite callback in BluetoothGattCallback.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Defining Discrete Operations: Encapsulating each GATT command (read, write, notify, MTU request) into a distinct, identifiable object.
  2. Queuing Operations: Submitting these operations to a single, ordered queue.
  3. Serial Processing: Consuming operations from the queue one at a time, ensuring the previous operation's BluetoothGattCallback completes before initiating the next.
  4. Asynchronous Awaiting: Allowing the calling code to suspend until 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 ...
Enter fullscreen mode Exit fullscreen mode

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_LOCATION is required for BLE scanning on Android 11 and below. BLUETOOTH_SCAN and BLUETOOTH_CONNECT are for Android 12+.)

The Core Components

  1. GattOperation sealed class: Defines the types of operations we support.
  2. BluetoothGattManager (or GattClient): The central class managing the BluetoothGatt instance, its callback, and the operation queue.
  3. Channel for the queue: A Channel is an excellent choice for sending a stream of operations.
  4. suspendCancellableCoroutine: The crucial piece to convert callback-based GATT operations into suspendable functions.
  5. Timeouts: Every GATT operation needs a timeout to prevent indefinite waiting.

Gotchas and Considerations

  • BluetoothGattCallback Thread: The BluetoothGattCallback methods are typically invoked on the main application thread if BluetoothDevice.connectGatt() is called without specifying a Handler. However, if you pass a Handler to connectGatt, callbacks will be on that Handler'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_FAILURE status codes) from the BluetoothGattCallback back to the awaiting suspend function.
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

Explanation of Key Parts:

  • GattOperation: Defines distinct types of BLE operations. We use data class to enable easy comparison. For WriteCharacteristic, contentEquals in equals and contentHashCode in hashCode are critical for correct comparison of byte arrays.
  • GattClient(..., scope: CoroutineScope): The main class. It takes a CoroutineScope to launch its internal processing coroutine and Flow emissions.
  • operationChannel: Channel<GattOperation>: An unlimited Channel acts as our queue. Operations are sent here and processed serially.
  • operationCompletions: ConcurrentHashMap<GattOperation, CompletableDeferred<Unit>>: This map is the crucial link. When a suspend function initiates an operation, it creates a CompletableDeferred and stores it here, keyed by the GattOperation instance. When the BluetoothGattCallback fires for that operation, it retrieves the Deferred and completes it.
  • processGattOperations(): This for (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 corresponding bluetoothGatt method. It then implicitly waits for the BluetoothGattCallback to trigger.
  • suspend fun readCharacteristic(...), writeCharacteristic(...), etc.: These public functions are what your application code calls. They wrap the entire process:
    1. Create a GattOperation instance.
    2. Create a CompletableDeferred<Unit> and map it to this GattOperation.
    3. send the operation to operationChannel.
    4. await() the Deferred with a withTimeout. If the callback doesn't fire in time, TimeoutCancellationException is thrown.
    5. resume the Continuation when deferred.await() completes.
  • completeOperation(...): Called from within the BluetoothGattCallback methods. It retrieves the Deferred associated with the completed operation and calls complete(Unit) for success or completeExceptionally(...) for failure.
  • onCharacteristicWrite(..., value: ByteArray, ...): Note the API 33+ version of this callback which provides the written value. This makes linking to the correct WriteCharacteristic operation more reliable if multiple writes to the same characteristic are pending (though our operationCompletions map still relies on exact GattOperation object 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Always Implement Timeouts for Every Operation:

    • Pitfall: Relying solely on BluetoothGattCallback to 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() with withTimeoutOrNull or withTimeout. A typical timeout for a single GATT operation is between 5 to 15 seconds. When a timeout occurs, ensure the pending Deferred is completed exceptionally (e.g., TimeoutCancellationException), and remove the operation from operationCompletions to prevent memory leaks or incorrect future callbacks. This allows the queue to move to the next operation or signal failure to the caller.
  2. Properly Manage GATT Resources and Connection State:

    • Pitfall: Neglecting to call bluetoothGatt.disconnect() and bluetoothGatt.close() at the appropriate times. Android's Bluetooth stack has a finite number of GATT client slots. Leaking BluetoothGatt instances can lead to future connectGatt calls failing silently or with obscure GATT_CONN_FAIL errors, even after a device is seemingly disconnected.
    • Fix:
      • Always call bluetoothGatt.disconnect() when you explicitly want to end a session.
      • Always call bluetoothGatt.close() after disconnect() has completed (usually in onConnectionStateChange with newState == STATE_DISCONNECTED) or if a connection fails. This frees up resources.
      • Nullify your bluetoothGatt reference after close() to prevent accidental reuse.
      • Handle BluetoothProfile.STATE_DISCONNECTED in onConnectionStateChange to clean up and potentially trigger reconnect logic if autoConnect was set to true.
      • Fail any pending operations in your operationCompletions map when the connection drops, as they can no longer complete.
  3. Handle Characteristic Notifications (onCharacteristicChanged) Separately:

    • Pitfall: Confusing characteristic notifications with requested read/write operations. onCharacteristicChanged is an unsolicited event from the peripheral, not a response to a specific command you issued through the BluetoothGatt object (except for the initial setCharacteristicNotification which enables it). Trying to queue these or make them block your operation queue is fundamentally incorrect.
    • Fix: Handle onCharacteristicChanged callbacks as a separate stream of data. Use Kotlin SharedFlow or Channel (as demonstrated in GattClient) to emit these changes. Your application can then collect from this flow independently, allowing real-time data processing without blocking the command queue. Remember that setCharacteristicNotification (the act of enabling or disabling notifications) is a GATT operation and should be queued.
  4. Consider a Unique Identifier for Each Operation:

    • Pitfall: Using GattOperation data classes directly as keys in operationCompletions can be problematic if multiple identical operations (e.g., writing the same value to the same characteristic) are enqueued. The equals method might conflate them, or you might struggle to match the exact pending operation to a callback.
    • Fix: Augment your GattOperation sealed class with a unique id (e.g., a UUID or an atomic counter) that is generated for each new operation instance. Store CompletableDeferred<Unit> keyed by this id. When a GATT callback fires, identify the operation by characteristic UUID/handle, then iterate through pending operations in operationCompletions to 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.

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)