Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Bluetooth Low Energy on Android: Top Tips For The Tricky Bits (Video)

Stuart Kent
September 13, 2017

Bluetooth Low Energy on Android: Top Tips For The Tricky Bits (Video)

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://youtu.be/jDykHjn-4Ng
Venue: GDG Detroit

Stuart Kent

September 13, 2017
Tweet

More Decks by Stuart Kent

Other Decks in Technology

Transcript

  1. Why This Talk • ! Smart speaker app • "

    Fun technology • # Frustrating stack • $ Scattered information @skentphd
  2. Content • ✅ BLE Basics • ✅ Android Landscape •

    ✅ Android Landmarks • ⏰ More Tricky Bits @skentphd
  3. BLE Features • Wireless personal area network • 100m range

    • Small/low power/long lifespan devices • Low-dimensional data • Low-friction communication @skentphd
  4. Peripheral • Usually acts as a server: • Characteristic, composed

    of: • a single value (int, float, string) • many descriptors = metadata • Service = group of related characteristics • Profile = all services • Identified using UUIDs @skentphd
  5. Heart Rate Monitor (Standard Profile) Profile: "Heart Rate" Services: "Heart

    Rate" Characteristics: • "Body Sensor Location" (read) • "Current Heart Rate" (sub) • "Settings" (write) @skentphd
  6. Smart Speaker (Custom Profile) Profile: Custom Services: Custom Characteristics: •

    Volume (read/write/sub) • Bass/Mid/Treble (read/write/sub) • Clipping (read/sub) • LED (write) @skentphd
  7. Central • Usually acts as a client • Can connect

    to up to 7 BLE servers simultaneously • Reads/writes/subscribes to characteristics • Typically a mobile device! @skentphd
  8. Challenges • ! Undocumented/unclear limitations • " Location required for

    scanning • # Connection instability • $ Frequent, obscure errors (GATT_ERROR 133) • ⚖ Unbalanced lifecycle calls @skentphd
  9. Abstractions • SweetBlue: proprietary • RxAndroidBle: not designed for persistent

    connection • AndroidBluetoothLibrary: unexplored • Nearby APIs: specialized feature set • All: necessarily opinionated; build from scratch @skentphd
  10. Steps 1. Planning 2. BLE Support 3. BLE State 4.

    Scanning For Devices 5. Connecting 6. Operation Queuing 7. Discovering Services 8. Performing Operations 9. Disconnecting @skentphd
  11. 1. Planning • Max. 7 concurrent connections • Max. 15

    characteristic subscriptions per peripheral @skentphd
  12. 2. BLE 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" /> @skentphd
  13. 2. BLE Support Correct boolean isBleSupported(Context context) { return BluetoothAdapter.getDefaultAdapter()

    != null && context.getPackageManager().hasSystemFeature(FEATURE_BLUETOOTH_LE); } Incorrect boolean isBleSupported() { return BluetoothAdapter.getDefaultAdapter() != null && BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner() != null; } @skentphd
  14. 2. BLE Support getDefaultAdapter() non-null if hardware supports Bluetooth; null

    otherwise (static). getBluetoothLeScanner() non-null if Bluetooth is currently on; null otherwise (dynamic). @skentphd
  15. 3. BLE State • Treat STATE_ON or STATE_BLE_ON as on

    • Treat every other state as off • Check state aggressively • Consider a blocking Activity • Prompt using BluetoothAdapter.ACTION_REQUEST_ENABLE @skentphd
  16. 4. Scanning For Devices // After checking that device Bluetooth

    is on... bleScanner.startScan( filters, // Describe relevant peripherals settings, // Specify power profile scanCallback); // Process scan results @skentphd
  17. 4. Scanning For Devices ScanCallback scanCallback = new ScanCallback() {

    @Override public void onScanResult(int callbackType, ScanResult result) { // Process scan result here. } }; @skentphd
  18. 4. Scanning For Devices 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 @skentphd
  19. 4. Scanning For Devices 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" /> @skentphd
  20. 4. Scanning For Devices Location permission request will confuse users.

    • Call out in Play Store description • Ignore shouldShowRequestPermissionRationale • Always present custom explanation dialog • Consider a blocking Activity @skentphd
  21. 4. Scanning For Devices • Location services must be on

    to receive scan results • Consider a blocking Activity • Query using Settings.Secure.getInt( context.getContentResolver(), LOCATION_MODE) • Prompt to enable using Settings.ACTION_LOCATION_SOURCE_SETTINGS or LocationSettingsRequest (Play Services) @skentphd
  22. 4. Scanning For Devices Scanning must be stopped manually: •

    First result • Fixed duration • App backgrounding @skentphd
  23. 4. Scanning For Devices 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(). } }; @skentphd
  24. 4. Scanning For Devices // 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()); } }; @skentphd
  25. 4. Scanning For Devices // Called after bluetoothLeScanner.startScan: new Handler(Looper.getMainLooper()).postDelayed(new

    Runnable() { @Override public void run() { // Check bleScanner is not null, then: bleScanner.stopScan(scanCallback); // Process contents of scannedDevices (display; connect). } }, scanDurationMs); @skentphd
  26. 5. Connecting // Retain; used for all future device commands.

    BluetoothGatt server = device.connectGatt( context, false, // autoConnect; safest set to false? serverCallback); @skentphd
  27. 5. Connecting // Receives command responses and information 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. } @skentphd
  28. 5. Connecting @Override public void onConnectionStateChange( BluetoothGatt server, int status,

    int newState) { if (BluetoothProfile.STATE_CONNECTED == newState) { // Proceed to service discovery. } } @skentphd
  29. 6. Operation Queuing • All other operations must happen serially

    • Confusing due to asynchronous APIs @skentphd
  30. 6. Operation Queuing (Single Server) Incorrect server.operationA(); // Overwritten; never

    executes. server.operationB(); Correct server.operationA(); // Wait for operationA to complete... server.operationB(); @skentphd
  31. 6. Operation Queuing (Multi Server) Incorrect server1.operationA(); // Overwritten; never

    executes. server2.operationB(); Correct server1.operationA(); // Wait for server1 operationA to complete... server2.operationB(); @skentphd
  32. 6. Operation Queuing • Submit Operation instances to a central

    OperationManager • Populate and process Queue<Operation> one-by-one: • Manager pops operation • Manager dispatches operation to server • Server notifies manager on completion • Repeat until empty @skentphd
  33. 6. Operation Queuing public abstract class Operation { public Operation(String

    serverId) { this.serverId = serverId; } } @skentphd
  34. 6. Operation Queuing public class OperationManager { private Queue<Operation> operations

    = new LinkedList<>(); private Operation currentOp; public synchronized void request(Operation operation) { operations.add(operation); if (currentOp == null) { currentOp = operations.poll(); getServer(currentOp.getServerId()).perform(currentOp); } } public synchronized void operationCompleted() { currentOp = null; if (operations.peek() != null) { currentOp = operations.poll(); getServer(currentOp.getServerId()).perform(currentOp); } } } @skentphd
  35. 7. Discovering Services • Necessary even if you know server

    profile • Must occur before any server characteristic operations @skentphd
  36. 7. Discovering Services public class DiscoverOperation extends Operation { public

    DiscoverOperation(String serverId) { super(serverId); } } @skentphd
  37. 7. Discovering Services // Operation created and enqueued: operationManager.request(new DiscoverOperation(/*

    Params */)); // (Later...) Operation dequeued and executed: server.discoverServices(); // (Later...) Operation completes: @Override public void onServicesDiscovered(/* Params */) { service = server.getService(/* UUID */); // Save reference. operationManager.operationCompleted(); } @skentphd
  38. 8: Reads public class ReadOperation extends Operation { public ReadOperation(

    String serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } @skentphd
  39. 8. Reads // Operation created and enqueued: operationManager.request(new ReadOperation(/* Params

    */)); // (Later...) Operation dequeued and executed: server.readCharacteristic(service.getCharacteristic(characteristicId)); // (Later...) Operation completes: @Override public void onCharacteristicRead(/* Params */) { operationManager.operationCompleted(); // Process new value here. } @skentphd
  40. 8. Writes public class WriteIntOperation extends Operation { public WriteIntOperation(

    String serverId, String characteristicId, int value) { // Type should match profile! super(serverId); this.characteristicId = characteristicId; this.value = value; } } @skentphd
  41. 8. Writes // Operation created and enqueued: operationManager.request(new WriteIntOperation(/* Params

    */)); // (Later...) Operation dequeued and executed: BluetoothGattCharacteristic characteristic = service.getCharacteristic(characteristicId); characteristic.setValue(value, FORMAT_UINT8, 0); server.writeCharacteristic(characteristic); // (Later...) Operation completes: @Override public void onCharacteristicWrite(/* Params */) { operationManager.operationCompleted(); } @skentphd
  42. 8. Notifications • A little more complex • Requires remote

    and local configuration • Controlled by per-characteristic descriptor @skentphd
  43. 8. Notifications public class EnableNotificationsOperation extends Operation { public EnableNotificationsOperation(

    String serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } @skentphd
  44. 8. Notifications // Enable notifications locally after service discovery (REQUIRED):

    server.setCharacteristicNotification(characteristic, true); // Operation created and enqueued: operationManager.request(new EnableNotificationsOperation(/* Params */)); // (Later...) Operation dequeued 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...) Operation completes: @Override public void onDescriptorWrite(/* Params */) { operationManager.operationCompleted(); } // (Later...) Characteristic changes: @Override public void onCharacteristicChanged(/* Params */) { // Process new value here. } @skentphd
  45. 9. Disconnecting • 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 @skentphd
  46. Operation Errors Problem • Most operations will fail occasionally and

    obscurely Solutions • Assume the worst; code defensively • Tear down and retry connection if status != GATT_SUCCESS • Timebox retries @skentphd
  47. Callback Exceptions Problem • BluetoothGattCallback automatically swallows exceptions • Failing

    to report completion stalls the operation queue Solution • Catch and handle all exceptions yourself • Report operation completion in finally block @skentphd
  48. Callback Exceptions @Override public void onCharacteristicRead(/* Params */) { try

    { // Happy path. } catch (final Exception exception) { // Sad path. } finally { // Always report operation completion! operationManager.operationCompleted(); } } @skentphd
  49. Unexpected Disconnections Problem • Will happen (a lot) Solutions •

    Tear down (close) and start over • Track boolean userDisconnect; auto-retry only if false • Timebox retries @skentphd
  50. 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 @skentphd
  51. 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."); } } @skentphd
  52. Avoiding Buffering Problem • Operations take ~100-300ms (round trip) •

    Some UI controls (SeekBar) update rapidly, flooding queue Solutions • Debouncing • Discard redundant operations @skentphd
  53. Avoiding Buffering OperationManager.java: public synchronized void request(Operation operation) { //

    Replaces a call to operations.add(operation): processNewOperation(operation); if (currentOp == null) { currentOp = operations.poll(); getServer(currentOp.getServerId()).perform(currentOp); } } @skentphd
  54. Avoiding Buffering processNewOperation(Operation operation): • If operation is a ReadOperation

    • And the operations queue already contains a ReadOperation for the same server and characteristic • Then skip adding the new ReadOperation to the queue @skentphd
  55. Avoiding Buffering processNewOperation(Operation operation): • If operation is a WriteIntOperation

    • And the operations queue contains a pending WriteIntOperation for the same server and characteristic • Then delete the pending WriteIntOperation from the queue • And add the new WriteIntOperation to the queue @skentphd