Slide 1

Slide 1 text

State of BLE on Android in 2023 Erik Hellman - hellsoft.se

Slide 2

Slide 2 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 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 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 - false1 • BluetoothGattCharacteristic is thread safe - false • Operations are queued safely - false • I'm safe if use I RxBLE/RxAndroidBle/Kable - false 1 Possible to run on dedicated HandlerThread from API level 26 - DO NOT USE!

Slide 6

Slide 6 text

Android BLE requirements • Use CompanionDeviceManager • Use a foreground service • Understand pairing vs bonding • Understand the implication of autoConnect • Understand when to use WRITE_TYPE_NO_RESPONSE • Never keep a BluetoothGattCharacteristic object

Slide 7

Slide 7 text

Best practices with BLE in 2023

Slide 8

Slide 8 text

Use CompanionDeviceManager 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) .setSingleDevice(true) // Also useful for enabling CDM pairing in existing apps! .build()

Slide 9

Slide 9 text

Use CompanionDeviceManager 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 10

Slide 10 text

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

Slide 11

Slide 11 text

CDM Example

Slide 12

Slide 12 text

CDM Bonus Permissions!

Slide 13

Slide 13 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 14

Slide 14 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 15

Slide 15 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 16

Slide 16 text

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

Slide 17

Slide 17 text

Companion Device Service From API Level 31

Slide 18

Slide 18 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) // Start our service as a foreground service startForegroundService(Intent(this, MyCompanionService::class.java)) } override fun onDeviceDisappeared(address: String) { val device = adapter.getRemoteDevice(address) gattDeviceRepo.deviceGone(device) } }

Slide 19

Slide 19 text

Companion Device Service val contract = StartIntentSenderForResult() val resultLauncher = registerForActivityResult(contract) { val bluetoothDevice = it.data ?.getParcelableExtra(EXTRA_DEVICE) ?: return val cdm = getSystemService(CompanionDeviceManager::class.java) // Register our service for wake-ups when device is nearby cdm.startObservingDevicePresence(bluetoothDevice.address) bluetoothDevice.connectGatt(...) }

Slide 20

Slide 20 text

BluetoothLeScanner Pre API level 31 val adapter = context.getSystemService(BluetoothManager::class.java).adapter val scanFilter = ScanFilter.Builder() .setDeviceAddress(address) .build() val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH) .setLegacy(true) .build()

Slide 21

Slide 21 text

BluetoothLeScanner val intent = Intent(context, MyScanningReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast(context, REQUEST_CODE, intent, 0) adapter.bluetoothLeScanner.startScan( scanFilter, scanSettings, pendingIntent )

Slide 22

Slide 22 text

BluetoothLeScanner class MyScanningReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val intent = Intent(context, MyForegroundService::class.java) context.startForegroundService(intent) } }

Slide 23

Slide 23 text

Foreground Services www.hellsoft.se/your-guide-to-foreground-services-on-android

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Two common GATT designs • "Message Bus" using two characteristics • One or two characteristics per data point (sensors etc.)

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Android GATT Architecture

Slide 28

Slide 28 text

Connecting val bluetoothGatt = bluetoothDevice.connectGatt( context, // Use Application, not Activity autoConnect, // Indirect or direction connection? callback, // BluetoothGattCallback instance. DO NOT REUSE! null // Keep the callbacks on the binder thread! IMPORTANT!!! )

Slide 29

Slide 29 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 30

Slide 30 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 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Deprecated callbacks

Slide 34

Slide 34 text

Deprecated callbacks

Slide 35

Slide 35 text

Dealing with memory safety before API level 33 • Don't use a Handler when connecting • Don't use the same characteristic for read and write • Don't retain BluetoothGattCharacteristic objects

Slide 36

Slide 36 text

Queue GATT operations

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 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 40

Slide 40 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 41

Slide 41 text

Simpler BLE API suspend fun connectAndWrite(gatt: BluetoothGattWrapper, charId: UUID, data: ByteArray) { gatt.connect(...) gatt.discoverServices() val characteristic = gatt.findCharacteristic(charId) gatt.writeCharacteristic(characteristic, data) gatt.disconnect() gatt.close() }

Slide 42

Slide 42 text

Connection Strategy 1. Connect with autoConnect = false and a short timeout 2. If connection failed, try again with autoConnect = true and a longer timeout 3. If second failure, assume connection isn't possible at the moment

Slide 43

Slide 43 text

Notifications

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

Faster write operations (no callbacks) fun fasterWriteOperation( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, segments: List) { segments.forEach { segment -> // No callback will happen with this set characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE characteristic.value = segment gatt.writeCharacteristic(characteeristic) } }

Slide 47

Slide 47 text

PHY

Slide 48

Slide 48 text

Long-range, low bandwidth val phy = BluetoothDevice.PHY_LE_CODED_MASK or BluetoothDevice.PHY_LE_1M_MASK val options = BluetoothDevice.PHY_OPTION_S8 bluetoothGatt.setPreferredPhy(phy, phy, options)

Slide 49

Slide 49 text

Short-ranged, 2MBit/s val phy = BluetoothDevice.PHY_LE_2M_MASK or BluetoothDevice.PHY_LE_1M_MASK val options = BluetoothDevice.PHY_OPTION_NO_PREFERRED bluetoothGatt.setPreferredPhy(phy, phy, options)

Slide 50

Slide 50 text

Common pitfalls • Not calling BluetoothGatt.discoverServices() • Not queueing operations • Retaining BluetoothGatt* objects • Misunderstanding how autoConnect works • BluetoothGattCallback instance reused • Not doing disconnect() AND close()

Slide 51

Slide 51 text

Future of Android BLE • AndroidX Library coming? • CompanionDeviceManager/Service required • Companion Device type registration in manifest • Hardware address restriction

Slide 52

Slide 52 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 53

Slide 53 text

Thank You Slides at https://speakerdeck.com/erikhellman hellsoft.se