Bluetooth LE for modern Android Development — part 1
This post will be the first in a series where I aim to explain how to use the BLE APIs on Android to their full potential.
Erik Hellman
Years ago I wrote a few blog posts on how to use the Bluetooth Low Energy APIs on Android. While they became very popular and contained many tips and tricks for getting your BLE code to work, they soon became deprecated as both Google and device manufacturers fixed their bugs. Today, the APIs for BLE on Android have changed very little, but they are important changes to understand to get the most reliable BLE code possible in your app.
This post will be the first in a series where I aim to explain how to use the BLE APIs on Android to their full potential. These will also be relevant for developers writing embedded code for BLE-enabled peripherals, as I will cover some details that I’ve noticed are often overlooked when it comes to how peripherals are implementing BLE.
This first post is an introduction to what is available and possible today, and I’ll follow them up with a more specific content and some sample Android code.
Bluetooth Low Energy overview
Bluetooth Low Energy has a different marketing name that nobody uses: Bluetooth Smart. Since nobody uses that name we can forget about it, but if you ever hear or read «Bluetooth Smart», you know that they mean Bluetooth Low Energy.
Android versions and Bluetooth
If possible, your app should have Android 8.0 (API level 26) as the minimum version. This is not only because of Bluetooth 5.0 support that comes in Android 8.0 but also because of some very important changes to the Android BLE APIs that we will look at later.
Discovery and Communication
When talking about Bluetooth Low Energy it is useful to think of it as two separate parts: discovery and communication (Note: these are not the formal terms). Discovery relates to parts of BLE that happen before your app connects to a peripheral, while communication is everything that happens when we know the address for a device.
A big difference between discovery and communication when it comes to the peripheral is that they aren’t dependent on each other. A peripheral might put information in its announcement packages (the broadcast messages sent by the peripheral to let devices discover it) that have nothing to do with the parts used for communication. Practically, this means that when scanning for a BLE peripheral, you cannot assume it will be discoverable by filtering on a GATT Service UUID.
One important thing when you’re building a new peripheral using BLE: make sure you include something more than the device name in the announcement packages. This will make discovery safer and simpler to implement and allow for a better user onboarding experience.
Bluetooth GATT
In Bluetooth, GATT is an acronym for Generic Attribute Profile (Yes, naming is hard). For BLE, GATT is the part that relates to communication and it consists of a hierarchy in three levels: Services, Characteristics, and Descriptors. A device can have multiple Services, and each Service can have multiple Characteristics, and each Characteristic can have multiple Descriptors. Think of it as a tree with the root being the device itself.
Note: A GATT Service can also include other Services, but as I’ve never seen this in real life and I haven’t figured out a good use case for it, I choose to ignore it for this post.
Services don’t have any functionality by themselves but simply work as a grouping for Characteristics. It’s at the Characteristics where all the fun is happening. One way to think of them is as I/O ports or endpoints. You can read and write values to a Characteristic, and you can «subscribe» to changes from the remote side. Descriptors are like shared properties that can be used to define how Characteristics behave or should be used.
I recommend that you use one Characteristic for reading and one for writing for a Service. This effectively means you remove the risk of collisions and will make your code much more clear. If you, for instance, have a smart lock that you connect to with BLE, I would use one Characteristic for controlling the lock from an app (writing) and another for getting the current state of the lock (reading). This approach makes it easier to build integrate the lock with an app.
BLE in Android 8 and beyond
With Android 8.0 many new features, improvements, and bug fixes for BLE were announced. The main reason for this was the support for Bluetooth 5.0, which required additional functions in the API. There are three major improvements that I will cover in this post: dedicated handler for BluetoothDevice.connectGatt() , support for PHY parameters, and the new CompanionDeviceManager API. There is also another significant change coming in Android S, the CompanionDeviceService . However, since this hasn’t been released yet I won’t include it in this post, but hopefully, get a chance to cover it in the future.
connectGatt improvements
To connect to a BLE peripheral in an Android app, you need to use the function connectGatt() found in the BluetoothDevice class. This function was added already in API level 18, but new functions were added in API level 23 (for setting which transport to use, which is outside the scope of this post), and most recently in API level 26 when the new version was added to specify a Handler to use for invoking the GATT callbacks.
Before API level 26, the thread on which the GATT callbacks were invoked was undefined in the API. In real life, the thread was always a Binder thread which was used by the system to communicate between the system services and the app. The problem with this is that the remote side of a Binder call will block until the processing on the Binder thread in the app completes. A call over a Binder thread is a blocking call. This effectively meant that an app could block a thread in the system service, sometimes causing a crash that would reboot the device, or in some cases, crash the entire Bluetooth stack on the device that could only be resolved by a reboot.
With API level 26, Google introduced versions of the connectGatt() function that takes a Handler as a parameter. What this does is making sure that callbacks to the BluetoothGattCallback instance are invoked on this Handler and not on whatever Binder thread that the system call came on. The API documentation says «If null, callbacks will happen on an un-specified background thread.», so I strongly advise you to use a specific Handler here. Since you’re not supposed to do any long-running jobs on these callbacks, it is ok to use a Handler running on the main thread for this.
Hopefully, this change will have a major improvement on the stability of BLE-enabled Android apps.
PHY
With Bluetooth 5.0 came several additions to the BLE capabilities. One thing that is of importance to developers is the support for long-range and 2 MBit/s PHY. These are two states that you can set for a BLE connection that affects range AND speed. You can choose to either get a longer range, but suffer a lower throughput, or decrease the possible range and gain a higher throughput.
In the Android BLE APIs, this is exposed in the PHY constants (which is an acronym for PHYsical layer) in the BluetoothDevice class: PHY_LE_1M , PHY_LE_2M , and PHY_LE_CODED . 1M is the default and is the same that is used on lower BLE versions, 2M means double the throughput (theoretically 2 MBit/s), and CODED means long-range with lower throughput. For a deep dive into Bluetooth LE and PHY, please read this excellent post by Henry Anfang.
In an Android app, you can specify which PHY to use by calling BluetoothGatt.setPreferredPhy() . This will then be sent to the remote device that will respond to the request, and then result in a call to BluetoothGattCallback.onPhyUpdate() . Note that this call is just a recommendation to the system. The onPhyUpdate() callback will always be invoked as a result of this request with the actual values for the connection.
Note that it is also possible to specify the preferred PHY why calling connectGatt() . However, this only works if autoConnect in the same call is set to false , and this is something you rarely want to do. I will go into the details of this in an upcoming post.
Updating the PHY can be done at any time during a connection, and the change has no functional impact apart from the change in throughput and range. However, it is highly recommended to use this feature if possible. If the throughput isn’t important (i.e., you’re only sending small messages at a low rate) it is recommended to use PHY_LE_CODED to allow for a longer range between the smartphone and the peripheral. This is because a longer range also means better penetration through thick walls, which will generally improve the reliability of the connection when the devices are not close all the time.
However, if the transfer rate is important, like when transferring a large OTA update for a peripheral, PHY_LE_2M is recommended as it gives a huge boost. Do note that the range is significantly reduced, so if the range is important you should remember to switch back to PHY_LE_CODED once it is no longer needed.
The setPreferredPhy() call also has a third parameter named phyOptions . This is only relevant when setting the txPhy and rxPhy to PHY_LE_CODED , and it controls how the coding should be done. There are three valid parameters here: PHY_OPTION_NO_PREFERRED , PHY_OPTION_S2 , and PHY_OPTION_S8 . The first leave it to the system to decide, the two others tell how many signals you want for each bit. More signals (S8) means better error correction and thus longer range. So for the best possible range (but also lowest transfer rate), use PHY_LE_CODED together with PHY_OPTION_S8 .
Companion Device Manager
A nice addition to the APIs in Android 8.0 was the Companion Device Manager. This can be used for pairing the app with a companion device, like a smartwatch or some other IoT device in your home. It removes the need for doing scanning of BLE devices yourself and instead presents a system UI with the discovered devices. Additionally, it lets your device declare permissions that allow it to run in the background as needed to communicate with the companion devices. This is especially useful for things like smartwatches, where the user rarely interacts with the dedicated app running on the phone.
The CompanionDeviceManager is a system service and can be retrieved using Context.getSystemService() . The API for this service is fairly simple. The associate() function initiates a scan for matching devices and results in an IntentSender to the callback you’re using. When you launch this using Activity.startIntentSenderForResult() the system will present a dialog with the devices discovered where the user can pick one, which will then pass the choice back to your Activity using onActivityResult() .
All of this is possible to do on your own, but the power of the CompanionDeviceManager comes from the association between the chosen peripheral and your application. Once associated, your application is allowed to run in the background to maintain the connection to the device. This used to be one of the major pain points of writing companion apps for Android, so it will definitively help us a lot.
Conclusions
In this post, I’ve given an overview of the new nice things for BLE that came with Android 8.0. Although that version has been available for quite some time, it is apparent that most apps doing BLE communication aren’t taking advantage of them. With this, I hope to give Android developers a better understanding of what is available and why you should use it.
Next, we’ll look at some code for how to use the CompanionDeviceManager and how it can help with creating a better onboarding experience in your BLE-enabled app.