Bluetooth LE for modern Android Development - part 2

Bluetooth LE for modern Android Development - part 2
Photo by Isaac Davis / Unsplash

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
}
Simple Compose function for our pairing screen

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)
}
Code snippet for checking if we're currently associated with a peripheral using the CompanionDeviceManager

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)
}
Code for getting the device address from the current associations, or default to "unknown" if none exists

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)
}
Button for calling associate or disassociate on the CompanionDeviceManager

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()
}
Simple code for building an 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)
            }
    }
Code for creating an activityResultLauncher to be used once at least one device is discovered

The 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)
How to get a BluetoothDevice from the hardware address

Using 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)
}
Simple extension function on BluetoothDevice for removing a bond

The 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!