feat(ble): Handle invalid BLE attributes (#4485)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-06 18:59:24 -06:00 committed by GitHub
parent bf4020a939
commit ba03aacdc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1546 additions and 57 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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 com.geeksville.mesh.model
import android.app.Application
@ -209,10 +208,11 @@ constructor(
Logger.i { "Bonding complete for ${entry.peripheral.address.anonymize}, selecting device..." }
changeDeviceAddress(entry.fullAddress)
} catch (ex: SecurityException) {
Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted" }
serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted")
} catch (ex: Exception) {
Logger.e(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
// Bonding is often flaky and can fail for many reasons (timeout, user cancel, etc)
Logger.w(ex) { "Bonding failed for ${entry.peripheral.address.anonymize}" }
serviceRepository.setErrorMessage("Bonding failed: ${ex.message}")
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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 com.geeksville.mesh.repository.radio
import com.geeksville.mesh.service.RadioNotConnectedException
@ -67,8 +66,7 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
*
* @param exception The underlying GattException.
*/
class GattError(exception: GattException) :
BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
class GattError(exception: GattException) : BleError("Gatt exception: ${exception.message}", shouldReconnect = true)
/**
* Wraps a generic BluetoothException. The reconnection strategy depends on the nature of the Bluetooth error.
@ -86,9 +84,12 @@ sealed class BleError(val message: String, val shouldReconnect: Boolean) {
class OperationFailed(exception: OperationFailedException) :
BleError("Operation failed: ${exception.message}", shouldReconnect = true)
/** An invalid attribute was used. This is a non-recoverable error. */
/**
* An invalid attribute was used. This usually happens when the GATT handles become stale (e.g. after a service
* change or an unexpected disconnect). This is recoverable via a fresh connection and discovery.
*/
class InvalidAttribute(exception: InvalidAttributeException) :
BleError("Invalid attribute: ${exception.message}", shouldReconnect = false)
BleError("Invalid attribute: ${exception.message}", shouldReconnect = true)
/** An error occurred while scanning for devices. This may be recoverable. */
class Scanning(exception: ScanningException) :

View file

@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
@ -49,6 +48,7 @@ import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
import no.nordicsemi.kotlin.ble.client.android.CentralManager
import no.nordicsemi.kotlin.ble.client.android.ConnectionPriority
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
@ -78,12 +78,12 @@ constructor(
) : IRadioInterface {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.e(throwable) { "[$address] Uncaught exception in connectionScope" }
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
peripheral?.disconnect()
} catch (e: Exception) {
Logger.e(e) { "[$address] Failed to disconnect in exception handler" }
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
service.onDisconnect(BleError.from(throwable))
@ -116,6 +116,10 @@ constructor(
val packet =
try {
fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() }
} catch (e: InvalidAttributeException) {
Logger.w(e) { "[$address] Attribute invalidated during read, clearing characteristics" }
handleInvalidAttribute(e)
null
} catch (e: Exception) {
Logger.w(e) { "[$address] Error reading fromRadioCharacteristic (likely disconnected)" }
null
@ -153,11 +157,6 @@ constructor(
dispatchPacket(packet)
}
.catch { ex -> Logger.w(ex) { "[$address] Exception while draining packet queue" } }
.onCompletion {
if (drainedCount > 0) {
Logger.d { "[$address] Drained $drainedCount packets from packet queue" }
}
}
.collect()
}
}
@ -184,7 +183,8 @@ constructor(
}
} catch (e: Exception) {
val failureTime = System.currentTimeMillis() - connectionStartTime
Logger.e(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
// BLE connection errors are common and often transient
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
service.onDisconnect(BleError.from(e))
}
}
@ -226,10 +226,7 @@ constructor(
.onEach { state ->
Logger.i { "[$address] BLE connection state changed to $state" }
if (state is ConnectionState.Disconnected) {
toRadioCharacteristic = null
fromNumCharacteristic = null
fromRadioCharacteristic = null
logRadioCharacteristic = null
clearCharacteristics()
val uptime =
if (connectionStartTime > 0) {
@ -301,11 +298,11 @@ constructor(
}
}
.catch { e ->
Logger.e(e) { "[$address] Service discovery failed" }
Logger.w(e) { "[$address] Service discovery failed" }
try {
peripheral.disconnect()
} catch (e2: Exception) {
Logger.e(e2) { "[$address] Failed to disconnect in discovery catch" }
Logger.w(e2) { "[$address] Failed to disconnect in discovery catch" }
}
service.onDisconnect(BleError.from(e))
}
@ -324,10 +321,9 @@ constructor(
connectionScope.launch { drainPacketQueueAndDispatch() }
}
?.catch { e ->
Logger.e(e) { "[$address] Error subscribing to fromNumCharacteristic" }
Logger.w(e) { "[$address] Error subscribing to fromNumCharacteristic" }
service.onDisconnect(BleError.from(e))
}
?.onCompletion { cause -> Logger.d { "[$address] fromNum sub flow completed, cause=$cause" } }
?.launchIn(scope = connectionScope)
retryCall { logRadioCharacteristic?.subscribe() }
@ -337,10 +333,9 @@ constructor(
dispatchPacket(notifyBytes)
}
?.catch { e ->
Logger.e(e) { "[$address] Error subscribing to logRadioCharacteristic" }
Logger.w(e) { "[$address] Error subscribing to logRadioCharacteristic" }
service.onDisconnect(BleError.from(e))
}
?.onCompletion { cause -> Logger.d { "[$address] logRadio sub flow completed, cause=$cause" } }
?.launchIn(scope = connectionScope)
}
@ -354,7 +349,7 @@ constructor(
} catch (e: Exception) {
currentAttempt++
if (currentAttempt >= RETRY_COUNT) {
Logger.e(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
Logger.w(e) { "[$address] BLE operation failed after $RETRY_COUNT attempts, giving up" }
throw e
}
Logger.w(e) {
@ -398,8 +393,11 @@ constructor(
characteristic.write(p, writeType = writeType)
}
drainPacketQueueAndDispatch()
} catch (e: InvalidAttributeException) {
Logger.w(e) { "[$address] Attribute invalidated during write, clearing characteristics" }
handleInvalidAttribute(e)
} catch (e: Exception) {
Logger.e(e) {
Logger.w(e) {
"[$address] Failed to write packet to toRadioCharacteristic after " +
"$packetsSent successful writes"
}
@ -435,6 +433,18 @@ constructor(
}
}
private fun handleInvalidAttribute(e: InvalidAttributeException) {
clearCharacteristics()
service.onDisconnect(BleError.from(e))
}
private fun clearCharacteristics() {
toRadioCharacteristic = null
fromNumCharacteristic = null
fromRadioCharacteristic = null
logRadioCharacteristic = null
}
companion object {
private const val RETRY_COUNT = 3
private const val RETRY_DELAY_MS = 500L

View file

@ -65,7 +65,7 @@ import javax.inject.Singleton
*/
@Suppress("LongParameterList")
@Singleton
class RadioInterfaceService
open class RadioInterfaceService
@Inject
constructor(
private val context: Application,
@ -224,7 +224,7 @@ constructor(
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
fun handleFromRadio(p: ByteArray) {
open fun handleFromRadio(p: ByteArray) {
if (logReceives) {
try {
receivedPacketsLog.write(p)

View file

@ -134,7 +134,8 @@ constructor(
} else {
0
}
Logger.e(ex) { "[$address] TCP IOException after ${uptime}ms - ${ex.message}" }
// Connection failures are common when the radio is offline or out of range
Logger.w(ex) { "[$address] TCP connection error after ${uptime}ms - ${ex.message}" }
onDeviceDisconnect(false)
} catch (ex: Throwable) {
val uptime =

View file

@ -135,25 +135,46 @@ class MeshService : Service() {
val notification = connectionManager.updateStatusNotification()
val foregroundServiceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
if (hasLocationPermission()) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}
types
} else {
0
}
@Suppress("TooGenericExceptionCaught")
try {
ServiceCompat.startForeground(
this,
SERVICE_NOTIFY_ID,
notification,
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType)
} catch (ex: SecurityException) {
// On Android 14+ starting a location FGS from the background can fail with SecurityException
// if the app is not in an allowed state. Retry without the location type if that was requested.
val connectedDeviceOnly =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
if (hasLocationPermission()) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
}
types
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
} else {
0
},
)
}
if (foregroundServiceType != connectedDeviceOnly) {
Logger.w(ex) {
"Failed to start foreground service with location type, retrying with connectedDevice only"
}
try {
ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, connectedDeviceOnly)
} catch (retryEx: Exception) {
Logger.e(retryEx) { "Failed to start foreground service even after retry" }
}
} else {
Logger.e(ex) { "SecurityException starting foreground service" }
}
} catch (ex: Exception) {
Logger.e(ex) { "Error starting foreground service" }
return START_NOT_STICKY
}
return if (!wantForeground) {
Logger.i { "Stopping mesh service because no device is selected" }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)