Slide 1

Slide 1 text

No content

Slide 2

Slide 2 text

Stuart Kent Detroit Labs @skentphd @skentphd

Slide 3

Slide 3 text

Why This Talk • ! Smart speaker app • " Fun technology • # Frustrating stack • $ Scattered information @skentphd

Slide 4

Slide 4 text

Content • ✅ BLE Basics • ✅ Android Landscape • ✅ Android Landmarks • ⏰ More Tricky Bits @skentphd

Slide 5

Slide 5 text

Content @skentphd

Slide 6

Slide 6 text

! BLE Basics @skentphd

Slide 7

Slide 7 text

BLE Features • Wireless personal area network • 100m range • Small/low power/long lifespan devices • Low-dimensional data • Low-friction communication @skentphd

Slide 8

Slide 8 text

BLE Device Roles • Peripheral • Central @skentphd

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

Peripheral @skentphd

Slide 11

Slide 11 text

Heart Rate Monitor (Standard Profile) Profile: "Heart Rate" Services: "Heart Rate" Characteristics: • "Body Sensor Location" (read) • "Current Heart Rate" (sub) • "Settings" (write) @skentphd

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

BLE Connections @skentphd

Slide 15

Slide 15 text

! Android Landscape @skentphd

Slide 16

Slide 16 text

Challenges • ! Undocumented/unclear limitations • " Location required for scanning • # Connection instability • $ Frequent, obscure errors (GATT_ERROR 133) • ⚖ Unbalanced lifecycle calls @skentphd

Slide 17

Slide 17 text

Abstractions • SweetBlue: proprietary • RxAndroidBle: not designed for persistent connection • AndroidBluetoothLibrary: unexplored • Nearby APIs: specialized feature set • All: necessarily opinionated; build from scratch @skentphd

Slide 18

Slide 18 text

! Android Landmarks @skentphd

Slide 19

Slide 19 text

Assumptions • Android 21+ • BLE only; no Bluetooth Classic fallbacks • Central role @skentphd

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

1. Planning @skentphd

Slide 22

Slide 22 text

1. Planning • Max. 7 concurrent connections • Max. 15 characteristic subscriptions per peripheral @skentphd

Slide 23

Slide 23 text

2. BLE Support @skentphd

Slide 24

Slide 24 text

2. BLE Support AndroidManifest.xml: @skentphd

Slide 25

Slide 25 text

2. BLE Support AndroidManifest.xml: @skentphd

Slide 26

Slide 26 text

2. BLE Support Runtime checks are also valuable: • emulator • demo mode • sideloads @skentphd

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

3. BLE State @skentphd

Slide 30

Slide 30 text

3. BLE State Query at any time: BluetoothAdapter.getDefaultAdapter().isLeEnabled(); @skentphd

Slide 31

Slide 31 text

3. BLE State Register for broadcasts: context.registerReceiver( receiver, new IntentFilter( BluetoothAdapter.ACTION_STATE_CHANGED)); @skentphd

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

4. Scanning For Devices @skentphd

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

4. Scanning For Devices ScanCallback scanCallback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { // Process scan result here. } }; @skentphd

Slide 36

Slide 36 text

4. Scanning For Devices No results! Quiet failure! @skentphd

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

4. Scanning For Devices AndroidManifest.xml: @skentphd

Slide 39

Slide 39 text

4. Scanning For Devices ActivityCompat.requestPermissions( this, new String[]{ACCESS_COARSE_LOCATION}, PERMISSIONS_REQUEST_ID); @skentphd

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

4. Scanning For Devices No results! Silent failure! @skentphd

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

4. Scanning For Devices Results! @skentphd

Slide 44

Slide 44 text

4. Scanning For Devices Scanning must be stopped manually: • First result • Fixed duration • App backgrounding @skentphd

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

4. Scanning For Devices // Clear between each scan. SortedSet scannedDevices = new TreeSet<>(); ScanCallback scanCallback = new ScanCallback() { @Override public void onScanResult(int callbackType, ScanResult result) { scannedDevices.add(result.getDevice()); } }; @skentphd

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

5. Connecting @skentphd

Slide 49

Slide 49 text

5. Connecting // Retain; used for all future device commands. BluetoothGatt server = device.connectGatt( context, false, // autoConnect; safest set to false? serverCallback); @skentphd

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

5. Connecting @Override public void onConnectionStateChange( BluetoothGatt server, int status, int newState) { if (BluetoothProfile.STATE_CONNECTED == newState) { // Proceed to service discovery. } } @skentphd

Slide 52

Slide 52 text

6. Operation Queuing @skentphd

Slide 53

Slide 53 text

6. Operation Queuing • All other operations must happen serially • Confusing due to asynchronous APIs @skentphd

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

6. Operation Queuing • Must be serial across all connected devices @skentphd

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

6. Operation Queuing Incorrect @skentphd

Slide 58

Slide 58 text

6. Operation Queuing • Submit Operation instances to a central OperationManager • Populate and process Queue one-by-one: • Manager pops operation • Manager dispatches operation to server • Server notifies manager on completion • Repeat until empty @skentphd

Slide 59

Slide 59 text

6. Operation Queuing @skentphd

Slide 60

Slide 60 text

6. Operation Queuing public abstract class Operation { public Operation(String serverId) { this.serverId = serverId; } } @skentphd

Slide 61

Slide 61 text

6. Operation Queuing public class OperationManager { private Queue 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

Slide 62

Slide 62 text

7. Discovering Services @skentphd

Slide 63

Slide 63 text

7. Discovering Services • Necessary even if you know server profile • Must occur before any server characteristic operations @skentphd

Slide 64

Slide 64 text

7. Discovering Services public class DiscoverOperation extends Operation { public DiscoverOperation(String serverId) { super(serverId); } } @skentphd

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

8. Performing Operations @skentphd

Slide 67

Slide 67 text

8: Reads public class ReadOperation extends Operation { public ReadOperation( String serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } @skentphd

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

8. Notifications • A little more complex • Requires remote and local configuration • Controlled by per-characteristic descriptor @skentphd

Slide 72

Slide 72 text

8. Notifications public class EnableNotificationsOperation extends Operation { public EnableNotificationsOperation( String serverId, String characteristicId) { super(serverId); this.characteristicId = characteristicId; } } @skentphd

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

8. Other Operations • Similar structures/processes @skentphd

Slide 75

Slide 75 text

9. Disconnecting @skentphd

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

! More Tricky Bits @skentphd

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

Callback Exceptions @Override public void onCharacteristicRead(/* Params */) { try { // Happy path. } catch (final Exception exception) { // Sad path. } finally { // Always report operation completion! operationManager.operationCompleted(); } } @skentphd

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

Service Caching Call refreshDeviceCache after connection succeeds and before discovering services. @skentphd

Slide 85

Slide 85 text

Avoiding Buffering Problem • Operations take ~100-300ms (round trip) • Some UI controls (SeekBar) update rapidly, flooding queue Solutions • Debouncing • Discard redundant operations @skentphd

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

Long-Lived Connections Problem • Monitoring applications need connections that outlive app UI Solution • Use a foreground Service @skentphd

Slide 90

Slide 90 text

! The End @skentphd

Slide 91

Slide 91 text

Resources: git.io/v5V2B @skentphd