Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. State of BLE on
    Android in 2023
    Erik Hellman - hellsoft.se

    View Slide

  2. 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

    View Slide

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

    View Slide

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

    View Slide

  5. 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!

    View Slide

  6. 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

    View Slide

  7. Best practices with
    BLE in 2023

    View Slide

  8. 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()

    View Slide

  9. 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))

    View Slide

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

    View Slide

  11. CDM Example

    View Slide

  12. CDM Bonus Permissions!
    android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND" />
    android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND" />
    android:name="android.permission.REQUEST_COMPANION_SELF_MANAGED" />
    android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" />
    android:name="android.permission.REQUEST_COMPANION_PROFILE_WATCH" />
    android:name="android.permission.REQUEST_COMPANION_PROFILE_COMPUTER" />
    android:name="android.permission.REQUEST_COMPANION_PROFILE_AUTOMOTIVE_PROJECTION" />
    android:name="android.permission.REQUEST_COMPANION_PROFILE_APP_STREAMING" />

    View Slide

  13. 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(...)

    View Slide

  14. 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) }
    }

    View Slide

  15. 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()
    }

    View Slide

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

    View Slide

  17. Companion Device Service
    From API Level 31
    android:name=".MyCompanionService"
    android:label="@string/service_name"
    android:exported="true"
    android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">




    View Slide

  18. 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)
    }
    }

    View Slide

  19. 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(...)
    }

    View Slide

  20. 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()

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  27. Android GATT Architecture

    View Slide

  28. 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!!!
    )

    View Slide

  29. 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...
    }

    View Slide

  30. 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()

    View Slide

  31. 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)
    }

    View Slide

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

    View Slide

  33. Deprecated callbacks

    View Slide

  34. Deprecated callbacks

    View Slide

  35. 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

    View Slide

  36. Queue GATT
    operations

    View Slide

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

    View Slide

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

    View Slide

  39. 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
    }
    }

    View Slide

  40. 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)
    }
    }

    View Slide

  41. 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()
    }

    View Slide

  42. 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

    View Slide

  43. Notifications

    View Slide

  44. 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)
    }

    View Slide

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

    View Slide

  46. 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)
    }
    }

    View Slide

  47. PHY

    View Slide

  48. 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)

    View Slide

  49. 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)

    View Slide

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

    View Slide

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

    View Slide

  52. 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

    View Slide

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

    View Slide