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()
+}