BluetoothGatt - what it is, methods and GATT BLE protocol in Android

Author: IT Sectr Published: 2026-07-16 Reading time: 10 min

BluetoothGatt is an Android class that provides an API for GATT (Generic Attribute Profile) client operations over a BLE connection. BluetoothGatt encapsulates the connection to a remote GATT server (peripheral BLE device) and manages all profile operations: service discovery, reading and writing characteristics, subscribing to notifications and indications. A BluetoothGatt instance is obtained via BluetoothDevice.connectGatt() with a BluetoothGattCallback. According to Android Developers, 2026, BluetoothGatt is the central class for bidirectional BLE communication, supporting GATT operations from BLE 4.0 to BLE 5.4.

Key Takeaways

  • BluetoothGatt is an Android class for GATT client that manages BLE connection with a peripheral device
  • connectGatt() is a BluetoothDevice method to create BluetoothGatt; it accepts context, autoConnect, callback and transport
  • discoverServices() is a method to obtain the GATT hierarchy: services (BluetoothGattService), characteristics (BluetoothGattCharacteristic)
  • readCharacteristic/writeCharacteristic are read and write methods with asynchronous results via BluetoothGattCallback
  • setCharacteristicNotification is a method for subscribing to BLE notifications with mandatory CCCD descriptor write

What is BluetoothGatt: essence and connection creation

BluetoothGatt is a proxy object representing a GATT connection between an Android device (central) and BLE peripheral (server). Each BluetoothGatt instance corresponds to one active BLE connection. All GATT profile operations are performed through it: discovery, reading, writing, notifications. BluetoothGatt is not created directly — it is returned by the BluetoothDevice.connectGatt() method.

Creating a BluetoothGatt requires four parameters. Context — the application context (Activity or Application). autoConnect — if false, Android immediately initiates a direct connection; if true, Android connects automatically when the device is detected (useful for background connection). BluetoothGattCallback — mandatory callback for all GATT events. transport — BluetoothDevice.TRANSPORT_LE (BLE) or TRANSPORT_BREDR (Classic). Always use TRANSPORT_LE on BLE devices.

BluetoothGatt lifecycle consists of five states. DISCONNECTED — initial state. CONNECTING — after calling connectGatt, before confirmation. CONNECTED — after onConnectionStateChange with STATE_CONNECTED. After connection, discoverServices() is called to obtain the GATT hierarchy. After finishing work — disconnect() and close() to free system resources. Without calling close(), the application may exhaust the Android BLE connection limit (usually 4–8).

kotlin
// Creating BluetoothGatt connection
import android.bluetooth.*

class GattConnector(private val context: Context) {

    private var bluetoothGatt: BluetoothGatt? = null

    fun connect(device: BluetoothDevice): BluetoothGatt? {
        // Close previous connection if exists
        close()

        bluetoothGatt = device.connectGatt(
            context,
            false,  // autoConnect = false ( )
            object : BluetoothGattCallback() {
                override fun onConnectionStateChange(
                    gatt: BluetoothGatt, status: Int, newState: Int
                ) {
                    when (newState) {
                        BluetoothProfile.STATE_CONNECTED -> {
                            // 2. Connection established → Discovery
                            gatt.discoverServices()
                        }
                        BluetoothProfile.STATE_DISCONNECTED -> {
                            // 3. Connection lost
                            close()
                        }
                    }
                }

                override fun onServicesDiscovered(
                    gatt: BluetoothGatt, status: Int
                ) {
                    if (status == BluetoothGatt.GATT_SUCCESS) {
                        // 4. GATT hierarchy received
                        onGattReady(gatt)
                    }
                }
            },
            BluetoothDevice.TRANSPORT_LE
        )
        return bluetoothGatt
    }

    private fun onGattReady(gatt: BluetoothGatt) {
        // GATT ready for read/write operations
    }

    fun close() {
        bluetoothGatt?.disconnect()
        bluetoothGatt?.close()
        bluetoothGatt = null
    }
}

The GattConnector class demonstrates proper BluetoothGatt creation. The autoConnect=false parameter means a direct connection (for scanned devices). On onConnectionStateChange with STATE_CONNECTED, discoverServices() is called immediately. onServicesDiscovered signals that GATT is ready. close() sequentially calls disconnect() and close() — without close() system resources are not freed, leading to BLE connection leaks.

Service and characteristic discovery via BluetoothGatt

discoverServices() is the first GATT method called after connection. It initiates an asynchronous search of all services on the BLE peripheral. The result arrives in onServicesDiscovered() with a status code: GATT_SUCCESS (0) — success, 133 — GATT_ERROR, 8 — GATT_CONNECTION_TIMEOUT. After successful discovery, BluetoothGatt populates the list of services accessible via getServices().

Each BluetoothGattService contains a list of BluetoothGattCharacteristic. A characteristic has a UUID, properties (PROPERTY_READ, PROPERTY_WRITE, PROPERTY_NOTIFY) and optional descriptors. Properties determine which operations are allowed: if a characteristic does not have PROPERTY_READ, calling readCharacteristic will return an error. Use getDescriptors() to obtain characteristic descriptors.

kotlin
// Service discovery and characteristic search
class GattServiceExplorer {

    // Find service by UUID
    fun findService(gatt: BluetoothGatt, uuid: UUID): BluetoothGattService? {
        return gatt.services?.firstOrNull { it.uuid == uuid }
    }

    // Find characteristic in service
    fun findCharacteristic(
        service: BluetoothGattService,
        uuid: UUID
    ): BluetoothGattCharacteristic? {
        return service.characteristics?.firstOrNull { it.uuid == uuid }
    }

    // Get all supported characteristic operations
    fun getCharacteristicProperties(chars: BluetoothGattCharacteristic): List<String> {
        val props = mutableListOf<String>()
        with(chars.properties) {
            if (and(BluetoothGattCharacteristic.PROPERTY_READ) != 0) props.add("READ")
            if (and(BluetoothGattCharacteristic.PROPERTY_WRITE) != 0) props.add("WRITE")
            if (and(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) != 0) props.add("WRITE_NO_RESP")
            if (and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) props.add("NOTIFY")
            if (and(BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) props.add("INDICATE")
        }
        return props
    }

    // Log entire GATT hierarchy
    fun dumpGattTree(gatt: BluetoothGatt) {
        gatt.services?.forEach { service ->
            print("Service: ${service.uuid}")
            service.characteristics?.forEach { char ->
                print("  Characteristic: ${char.uuid}, properties: ${char.properties}")
                char.descriptors?.forEach { desc ->
                    print("    Descriptor: ${desc.uuid}")
                }
            }
        }
    }
}

The GattServiceExplorer class provides utilities for navigating the GATT hierarchy. findService and findCharacteristic search for services and characteristics by UUID. getCharacteristicProperties checks bit masks of properties via and(). dumpGattTree prints the full hierarchy to the log — useful when debugging BLE devices. All operations on BluetoothGatt must be performed after successful onServicesDiscovered, otherwise getServices() will return an empty list.

Reading BLE characteristics: readCharacteristic and readDescriptor

readCharacteristic() is an asynchronous BluetoothGatt method for reading a characteristic value from a remote BLE device. The result arrives in onCharacteristicRead() of BluetoothGattCallback. If a device has 2+ matching characteristics (unlikely but possible), readCharacteristic() may read a non-target one — it is safer to call readCharacteristic() on a BluetoothGattCharacteristic instance rather than by UUID.

readDescriptor() is a method for reading a characteristic descriptor value. A typical descriptor is CCCD (Client Characteristic Configuration Descriptor, UUID 0x2902), which determines whether notifications are enabled. The result is in onDescriptorRead(). Reading descriptors is rarely needed in practice — CCCD is managed by setCharacteristicNotification(), but for custom descriptors (User Description 0x2901, Presentation Format 0x2904) readDescriptor() is the only way to obtain metadata.

MTU and reading large data — if the characteristic value exceeds MTU (23 bytes for BLE 4.0), Android automatically fragments and reassembles data through a sequence of read requests via the BLE stack. For BLE 5.0+ with extended MTU (up to 251 bytes), fragmentation is not required — a single read returns complete data. Before reading, you can call requestMtu() to negotiate the maximum MTU.

kotlin
// Read characteristic and descriptor via BluetoothGatt
class GattReader {

    fun readHeartRate(gatt: BluetoothGatt) {
        // Heart Rate Service UUID = 0x180D
        val service = gatt.getService(UUID.fromString("0000180D-0000-1000-8000-00805F9B34FB"))
            ?: return
        // Heart Rate Measurement UUID = 0x2A37
        val characteristic = service.getCharacteristic(
            UUID.fromString("00002A37-0000-1000-8000-00805F9B34FB")
        ) ?: return

        if (hasProperty(characteristic, BluetoothGattCharacteristic.PROPERTY_READ)) {
            gatt.readCharacteristic(characteristic)
        }
    }

    fun readDescriptor(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
        // CCCD  (0x2902)
        val cccd = characteristic.getDescriptor(
            UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
        )
        cccd?.let { gatt.readDescriptor(it) }
    }

    // Process data in onCharacteristicRead callback:
    fun parseHeartRate(value: ByteArray): Int {
        // BLE Heart Rate:  bytes = flags, second =  bpm
        return if (value.isNotEmpty()) value[1].toInt() and 0xFF else 0
    }

    private fun hasProperty(char: BluetoothGattCharacteristic, prop: Int): Boolean {
        return char.properties and prop != 0
    }
}

The GattReader class demonstrates reading a Heart Rate characteristic. Service 0x180D contains characteristic 0x2A37 (Heart Rate Measurement) — a standard Bluetooth SIG BLE profile. Before reading, the PROPERTY_READ property is checked via hasProperty. parseHeartRate parses the BLE heart rate format: first byte — flags (data format), second — bpm value. The CCCD descriptor (0x2902) is read to check notification status.

Writing characteristics: writeCharacteristic with WriteType

writeCharacteristic() is a BluetoothGatt method for writing data to a BLE peripheral. On Android API 33+, writeCharacteristic() has been replaced with writeCharacteristic(request), where BluetoothGattCharacteristicWriteRequest is a request object containing the characteristic, byte array, and WriteType. The old writeCharacteristic(characteristic) method with setValue() is deprecated. WriteType determines the request behavior: WRITE_TYPE_DEFAULT (depends on characteristic properties), WRITE_TYPE_NO_RESPONSE (without response), and WRITE_TYPE_SIGNED (authorization).

Choosing the WriteType affects speed and reliability. WRITE_TYPE_DEFAULT usually corresponds to withResponse (if the characteristic has PROPERTY_WRITE) or withoutResponse (if it has PROPERTY_WRITE_NO_RESPONSE). For streaming data (OTA updates, logs), use WRITE_TYPE_NO_RESPONSE — maximum throughput. For commands with delivery guarantee (enable, configure) — WRITE_TYPE_DEFAULT with confirmation via onCharacteristicWrite.

kotlin
// BLE characteristic write on Android API 33+
class GattWriter {

    // Write with response (withResponse)
    fun writeWithResponse(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // API 33+:   writeCharacteristic
            val request = BluetoothGattCharacteristicWriteRequest(
                characteristic,
                data,
                BluetoothGattCharacteristicWriteRequest.WRITE_TYPE_DEFAULT,
                @android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
            )
            gatt.writeCharacteristic(request)
        } else {
            // API < 33:   (deprecated)
            characteristic.setValue(data)
            gatt.writeCharacteristic(characteristic)
        }
    }

    // Write without response (max speed)
    fun writeWithoutResponse(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, data: ByteArray) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val request = BluetoothGattCharacteristicWriteRequest(
                characteristic,
                data,
                BluetoothGattCharacteristicWriteRequest.WRITE_TYPE_NO_RESPONSE,
                @android.annotation.RequiresPermission(android.Manifest.permission.BLUETOOTH_CONNECT)
            )
            gatt.writeCharacteristic(request)
        } else {
            characteristic.setValue(data)
            characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
            gatt.writeCharacteristic(characteristic)
        }
    }

    // onCharacteristicWrite callback (API 33+)
    private val writeCallback = object : BluetoothGattCallback() {
        override fun onCharacteristicWrite(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            value: ByteArray,
            status: Int,
            callbackType: Int
        ) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                print("Write success: ${value.size} bytes")
            }
        }
    }
}

The GattWriter class supports both WriteTypes for different API levels. writeWithResponse uses WRITE_TYPE_DEFAULT — the BLE device confirms the write via onCharacteristicWrite. writeWithoutResponse uses WRITE_TYPE_NO_RESPONSE — data is sent without confirmation, maximum throughput. On API 33+, the new writeCharacteristic(request) with BluetoothGattCharacteristicWriteRequest is used. On API < 33 — the old setValue() + writeCharacteristic().

Subscribing to notifications: setCharacteristicNotification and CCCD

setCharacteristicNotification() is a BluetoothGatt method for subscribing to notifications about characteristic changes on the peripheral. After activating the subscription, the BLE device sends new values via onCharacteristicChanged(). However, setCharacteristicNotification() only activates the local Android notification — to enable notifications on the BLE device itself, you must also write the value 0x0100 to the CCCD descriptor (0x2902).

CCCD (Client Characteristic Configuration Descriptor) is a descriptor that controls sending notifications from the BLE peripheral. Value 0x0000 — notifications disabled, 0x0100 — notifications enabled, 0x0200 — indications enabled. Writing to CCCD is done via writeDescriptor() on BluetoothGatt after calling setCharacteristicNotification(). Android does not write CCCD automatically — this responsibility lies with the developer.

kotlin
// Correct BLE notification subscription
class GattNotificationManager {

    // 1. Enable notifications
    fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
        // Step 1: local Android subscription
        val success = gatt.setCharacteristicNotification(characteristic, true)
        if (!success) {
            print("Failed to subscribe")
            return
        }

        // Step 2: write CCCD (0x2902) on BLE device
        val cccdDescriptor = characteristic.getDescriptor(
            UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
        ) ?: return

        // 0x0100 =  notification, 0x0200 = indicate
        val cccdValue = if (characteristic.properties
            and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) {
            BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE  // [0x01, 0x00]
        } else {
            BluetoothGattDescriptor.ENABLE_INDICATION_VALUE     // [0x02, 0x00]
        }

        cccdDescriptor.setValue(cccdValue)
        gatt.writeDescriptor(cccdDescriptor)
    }

    // 2. Characteristic discovery
    fun disableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
        gatt.setCharacteristicNotification(characteristic, false)
        val cccdDescriptor = characteristic.getDescriptor(
            UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
        ) ?: return
        cccdDescriptor.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)
        gatt.writeDescriptor(cccdDescriptor)
    }

    // 3. Notification callback
    private val notificationCallback = object : BluetoothGattCallback() {
        override fun onCharacteristicChanged(
            gatt: BluetoothGatt,
            characteristic: BluetoothGattCharacteristic,
            value: ByteArray,
            callbackType: Int
        ) {
            // New value from BLE peripheral
            print("Notification: ${value.size} bytes")
        }
    }
}

The GattNotificationManager class implements the correct two-step BLE notification subscription protocol. enableNotification first calls setCharacteristicNotification(true) on Android, then writes 0x0100 to the CCCD descriptor via writeDescriptor. disableNotification performs the reverse operations. Without writing CCCD, the BLE device does not send notifications — this is the most common mistake among BLE developers on Android.

GATT client example in Kotlin with BluetoothGattCallback

Complete example of a GATT client in Kotlin that combines BluetoothGatt creation, discovery, reading, and notification subscription in a single manager using coroutines for asynchronous processing.

kotlin
// Full GATT client with coroutines in Kotlin
class GattClient(context: Context) {

    private val context = context.applicationContext
    private var gatt: BluetoothGatt? = null

    // Connect with coroutine
    suspend fun connect(device: BluetoothDevice): Boolean =
        suspendCoroutine { continuation ->
            gatt = device.connectGatt(
                context, false,
                object : BluetoothGattCallback() {
                    override fun onConnectionStateChange(
                        gatt: BluetoothGatt, status: Int, newState: Int
                    ) {
                        if (newState == BluetoothProfile.STATE_CONNECTED) {
                            gatt.discoverServices()
                        } else {
                            continuation.resume(false)
                        }
                    }

                    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
                        continuation.resume(status == BluetoothGatt.GATT_SUCCESS)
                    }
                },
                BluetoothDevice.TRANSPORT_LE
            )
        }

    // Read characteristic via coroutine
    suspend fun readCharacteristicValue(char: BluetoothGattCharacteristic): ByteArray? =
        suspendCoroutine { continuation ->
            gatt?.let { gatt ->
                // Save characteristic tag for callback identification
                gatt.setCharacteristic(char, null)  // for API < 33
                gatt.readCharacteristic(char)
            }
        }

    // Close connection
    fun release() {
        gatt?.disconnect()
        gatt?.close()
        gatt = null
    }
}

The GattClient GATT client uses coroutines (suspendCoroutine) to transform the callback-based BluetoothGatt API into sequential calls. connect() waits for onServicesDiscovered, after which the GATT hierarchy is available. readCharacteristicValue() waits for onCharacteristicRead. This approach eliminates callback nesting and makes BLE code linear. release() guarantees resource cleanup — a mandatory call in Activity onDestroy or ViewModel.onCleared.

Frequently Asked Questions

What is BluetoothGatt in Android?

BluetoothGatt is an Android class for GATT client that manages a BLE connection with a peripheral device. It is created via BluetoothDevice.connectGatt(), providing methods discoverServices(), readCharacteristic(), writeCharacteristic(), setCharacteristicNotification(). Results of all operations arrive asynchronously via BluetoothGattCallback. Without BluetoothGatt, bidirectional BLE communication on Android is impossible.

Why does onServicesDiscovered return status 133?

Status 133 (GATT_ERROR) indicates an internal Android BLE stack error. Causes: the device disconnected during discovery, MTU is below minimum (23 bytes), or the BLE stack is overloaded. Solution: retry discoverServices() with a 500ms delay, check the device RSSI, and ensure the peripheral supports GATT discovery in its current state.

How to correctly write a characteristic with confirmation?

For writing with confirmation, call writeCharacteristic() with WRITE_TYPE_DEFAULT (API 33+: BluetoothGattCharacteristicWriteRequest). On success, the BLE device sends a confirmation, and Android calls onCharacteristicWrite with GATT_SUCCESS. If the device does not respond within 30 seconds (stack timeout), the callback returns an error status. For watchdog, use Handler with postDelayed.

How to work with BLE on Android 33+?

On Android 13+ (API 33), BluetoothGatt methods have changed: writeCharacteristic() now accepts BluetoothGattCharacteristicWriteRequest, readCharacteristic() — BluetoothGattCharacteristicReadRequest. The old setValue()/writeCharacteristic() are deprecated. BluetoothGattCallback has also changed: onCharacteristicRead(), onCharacteristicWrite(), onCharacteristicChanged() receive ByteArray value and callbackType. Use Build.VERSION.SDK_INT for branching.

How many BLE connections does Android support?

Android supports 4–8 simultaneous BLE GATT connections (varies by manufacturer and Android version). Pixel/Google: up to 7, Samsung: up to 5, Xiaomi: up to 4. When the limit is exceeded, connectGatt returns null or onConnectionStateChange with an error. For working with a large number of devices, use cyclic connection or Bluetooth Mesh.

Summary

  • BluetoothGatt is an Android GATT client for BLE connection, created via BluetoothDevice.connectGatt() with BluetoothGattCallback
  • discoverServices() is a mandatory step after connection to obtain BLE device services, characteristics and descriptors
  • Reading — readCharacteristic() with asynchronous result in onCharacteristicRead(); for large data, MTU negotiation is required
  • Writing — writeCharacteristic() with WriteType: DEFAULT (withResponse) or NO_RESPONSE (without confirmation)
  • Notifications — two-step activation: setCharacteristicNotification() + writing CCCD (0x2902) with value 0x0100
  • API 33+ — new methods writeCharacteristic(request) and readCharacteristic(request) with request objects
  • Release resources — mandatory call to disconnect() and close() to prevent BLE connection leaks

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also