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 full-size 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 full-size 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 full-size slide

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

    View full-size 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 full-size 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 full-size slide

  7. Best practices with
    BLE in 2023

    View full-size 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 full-size 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 full-size slide

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

    View full-size slide

  11. 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 full-size slide

  12. 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 full-size slide

  13. 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 full-size slide

  14. 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 full-size slide

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

    View full-size slide

  16. 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 full-size slide

  17. 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 full-size slide

  18. 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 full-size slide

  19. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  26. Android GATT Architecture

    View full-size slide

  27. 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 full-size slide

  28. 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 full-size slide

  29. 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 full-size slide

  30. 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 full-size slide

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

    View full-size slide

  32. Deprecated callbacks

    View full-size slide

  33. Deprecated callbacks

    View full-size slide

  34. 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 full-size slide

  35. Queue GATT
    operations

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  38. 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 full-size slide

  39. 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 full-size slide

  40. 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 full-size slide

  41. 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 full-size slide

  42. Notifications

    View full-size slide

  43. 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 full-size slide

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

    View full-size slide

  45. 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 full-size slide

  46. 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 full-size slide

  47. 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 full-size slide

  48. 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 full-size slide

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

    View full-size slide

  50. 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 full-size slide

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

    View full-size slide