Slide 1

Slide 1 text

State of BLE on Android in 2022 @ErikHellman

Slide 2

Slide 2 text

Bluetooth Classic & Bluetooth Smart

Slide 3

Slide 3 text

Bluetooth Classic & Bluetooth Smart

Slide 4

Slide 4 text

Bluetooth Smart

Slide 5

Slide 5 text

Bluetooth Low Energy Bluetooth Smart1 1 Marketing name nobody uses...

Slide 6

Slide 6 text

History of BLE APIs on Android • Support for BLE in API level 18 (July 24, 2013) • New scanner API in API level 21 • BLE 5.0 and Companion Device Manager in API level 26 • Companion Device Service in API level 31 • API changes for GATT in API level 33

Slide 7

Slide 7 text

Anatomy of BLE • Scanning & Advertising • Discovery • Pairing & Bonding • Connecting & Comunicating

Slide 8

Slide 8 text

BLE Challenges on Android • Bugs! • System behavior (More bugs!) • Race conditions (Even more bugs!) • Testing (Complicated.) • Emulators (No bugs. Doesn't exist!)

Slide 9

Slide 9 text

minSDK 26 Avoid supporting anything below API level 26 (Android 8.0) for reliable BLE support!

Slide 10

Slide 10 text

Android BLE myths • Must run on the main thread - false • Must run on a background thread - false • BluetoothGattCallback callbacks run on a background thread - false2 • BluetoothGattCharacteristic is thread safe - false • Operations are queued safely - false • I'm safe if use I RxBLE/RxAndroidBle/Kable - false 2 Possible to run on dedicated HandlerThread from API level 26 - DO NOT USE!

Slide 11

Slide 11 text

The Official Documentation Is not good

Slide 12

Slide 12 text

No content

Slide 13

Slide 13 text

Best practices with BLE in 2022

Slide 14

Slide 14 text

Use CompanionDeviceManager

Slide 15

Slide 15 text

CDM Example val regexp = Pattern.compile("^My Watch [a-z]{2}[0-9]{2}$") val filter = BluetoothLeDeviceFilter.Builder().setNamePattern(regexp).build() val request = AssociationRequest.Builder() .addDeviceFilter(filter) .build() val callback = object : CompanionDeviceManager.Callback() { override fun onDeviceFound(sender: IntentSender) { val senderRequest = IntentSenderRequest.Builder(sender).build() resultLauncher.launch(senderRequest) } override fun onFailure(error: CharSequence?) { // TODO Handle error } } val cdm = getSystemService(CompanionDeviceManager::class.java) cdm.associate(request, callback, Handler(mainLooper))

Slide 16

Slide 16 text

CDM Example val contract = StartIntentSenderForResult() val resultLauncher = registerForActivityResult(contract) { val bluetoothDevice = it.data ?.getParcelableExtra(EXTRA_DEVICE) bluetoothDevice?.connectGatt(...) }

Slide 17

Slide 17 text

CDM Example

Slide 18

Slide 18 text

CDM Bonus Permissions!

Slide 19

Slide 19 text

CDM Example - After CDM pairing val cdm = getSystemService(CompanionDeviceManager::class.java) val deviceAddress = cdm.associations.firstOrNull() ?: throw IllegalStateException() val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager.adapter val bluetoothDevice = bluetoothAdapter.getRemoteDevice(deviceAddress) bluetoothDevice?.connectGatt(...)

Slide 20

Slide 20 text

Pairing vs Bonding

Slide 21

Slide 21 text

Bonding data class BondState(val device: BluetoothDevice, val state: Int) val bondStateFlow = callbackFlow { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, data: Intent) { trySendBlocking(BondState( intent.getParcelableExtra(EXTRA_DEVICE)!!, intent.getIntExtra(EXTRA_BOND_STATE, BOND_NONE) )) } } val filter = IntentFilter(ACTION_BOND_STATE_CHANGED) registerReceiver(receiver, filter) trySendBlocking(BondState( bluetoothDevice, bluetoothDevice.getBondState() )) awaitClose { unregisterReceiver(receiver) } }

Slide 22

Slide 22 text

Bonding suspend fun bondWithDevice(bondStateFlow: Flow): Boolean { bondStateFlow.collect { when (it.state) { BOND_BONDED -> // Device bonding done BOND_BONDING -> // Device bonding in progress BOND_NONE -> // Device not bonded } } bluetoothDevice.createBond() }

Slide 23

Slide 23 text

Unbounding - Use hidden API fun BluetoothDevice.releaseBond() { val method: Method = this.javaClass.getMethod("removeBond") method.invoke(this) }

Slide 24

Slide 24 text

Companion Device Service From API Level 31

Slide 25

Slide 25 text

Companion Device Service @AndroidEntryPoint class MyCompanionService : CompanionDeviceService() { private val adapter = getSystemService(BluetoothManager::class.java).adapter @Inject lateinit var gattDeviceRepo: GattDeviceRepo override fun onDeviceAppeared(address: String) { val device = adapter.getRemoteDevice(address) gattDeviceRepo.deviceNearby(device) } override fun onDeviceDisappeared(address: String) { val device = adapter.getRemoteDevice(address) gattDeviceRepo.deviceGone(device) } }

Slide 26

Slide 26 text

Companion Device Service val contract = StartIntentSenderForResult() val resultLauncher = registerForActivityResult(contract) { val bluetoothDevice = it.data ?.getParcelableExtra(EXTRA_DEVICE) val cdm = getSystemService(CompanionDeviceManager::class.java) cdm.startObservingDevicePresence(bluetoothDevice.address) bluetoothDevice?.connectGatt(...) }

Slide 27

Slide 27 text

Companion Device Manager Always use the CDM for discovery and pairing!

Slide 28

Slide 28 text

Companion Device Service (API level 31+) Use the CDS when available!

Slide 29

Slide 29 text

Generic ATTribute profile

Slide 30

Slide 30 text

GATT • BluetoothGattService <- Grouping of characteristics • BluetoothGattCharacteristic <- Data "endpoint" • BluetoothGattDescriptor <- Configuration • BluetoothGattDescriptor • BluetoothGattCharacteristic • BluetoothGattDescriptor • BluetoothGattDescriptor

Slide 31

Slide 31 text

GATT Operations 1. Connect 2. Discover services 3. Register for notifications 4. Read and Write 5. Disconnect & Close

Slide 32

Slide 32 text

Android GATT Architecture

Slide 33

Slide 33 text

Connecting val bluetoothGatt = bluetoothDevice.connectGatt( context, // Use Application, not Activity autoConnect, // Connect when device is available? Always use true! callback, // BluetoothGattCallback instance. DO NOT REUSE! null // Keep the callbacks on the binder thread! IMPORTANT!!! )

Slide 34

Slide 34 text

BluetoothGattCallback class GattCallbackWrapper : BluetoothGattCallback() { private val _events = MutableSharedFlow() val events: SharedFlow = _events override fun onCharacteristicRead( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { // TODO } override fun onCharacteristicChanged( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) { // TODO } // More callbacks here... }

Slide 35

Slide 35 text

BluetoothGattCallback sealed class GattEvent data class CharacteristicRead( val service: UUID, val characteristic: UUID, val data: ByteArray, val status: Int, ) : GattEvent() data class CharacteristicChanged( val service: UUID, val characteristic: UUID, val data: ByteArray ) : GattEvent()

Slide 36

Slide 36 text

BluetoothGattCallback override fun onCharacteristicRead( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int ) { val event = CharacteristicRead( characteristic.service.uuid, characteristic.uuid, characteristic.value, status ) _events.tryEmit(event) }

Slide 37

Slide 37 text

BluetoothGattCallback override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic ) { val event = CharacteristicChanged( characteristic.service.uuid, characteristic.uuid, characteristic.value ) _events.tryEmit(event) }

Slide 38

Slide 38 text

Deprecated callbacks

Slide 39

Slide 39 text

Deprecated callbacks

Slide 40

Slide 40 text

Deprecated callbacks val bluetoothGatt = bluetoothDevice.connectGatt( context, // Use Application, not Activity autoConnect, // Connect when device is available? Always use true! callback, // BluetoothGattCallback instance. DO NOT REUSE! null // Keep the callbacks on the binder thread! IMPORTANT!!! )

Slide 41

Slide 41 text

BluetoothGatt.java AOSP, API level 26 public void onNotify(String address, int handle, byte[] value) { BluetoothGattCharacteristic characteristic = getCharacteristicById(mDevice, handle); characteristic.setValue(value); runOrQueueCallback(new Runnable() { @Override public void run() { if (mCallback != null) { mCallback.onCharacteristicChanged( BluetoothGatt.this, characteristic); } } }); }

Slide 42

Slide 42 text

BluetoothGatt.java AOSP, API level 26 BluetoothGattCharacteristic getCharacteristicById( BluetoothDevice device, int instanceId) { for(BluetoothGattService svc : mServices) { for(BluetoothGattCharacteristic charac : svc.getCharacteristics()) { if (charac.getInstanceId() == instanceId) return charac; } } return null; }

Slide 43

Slide 43 text

BluetoothGatt.java AOSP, API level 26 private void runOrQueueCallback(final Runnable cb) { if (mHandler == null) { try { cb.run(); } catch (Exception ex) { Log.w(TAG, "Unhandled exception in callback", ex); } } else { mHandler.post(cb); } }

Slide 44

Slide 44 text

BluetoothGatt.java AOSP, API level 33 public void onNotify(String address, int handle, byte[] value) { BluetoothGattCharacteristic characteristic = getCharacteristicById(mDevice, handle); if (characteristic == null) return; runOrQueueCallback(new Runnable() { @Override public void run() { final BluetoothGattCallback callback = mCallback; if (callback != null) { characteristic.setValue(value); callback.onCharacteristicChanged(BluetoothGatt.this, characteristic, value); } } }); }

Slide 45

Slide 45 text

Dealing with GATT race conditions • Don't use a Handler when connecting • Don't mix characteristics for read and write • Notification sequence numbers

Slide 46

Slide 46 text

Queue GATT operations

Slide 47

Slide 47 text

Common mistake fun connectAndDiscoverServices(device: BluetoothDevice) { val gatt = device.connectGatt(...) gatt.discoverServices() }

Slide 48

Slide 48 text

Common mistake fun readAndWrite( gatt: BluetoothGatt, forReading: BluetoothGattCharacteristic, forWriting: BluetoothGattCharacteristic, data: ByteArray) { forWriting.value = data gatt.writeCharacteristic(forWriting) gatt.readCharacteristic(forReading) }

Slide 49

Slide 49 text

Queue GATT operations private suspend fun Mutex.queueWithTimeout( timeout: Long = DEFAULT_GATT_TIMEOUT, block: suspend CoroutineScope.() -> T ): T { return try { withLock { withTimeout(timeMillis = timeout, block = block) } } catch (e: Exception) { throw e } }

Slide 50

Slide 50 text

Queue GATT operations suspend fun writeCharacteristic( char: BluetoothGattCharacteristic, value: ByteArray): CharacteristicWritten { mutex.queueWithTimeout { events // events Flow from the BluetoothGattCallback .onSubscription { bluetoothGatt.writeCharacteristic(char, value) } .firstOrNull { it is CharacteristicWritten && it.characteristic.uuid == char.uuid } as CharacteristicWritten? ?: CharacteristicWritten(char, BluetoothGatt.GATT_FAILURE) } }

Slide 51

Slide 51 text

Notifications

Slide 52

Slide 52 text

Notifications // Tell system server to send our app notifications received bluetoothGatt.setCharacteristicNotification(characteristic, true) characteristic.descriptors .find { it.uuid == ClientCharacteristicConfiguration } ?.let { descriptor -> descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE bluetoothGatt.writeDescriptor(descriptor) }

Slide 53

Slide 53 text

ClientCharacteristicConfigurationID private val ClientCharacteristicConfiguration = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")

Slide 54

Slide 54 text

PHY

Slide 55

Slide 55 text

PHY Options - BluetoothDevice public static final int PHY_LE_1M_MASK = 1; public static final int PHY_LE_2M_MASK = 2; public static final int PHY_LE_CODED_MASK = 4; // PHY Options public static final int PHY_OPTION_NO_PREFERRED = 0; public static final int PHY_OPTION_S2 = 1; public static final int PHY_OPTION_S8 = 2;

Slide 56

Slide 56 text

Setting PHY for long-range, low bandwidth val phy = BluetoothDevice.PHY_LE_CODED or BluetoothDevice.PHY_LE_1M val options = BluetoothDevice.PHY_OPTION_S8 bluetoothGatt.setPreferredPhy(phy, phy, options)

Slide 57

Slide 57 text

Setting PHY for 2 MBit/s, short- ranged val phy = BluetoothDevice.PHY_LE_2M or BluetoothDevice.PHY_LE_1M val options = BluetoothDevice.PHY_OPTION_NO_PREFERRED bluetoothGatt.setPreferredPhy(phy, phy, options)

Slide 58

Slide 58 text

Common pitfalls • Not calling BluetoothGatt.discoverServices() • Not queueing operations • Retaining BluetoothGatt* objects • Setting autoConnect to false • BluetoothGattCallback instance reused • Not doing disconnect() AND close()

Slide 59

Slide 59 text

Summary • minSDK 26 • Use the CDM and CDS • Race condition fixed in API level 33 • Queue all GATT operations • Client Characteristic Configuration ID • PHY Options

Slide 60

Slide 60 text

Thank You Slides at https://speakerdeck.com/erikhellman @ErikHellman