mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
chore(ble): record bond loss events to decide how to handle them (#7462)
This commit is contained in:
1 parent
461e215a2d
commit
43b64f2ebe
5 files changed
+614
-5
No files matched your search
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<ShadowApplication.Wrapper> = 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()
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -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<String, MeshtasticBleDevice>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> = 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()
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String> = 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()
|
||||
}
|
||||
Reference in new issue
Block a user