Bluetooth Low Energy (BLE) is a crucial programming language especially on Android for the IoT world. It allows smart devices such as smartphones, tablets, or wearables to communicate with smart electronic environments such as smartwatches, smart beds, sensors, etc.
Bluetooth LE programming on Android is available from version 4.3 Jelly Bean. It allows for significant battery saving on the device and constant connectivity to several devices simultaneously. Yet, the implementation of BT LE is more complex than it may seem and has several pitfalls. Below I'll show how to implement it properly and make the connection as stable as possible for your android development project.
Before talking about Bluetooth LE connection, let's talk a little about the GATT application; or GATT profile.
What is GATT?
The Generic Attributes (GATT) define a hierarchical data structure exposed to connected Bluetooth LE devices. GATT is widely used in Android BLE programming.
GATT profiles enable extensive innovation while maintaining complete interoperability with other Bluetooth® devices. The profile describes a use case, roles and general behaviors based on the GATT functionality. Services are collections of characteristics and relationships to other services that encapsulate the behavior of part of a device. This also includes hierarchy of services, characteristics and attributes used in the attribute server.
How to use GATT on Android:
- Scan the device
- Connect GATT
- Discover services
- Enable notifications
- Start communicating
Let's review each of the steps in detal.
1. Device Scan
Devices can be scanned either with or without any filters and settings. If you know a required device name or UUID, you can significantly save the energy by applying filter. UUID should be in a 16-bit format.
What is 16-bit UUID?
Regular UUID: 83F94223-98DC-2387-1212-FD34158790E
Bluetooth SIG base UUID: XXXXXXXX-0000-1000-8000-00805F9B34FB
32 bits left, top 16 are always 0
0x180D (Heart Rate Service) is the same as 0000180D-0000-1000-8000-00805F9B34FB
Tip: Don’t perform device scan and connection simultaneously, as a scanning process can interfere the connection. Skip discovery if possible - If you can assume the device is present - try to connect it.
Scanning on Android API >= v21+:
ScanSettings scanSettings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build();
//If name or address of peripheral is known
ScanFilter scanFilter = new ScanFilter.Builder()
.setDeviceName(deviceName)
.setDeviceAddress(deviceAddress)
.build();
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
BluetoothLeScanner bluetoothLeScanner = adapter.getBluetoothLeScanner();
bluetoothLeScanner.startScan(Collections.singletonList(scanFilter), scanSettings, new MyScanCallback());
class MyScanCallback extends ScanCallback {
//Fields and constructor omitted
@Override
public void onScanResult(int callbackType, ScanResult result) {
//Will execute on the main thread!
//Do the work below on a worker thread instead!
if (showldConnect(result)) {
BluetoothDevice device = result.getDevice();
scaner.stopScan(this);
device.connectGatt(context, false, gattCallback);
}
}
}
Scanning on Android API < v21:
//Adding UUID’s filters array is not recommended
BluetoothAdapter adapter = ((BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE)).getAdapter();
adapter.startLeScan(new MyLeScanCallback);
2. Connect to Gatt
Tip: Don’t use Auto-connect for Gatt connection. Auto-connect is flaky and doesn’t behave as expected.
private void connectToPeripheral(BluetoothDevice device) {
MyGattCallback gattCallback = new MyGattCallback();
//Always use false on auto-connect parameter
device.connectGatt(context, false, gattCallback);
}
3. Discover services (GattCallback implementation)
Tip: All BLE callbacks happen on binder thread. Never block the BluetoothGattCallback methods. Use HandlerThread for all GATT operations.
//Create and start HandlerThread to handle GattCallbacks
public MyGattCallback() {
HandlerThread handlerThread = new HandlerThread("BLE-Worker");
handlerThread.start();
bleHandler = new Handler(handlerThread.getLooper(), this);
}
//Don’t forget to dispose it after finish work
public void dispose() {
bleHandler.removeCallbacksAndMessages(null);
bleHandler.getLooper().quit();
}
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
//Discover services when Gatt is connected
bleHandler.obtainMessage(MSG_DISCOVER_SERVICES, gatt).sendToTarget();
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
bleHandler.obtainMessage(MSG_GATT_DISCONNECTED, gatt).sendToTarget();
} else {
//If received any error
reconnectOnError("onConnectionStateChange", status);
}
}
Tip: Services must be discovered! discoverServices() must be called before communicating.
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_DISCOVER_SERVICES:
BluetoothGatt gatt = ((BluetoothGatt) msg.obj);
gatt.discoverServices();
return true;
case MSG_SERVICES_DISCOVERED:
BluetoothGatt gatt = ((BluetoothGatt) msg.obj);
subscribeNotifications(gatt);
broadcastUpdate(ACTION_GATT_SERVICES_DISCOVERED);
return true;
case MSG_DATA_READ:
processDataReceived((byte[]) msg.obj);
return true;
case MSG_RECONNECT:
broadcastUpdate(ACTION_RECONNECT_DEVICE, deviceName);
return true;
case MSG_GATT_DISCONNECTED:
broadcastUpdate(ACTION_GATT_DISCONNECTED);
return true;
}
}
Getting ready to read and write
Tip: Most error codes are undocumented. Treat everything but GATT_SUCCESS as an error. When an error occurs - disconnect, close and reconnect. Most BLE errors are “unrecoverable”!
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
bleHandler.obtainMessage(MSG_SERVICES_DISCOVERED, gatt).sendToTarget();
} else {
reconnectOnError("onServicesDiscovered", status);
}
}
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_SERVICES_DISCOVERED:
BluetoothGatt gatt = ((BluetoothGatt) msg.obj);
commService = gatt.getService(DATA_SERVICE_ID);
inputChar = commService.getCharacteristic(INPUT_CHAR_ID);
outputChar = commService.getCharacteristic(OUTPUT_CHAR_ID);
return true;
Send data and receive response
Tip: 20 byte write limit. The max size of a write operation is 20 bytes. (Except devices supporting BluetoothGatt.requestMtu())
//Should always be called on our BLE thread!
private void writeDataToChar(byte [] data) {
if (data.length > 20) {
throw new IllegalArgumentException();
}
outputChar.setValue(data);
gatt.writeCharacteristic(characteristic);
}
@Override
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
if (status == BluetoothGatt.GATT_SUCCESS && c.getUUID().equals(outputChar.getUUID())) {
//Write operation successful - process with next chunk!
}
}
public void readData(BluetoothGatt gatt, BluetoothGattCharacteristic c) {
gatt.readCharacteristic(c);
}
@Override
public void onCharacteristicRead(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status) {
if (status == BluetoothGatt.GATT_SUCCESS &&
getReadCharacteristicUuid().equals(characteristic.getUuid())) {
byte[] data = characteristic.getValue();
if (data == null) {
Log.e(TAG, "onCharacteristicRead: data = null");
return;
}
bleHandler.obtainMessage(MSG_DATA_READ, data).sendToTarget();
} else {
reconnectOnError("onCharacteristicRead", status);
}
}
4. Enable notifications
Subscribe for data change notifications
Tip: If possible, enable notifications for data changes - it’s more energy efficient and allows to get update as soon as it appears.
private void subscribeNotifications(BluetoothGatt gatt,
BluetoothGattCharacteristic c) {
gatt.setCharacteristicNotification(c, true);
//This is usually needed as well
BluetoothGattDescriptor desc = c.getDescriptor(INPUT_DESC_ID);
//Could also be ENABLE_NOTIICATION_VALUE
desc.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(desc);
}
5. Start communicating
Close connection
close () == “Unregister application”
disconnect() = cut connection
When in doubt, do both:
gatt.disconnect();
gatt.close();
When debugging, use the following rules:
- Don’t put breakpoints in your BluetoothGattCallbacks
- Log all BluetoothGAttCallback calls and their data
- Enable Bluetooth HCI snoop log (at OS developer options)
Summary
So let’s summarize the key moments that make your connection stable and fast. Once you've found a needed device while scanning - stop scan and only then connect to it. Try to connect the device with a known address instead of rescanning it.
Do all work out of BluetoothGattCallback callback methods - use HadlerThread for that purpose. Reconnect to the device if any error code came to the GATT callback method. And don’t forget to close the connection when you finished or reconnecting - calling gatt.disconnect() and gatt.close() is ok in most cases.