Beginner’s Guide to Building a BLE App with Android | by ravinder bhandari | Nov, 2024

I’ve been working with BLE interactions in Android for over a year now, and I can confidently say this is not an easy path if you are just beginning to explore the world of BLE, especially for Android. I’ll try to keep it simple and short for anyone who is just getting started and is probably feeling overwhelmed.

  • Install Android Studio.
  • Ensure your project uses a minimum SDK of 18 or higher (BLE support was introduced in API level 18).

Android BLE Permissions

If you’ve worked on an Android application before, you already know how tricky it can be to request the right permissions and handle callbacks if they are denied. With BLE, there’s an added complexity to that, as you have to request different permissions depending on which version of Android you are targeting for your application.

To keep it short, I’ll list the permissions you should add in the manifest file and what should you request dynamically.

<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />

if (minSdkAboveS()) {
// For Android 12 (API level 31) and above
listOf(
Manifest.permission.BLUETOOTH_SCAN, // For scanning
Manifest.permission.BLUETOOTH_CONNECT, // For connecting
Manifest.permission.ACCESS_FINE_LOCATION, // Required for scanning in Android 12+
)
} else {
// For devices below Android 12
listOf(
Manifest.permission.BLUETOOTH, // For Bluetooth communication
Manifest.permission.BLUETOOTH_ADMIN, // For Bluetooth management (optional)
Manifest.permission.ACCESS_FINE_LOCATION, // Required for scanning
)
}
  • For Android 6.0 (API level 23) and above, request location permissions at runtime if scanning for BLE devices.
  • For Android 12 (API level 31) and above, request the BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and ACCESS_FINE_LOCATION permissions.

Follow this link for a detailed explanation from Android’s official documentation of why these permissions are mandatory, and feel free to adjust based on your use case.

In this post, we are not focusing on how to request these permissions dynamically, please follow official documentation if you are new to Android.

Scanning BLE devices

Initialise BluetoothAdapter instance using BluetoothManager Class.

val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val bluetoothAdapter = bluetoothManager.adapter

Check if Bluetooth is enabled on the device. You can launch an intent to enable Bluetooth if it’s turned off.

private var activityResultContractLauncher: ActivityResultLauncher<Intent>? = null

fun enableBluetoothIfDisabled(){
if (!bluetoothAdapter.isEnabled){
val enableBluetoothIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
activityResultContractLauncher?.launch(enableBluetoothIntent)
}
}

Once you have taken care of permissions and enabled Bluetooth you can start scanning BLE devices using the BluetoothAdapter instance we created earlier. Follow this SimpleBleScanner class that takes care of scanning BLE devices that stop scanning after 10 seconds and use scanCallback to deliver results.

import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.os.Handler
import android.os.Looper
import android.util.Log

class SimpleBleScanner(private val bluetoothAdapter: BluetoothAdapter) {

private val scanner: BluetoothLeScanner? = bluetoothAdapter.bluetoothLeScanner
private val handler = Handler(Looper.getMainLooper()) // For stopping scan
private var isScanning = false

// Start scanning for BLE devices
fun startScan() {
if (isScanning) return

isScanning = true
Log.d("BLE", "Starting BLE scan")

scanner?.startScan(scanCallback)

// Stop scanning after a timeout (e.g., 10 seconds)
handler.postDelayed({
stopScan()
}, 10000) // 10,000 ms = 10 seconds
}

// Stop scanning for BLE devices
fun stopScan() {
if (!isScanning) return

isScanning = false
Log.d("BLE", "Stopping BLE scan")
scanner?.stopScan(scanCallback)
}

// Handle scan results
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val device = result.device
Log.d("BLE", "Device found: ${device.name ?: "Unnamed"} (${device.address})")
}

override fun onScanFailed(errorCode: Int) {
Log.e("BLE", "Scan failed with error code: $errorCode")
}
}
}

If you want to filter the scan results you can also define the scan filters in the class above.

// Define ScanFilters
val filters = listOf(
ScanFilter.Builder()
.setDeviceName("TargetDevice") // Filter by device name
// .setDeviceAddress("XX:XX:XX:XX:XX:XX") // Optional: Filter by MAC address
// .setServiceUuid(ParcelUuid(UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb"))) // Optional: Filter by Service UUID
.build()
)

// Define ScanSettings
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // Adjust based on use case
.build()

// Start scanning
scanner?.startScan(filters, scanSettings, scanCallback)

Once you’ve discovered the devices you can connect to one of them. After selecting a device from the scan results, use connectGatt to initiate a connection.

private var bluetoothGatt: BluetoothGatt? = null // make a class level property.
bluetoothGatt = device.connectGatt(context, false, gattCallback) // init when device connection is requested.

gattCallback is the BluetoothGattCallback object used to listen to the callbacks of device connection status. Implement gattCallback to know when the device gets connected/disconnected and show appropriate states to the user.

private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.d("BLE", "Connected to GATT server")
gatt.discoverServices()
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Log.d("BLE", "Disconnected from GATT server")
}
}

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
val services = gatt.services
Log.d("BLE", "Discovered services: ${services.size}")
}
}
}

Call gatt.discoverServices() when STATE_CONNECTED is received in the callback.

GattCallback is not just used for connectionStateChanges it also provides callbacks for other BLE interactions such as service discovery, read/write characteristics, etc. These callbacks are useful for BLE read, write, and notify operations.

Reading a BLE Characteristic

Once you’ve discovered the services the BLE device supports, you can get the service with the given UUID to read/write characteristics from it.

Next, get the characteristic from your given service using the characteristic UUID and initiate a read request. The callback for the read request is received in the onCharacteristicRead method you can override it when you implement the BluetoothGattCallback interface.

override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// Locate the desired service and characteristic
val service = gatt.getService(SERVICE_UUID)
val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID)

// Initiate a read request
characteristic?.let {
gatt.readCharacteristic(it)
}
}
}

override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// Handle the received data
val value = characteristic.value // Byte array
Log.d("BLE", "Read characteristic: ${characteristic.uuid}, value: ${value?.contentToString()}")
}
}

If you want to fetch the value on-demand, you can use the readCharacteristic() method directly without enabling notifications. This operation is synchronous, meaning you’ll need to call it explicitly each time you want the value. The result will be delivered to the onCharacteristicRead() callback.

Writing to a BLE Characteristic

Writing data to a characteristic requires you to use the service UUID of your given service and get the write characteristic that matches your characteristic UUID. Below is a simple implementation of the writeToCharacteristic() function that accepts a byteArray you want to write using a gatt instance.

fun writeToCharacteristic(gatt: BluetoothGatt, data: ByteArray) {
val service = gatt.getService(SERVICE_UUID)
val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID)

characteristic?.let {
it.value = data // Set the value to write
it.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT // Or WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(it)
}
}

override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.d("BLE", "Write successful to characteristic: ${characteristic.uuid}")
} else {
Log.e("BLE", "Write failed with status: $status")
}
}

callbacks for writeCharacteristic are received in the onCharacteristicWrite() method.

Enabling Notifications on a BLE Characteristic

Notifications allow the BLE device to send updates automatically when the characteristic value changes.

Steps:

  1. Enable notifications for the characteristic.
  2. Write to the Client Characteristic Configuration Descriptor (CCCD) to subscribe to notifications.
fun enableNotifications(gatt: BluetoothGatt) {
val service = gatt.getService(SERVICE_UUID)
val characteristic = service?.getCharacteristic(CHARACTERISTIC_UUID)

characteristic?.let {
// Enable notifications
gatt.setCharacteristicNotification(it, true)

// Write to CCCD to enable notifications
val descriptor = it.getDescriptor(CCCD_UUID)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
}

override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
// Handle the updated value
val value = characteristic.value // Byte array
Log.d("BLE", "Notification received from characteristic: ${characteristic.uuid}, value: ${value?.contentToString()}")
}

When to Enable Notifications

Notifications are used for asynchronous updates from the BLE device. You can enable notifications on a characteristic if the device has a characteristic that supports automatic updates of its value (e.g., a temperature sensor sending periodic status updates). The updates will be received in the onCharacteristicChanged() callback whenever the characteristic’s value changes.

When to Use Read Without Notifications

If you want to fetch the value on-demand, you can use the readCharacteristic() method directly without enabling notifications. This operation is synchronous, meaning you’ll need to call it explicitly each time you want the value. The result will be delivered to the oncharacteristicRead() callback.

Disconnecting BLE Device

To ensure a reliable re-connection and optimal power management while working with BLE devices, make sure you reset resources when the BLE connection state changes to DISCONNECTED. To disconnect the device based on the user action when the app closes use the same bluetoothGatt instance, initialized when the connection was established.

gatt.disconnect()
gatt.close()

Points to Remember

  • Permissions: Ensure the app has the necessary runtime permissions (BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and possibly location permissions) for Android 12+.
  • Always check for and request permissions dynamically if they are not granted.
  • Connection Timeout: Implement a timeout mechanism to handle situations where the BLE device doesn’t respond during connection or service discovery.
  • Threading: Perform GATT operations sequentially; wait for the previous operation’s callback before initiating a new one.
  • Error Handling: Check the status parameters in callbacks to handle failures.
  • Debugging: Use tools like the nRF Connect app to inspect your device’s services and characteristics.
  • Service UUID: The UUID of the BLE service.
  • Characteristic UUID: The UUID of the characteristic you want to interact with.
  • CCCD UUID: The UUID for the Client Characteristic Configuration Descriptor.

Hope all the amazing developers out there found this helpful. This is the first in the series of working with BLE in Android, in upcoming posts we will discuss more advanced topics.

Leave a Reply