mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(ble): Handle invalid BLE attributes (#4485)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
bf4020a939
commit
ba03aacdc9
15 changed files with 1546 additions and 57 deletions
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) :
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue