Share
Embed
Bluetooth Low Energy on Android: Top Tips For The Tricky Bits (Mobilization 2017)
Now that 90% of Android consumer devices and 100% of Android Things devices run software that supports Bluetooth Low Energy (BLE), it’s the perfect time for Android developers to dive into the Internet of Things and start building companion apps or custom smart devices. Unfortunately, Android’s Bluetooth stack has a well-deserved reputation for being difficult to work with. Join me for a journey through battle-tested strategies and code that will provide you with a roadmap for navigating the nasty parts. No prior experience with BLE is required; a gentle introduction is included.
Favorite Resources: https://gist.github.com/stkent/a7f0d6b868e805da326b112d60a9f59b
Video: pending
Stuart Kent
October 21, 2017
Transcript
89 slides
-
Why This Talk? • ! Smart speaker app • "
Fun technology • # Frustrating Android stack • $ Scattered information Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Content • ! BLE Basics • " Android Landscape •
# Android Landmarks Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
! BLE Basics Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
BLE Basics • Small amounts of data • Easy connection
• Easy communication • 100m range • Low power usage Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
BLE Device Roles Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
BLE Device Roles Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
BLE Device Roles Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Server Profiles Bluetooth Low Energy on Android / Stuart Kent
/ @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Server Profiles: Custom • For fully-custom peripherals • No UUID
restrictions Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
! Android Landscape Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Abstractions • SweetBlue: proprietary • RxAndroidBle: no persistent connections •
AndroidBluetoothLibrary: unexplored • Nearby APIs: specialized feature set If none are suitable, build from scratch! Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
! Android Landmarks Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Assumptions • Central role • Android 21+ • BLE only
(no Classic fallbacks) • No pairing Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Stages • Planning • Preparing • Scanning • Connecting •
Interacting • Disconnecting Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Planning Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Preparing Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
BLE 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 / Mobilization 2017 -
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" /> Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
BLE 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 / Mobilization 2017 -
BLE State Query at any time: BluetoothAdapter.getDefaultAdapter().isLeEnabled(); Bluetooth Low Energy
on Android / Stuart Kent / @skentphd / Mobilization 2017 -
BLE State Register for broadcasts: context.registerReceiver( receiver, new IntentFilter( BluetoothAdapter.ACTION_STATE_CHANGED));
Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
BLE State • Treat STATE_ON or STATE_BLE_ON as on •
Treat every other state as off • Prompt to enable with BluetoothAdapter.ACTION_REQUEST_ENABLE • Use a blocking Activity if required Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Scanning Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
Scanning No results! Quiet failure! Bluetooth Low Energy on Android
/ Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
Scanning ActivityCompat.requestPermissions( this, new String[]{ACCESS_COARSE_LOCATION}, PERMISSIONS_REQUEST_ID); Bluetooth Low Energy on
Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Scanning No results! Silent failure! Bluetooth Low Energy on Android
/ Stuart Kent / @skentphd / Mobilization 2017 -
Scanning Location services must be enabled to receive scan results:
• Enabled whenever Settings.Secure.getInt( context.getContentResolver(), LOCATION_MODE) does not return LOCATION_MODE_OFF • Prompt to enable with ACTION_LOCATION_SOURCE_SETTINGS or LocationSettingsRequest (Play Services) • Use a blocking Activity if required Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Scanning ! Results! Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Scanning Scanning must be stopped manually: • First result •
Fixed duration • App backgrounding Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
Scanning: Fixed Duration // 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); Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Connecting Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
Connecting // Retain; used to initiate future commands. BluetoothGatt server
= device.connectGatt( context, false, // autoConnect; safest set to false? serverCallback); Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
Interacting Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
Command Queuing • All other interactions must happen serially •
Confusing due to asynchronous APIs Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Command Queuing • Must be serial across all connected devices
Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Command Queuing Incorrect Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Command Queuing Incorrect Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Command Queuing Correct Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
Command Queuing public abstract class Command { public Command(String serverId)
{ this.serverId = serverId; } } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Command Queuing • Don't call server methods immediately • Instead,
submit Command instances to a CommandManager • Process Queue<Command> one-by-one: • Manager dispatches next Command to correct server • Command completion reported by serverCallback • Repeat while queue is non-empty Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Discovering Services Bluetooth Low Energy on Android / Stuart Kent
/ @skentphd / Mobilization 2017 -
Discovering Services • Necessary even though you know 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 / Mobilization 2017 -
Discovering Services public class DiscoverCommand extends Command { public DiscoverCommand(String
serverId) { super(serverId); } } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 operation can begin. } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Characteristic Commands Bluetooth Low Energy on Android / Stuart Kent
/ @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 operation can begin. // Process new value here. } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 operation can begin. } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Notifications • Requires remote and local configuration • Controlled by
per-characteristic descriptor Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 operation can begin. } // (Later...) Characteristic changes: @Override public void onCharacteristicChanged(/* Params */) { // Process new value here. } Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Callback Exception Handling • serverCallback methods catch and log all
exceptions • Failing to report command completion stalls the queue • Catch all exceptions yourself • Report command completion in finally block Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Command Errors • Most commands fail occasionally and obscurely •
Assume the worst; code defensively • Tear down and reconnect if status != GATT_SUCCESS • Timebox and limit number of retries Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
Disconnecting Bluetooth Low Energy on Android / Stuart Kent /
@skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Disconnecting (Unexpectedly) • Will happen (a lot) • Tear down
(close) and start over • Track boolean userDisconnect; auto-retry only if false • Timebox and limit number of retries Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
! The "End"! Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
Thanks! @skentphd Bluetooth Low Energy on Android / Stuart Kent
/ @skentphd / Mobilization 2017 -
! Advanced Tips Bluetooth Low Energy on Android / Stuart
Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
Service Caching Call refreshDeviceCache after connection succeeds and before discovering
services. Bluetooth Low Energy on Android / Stuart Kent / @skentphd / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017 -
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 / Mobilization 2017