Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

State of BLE on Android in 2023

State of BLE on Android in 2023

It’s 2023 and we’ve had Bluetooth LE APIs on Android for almost 10 years. Are they stable now? Can you work with them? Why is this so difficult? In this session, you’ll learn how to use BLE on Android and how to avoid common pitfalls. This session is useful for anyone doing anything around Bluetooth connectivity on Android.

Erik Hellman

April 20, 2023
Tweet

More Decks by Erik Hellman

Other Decks in Programming

Transcript

  1. 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
  2. BLE Challenges on Android • Bugs! • System behavior (More

    bugs!) • Race conditions (Even more bugs!) • Testing (Complicated.) • Emulators (No bugs. Doesn't exist!)
  3. 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!
  4. 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
  5. 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()
  6. 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))
  7. Use CompanionDeviceManager val contract = StartIntentSenderForResult() val resultLauncher = registerForActivityResult(contract)

    { val bluetoothDevice = it.data ?.getParcelableExtra<BluetoothDevice>(EXTRA_DEVICE) bluetoothDevice?.connectGatt(...) }
  8. CDM Bonus Permissions! <uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND" /> <uses-permission

    android:name="android.permission.REQUEST_COMPANION_SELF_MANAGED" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_WATCH" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_COMPUTER" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_AUTOMOTIVE_PROJECTION" /> <uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_APP_STREAMING" />
  9. 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(...)
  10. 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) } }
  11. Bonding suspend fun bondWithDevice(bondStateFlow: Flow<BondState>): Boolean { bondStateFlow.collect { when

    (it.state) { BOND_BONDED -> // Device bonding done BOND_BONDING -> // Device bonding in progress BOND_NONE -> // Device not bonded } } bluetoothDevice.createBond() }
  12. Unbounding - Use hidden API fun BluetoothDevice.releaseBond() { val method:

    Method = this.javaClass.getMethod("removeBond") method.invoke(this) }
  13. Companion Device Service From API Level 31 <service android:name=".MyCompanionService" android:label="@string/service_name"

    android:exported="true" android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE"> <intent-filter> <action android:name="android.companion.CompanionDeviceService" /> </intent-filter> </service>
  14. 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) } }
  15. Companion Device Service val contract = StartIntentSenderForResult() val resultLauncher =

    registerForActivityResult(contract) { val bluetoothDevice = it.data ?.getParcelableExtra<BluetoothDevice>(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(...) }
  16. 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()
  17. BluetoothLeScanner val intent = Intent(context, MyScanningReceiver::class.java) val pendingIntent = PendingIntent.getBroadcast(context,

    REQUEST_CODE, intent, 0) adapter.bluetoothLeScanner.startScan( scanFilter, scanSettings, pendingIntent )
  18. BluetoothLeScanner class MyScanningReceiver : BroadcastReceiver() { override fun onReceive(context: Context,

    intent: Intent) { val intent = Intent(context, MyForegroundService::class.java) context.startForegroundService(intent) } }
  19. GATT BluetoothDevice GATT structure: • BluetoothGattService <- Grouping of characteristics

    • BluetoothGattCharacteristic <- Data "endpoint" • BluetoothGattDescriptor <- Configuration • BluetoothGattCharacteristic • BluetoothGattDescriptor
  20. Two common GATT designs • "Message Bus" using two characteristics

    • One or two characteristics per data point (sensors etc.)
  21. GATT Operations 1. Connect 2. Discover services 3. Register for

    notifications 4. Read and Write 5. Disconnect & Close
  22. 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!!! )
  23. BluetoothGattCallback class GattCallbackWrapper : BluetoothGattCallback() { private val _events =

    MutableSharedFlow<GattEvent>() val events: SharedFlow<GattEvent> = _events override fun onCharacteristicRead( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?, status: Int) { // TODO } override fun onCharacteristicChanged( gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) { // TODO } // More callbacks here... }
  24. 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()
  25. 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) }
  26. BluetoothGattCallback override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray

    ) { val event = CharacteristicChanged( characteristic.service.uuid, characteristic.uuid, value ) _events.tryEmit(event) }
  27. 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
  28. Common mistake fun readAndWrite( gatt: BluetoothGatt, forReading: BluetoothGattCharacteristic, forWriting: BluetoothGattCharacteristic,

    data: ByteArray) { forWriting.value = data gatt.writeCharacteristic(forWriting) gatt.readCharacteristic(forReading) }
  29. Queue GATT operations private suspend fun <T> 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 } }
  30. 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) } }
  31. 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() }
  32. 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
  33. 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) }
  34. Faster write operations (no callbacks) fun fasterWriteOperation( gatt: BluetoothGatt, characteristic:

    BluetoothGattCharacteristic, segments: List<ByteArray>) { segments.forEach { segment -> // No callback will happen with this set characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE characteristic.value = segment gatt.writeCharacteristic(characteeristic) } }
  35. PHY

  36. 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)
  37. 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)
  38. Common pitfalls • Not calling BluetoothGatt.discoverServices() • Not queueing operations

    • Retaining BluetoothGatt* objects • Misunderstanding how autoConnect works • BluetoothGattCallback instance reused • Not doing disconnect() AND close()
  39. Future of Android BLE • AndroidX Library coming? • CompanionDeviceManager/Service

    required • Companion Device type registration in manifest • Hardware address restriction
  40. 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