Bluetooth LE for modern Android Development - part 2
In the previous post, I introduced the three major BLE-related news that came with Android 8.0. As that version was released in August 2017, I consider it safe to have Android 8.0 as the minSdkVersion
today. The chance that you have a significant number of users on older Android versions is probably quite small.
In this post, we will look at how to do discovery of BLE devices from a modern Android app (supporting Android 8.0 and higher). This will mostly focus on the use of the CompanionDeviceManager
. We will also look at the concept of bonding a Bluetooth device, and why this is relevant in some cases.
Discovering, associating, and (optionally) bonding
Before you can communicate with a BLE device, you need to know its address. A user who installed your app will likely have an onboarding experience where they are asked to set up the BLE device and connect the app to it. This process can (on Android) be divided into two or three steps: discovery, associating, and bonding. Bonding is optional and only relevant for some peripherals, which I will go into detail about later.
Discovery is about finding the hardware address (also called the MAC address) of a BLE device. Most devices can be set in a pairing mode that will cause them to send out an advertisement package at regular intervals. This commonly happens you long-press a button on the device and it starts blinking blue. Advertisement packagers are also what is used for BLE beacons, which I hope to cover in a future post.
When in pairing mode, a peripheral starts broadcasting advertisement packages at regular intervals. I won't go into details about how these packages work, but there is a great series of posts by Mohammad Afaneh on this topic if you're interested.
On the Android app side, we now (starting in Android 8.0) have two options for discovering a device. The first is to use the BluetoothLeScanner
, and the second is to use the CompanionDeviceManager
. For a BLE peripheral that you will pair up with your app, it is strongly recommended to use the CompanionDeviceManager
today. The BluetoothLeScanner
is more useful for scanning for BLE Beacons.
Discover and Associate with CompanionDeviceManager
The CompanionDeviceManager
will be used for both discovery and associating. The pairing step is specific to Android and not a BLE feature. What it means, in the context of Android 8.0 and the CompanionDeviceManager
, is that your app is associated with the device and allowed to run in the background to keep the connection alive. Discovery is all about scanning for the advertisement packages sent by the device.
We'll start by creating a simple screen in Jetpack Compose for starting the discovery and associate process:
@Preview
@Composable
fun PairingScreen(
companionDeviceManager: CompanionDeviceManager
) {
val (pairingStatus, setPairingStatus) = remember {
mutableStateOf(PairingStatus.NotPaired)
}
val (deviceAddress, setDeviceAddres) = remember {
mutableStateOf("unknown")
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceEvenly,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Device pairing",
style = MaterialTheme.typography.h2
)
Text(
"Pairing status: ${pairStatus.name}",
style = MaterialTheme.typography.subtitle1
)
Text(
"Device address: $deviceAddress",
style = MaterialTheme.typography.subtitle2
)
Button(onClick = { /*TODO*/ }) {
Text("Start pairing...")
}
}
}
enum class PairingStatus {
NotPaired, Pairing, Paired, PairingFailed
}
At this point, the code isn't doing anything interesting. We'll start by using the CompanionDeviceManager
for checking if we're associated with a device or not.
val (pairingStatus, setPairingStatus) = remember {
val initialState = if(companionDeviceManager.associations
.isNotEmpty()) {
PairingStatus.Paired
} else {
PairingStatus.NotPaired
}
mutableStateOf(initialState)
}
The code above simply checks the list of associated addresses for this app from the CompanionDeviceManager
and uses that as the initial state for pairingStatus
.
Next, if we are associated, we want to show the device address by setting the deviceAddress
to that value. We'll make a similar change here.
val (deviceAddress, setDeviceAddres) = remember {
val initialState = companionDeviceManager.associations
.firstOrNull() ?: "unknown"
mutableStateOf(initialState)
}
Now that we got the initial state up, we can implement the call to associate()
and disassociate()
on the CompanionDeviceManager
.
val btnEnabled = when (pairingStatus) {
PairingStatus.Paired, PairingStatus.NotPaired -> true
else -> false
}
Button(
onClick = {
if (pairingStatus == PairingStatus.NotPaired) {
companionDeviceManager.associate(
associationRequest,
object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender?) {
// TODO Launch the IntentSender
}
override fun onFailure(error: CharSequence?) {
setPairingStatus(PairingStatus.PairingFailed)
}
},
)
} else if (pairingStatus == PairingStatus.Paired) {
companionDeviceManager.disassociate(deviceAddress)
}
}
) {
val label = when (pairingStatus) {
PairingStatus.Paired -> "Forget device"
PairingStatus.NotPaired -> "Start pairing"
PairingStatus.PairingFailed -> "Error!"
PairingStatus.Pairing -> "Pairing..."
}
Text(label)
}
The snippet above shows how to implement the button that will either start the association process or disassociate a paired device. We use the pairingState
to decide what the action should be when clicked, and what text should be displayed.
The associate()
is called with two parameters, an associationRequest
and a CompanionDeviceManager.Callback
instance. The first contains the instructions to the CompanionDeviceManager
on what devices it should scan for. We can scan for three types of devices, WiFi connected, Bluetooth Classic, and Bluetooth Low Energy. Since we're only interested in BLE devices, I'll only show the code for that.
fun buildAssociationRequest(): AssociationRequest {
val deviceFilter = BluetoothLeDeviceFilter.Builder()
.setNamePattern(NAME_REGEXP)
.build()
return AssociationRequest.Builder()
.addDeviceFilter(deviceFilter)
.build()
}
AssociationRequest
The code above shows how to build a simple AssociationRequest
that will filter on a device name that matches the NAME_REGEXP
. There are also other filters you can use, including the ScanFilter
that can be used with the BluetoothLeScanner
. If the device you're working with exposes a unique Service UUID as part of the advertisement packages, I strongly recommend you add a ScanFilter
for that in the DeviceFilter
for your AssociationRequest
. However, it is my experience that many custom BLE peripherals don't include any Service UUIDs in the advertisement requests, so you might be stuck with filtering on the device name. Fortunately, the BluetoothLeDeviceFilter
allows you to use a regular expression, which makes it more powerful than the simpler ScanFilter
in this case.
The builder for the AssociationRequest
also has a function named setSingleDevice()
that takes a boolean. The naming for this is a bit confusing, as it not only limits the search for the first found device but also searches among the already bonded devices on this phone. If your device uses Bluetooth bonding (I'll go into details about that later), I recommend setting this to true.
The callback to the associate()
request has two functions, onDeviceFound
and onFailure
. The latter is easy enough to grasp and will be called if something goes wrong, like if the Bluetooth is disabled or if you lack permission to scan. The onDeviceFound
function is called once at least one device is found. However, you're not getting a list of devices to choose from here, but an IntentSender
that you need to launch which will present a system UI where the user can select one of the discovered devices.
The easiest way to launch the IntentSender
in a Compose UI is by using rememberLauncherForActivityResult()
and ActivityResultContracts.StartIntentSenderForResult
.
val contract = ActivityResultContracts.StartIntentSenderForResult()
val activityResultLauncher =
rememberLauncherForActivityResult(contract = contract) {
it.data
?.getParcelableExtra<ScanResult>(CompanionDeviceManager.EXTRA_DEVICE)
?.let { scanResult ->
val device = scanResult.device
setPairingStatus(PairingStatus.Paired)
setDeviceAddres(device.address)
}
}
activityResultLauncher
to be used once at least one device is discoveredThe callback lambda for this launcher is where we get the actual ScanResult
containing our BluetoothDevice
. Once this is invoked, we extract the result and update the UI.
In our onDeviceFound
callback, we can now launch this.
override fun onDeviceFound(chooserLauncher: IntentSender?) {
chooserLauncher?.let {
val request = IntentSenderRequest.Builder(it).build()
activityResultLauncher.launch(request)
}
}
We now have all the code needed to discover and associate a BLE device. Once a device is associated, we are also allowed to be running in the background through the REQUEST_COMPANION_RUN_IN_BACKGROUND
and REQUEST_COMPANION_USE_DATA_IN_BACKGROUND
permissions, which is not possible (or severely limited) otherwise. This is the primary reason why you want to use the CompanionDeviceManager
. It will make the onboarding smoother for your users, and it will allow your app to run in the background to keep a connection to your peripheral.
Keeping track of the device address
One additional advantage of using the CompanionDeviceManager
is that it can be used to keep track of the hardware address for your peripheral. Since all peripherals that have been associated with your app are listed from getAssociations()
, you can use that for fetching the address the next time your app starts up. If the list is empty, that means no peripherals have been associated with your app and you should show the onboarding for the user.
Since you need an instance of a BluetoothDevice
to connect and communicate with it, you can use the following code to create one given the hardware address.
val device = BluetoothAdapter.getDefaultAdapter()
.getRemoteDevice(address)
BluetoothDevice
from the hardware addressUsing the CompanionDeviceManager
to keep track of the hardware address of your devices will make your app more simple as you no longer need to store this information yourself.
Bonding
Discovery is about finding the hardware address of the peripheral. Associating, in this context, is about telling the Android system that a specific Bluetooth peripheral should be associated with your app and when that is done you are granted the permissions to run in the background so that you can keep the connection alive. Bonding is a different thing that relates to a part of the Bluetooth specification.
For two Bluetooth-enabled peripherals to trust each other, the user has to grant a bond between them. On Android, this happens when we call BluetoothDevice.createBond()
. This will trigger a system notification asking the user to allow pairing and bonding with the device in question. The system also lets you check if the device is granted access to your contacts. This exists to allow car displays (and other screens) to display your phone's contacts when connected via Bluetooth.
Some peripherals require you to bond them to the phone before you're allowed to communicate with them. If this is the case for your device, you need to call createBond()
once you have discovered it and guide the user through the system notifications. My recommendation is to do this after the association is completed. Unfortunately, there is no regular callback for the createBond()
call. Instead, you have to register a BroadcastReceiver
on the BluetoothDevice.ACTION_BOND_STATE_CHANGED
action to be notified when the bonding is completed.
While we have a function for creating a bond, there is no public API for removing it. However, the method is still available, it is just not published in the official Android API.
fun BluetoothDevice.releaseBond() {
val method: Method = this.javaClass.getMethod("removeBond")
method.invoke(this)
}
BluetoothDevice
for removing a bondThe code above shows how to add a simple extension function to BluetoothDevice
that removes a bond. This will not trigger any system UI to appear, but the BroadcastReceiver
will receive the Intent
indicating that it is no longer bonded.
CompanionDeviceService
Starting with Android 12 (API level 31), we have a new API for working with BLE peripherals called CompanionDeviceService
. This is a special Service
that you implement in your companion app that will be called by the system whenever a previously associated peripheral (using the CompanionDeviceManager
) is nearby. The Android system will keep the service, and your app, alive as long as the peripheral is nearby. This will make the development of companion apps even easier, but since it is only available from API level 31 it is a bit soon to start using. I plan to cover this API in more detail once Android 12 is released and more widely available.
Summary
In this post, we looked at how to use the CompanionDeviceManager
to discover a device and associate it with your app. Once completed your app can keep running in the background to maintain the connection to your BLE device.
We also looked briefly at bonding and how to create and remove a bond. Since there is no regular callback API, you will need to use a BroadcastReceiver
to get notified once this operation is completed.
In the next post, we will look at how to connect to a BLE device from your Android app and how to communicate with it. Stay tuned for part 3!