Share
Bluetooth Low Energy on Android: Top Tips for the Tricky Bits v3 (droidcon London)
Bluetooth Low Energy (BLE) powers the Internet of Things (IoT): smart watches, smart bulbs, and smart cars all use it for short-range communication. Now that 90% of Android consumer devices and 100% of Android Things devices run software that supports BLE, there’s never been a better time for Android developers to jump into the rapidly-growing IoT ecosystem and start building their own companion apps or custom smart devices.
Unfortunately, Android’s Bluetooth stack has a well-deserved reputation for being difficult to work with. The documentation is patchy, the API abstractions are leaky, and the stack itself is unreliable. I worked through all these challenges while building a pro audio app at the start of 2017, and now I'm sharing my story to save you time.
The beginning of this talk will cover BLE basics. The remainder will showcase code samples and strategies for tackling the quirks of the Android Bluetooth stack. No prior experience with BLE is required to enjoy this talk. You’ll leave excited to tinker with BLE on Android and equipped with a roadmap and toolkit to help you navigate the nasty parts.
Favorite Resources: https://gist.github.com/stkent/a7f0d6b868e805da326b112d60a9f59b
Video: https://skillsmatter.com/skillscasts/10529-bluetooth-low-energy-on-android-top-tips-for-the-tricky-bits
Stuart Kent
Transcript
91 slides
-
Why This Talk? • ! Smart speaker app • "
Fun technology • # Frustrating Android stack • $ Scattered information Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Content • ! BLE Basics • " Android Landscape •
# Android Landmarks Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
! BLE Basics Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
BLE Basics • Small amounts of data • Easy connection
• Easy communication • 100m range • Low power usage Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
BLE Device Roles • Central • Usually acts as a
client • Consumes data; sends commands • Peripheral • Usually acts as a server • Exposes data; responds to commands Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
BLE Device Roles Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
BLE Device Roles Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
BLE Device Roles Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Server Profiles • Servers expose characteristics. Each has: • one
value (int, float, string) • descriptors (metadata) • Characteristics are grouped into services • Everything addressed using 128-bit UUIDs Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Server Profiles: Standard • Defined by Bluetooth Special Interest Group
(SIG) • For interoperability • 16-bit UUIDs inserted into 128-bit template UUID Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Server Profiles: Custom • For fully-custom peripherals • No UUID
restrictions Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
! Android Landscape Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Android Landscape Abstractions: • SweetBlue: proprietary • RxAndroidBle: no persistent
connections • AndroidBluetoothLibrary • Nearby APIs: specialized feature set Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Android Landscape Samples: • puck-central-android: complete → complex Bluetooth Low
Energy on Android · Stuart Kent · @skentphd -
! Android Landmarks Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Assumptions • Central role • Android 21+ • BLE only
(no Classic fallbacks) • No pairing Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Stages • Planning • Support • State • Scanning •
Connecting • Interacting • Disconnecting -
Planning • at most 7 peripheral connections per central •
at most 15 subscriptions per peripheral (Android only) • BLE features: required or nice-to-have? Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Support AndroidManifest.xml: <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> Bluetooth Low
Energy on Android · Stuart Kent · @skentphd -
Support AndroidManifest.xml: <!-- For Play Store feature-based filtering --> <!--
required="true" => mandatory --> <!-- required="false" => preferred --> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Support Runtime check for emulator & sideloads: boolean isBleSupported(Context context)
{ return BluetoothAdapter.getDefaultAdapter() != null && context.getPackageManager().hasSystemFeature(FEATURE_BLUETOOTH_LE); } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
State Query at any time: BluetoothAdapter.getDefaultAdapter().isLeEnabled(); Bluetooth Low Energy on
Android · Stuart Kent · @skentphd -
State Register for broadcasts: context.registerReceiver( receiver, new IntentFilter( BluetoothAdapter.ACTION_STATE_CHANGED)); Bluetooth
Low Energy on Android · Stuart Kent · @skentphd -
State • Treat STATE_ON or STATE_BLE_ON as on • Treat
every other state as off • Prompt with BluetoothAdapter.ACTION_REQUEST_ENABLE • Use a blocking Activity if required Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning Get scanner using: BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner(); Warning: getDefaultAdapter null 㲗 Bluetooth
not supported (static) getBluetoothLeScanner null 㲗 Bluetooth disabled (dynamic) Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning // After checking that device Bluetooth is on... bleScanner.startScan(
filters, // Describe relevant peripherals settings, // Specify power profile scanCallback); // Process scan results Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning ScanCallback scanCallback = new ScanCallback() { @Override public void
onScanResult(int callbackType, ScanResult result) { // Process single scan result here. } }; Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning No results! Quiet failure! Bluetooth Low Energy on Android
· Stuart Kent · @skentphd -
Scanning 09-07 11:20:47.500 W: Caught a RuntimeException from the binder
stub implementation. java.lang.SecurityException: Need ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION permission to get scan results Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning AndroidManifest.xml: <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- For Play Store feature-based
filtering --> <!-- required="true" => mandatory --> <!-- required="false" => preferred --> <uses-feature android:name="android.hardware.location" android:required="true" /> Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning ActivityCompat.requestPermissions( this, new String[]{ACCESS_COARSE_LOCATION}, PERMISSIONS_REQUEST_ID); Bluetooth Low Energy on
Android · Stuart Kent · @skentphd -
Scanning Location permission request will confuse users: • Explain in
Play Store description • Always present custom explanation dialog (ignore shouldShowRequestPermissionRationale) • Use a blocking Activity if required Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning No results! Silent failure! Bluetooth Low Energy on Android
· Stuart Kent · @skentphd -
Scanning Location services must be enabled to receive scan results:
• Enabled if Settings.Secure.getInt( context.getContentResolver(), LOCATION_MODE) does not return LOCATION_MODE_OFF • Prompt with ACTION_LOCATION_SOURCE_SETTINGS or LocationSettingsRequest (Play Services) • Use a blocking Activity if required Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning ! Results! Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Scanning Scanning must be stopped manually: • First result •
Fixed duration • App backgrounding Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning: First Result ScanCallback scanCallback = new ScanCallback() { @Override
public void onScanResult(int callbackType, ScanResult result) { // Check bleScanner is not null, then: bleScanner.stopScan(scanCallback); // Connect to result.getDevice(). } }; Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning: Fixed Duration // Clear between each scan. SortedSet<BluetoothDevice> scannedDevices
= new TreeSet<>(); ScanCallback scanCallback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { scannedDevices.add(result.getDevice()); } }; Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Scanning: Fixed Duration // Called after bleScanner.startScan: new Handler(Looper.getMainLooper()).postDelayed(new Runnable()
{ @Override public void run() { // Check bleScanner is not null, then: bleScanner.stopScan(scanCallback); // Process scannedDevices (display; connect). } }, scanDurationMs); Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Connecting // Retain; used to initiate future commands. BluetoothGatt server
= device.connectGatt( context, false, // autoConnect; aggressive serverCallback); // Receives command results Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Connecting // Receives command responses and updates from device server.
BluetoothGattCallback serverCallback = new BluetoothGattCallback() { @Override public void onConnectionStateChange(/* Params */) {} @Override public void onServicesDiscovered(/* Params */) {} @Override public void onCharacteristicRead(/* Params */) {} @Override public void onCharacteristicWrite(/* Params */) {} @Override public void onCharacteristicChanged(/* Params */) {} @Override public void onDescriptorWrite(/* Params */) {} // Other, more esoteric, methods. } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Connecting @Override public void onConnectionStateChange( BluetoothGatt server, int status, int
newState) { if (BluetoothProfile.STATE_CONNECTED == newState) { // Connection established; begin interacting! } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing • All other interactions must happen serially •
Confusing due to asynchronous APIs Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing (Single Server) Incorrect server.commandA(); // Overwritten; never executes.
server.commandB(); Correct server.commandA(); // (Wait for commandA completion callback...) server.commandB(); Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing • Must be serial across all connected servers
Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing (Multi Server) Incorrect server1.commandA(); // Overwritten; never executes.
server2.commandB(); Correct server1.commandA(); // (Wait for server1 commandA completion callback...) server2.commandB(); Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing Incorrect Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Command Queuing Incorrect Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Command Queuing Correct Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Command Queuing • Don't call server methods immediately • Instead,
submit Command to a CommandManager • Process Queue<Command> one-by-one: • Next Command dispatched to correct server • Command completion reported in serverCallback • Repeat until empty Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing public abstract class Command { public Command(String serverId)
{ this.serverId = serverId; } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Queuing public class CommandManager { private Queue<Command> commands =
new LinkedList<>(); private Command command; // Command in progress; may be null. public synchronized void request(Command newCommand) { commands.add(newCommand); if (command == null) { command = commands.poll(); getServer(command.getServerId()).perform(command); } } public synchronized void reportCompleted() { command = null; if (commands.peek() != null) { command = commands.poll(); getServer(command.getServerId()).perform(command); } } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Discovering Services • Required even for known server profile •
Takes 1-3 seconds per peripheral • Must occur: • after connection • before any characteristic-level commands Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Discovering Services public class DiscoverCommand extends Command { public DiscoverCommand(String
serverId) { super(serverId); } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Discovering Services // Command created and enqueued: commandManager.request(new DiscoverCommand(/* Params
*/)); // (Later...) Command dispatched and executed: server.discoverServices(); // (Later...) Command completes: @Override public void onServicesDiscovered(/* Params */) { service = server.getService(/* UUID */); // Save reference. commandManager.reportCompleted(); // Next command can begin. } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Reads public class ReadCommand extends Command { public ReadCommand( String
serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Reads // Command created and enqueued: commandManager.request(new ReadCommand(/* Params */));
// (Later...) Command dispatched and executed: server.readCharacteristic(service.getCharacteristic(characteristicId)); // (Later...) Command completes: @Override public void onCharacteristicRead(/* Params */) { commandManager.reportCompleted(); // Next command can begin. // Process new value here. } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Writes public class WriteIntCommand extends Command { public WriteIntCommand( String
serverId, String characteristicId, int value) { // Type should match profile! super(serverId); this.characteristicId = characteristicId; this.value = value; } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Writes // Command created and enqueued: commandManager.request(new WriteIntCommand(/* Params */));
// (Later...) Command dispatched and executed: BluetoothGattCharacteristic characteristic = service.getCharacteristic(characteristicId); characteristic.setValue(value, FORMAT_UINT8, 0); server.writeCharacteristic(characteristic); // (Later...) Command completes: @Override public void onCharacteristicWrite(/* Params */) { commandManager.reportCompleted(); // Next command can begin. } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Notifications • Requires remote and local configuration • Controlled by
per-characteristic descriptor Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Notifications public class EnableNotificationsCommand extends Command { public EnableNotificationsCommand( String
serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Notifications // Enable notifications locally after service discovery (required): server.setCharacteristicNotification(characteristic,
true); // Command created and enqueued: commandManager.request(new EnableNotificationsCommand(/* Params */)); // (Later...) Command dispatched and executed: characteristic = gattService.getCharacteristic(characteristicId); descriptor = characteristic.getDescriptor("00002902-0000-1000-8000-00805F9B34FB"); // Notifications enabled descriptor UUID. descriptor.setValue({0x01, 0x00}); // Byte array representing enabled state. server.writeDescriptor(descriptor); // (Later...) Command completes: @Override public void onDescriptorWrite(/* Params */) { commandManager.reportCompleted(); // Next command can begin. } // (Later...) Characteristic changes: @Override public void onCharacteristicChanged(/* Params */) { // Process new value here. } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Callback Exception Handling Problem • serverCallback methods catch all exceptions
• Failing to report command completion stalls the queue Solutions • Catch all exceptions yourself • Report command completion in finally block Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Callback Exception Handling Example @Override public void onCharacteristicRead(/* Params */)
{ try { // Happy path. } catch (Exception e) { // Sad path. } finally { // Always report command completion! commandManager.reportCompleted(); } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Command Errors Problem • Most commands fail occasionally and obscurely
Solutions • Assume the worst; code defensively • Tear down and reconnect if status != GATT_SUCCESS • Timebox and limit # of retries Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Disconnecting (Deliberately) • Call server.disconnect • Call server.close once onConnectionStateChange
is called with state STATE_DISCONNECTED • This might not happen (disconnect while connecting) • Post delayed Runnable to call server.close() • Cancel Runnable if onConnectionStateChange is called Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Disconnecting (Unexpectedly) Problem • Will happen (a lot) Solutions •
Tear down (close) and reconnect • Track boolean userDisconnect; auto-retry only if false • Timebox and limit # of retries Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
! The "End"! Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Where Next? • Advanced Tips slides (end of this deck)
• service caching • buffering • long-lived connections • Resources Gist: git.io/v5V2B Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
! Advanced Tips Bluetooth Low Energy on Android · Stuart
Kent · @skentphd -
Service Caching Problem • First service discovery takes 2-3 seconds
• Results are cached by the framework • Evolving peripherals lead to a stale cache Solution • Manually clear cache in development builds Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Service Caching private void refreshDeviceCache(BluetoothGatt bluetoothGatt) { try { Method
hiddenClearCacheMethod = bluetoothGatt.getClass().getMethod("refresh"); if (hiddenClearCacheMethod != null) { Boolean succeeded = (Boolean) hiddenClearCacheMethod.invoke(bluetoothGatt); if (succeeded == null) { Log.e("Ambiguous cache-clearing result. Cache may or may not have been cleared."); } else if (succeeded) { Log.d("Cache was successfully cleared."); } else { Log.e("Hidden cache-clearing method was called but failed. Cache was not cleared."); } } else { Log.e("Could not locate hidden cache-clearing method. Cache was not cleared."); } } catch (Exception ignored) { Log.e("An exception occurred while clearing cache. Cache was not cleared."); } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Service Caching Call refreshDeviceCache after connection succeeds and before discovering
services. Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Buffering Problem • Commands take ~100-300ms (round trip) • Some
UI controls (SeekBar) update rapidly, flooding queue Solutions • Debouncing • Discard redundant commands Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Buffering CommandManager.java: public synchronized void request(Command newCommand) { // Replaces
a call to commands.add(newCommand): processNewCommand(newCommand); if (command == null) { command = commands.poll(); getServer(command.getServerId()).perform(command); } } Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Buffering processNewCommand(Command newCommand) logic: • If newCommand is a ReadCommand
• And the commands queue already contains a ReadCommand for the same server and characteristic • Then skip adding the new ReadCommand to the queue Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Buffering processNewCommand(Command newCommand) logic: • If newCommand is a WriteIntCommand
• And the commands queue contains a pending WriteIntCommand for the same server and characteristic • Then delete the pending WriteIntCommand from the queue • And add the new WriteIntCommand to the queue Bluetooth Low Energy on Android · Stuart Kent · @skentphd -
Long-Lived Connections Problem • Monitoring applications need connections that outlive
app UI Solution • Use a foreground Service Bluetooth Low Energy on Android · Stuart Kent · @skentphd