How to Implement an Android BLE Connect Flow

Pt 97 Software Development Cropped

If you’ve already got permissions sorted and your scan is returning devices, you’re holding a ScanResult and the obvious next move is to connect to one of them. It feels like it should be a single call that hands you back a working connection.

On Android it doesn’t quite work that way. Calling connectGatt() starts the connection process, but it doesn’t return the connection itself. The actual result, whether you connected or something went wrong, arrives later in a callback. Once that clicks, the rest of the flow makes a lot more sense.

This article picks up right after scanning and runs through the connect call, the callback that reports the result, and how to recover when it fails with errors like status 133. If you haven’t handled permissions yet, start with our Android BLE permissions article, since everything here assumes the app already has what it needs to connect.


Getting a BluetoothDevice Handle and Stopping Your Scan First

There are multiple ways to get a BluetoothDevice handle, which represents the BLE device you are trying to connect to. The easiest and most common way is to simply obtain one from the ScanResult object you receive when scanning for BLE devices that match your provided criteria. There are also other ways, such as BluetoothAdapter’s getRemoteLeDevice method.

One thing holds regardless of which route you take. Stop your BLE scan before you connect. Doing so saves power, and in our experience, it also makes the connection process more reliable.


Using connectGatt() and the autoConnect Argument

The connectGatt()method on a BluetoothDevice handle starts a connection with a BLE device.

There are five overloads and a complicated history behind all of them. The one we’ll be using depends on the Android version the app is running on.

// API 21-23 (usable all the way down to API 18, but we recommend min. API 21)
public BluetoothGatt connectGatt(
    Context context,
    boolean autoConnect,
    BluetoothGattCallback callback
)

// API 23-25
public BluetoothGatt connectGatt (
    Context context, 
    boolean autoConnect, 
    BluetoothGattCallback callback, 
    int transport
)

// API 26-36
public BluetoothGatt connectGatt (Context context, 
    boolean autoConnect, 
    BluetoothGattCallback callback, 
    int transport, 
    int phy
)

// API 37+
public BluetoothGatt connectGatt (
    BluetoothGattConnectionSettings gattConnectionSettings, 
    Executor executor, 
    BluetoothGattCallback callback
)

As the older connectGatt methods were deprecated on API 37 while the new overload with the BluetoothGattConnectionSettings and Executor arguments are only available in API 37+, this allows us to cleanly wrap the various overloads in a function, like so:

@SuppressLint("MissingPermission") // App's role to ensure permissions are available
fun connect(
    device: BluetoothDevice,
    callback: BluetoothGattCallback
): BluetoothGatt {
    return when {
        Build.VERSION.SDK_INT >= 37 -> {
            val settings = BluetoothGattConnectionSettings.Builder()
                .setTransport(BluetoothDevice.TRANSPORT_LE)
                .setAutoConnectEnabled(false)
                .build()
            device.connectGatt(settings, connectExecutor, callback)
        }
        Build.VERSION.SDK_INT >= 26 -> {
            device.connectGatt(
                context,
                false,
                callback,
                BluetoothDevice.TRANSPORT_LE,
                BluetoothDevice.PHY_LE_1M_MASK // Assumes 1M PHY
            )
        }
        Build.VERSION.SDK_INT >= 23 -> {
            device.connectGatt(
                context,
                false,
                callback,
                BluetoothDevice.TRANSPORT_LE
            )
        }
        else -> {
            // API 21–25: this overload takes no transport argument
            device.connectGatt(context, false, callback)
        }
    }
}

Every variant takes an autoConnect configuration, and it trips up a lot of people. Setting it to true does not tell Android to automatically reconnect to the device if the connection drops. What it actually does is stop the connect operation from timing out, which can help when you’re connecting to a device you cached from an earlier scan. In our experience, setting it to true can also make the connection slower than usual even when the device is sitting right next to you. We prefer to set the flag to false and let the onConnectionStateChange callback tell us whether the connection worked. If it fails, we just try again with autoConnect still set to false. Under the API 37 overload, this behavior is expressed through BluetoothGattConnectionSettings rather than as a positional argument, but the reasoning is unchanged.

Every connectGatt() variant returns a BluetoothGatt object, which is your handle on the connection and how you’ll issue reads and writes later on. In practice, we don’t retain the returned object. We keep the one handed to us in the BluetoothGattCallback methods instead.


Handling the Result in BluetoothGattCallback

The connectGatt() methods also takes an object called BluetoothGattCallback. It’s an abstract class with methods you override to get notified about BluetoothGatt-related events. The methods are well-documented, so we’ll leave the details to the official docs.

The one you need at this stage is onConnectionStateChange(), which reports the status of the connection. It’s the source of truth for connection and disconnection events on a given connection.

Identifying a Successful Connection

A successful connection shows up as an onConnectionStateChange() callback with its status parameter set to GATT_SUCCESS and its newState parameter set to BluetoothProfile.STATE_CONNECTED. At that point, store a reference to the BluetoothGatt object the callback hands you. It’s the main interface you’ll use to issue commands to the device from here on.

Handling Connection Errors and the Status 133 Problem

Errors during a connection come through onConnectionStateChange() , too. The usual check is whether the status parameter is GATT_SUCCESS. If it isn’t, you’ve got an error on your hands, and it’s represented by that status value.

Some of the error codes are documented as BluetoothGatt public constants. The single most common one we’ve hit while connecting, though, is the infamous status 133. It’s undocumented in the BluetoothGatt constants, and a peek at the Android source shows its name is just GATT_ERROR (0x85, which is hex for 133). You’re not alone if you think that’s vague. We think Google could do better here.

Aside from the random occurrences of 133, we’ve seen it happen most often in one of two situations:

  • The device we’re trying to connect to is no longer advertising or is out of range, and the connectGatt() call has timed out after about 30 seconds (this value is OEM-dependent, and we’ve seen Samsung devices time out after 10) of trying with autoConnect set to false.
  • The firmware on the BLE device has rejected the connection attempt.

Whatever the error code, the recovery flow usually goes like this:

  1. Call close() on the BluetoothGatt object to signal that you’re done with it and the system can release any pending resources.
  2. Null out any references to that BluetoothGatt object.
  3. If the code is GATT_INSUFFICIENT_ENCRYPTION or GATT_INSUFFICIENT_AUTHENTICATION, call createBond() first and wait for bonding to finish before calling connectGatt() again. Bonding is its own topic, and the Ultimate Guide to Android BLE walks through how to initiate it.
  4. For other codes, either surface an error to the user or quietly retry a few times before giving up. Retrying is a reasonable move, since 133 can be random enough to fail a few times in a row before the connection finally goes through.

Putting the Connect Flow Together

Since the user is the one telling us which device they want, we can update the ScanResultAdapter from earlier. This code runs when the user taps a scan result in the RecyclerView.

private val scanResultAdapter: ScanResultAdapter by lazy {
    ScanResultAdapter(scanResults) { result ->
        // User tapped on a scan result
        if (isScanning) {
            stopBleScan()
        }
        with(result.device) {
            Log.w("ScanResultAdapter", "Connecting to $address")
            connect(this, gattCallback)
        }
    }
}

Nothing surprising here. We stop the scan if it’s still running, then call connectGatt() on the tapped result’s BluetoothDevice handle, passing in a BluetoothGattCallback that we define like this.

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        val deviceAddress = gatt.device.address
        if (status == BluetoothGatt.GATT_SUCCESS) {
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                Log.w("BluetoothGattCallback", "Successfully connected to $deviceAddress")
                // TODO: Store a reference to BluetoothGatt
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                Log.w("BluetoothGattCallback", "Successfully disconnected from $deviceAddress")
                gatt.close()
            }
        } else {
            Log.w("BluetoothGattCallback", "Error $status encountered for $deviceAddress! Disconnecting...")
            gatt.close()
        }
    }
}

Note the TODO in the success branch. That’s where you store the reference to the BluetoothGatt object, which as we mentioned is how you reach every other BLE operation, from service discovery to reading and writing data to tearing the connection down. Run your app, start a scan, and tap a result. Check Logcat to see how the connection attempt went, and retry if it fails the first few times.


What Comes After Connecting

If you got here, your app can connect to BLE devices. Right now there isn’t much you can do with that connection, though. The next step is to walk the GATT table and discover the services and characteristics the device exposes, which you kick off with a single call on the BluetoothGatt object you saved.

gatt.discoverServices()

From there you’re into reading, writing, and subscribing to notifications, and that’s also where you meet the reason Android BLE has the reputation it does. Operations have to run one at a time, and the callback-driven pattern you just saw with connectGatt() is the same one that governs everything after it. When you reach that point, our guide on building a reliable Android BLE operation queue is the piece you’ll want. For service discovery and everything between here and there, the Ultimate Guide to Android BLE walks the full flow.

Share:

Punch Through
Punch Through
We’re a team of engineers who obsess over making connected things actually work — reliably, securely, and without the handwaving. From BLE to backend, we build the software and systems behind connected medical devices and custom connected products that can’t afford to fail.

Subscribe to stay up-to-date with our latest articles and resources.