From 43b64f2ebe436c8d15500fd045839db4cda3ba30 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:47:48 +0000 Subject: [PATCH] chore(ble): record bond loss events to decide how to handle them (#7462) --- .../meshtastic/core/ble/BondEventLogTest.kt | 107 +++++++ .../core/ble/BondEventReceiverTest.kt | 273 ++++++++++++++++++ .../core/ble/AndroidBluetoothRepository.kt | 17 +- .../org/meshtastic/core/ble/BondEventLog.kt | 88 ++++++ .../meshtastic/core/ble/BondEventReceiver.kt | 134 +++++++++ 5 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventLogTest.kt create mode 100644 core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventReceiverTest.kt create mode 100644 core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventLog.kt create mode 100644 core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventReceiver.kt diff --git a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventLogTest.kt b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventLogTest.kt new file mode 100644 index 0000000000..2c1368b436 --- /dev/null +++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventLogTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import android.bluetooth.BluetoothDevice +import co.touchlab.kermit.Severity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class BondEventLogTest { + + private val mac = "EF:E2:0A:BE:95:6A" + + @Test + fun `key missing is a warning with the loss reason and only the address tail`() { + val line = BondEvent.KeyMissing(mac, lossReason = 3).toLogLine("36.1") + + assertEquals(Severity.Warn, line.severity) + assertEquals("BLE bond event key-missing sdk=36.1 lossReason=3 device=...:6A", line.message) + assertFalse(mac in line.message) + } + + @Test + fun `key missing without a loss reason leaves the field out`() { + val line = BondEvent.KeyMissing(mac, lossReason = null).toLogLine("36.0") + + assertEquals("BLE bond event key-missing sdk=36.0 device=...:6A", line.message) + } + + @Test + fun `losing an established bond is a warning`() { + val line = bondStateLine(previous = BluetoothDevice.BOND_BONDED, current = BluetoothDevice.BOND_NONE) + + assertEquals(Severity.Warn, line.severity) + assertEquals("BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A", line.message) + } + + @Test + fun `a pairing attempt that ends unbonded is a warning`() { + val line = bondStateLine(previous = BluetoothDevice.BOND_BONDING, current = BluetoothDevice.BOND_NONE) + + assertEquals(Severity.Warn, line.severity) + } + + @Test + fun `starting and completing a bond are info`() { + val started = bondStateLine(previous = BluetoothDevice.BOND_NONE, current = BluetoothDevice.BOND_BONDING) + val completed = bondStateLine(previous = BluetoothDevice.BOND_BONDING, current = BluetoothDevice.BOND_BONDED) + + assertEquals(Severity.Info, started.severity) + assertEquals("BLE bond event bond-state NONE->BONDING sdk=35 device=...:6A", started.message) + assertEquals(Severity.Info, completed.severity) + } + + @Test + fun `a bond state outside the known three is logged as its number`() { + val line = bondStateLine(previous = BluetoothDevice.ERROR, current = BluetoothDevice.BOND_BONDED) + + assertEquals("BLE bond event bond-state ${BluetoothDevice.ERROR}->BONDED sdk=35 device=...:6A", line.message) + } + + @Test + fun `an encrypted link is info and a failed or dropped encryption is a warning`() { + val encrypted = BondEvent.EncryptionChange(mac, encrypted = true, status = 0).toLogLine("36.0") + val failed = BondEvent.EncryptionChange(mac, encrypted = false, status = 6).toLogLine("36.0") + val errorStatus = BondEvent.EncryptionChange(mac, encrypted = true, status = 6).toLogLine("36.0") + + assertEquals(Severity.Info, encrypted.severity) + assertEquals(Severity.Warn, failed.severity) + assertEquals( + "BLE bond event encryption-change sdk=36.0 encrypted=false status=6 device=...:6A", + failed.message, + ) + assertEquals(Severity.Warn, errorStatus.severity) + } + + @Test + fun `key missing and encryption change are only registered from API 36`() { + assertEquals(listOf(BluetoothDevice.ACTION_BOND_STATE_CHANGED), bondEventActions(sdkInt = 35)) + assertEquals( + listOf( + BluetoothDevice.ACTION_BOND_STATE_CHANGED, + BluetoothDevice.ACTION_KEY_MISSING, + BluetoothDevice.ACTION_ENCRYPTION_CHANGE, + ), + bondEventActions(sdkInt = 36), + ) + } + + private fun bondStateLine(previous: Int, current: Int) = + BondEvent.BondStateChanged(mac, previous = previous, current = current).toLogLine("35") +} diff --git a/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventReceiverTest.kt b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventReceiverTest.kt new file mode 100644 index 0000000000..9071ead186 --- /dev/null +++ b/core/ble/src/androidHostTest/kotlin/org/meshtastic/core/ble/BondEventReceiverTest.kt @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.content.Intent +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import co.touchlab.kermit.Severity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.testing.CapturingLogWriter +import org.meshtastic.core.testing.RobolectricBleBonding +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Robolectric's API 36 sandbox needs `jdk.internal.access` exported, which the host test JVM does not do, so the API 36 + * broadcasts run on API 35 with the receiver told it is on 36. Log lines therefore carry `sdk=35`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BondEventReceiverTest { + + private val radio = "EF:E2:0A:BE:95:6A" + private val headphones = "11:22:33:44:55:66" + + private val app + get() = RuntimeEnvironment.getApplication() + + private val scope = CoroutineScope(Job() + UnconfinedTestDispatcher()) + private lateinit var logs: CapturingLogWriter + + @Before + fun setUp() { + logs = CapturingLogWriter.install() + } + + @After + fun tearDown() { + scope.cancel() + CapturingLogWriter.uninstall() + } + + @Test + fun `registers once for all three bond broadcasts as an exported receiver on API 36`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + val receiver = BondEventReceiver(app, scope, sdkInt = 36) + + receiver.watch(radio) + receiver.watch(headphones) + + val registration = bondReceivers().single() + assertEquals( + setOf( + BluetoothDevice.ACTION_BOND_STATE_CHANGED, + BluetoothDevice.ACTION_KEY_MISSING, + BluetoothDevice.ACTION_ENCRYPTION_CHANGE, + ), + registration.intentFilter.actionsIterator().asSequence().toSet(), + ) + assertEquals(Context.RECEIVER_EXPORTED, registration.flags and Context.RECEIVER_EXPORTED) + } + + @Test + fun `is registered by the time watch returns on a dispatcher that has not run yet`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + val pending = CoroutineScope(Job() + StandardTestDispatcher()) + + BondEventReceiver(app, pending).watch(radio) + + assertEquals(1, bondReceivers().size) + pending.cancel() + } + + @Test + fun `registers only for bond state changes below API 36`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + + BondEventReceiver(app, scope).watch(radio) + + assertEquals( + setOf(BluetoothDevice.ACTION_BOND_STATE_CHANGED), + bondReceivers().single().intentFilter.actionsIterator().asSequence().toSet(), + ) + } + + @Test + fun `waits for BLUETOOTH_CONNECT before registering`() { + shadowOf(app).denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + val receiver = BondEventReceiver(app, scope) + + receiver.watch(radio) + assertTrue(bondReceivers().isEmpty()) + + RobolectricBleBonding.grantBluetoothConnectPermission() + receiver.watch(radio) + assertEquals(1, bondReceivers().size) + } + + @Test + @Config(sdk = [30]) + fun `registers without a runtime permission before API 31`() { + BondEventReceiver(app, scope).watch(radio) + + assertEquals(1, bondReceivers().size) + } + + @Test + fun `unregisters when its scope is cancelled`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + BondEventReceiver(app, scope).watch(radio) + + scope.cancel() + + assertTrue(bondReceivers().isEmpty()) + } + + @Test + fun `logs key missing for a watched radio without its address`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + BondEventReceiver(app, scope, sdkInt = 36).watch(radio) + + send( + Intent(BluetoothDevice.ACTION_KEY_MISSING) + .putExtra(BluetoothDevice.EXTRA_DEVICE, device(radio)) + .putExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON, BluetoothDevice.BOND_LOSS_REASON_LE_ENCRYPT_FAILURE), + ) + + assertEquals( + listOf( + "BLE bond event key-missing sdk=35 " + + "lossReason=${BluetoothDevice.BOND_LOSS_REASON_LE_ENCRYPT_FAILURE} device=...:6A", + ), + logs.messages(Severity.Warn), + ) + logs.assertNotLogged(radio) + } + + @Test + fun `logs encryption changes and bond state transitions from their intent extras`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + BondEventReceiver(app, scope, sdkInt = 36).watch(radio) + + send( + Intent(BluetoothDevice.ACTION_ENCRYPTION_CHANGE) + .putExtra(BluetoothDevice.EXTRA_DEVICE, device(radio)) + .putExtra(BluetoothDevice.EXTRA_ENCRYPTION_ENABLED, false) + .putExtra(BluetoothDevice.EXTRA_ENCRYPTION_STATUS, 6), + ) + RobolectricBleBonding.sendBondStateChanged( + radio, + newState = BluetoothDevice.BOND_NONE, + previousState = BluetoothDevice.BOND_BONDED, + ) + + assertEquals( + listOf( + "BLE bond event encryption-change sdk=35 encrypted=false status=6 device=...:6A", + "BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A", + ), + logs.messages(Severity.Warn), + ) + } + + @Test + fun `ignores bond events for devices that are not a radio`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + BondEventReceiver(app, scope, connectingAddress = { null }).watch(radio) + + RobolectricBleBonding.sendBondStateChanged( + headphones, + newState = BluetoothDevice.BOND_NONE, + previousState = BluetoothDevice.BOND_BONDED, + ) + + assertTrue(logs.messages().none { it.startsWith("BLE bond event") }, logs.messages().toString()) + } + + @Test + fun `logs the radio being connected to even when it was not watched`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + BondEventReceiver(app, scope, connectingAddress = { headphones.lowercase() }).watch(radio) + + RobolectricBleBonding.sendBondStateChanged( + headphones, + newState = BluetoothDevice.BOND_BONDING, + previousState = BluetoothDevice.BOND_NONE, + ) + + assertEquals( + listOf("BLE bond event bond-state NONE->BONDING sdk=35 device=...:66"), + logs.messages(Severity.Info), + ) + } + + @Test + fun `the repository logs bond events for a radio it is asked about`() { + RobolectricBleBonding.grantBluetoothConnectPermission() + val dispatcher = UnconfinedTestDispatcher() + val repository = + AndroidBluetoothRepository( + app, + CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher), + startedLifecycle(), + ) + + repository.isBonded(radio) + RobolectricBleBonding.sendBondStateChanged( + radio, + newState = BluetoothDevice.BOND_NONE, + previousState = BluetoothDevice.BOND_BONDED, + ) + + assertEquals( + listOf("BLE bond event bond-state BONDED->NONE sdk=35 device=...:6A"), + logs.messages(Severity.Warn).filter { it.startsWith("BLE bond event") }, + ) + } + + private fun startedLifecycle(): Lifecycle { + val owner = + object : LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.STARTED } + } + return owner.lifecycle + } + + private fun bondReceivers(): List = shadowOf(app).registeredReceivers.filter { + it.intentFilter.hasAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + } + + private fun device(mac: String): BluetoothDevice = RobolectricBleBonding.adapter.getRemoteDevice(mac) + + private fun send(intent: Intent) { + app.sendBroadcast(intent) + shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index ae3b970017..3a82244b9f 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Named import org.koin.core.annotation.Single @@ -68,6 +69,8 @@ class AndroidBluetoothRepository( private val deviceCache = mutableMapOf() + private val bondEvents = BondEventReceiver(context, processLifecycle.coroutineScope + dispatchers.default) + init { processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() } } @@ -95,6 +98,7 @@ class AndroidBluetoothRepository( @SuppressLint("MissingPermission") override suspend fun bond(device: BleDevice) { val macAddress = device.address + bondEvents.watch(macAddress) val remoteDevice = bluetoothAdapter?.getRemoteDevice(macAddress) ?: throw Exception("Bluetooth adapter unavailable") @@ -327,10 +331,13 @@ class AndroidBluetoothRepository( } @SuppressLint("MissingPermission") - override fun isBonded(address: String): Boolean = try { - bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false - } catch (e: SecurityException) { - Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" } - false + override fun isBonded(address: String): Boolean { + bondEvents.watch(address) + return try { + bluetoothAdapter?.bondedDevices?.any { it.address.equals(address, ignoreCase = true) } ?: false + } catch (e: SecurityException) { + Logger.w(e) { "SecurityException checking bonded devices. Missing BLUETOOTH_CONNECT?" } + false + } } } diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventLog.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventLog.kt new file mode 100644 index 0000000000..9c2c06e3e5 --- /dev/null +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventLog.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import android.bluetooth.BluetoothDevice +import android.os.Build +import co.touchlab.kermit.Severity +import org.meshtastic.core.model.util.anonymize + +/** HCI Encryption Change status for success, as carried by `EXTRA_ENCRYPTION_STATUS`. */ +private const val HCI_SUCCESS = 0 + +/** A Bluetooth bond broadcast for one device, reduced to the fields worth recording. */ +internal sealed interface BondEvent { + val address: String + + /** `ACTION_KEY_MISSING`. [lossReason] is `EXTRA_BOND_LOSS_REASON`, which only newer releases send. */ + data class KeyMissing(override val address: String, val lossReason: Int?) : BondEvent + + /** `ACTION_ENCRYPTION_CHANGE`. [status] is the controller's HCI status. */ + data class EncryptionChange(override val address: String, val encrypted: Boolean, val status: Int) : BondEvent + + /** `ACTION_BOND_STATE_CHANGED`, as `BluetoothDevice.BOND_*` values. */ + data class BondStateChanged(override val address: String, val previous: Int, val current: Int) : BondEvent +} + +internal data class BondEventLogLine(val severity: Severity, val message: String) + +/** The bond broadcasts worth registering for on [sdkInt]; KEY_MISSING and ENCRYPTION_CHANGE exist from API 36. */ +internal fun bondEventActions(sdkInt: Int): List = buildList { + add(BluetoothDevice.ACTION_BOND_STATE_CHANGED) + if (sdkInt >= Build.VERSION_CODES.BAKLAVA) { + add(BluetoothDevice.ACTION_KEY_MISSING) + add(BluetoothDevice.ACTION_ENCRYPTION_CHANGE) + } +} + +/** + * Warn when the radio is left without a bond or an encrypted link, Info otherwise. A lost bond is the user's radio, not + * a defect, so it never logs at Error. The device is named only by [anonymize]. + */ +internal fun BondEvent.toLogLine(sdk: String): BondEventLogLine { + val device = address.anonymize + return when (this) { + is BondEvent.KeyMissing -> { + val reason = lossReason?.let { " lossReason=$it" }.orEmpty() + BondEventLogLine(Severity.Warn, "BLE bond event key-missing sdk=$sdk$reason device=$device") + } + + is BondEvent.EncryptionChange -> { + val severity = if (encrypted && status == HCI_SUCCESS) Severity.Info else Severity.Warn + BondEventLogLine( + severity, + "BLE bond event encryption-change sdk=$sdk encrypted=$encrypted status=$status device=$device", + ) + } + + is BondEvent.BondStateChanged -> { + val severity = if (current == BluetoothDevice.BOND_NONE) Severity.Warn else Severity.Info + BondEventLogLine( + severity, + "BLE bond event bond-state ${previous.bondStateName()}->${current.bondStateName()} " + + "sdk=$sdk device=$device", + ) + } + } +} + +private fun Int.bondStateName(): String = when (this) { + BluetoothDevice.BOND_NONE -> "NONE" + BluetoothDevice.BOND_BONDING -> "BONDING" + BluetoothDevice.BOND_BONDED -> "BONDED" + else -> toString() +} diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventReceiver.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventReceiver.kt new file mode 100644 index 0000000000..2e377311d7 --- /dev/null +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/BondEventReceiver.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ble + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Logs Bluetooth bond broadcasts for the radios the app bonds to or connects to, so field data can show how often bonds + * are lost and what follows. It changes nothing about bonding or reconnection. + * + * The receiver registers on the first [watch] made while BLUETOOTH_CONNECT is granted, and stays registered until + * [scope] is cancelled. + */ +internal class BondEventReceiver( + private val context: Context, + private val scope: CoroutineScope, + private val connectingAddress: () -> String? = { ActiveBleConnection.active?.address }, + private val sdkInt: Int = Build.VERSION.SDK_INT, +) { + private val watched: MutableSet = ConcurrentHashMap.newKeySet() + private val registered = AtomicBoolean(false) + + private val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val event = intent.toBondEvent() ?: return + if (!isRadio(event.address)) return + val line = event.toLogLine(sdkLabel()) + Logger.log(line.severity, Logger.tag, null, line.message) + } + } + + /** Marks [address] as a radio whose bond events are logged, registering the receiver if it can now. */ + fun watch(address: String) { + watched += address.uppercase() + if (!registered.get() && hasConnectPermission() && registered.compareAndSet(false, true)) { + // Undispatched so the receiver is registered before a bond() that follows can broadcast. + scope.launch(start = CoroutineStart.UNDISPATCHED) { receiveUntilCancelled() } + } + } + + private suspend fun receiveUntilCancelled() { + val filter = IntentFilter().apply { bondEventActions(sdkInt).forEach(::addAction) } + // The Bluetooth app sends these under its own uid, which a NOT_EXPORTED receiver refuses. All three are + // protected broadcasts, so exporting admits no other sender. + ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_EXPORTED) + try { + awaitCancellation() + } finally { + context.unregisterReceiver(receiver) + } + } + + private fun isRadio(address: String): Boolean { + val normalized = address.uppercase() + return normalized in watched || normalized == connectingAddress()?.uppercase() + } + + private fun hasConnectPermission(): Boolean = sdkInt < Build.VERSION_CODES.S || + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED +} + +internal fun Intent.toBondEvent(): BondEvent? { + val address = + IntentCompat.getParcelableExtra(this, BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)?.address + ?: return null + return when (action) { + BluetoothDevice.ACTION_BOND_STATE_CHANGED -> + BondEvent.BondStateChanged( + address = address, + previous = getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.ERROR), + current = getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.ERROR), + ) + + BluetoothDevice.ACTION_KEY_MISSING -> + BondEvent.KeyMissing( + address = address, + lossReason = + if (hasExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON)) { + getIntExtra(BluetoothDevice.EXTRA_BOND_LOSS_REASON, BluetoothDevice.ERROR) + } else { + null + }, + ) + + BluetoothDevice.ACTION_ENCRYPTION_CHANGE -> + BondEvent.EncryptionChange( + address = address, + encrypted = getBooleanExtra(BluetoothDevice.EXTRA_ENCRYPTION_ENABLED, false), + status = getIntExtra(BluetoothDevice.EXTRA_ENCRYPTION_STATUS, BluetoothDevice.ERROR), + ) + + else -> null + } +} + +/** `36.1` style from API 36, where `SDK_INT_FULL` carries the minor release; the plain API level before it. */ +private fun sdkLabel(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + val full = Build.VERSION.SDK_INT_FULL + "${Build.getMajorSdkVersion(full)}.${Build.getMinorSdkVersion(full)}" +} else { + Build.VERSION.SDK_INT.toString() +}