diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 4adea4197..6bc93d176 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -10,6 +10,7 @@ ComposableParamOrder:ConnectionsNavIcon.kt$ConnectionsNavIcon ComposableParamOrder:EmptyStateContent.kt$EmptyStateContent ComposableParamOrder:Share.kt$ShareScreen + CyclomaticComplexMethod:BleError.kt$BleError.Companion$fun from(exception: Throwable): BleError CyclomaticComplexMethod:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) CyclomaticComplexMethod:SettingsNavigation.kt$@Suppress("LongMethod") fun NavGraphBuilder.settingsGraph(navController: NavHostController) EmptyClassBlock:DebugLogFile.kt$BinaryLogFile${ } @@ -17,7 +18,6 @@ EmptyFunctionBlock:NsdManager.kt$<no name provided>${ } EmptyFunctionBlock:TrustAllX509TrustManager.kt$TrustAllX509TrustManager${} FinalNewline:BLEException.kt$com.geeksville.mesh.service.BLEException.kt - FinalNewline:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt FinalNewline:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt FinalNewline:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt FinalNewline:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt @@ -33,9 +33,7 @@ FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt - ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", b) - ImplicitDefaultLocale:NordicBleInterface.kt$NordicBleInterface$String.format("0x%02x", b) - ImplicitDefaultLocale:RadioInterfaceService.kt$RadioInterfaceService$String.format("0x%02x", b) + ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", byte) LambdaParameterEventTrailing:Channel.kt$onConfirm LambdaParameterInRestartableEffect:Channel.kt$onConfirm LargeClass:MeshService.kt$MeshService : Service @@ -69,14 +67,13 @@ MagicNumber:UIState.kt$4 MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]" - MaxLineLength:MeshService.kt$MeshService$"handleConfigComplete called with id=$configCompleteId, configOnly=$configOnlyNonce, nodeInfo=$nodeInfoNonce" + MaxLineLength:MeshService.kt$MeshService$"Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord." MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable" MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})" - MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"Peripheral not ready, cannot send data. Peripheral is ${if (peripheral == null) "null" else "not null"}. Characteristic is ${if (characteristic == null) "null" else "not null"}." - MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"fromNumCharacteristic discovered: uuid=${fromNumCharacteristic?.uuid} instanceId=${fromNumCharacteristic?.instanceId}" - MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"fromRadioCharacteristic discovered (packet queue): uuid=${fromRadioCharacteristic?.uuid} instanceId=${fromRadioCharacteristic?.instanceId}" - MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"service.serviceScope not active while dispatching from packet queue (source=$source); using localScope as fallback" - MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"toRadioCharacteristic discovered: uuid=${toRadioCharacteristic?.uuid} instanceId=${toRadioCharacteristic?.instanceId}" + MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}" + MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}" + MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}" + MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}" ModifierClickableOrder:Channel.kt$clickable(onClick = onClick) ModifierMissing:BLEDevices.kt$BLEDevices ModifierMissing:Channel.kt$ChannelScreen @@ -91,7 +88,6 @@ NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) NestedBlockDepth:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket) NewLineAtEndOfFile:BLEException.kt$com.geeksville.mesh.service.BLEException.kt - NewLineAtEndOfFile:BluetoothRepositoryModule.kt$com.geeksville.mesh.repository.bluetooth.BluetoothRepositoryModule.kt NewLineAtEndOfFile:Coroutines.kt$com.geeksville.mesh.concurrent.Coroutines.kt NewLineAtEndOfFile:DateUtils.kt$com.geeksville.mesh.android.DateUtils.kt NewLineAtEndOfFile:DebugLogFile.kt$com.geeksville.mesh.android.DebugLogFile.kt @@ -131,7 +127,6 @@ TooGenericExceptionCaught:MeshService.kt$MeshService.<no name provided>$ex: Exception TooGenericExceptionCaught:MeshServiceStarter.kt$ServiceStarter$ex: Exception TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$e: Exception - TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$ex: Exception TooGenericExceptionCaught:NordicBleInterface.kt$NordicBleInterface$t: Throwable TooGenericExceptionCaught:RadioInterfaceService.kt$RadioInterfaceService$t: Throwable TooGenericExceptionCaught:SyncContinuation.kt$Continuation$ex: Throwable diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt new file mode 100644 index 000000000..c9100ad5f --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/BleError.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 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 com.geeksville.mesh.repository.radio + +import com.geeksville.mesh.service.RadioNotConnectedException +import no.nordicsemi.kotlin.ble.client.exception.BluetoothUnavailableException +import no.nordicsemi.kotlin.ble.client.exception.ConnectionFailedException +import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException +import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException +import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException +import no.nordicsemi.kotlin.ble.client.exception.ScanningException +import no.nordicsemi.kotlin.ble.client.exception.ValueDoesNotMatchException +import no.nordicsemi.kotlin.ble.core.ConnectionState +import no.nordicsemi.kotlin.ble.core.exception.BluetoothException +import no.nordicsemi.kotlin.ble.core.exception.GattException +import no.nordicsemi.kotlin.ble.core.exception.ManagerClosedException + +/** + * Represents specific BLE failures, modeled after the iOS implementation's AccessoryError. This allows for more + * granular error handling and intelligent reconnection strategies. + */ +sealed class BleError(val message: String, val shouldReconnect: Boolean) { + + /** + * An error indicating that the peripheral was not found. This is a non-recoverable error and should not trigger a + * reconnect. + */ + data object PeripheralNotFound : BleError("Peripheral not found", shouldReconnect = false) + + /** + * An error indicating a failure during the connection attempt. This may be recoverable, so a reconnect attempt is + * warranted. + */ + class ConnectionFailed(exception: Throwable) : + BleError("Connection failed: ${exception.message}", shouldReconnect = true) + + /** + * An error indicating a failure during the service discovery process. This may be recoverable, so a reconnect + * attempt is warranted. + */ + class DiscoveryFailed(message: String) : BleError("Discovery failed: $message", shouldReconnect = true) + + /** + * An error indicating a disconnection initiated by the peripheral. This may be recoverable, so a reconnect attempt + * is warranted. + */ + class Disconnected(reason: ConnectionState.Disconnected.Reason?) : + BleError("Disconnected: ${reason ?: "Unknown reason"}", shouldReconnect = true) + + /** + * Wraps a generic GattException. The reconnection strategy depends on the nature of the Gatt error. + * + * @param exception The underlying GattException. + */ + 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. + * + * @param exception The underlying BluetoothException. + */ + class BluetoothError(exception: BluetoothException) : + BleError("Bluetooth exception: ${exception.message}", shouldReconnect = true) + + /** The BLE manager was closed. This is a non-recoverable error. */ + class ManagerClosed(exception: ManagerClosedException) : + BleError("Manager closed: ${exception.message}", shouldReconnect = false) + + /** A BLE operation failed. This may be recoverable. */ + class OperationFailed(exception: OperationFailedException) : + BleError("Operation failed: ${exception.message}", shouldReconnect = true) + + /** An invalid attribute was used. This is a non-recoverable error. */ + class InvalidAttribute(exception: InvalidAttributeException) : + BleError("Invalid attribute: ${exception.message}", shouldReconnect = false) + + /** An error occurred while scanning for devices. This may be recoverable. */ + class Scanning(exception: ScanningException) : + BleError("Scanning error: ${exception.message}", shouldReconnect = true) + + /** Bluetooth is unavailable on the device. This is a non-recoverable error. */ + class BluetoothUnavailable(exception: BluetoothUnavailableException) : + BleError("Bluetooth unavailable: ${exception.message}", shouldReconnect = false) + + /** The peripheral is not connected. This may be recoverable. */ + class PeripheralNotConnected(exception: PeripheralNotConnectedException) : + BleError("Peripheral not connected: ${exception.message}", shouldReconnect = true) + + /** A value did not match what was expected. This may be recoverable. */ + class ValueDoesNotMatch(exception: ValueDoesNotMatchException) : + BleError("Value does not match: ${exception.message}", shouldReconnect = true) + + /** A generic error for other exceptions that may occur. */ + class GenericError(exception: Throwable) : + BleError("An unexpected error occurred: ${exception.message}", shouldReconnect = true) + + companion object { + fun from(exception: Throwable): BleError = when (exception) { + is GattException -> { + when (exception) { + is ConnectionFailedException -> ConnectionFailed(exception) + is PeripheralNotConnectedException -> PeripheralNotConnected(exception) + is OperationFailedException -> OperationFailed(exception) + is ValueDoesNotMatchException -> ValueDoesNotMatch(exception) + else -> GattError(exception) + } + } + is BluetoothException -> { + when (exception) { + is BluetoothUnavailableException -> BluetoothUnavailable(exception) + is InvalidAttributeException -> InvalidAttribute(exception) + is ScanningException -> Scanning(exception) + else -> BluetoothError(exception) + } + } + + is RadioNotConnectedException -> PeripheralNotFound + is ManagerClosedException -> ManagerClosed(exception) + else -> GenericError(exception) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index ee241a23a..2c933f8c9 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -20,6 +20,7 @@ package com.geeksville.mesh.repository.radio import android.annotation.SuppressLint import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMNUM_CHARACTER import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMRADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_LOGRADIO_CHARACTER import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_SERVICE_UUID import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_TORADIO_CHARACTER import com.geeksville.mesh.service.RadioNotConnectedException @@ -32,10 +33,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -45,6 +46,8 @@ 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.core.CharacteristicProperty +import no.nordicsemi.kotlin.ble.core.ConnectionState import no.nordicsemi.kotlin.ble.core.WriteType import timber.log.Timber import java.util.UUID @@ -80,6 +83,7 @@ constructor( private var toRadioCharacteristic: RemoteCharacteristic? = null private var fromNumCharacteristic: RemoteCharacteristic? = null private var fromRadioCharacteristic: RemoteCharacteristic? = null + private var logRadioCharacteristic: RemoteCharacteristic? = null init { connect() @@ -87,42 +91,43 @@ constructor( // --- Packet Flow Management --- - private fun packetQueueFlow(): Flow = channelFlow { + private fun fromRadioPacketFlow(): Flow = channelFlow { while (isActive) { // Use safe call and Elvis operator for cleaner loop termination if read fails or returns empty val packet = fromRadioCharacteristic?.read()?.takeIf { it.isNotEmpty() } ?: run { - Timber.d("Packet queue drain complete (empty or null read)") + Timber.d("[$address] fromRadio queue drain complete (read empty/null)") break } send(packet) } } - private fun dispatchPacket(packet: ByteArray, source: String) { + private fun dispatchPacket(packet: ByteArray) { + Timber.d("[$address] Dispatching packet to service.handleFromRadio()") connectionScope.launch { try { service.handleFromRadio(p = packet) } catch (t: Throwable) { - Timber.e(t, "Failed to schedule service.handleFromRadio (source=$source)") + Timber.e(t, "[$address] Failed to schedule service.handleFromRadio)") } } } - private suspend fun drainPacketQueueAndDispatch(source: String) { + private suspend fun drainPacketQueueAndDispatch() { drainMutex.withLock { var drainedCount = 0 - packetQueueFlow() + fromRadioPacketFlow() .onEach { packet -> drainedCount++ - logPacketRead(source, packet) - dispatchPacket(packet, source) + Timber.d("[$address] Read packet from queue (${packet.size} bytes)") + dispatchPacket(packet) } - .catch { ex -> Timber.w(ex, "Exception while draining packet queue (source=$source)") } + .catch { ex -> Timber.w(ex, "[$address] Exception while draining packet queue") } .onCompletion { if (drainedCount > 0) { - Timber.d("[$source] Drained $drainedCount packets from packet queue") + Timber.d("[$address] Drained $drainedCount packets from packet queue") } } .collect() @@ -145,8 +150,8 @@ constructor( discoverServicesAndSetupCharacteristics(it) } } catch (e: Exception) { - Timber.e(e, "Failed to connect to peripheral $address") - service.onDisconnect(false) + Timber.e(e, "[$address] Failed to connect to peripheral") + service.onDisconnect(BleError.from(e)) } } } @@ -165,27 +170,27 @@ constructor( try { peripheral?.let { p -> val rssi = p.readRssi() - Timber.d("Peripheral $address: RSSI: $rssi dBm") + Timber.d("[$address] Connection established. RSSI: $rssi dBm") val phyInUse = p.readPhy() - Timber.d("Peripheral $address: PHY in use: $phyInUse") + Timber.d("[$address] PHY in use: $phyInUse") } } catch (e: Exception) { - Timber.w(e, "Failed to read initial connection properties for $address") + Timber.w(e, "[$address] Failed to read initial connection properties") } } private fun observePeripheralChanges() { peripheral?.let { p -> - p.phy.onEach { phy -> Timber.d("Peripheral $address: PHY changed to $phy") }.launchIn(connectionScope) + p.phy.onEach { phy -> Timber.d("[$address] PHY changed to $phy") }.launchIn(connectionScope) p.connectionParameters - .onEach { Timber.d("Peripheral $address: Connection parameters changed to $it") } + .onEach { Timber.d("[$address] Connection parameters changed to $it") } .launchIn(connectionScope) p.state .onEach { state -> - Timber.d("Peripheral $address: State changed to $state") - if (!state.isConnected) { - service.onDisconnect(false) + Timber.d("[$address] State changed to $state") + if (state is ConnectionState.Disconnected) { + service.onDisconnect(BleError.Disconnected(reason = state.reason)) } } .launchIn(connectionScope) @@ -210,22 +215,35 @@ constructor( meshtasticService.characteristics.find { it.uuid == BTM_FROMNUM_CHARACTER.toKotlinUuid() } fromRadioCharacteristic = meshtasticService.characteristics.find { it.uuid == BTM_FROMRADIO_CHARACTER.toKotlinUuid() } + logRadioCharacteristic = + meshtasticService.characteristics.find { it.uuid == BTM_LOGRADIO_CHARACTER.toKotlinUuid() } if ( listOf(toRadioCharacteristic, fromNumCharacteristic, fromRadioCharacteristic).all { it != null } ) { - logCharacteristicInfo() + Timber.d( + "[$address] Found toRadio: ${toRadioCharacteristic?.uuid}, ${toRadioCharacteristic?.instanceId}", + ) + Timber.d( + "[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}", + ) + Timber.d( + "[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}", + ) + Timber.d( + "[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}", + ) setupNotifications() service.onConnect() } else { - Timber.w("One or more characteristics not found on peripheral $address") - service.onDisconnect(false) + Timber.w("[$address] Discovery failed: missing required characteristics") + service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found")) } } else { - Timber.w("Meshtastic service not found on peripheral $address") - service.onDisconnect(false) + Timber.w("[$address] Discovery failed: Meshtastic service not found") + service.onDisconnect(BleError.DiscoveryFailed("Meshtastic service not found")) } } .launchIn(connectionScope) @@ -238,12 +256,30 @@ constructor( private suspend fun setupNotifications() { fromNumCharacteristic ?.subscribe() + ?.onStart { Timber.d("[$address] Subscribing to fromNumCharacteristic") } ?.onEach { notifyBytes -> - logFromNumNotification(notifyBytes) - connectionScope.launch { drainPacketQueueAndDispatch("notify") } + Timber.d("[$address] FromNum Notification (${notifyBytes.size} bytes), draining queue") + connectionScope.launch { drainPacketQueueAndDispatch() } } - ?.catch { e -> Timber.e(e, "Error in subscribe flow for fromNumCharacteristic") } - ?.onCompletion { cause -> Timber.d("fromNum subscribe flow completed, cause=$cause") } + ?.catch { e -> + Timber.e(e, "[$address] Error subscribing to fromNumCharacteristic") + service.onDisconnect(BleError.from(e)) + } + ?.onCompletion { cause -> Timber.d("[$address] fromNum sub flow completed, cause=$cause") } + ?.launchIn(scope = connectionScope) + + logRadioCharacteristic + ?.subscribe() + ?.onStart { Timber.d("[$address] Subscribing to logRadioCharacteristic") } + ?.onEach { notifyBytes -> + Timber.d("[$address] LogRadio Notification (${notifyBytes.size} bytes), dispatching packet") + dispatchPacket(notifyBytes) + } + ?.catch { e -> + Timber.e(e, "[$address] Error subscribing to logRadioCharacteristic") + service.onDisconnect(BleError.from(e)) + } + ?.onCompletion { cause -> Timber.d("[$address] logRadio sub flow completed, cause=$cause") } ?.launchIn(scope = connectionScope) } @@ -257,17 +293,23 @@ constructor( override fun handleSendToRadio(p: ByteArray) { toRadioCharacteristic?.let { characteristic -> if (peripheral == null) return@let - connectionScope.launch { try { - characteristic.write(p, writeType = WriteType.WITHOUT_RESPONSE) - // Post-write action initiation - drainPacketQueueAndDispatch("post-write") + val writeType = + if (characteristic.properties.contains(CharacteristicProperty.WRITE_WITHOUT_RESPONSE)) { + WriteType.WITHOUT_RESPONSE + } else { + WriteType.WITH_RESPONSE + } + Timber.d("[$address] Writing packet to toRadioCharacteristic with $writeType") + characteristic.write(p, writeType = writeType) + drainPacketQueueAndDispatch() } catch (e: Exception) { - Timber.e(e, "Failed to write packet to $address") + Timber.e(e, "[$address] Failed to write packet to toRadioCharacteristic") + service.onDisconnect(BleError.from(e)) } } - } ?: Timber.w("toRadioCharacteristic not available when attempting to send data to $address") + } ?: Timber.w("[$address] toRadio unavailable, can't send data") } /** Closes the connection to the device. */ @@ -278,34 +320,6 @@ constructor( service.onDisconnect(true) } } - - // --- Logging Helpers --- - - @OptIn(ExperimentalUuidApi::class) - private fun logCharacteristicInfo() { - Timber.d( - "toRadioCharacteristic discovered: uuid=${toRadioCharacteristic?.uuid} instanceId=${toRadioCharacteristic?.instanceId}", - ) - Timber.d( - "fromNumCharacteristic discovered: uuid=${fromNumCharacteristic?.uuid} instanceId=${fromNumCharacteristic?.instanceId}", - ) - Timber.d( - "fromRadioCharacteristic discovered (packet queue): uuid=${fromRadioCharacteristic?.uuid} instanceId=${fromRadioCharacteristic?.instanceId}", - ) - } - - private fun logPacketRead(source: String, packet: ByteArray) { - val hexString = packet.joinToString(prefix = "[", postfix = "]") { b -> String.format("0x%02x", b) } - Timber.d( - "[$source] Read packet queue returned ${packet.size}" + - " bytes: $hexString - dispatching to service.handleFromRadio()", - ) - } - - private fun logFromNumNotification(notifyBytes: ByteArray) { - val hexString = notifyBytes.joinToString(prefix = "[", postfix = "]") { b -> String.format("0x%02x", b) } - Timber.d("FROMNUM notify, ${notifyBytes.size} bytes: $hexString - reading packet queue") - } } object BleUuidConstants { @@ -313,4 +327,5 @@ object BleUuidConstants { val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7") val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453") val BTM_FROMRADIO_CHARACTER: UUID = UUID.fromString("2c55e69e-4993-11ed-b878-0242ac120002") + val BTM_LOGRADIO_CHARACTER: UUID = UUID.fromString("5a3d6e49-06e6-4423-9944-e9de8cdf9547") } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 03d7f8f36..f78516889 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -83,6 +83,9 @@ constructor( private val _receivedData = MutableSharedFlow() val receivedData: SharedFlow = _receivedData + private val _connectionError = MutableSharedFlow() + val connectionError: SharedFlow = _connectionError.asSharedFlow() + // Thread-safe StateFlow for tracking device address changes private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr) val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() @@ -221,15 +224,6 @@ constructor( // Handle an incoming packet from the radio, broadcasts it as an android intent fun handleFromRadio(p: ByteArray) { - Timber.d( - "RadioInterfaceService.handleFromRadio called with ${p.size} bytes: ${p.joinToString( - prefix = "[", - postfix = "]", - ) { b -> - String.format("0x%02x", b) - }}", - ) - if (logReceives) { try { receivedPacketsLog.write(p) @@ -248,7 +242,6 @@ constructor( try { processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) } emitReceiveActivity() - Timber.d("RadioInterfaceService.handleFromRadio dispatched successfully") } catch (t: Throwable) { Timber.e(t, "RadioInterfaceService.handleFromRadio failed while emitting data") } @@ -267,6 +260,11 @@ constructor( } } + fun onDisconnect(error: BleError) { + processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) } + onDisconnect(!error.shouldReconnect) + } + /** Start our configured interface (if it isn't already running) */ private fun startInterface() { if (radioIf !is NopInterface) { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index fc664ec50..2b0a40189 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -220,7 +220,7 @@ class MeshService : Service() { private const val DEFAULT_CONFIG_ONLY_NONCE = 69420 private const val DEFAULT_NODE_INFO_NONCE = 69421 - private const val WANT_CONFIG_DELAY = 50L + private const val WANT_CONFIG_DELAY = 250L } private val serviceJob = Job() @@ -313,6 +313,9 @@ class MeshService : Service() { serviceScope.handledLaunch { radioInterfaceService.connect() } radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope) + radioInterfaceService.connectionError + .onEach { error -> Timber.e("BLE Connection Error: ${error.message}") } + .launchIn(serviceScope) radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope) radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope) radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope) @@ -758,6 +761,7 @@ class MeshService : Service() { } Portnums.PortNum.WAYPOINT_APP_VALUE -> { + Timber.d("Received WAYPOINT_APP from $fromId") val u = MeshProtos.Waypoint.parseFrom(data.payload) // Validate locked Waypoints from the original sender if (u.lockedTo != 0 && u.lockedTo != packet.from) return @@ -765,8 +769,8 @@ class MeshService : Service() { } Portnums.PortNum.POSITION_APP_VALUE -> { + Timber.d("Received POSITION_APP from $fromId") val u = MeshProtos.Position.parseFrom(data.payload) - // Timber.d("position_app ${packet.from} ${u.toOneLineString()}") if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) { Timber.d("Ignoring nop position update from position request") } else { @@ -776,6 +780,7 @@ class MeshService : Service() { Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromUs) { + Timber.d("Received NODEINFO_APP from $fromId") val u = MeshProtos.User.parseFrom(data.payload).copy { if (isLicensed) clearPublicKey() @@ -786,6 +791,7 @@ class MeshService : Service() { // Handle new telemetry info Portnums.PortNum.TELEMETRY_APP_VALUE -> { + Timber.d("Received TELEMETRY_APP from $fromId") val u = TelemetryProtos.Telemetry.parseFrom(data.payload).copy { if (time == 0) time = (dataPacket.time / 1000L).toInt() @@ -794,6 +800,7 @@ class MeshService : Service() { } Portnums.PortNum.ROUTING_APP_VALUE -> { + Timber.d("Received ROUTING_APP from $fromId") // We always send ACKs to other apps, because they might care about the // messages they sent shouldBroadcast = true @@ -808,24 +815,28 @@ class MeshService : Service() { } Portnums.PortNum.ADMIN_APP_VALUE -> { + Timber.d("Received ADMIN_APP from $fromId") val u = AdminProtos.AdminMessage.parseFrom(data.payload) handleReceivedAdmin(packet.from, u) shouldBroadcast = false } Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + Timber.d("Received PAXCOUNTER_APP from $fromId") val p = PaxcountProtos.Paxcount.parseFrom(data.payload) handleReceivedPaxcounter(packet.from, p) shouldBroadcast = false } Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + Timber.d("Received STORE_FORWARD_APP from $fromId") val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) handleReceivedStoreAndForward(dataPacket, u) shouldBroadcast = false } Portnums.PortNum.RANGE_TEST_APP_VALUE -> { + Timber.d("Received RANGE_TEST_APP from $fromId") if (!moduleConfig.rangeTest.enabled) return val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) rememberDataPacket(u) @@ -856,7 +867,7 @@ class MeshService : Service() { } } - else -> Timber.d("No custom processing needed for ${data.portnumValue}") + else -> Timber.d("No custom processing needed for ${data.portnumValue} from $fromId") } // We always tell other apps when new data packets arrive @@ -962,10 +973,7 @@ class MeshService : Service() { if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { Timber.d("Ignoring nop position update for the local node") } else { - updateNodeInfo(fromNum) { - Timber.d("update position: ${it.longName?.toPIIString()} with ${p.toPIIString()}") - it.setPosition(p, (defaultTime / 1000L).toInt()) - } + updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / 1000L).toInt()) } } } @@ -1033,7 +1041,6 @@ class MeshService : Service() { } private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { - Timber.d("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") when (s.variantCase) { StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { val u = @@ -1078,6 +1085,7 @@ class MeshService : Service() { // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { + Timber.d("[packet]: ${packet.toOneLineString()}") if (haveNodeDB) { processReceivedMeshPacket( packet @@ -1142,12 +1150,6 @@ class MeshService : Service() { // Update our model and resend as needed for a MeshPacket we just received from the radio private fun processReceivedMeshPacket(packet: MeshPacket) { val fromNum = packet.from - - // FIXME, perhaps we could learn our node ID by looking at any to packets the radio - // decided to pass through to us (except for broadcast packets) - // val toNum = packet.to - - // Timber.d("Received: $packet") if (packet.hasDecoded()) { val packetToSave = MeshLog( @@ -1276,6 +1278,7 @@ class MeshService : Service() { } fun startDisconnect() { + Timber.d("Starting disconnect") packetHandler.stopPacketQueue() stopLocationRequests() stopMqttClientProxy() @@ -1288,6 +1291,7 @@ class MeshService : Service() { } fun startConnect() { + Timber.d("Starting connect") try { connectTimeMsec = System.currentTimeMillis() startConfigOnly() @@ -1355,7 +1359,6 @@ class MeshService : Service() { private val packetHandlers: Map Unit)> by lazy { PayloadVariantCase.entries.associateWith { variant: PayloadVariantCase -> - Timber.d("PacketHandler - handling $variant") when (variant) { PayloadVariantCase.PACKET -> { proto: MeshProtos.FromRadio -> handleReceivedMeshPacket(proto.packet) } @@ -1364,9 +1367,11 @@ class MeshService : Service() { } PayloadVariantCase.MY_INFO -> { proto: MeshProtos.FromRadio -> handleMyInfo(proto.myInfo) } + PayloadVariantCase.NODE_INFO -> { proto: MeshProtos.FromRadio -> handleNodeInfo(proto.nodeInfo) } PayloadVariantCase.CHANNEL -> { proto: MeshProtos.FromRadio -> handleChannel(proto.channel) } + PayloadVariantCase.CONFIG -> { proto: MeshProtos.FromRadio -> handleDeviceConfig(proto.config) } PayloadVariantCase.MODULECONFIG -> { proto: MeshProtos.FromRadio -> @@ -1378,6 +1383,7 @@ class MeshService : Service() { } PayloadVariantCase.METADATA -> { proto: MeshProtos.FromRadio -> handleMetadata(proto.metadata) } + PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> { proto: MeshProtos.FromRadio -> handleMqttProxyMessage(proto.mqttClientProxyMessage) } @@ -1387,22 +1393,22 @@ class MeshService : Service() { } PayloadVariantCase.FILEINFO -> { proto: MeshProtos.FromRadio -> handleFileInfo(proto.fileInfo) } + PayloadVariantCase.CLIENTNOTIFICATION -> { proto: MeshProtos.FromRadio -> handleClientNotification(proto.clientNotification) } - PayloadVariantCase.LOG_RECORD -> { proto: MeshProtos.FromRadio -> handleLogReord(proto.logRecord) } + PayloadVariantCase.LOG_RECORD -> { proto: MeshProtos.FromRadio -> handleLogRecord(proto.logRecord) } PayloadVariantCase.REBOOTED -> { proto: MeshProtos.FromRadio -> handleRebooted(proto.rebooted) } + PayloadVariantCase.XMODEMPACKET -> { proto: MeshProtos.FromRadio -> handleXmodemPacket(proto.xmodemPacket) } // Explicitly handle default/unwanted cases to satisfy the exhaustive `when` PayloadVariantCase.PAYLOADVARIANT_NOT_SET -> { proto -> - Timber.e("Unexpected or unrecognized FromRadio variant: ${proto.payloadVariantCase}") - // Additional debug: log raw bytes if possible (can't access bytes here) and full proto - Timber.d("Full FromRadio proto: $proto") + Timber.d("Received variant PayloadVariantUnset: Full FromRadio proto: ${proto.toPIIString()}") } } } @@ -1412,22 +1418,33 @@ class MeshService : Service() { packetHandlers[this.payloadVariantCase]?.invoke(this) } + /** + * Parses and routes incoming data from the radio. + * + * This function first attempts to parse the data as a `FromRadio` protobuf message. If that fails, it then tries to + * parse it as a `LogRecord` for debugging purposes. + */ private fun onReceiveFromRadio(bytes: ByteArray) { - try { - val proto = MeshProtos.FromRadio.parseFrom(bytes) - if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) { - Timber.w( - "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.joinToString(",") { b -> - String.format("0x%02x", b) - }} proto=$proto", - ) + runCatching { MeshProtos.FromRadio.parseFrom(bytes) } + .onSuccess { proto -> proto.route() } + .onFailure { primaryException -> + runCatching { + val logRecord = MeshProtos.LogRecord.parseFrom(bytes) + handleLogRecord(logRecord) + } + .onFailure { _ -> + val packet = bytes.toHexString() + Timber.e( + primaryException, + "Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord.", + ) + } } - proto.route() - } catch (ex: InvalidProtocolBufferException) { - Timber.e("Invalid Protobuf from radio, len=${bytes.size}", ex) - } } + /** Extension function to convert a ByteArray to a hex string for logging. Example output: "0x0a,0x1f,0x..." */ + private fun ByteArray.toHexString(): String = this.joinToString(",") { byte -> String.format("0x%02x", byte) } + // A provisional MyNodeInfo that we will install if all of our node config downloads go okay private var newMyNodeInfo: MyNodeEntity? = null @@ -1439,7 +1456,7 @@ class MeshService : Service() { private var nodeInfoNonce: Int = DEFAULT_NODE_INFO_NONCE private fun handleDeviceConfig(config: ConfigProtos.Config) { - Timber.d("Received config ${config.toOneLineString()}") + Timber.d("[deviceConfig] ${config.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1455,7 +1472,7 @@ class MeshService : Service() { } private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { - Timber.d("Received moduleConfig ${config.toOneLineString()}") + Timber.d("[moduleConfig] ${config.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1471,7 +1488,7 @@ class MeshService : Service() { } private fun handleChannel(ch: ChannelProtos.Channel) { - Timber.d("Received channel ${ch.index}") + Timber.d("[channel] ${ch.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1529,13 +1546,7 @@ class MeshService : Service() { } private fun handleNodeInfo(info: MeshProtos.NodeInfo) { - Timber.d( - "Received nodeinfo num=${info.num}," + - " hasUser=${info.hasUser()}," + - " hasPosition=${info.hasPosition()}," + - " hasDeviceMetrics=${info.hasDeviceMetrics()}", - ) - + Timber.d("[nodeInfo] ${info.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1600,6 +1611,7 @@ class MeshService : Service() { /** Update MyNodeInfo (called from either new API version or the old one) */ private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { + Timber.d("[myInfo] ${myInfo.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1623,7 +1635,7 @@ class MeshService : Service() { /** Update our DeviceMetadata */ private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { - Timber.d("Received deviceMetadata ${metadata.toOneLineString()}") + Timber.d("[deviceMetadata] ${metadata.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1639,6 +1651,7 @@ class MeshService : Service() { /** Publish MqttClientProxyMessage (fromRadio) */ private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { + Timber.d("[mqttClientProxyMessage] ${message.toPIIString()}") with(message) { when (payloadVariantCase) { MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { @@ -1655,16 +1668,25 @@ class MeshService : Service() { } private fun handleClientNotification(notification: MeshProtos.ClientNotification) { - Timber.d("Received clientNotification ${notification.toOneLineString()}") + Timber.d("[clientNotification] ${notification.toPIIString()}") serviceRepository.setClientNotification(notification) serviceNotifications.showClientNotification(notification) // if the future for the originating request is still in the queue, complete as unsuccessful // for now packetHandler.removeResponse(notification.replyId, complete = false) + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "ClientNotification", + received_date = System.currentTimeMillis(), + raw_message = notification.toString(), + fromRadio = fromRadio { this.clientNotification = notification }, + ) + insertMeshLog(packetToSave) } private fun handleFileInfo(fileInfo: MeshProtos.FileInfo) { - Timber.d("Received fileInfo ${fileInfo.toOneLineString()}") + Timber.d("[fileInfo] ${fileInfo.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1676,8 +1698,8 @@ class MeshService : Service() { insertMeshLog(packetToSave) } - private fun handleLogReord(logRecord: MeshProtos.LogRecord) { - Timber.d("Received logRecord ${logRecord.toOneLineString()}") + private fun handleLogRecord(logRecord: MeshProtos.LogRecord) { + Timber.d("[logRecord] ${logRecord.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1690,7 +1712,7 @@ class MeshService : Service() { } private fun handleRebooted(rebooted: Boolean) { - Timber.d("Received rebooted ${rebooted.toOneLineString()}") + Timber.d("[rebooted] $rebooted") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1703,7 +1725,7 @@ class MeshService : Service() { } private fun handleXmodemPacket(xmodemPacket: XmodemProtos.XModem) { - Timber.d("Received XmodemPacket ${xmodemPacket.toOneLineString()}") + Timber.d("[xmodemPacket] ${xmodemPacket.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1716,7 +1738,7 @@ class MeshService : Service() { } private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { - Timber.d("Received deviceUIConfig ${deviceuiConfig.toOneLineString()}") + Timber.d("[deviceuiConfig] ${deviceuiConfig.toPIIString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1751,18 +1773,16 @@ class MeshService : Service() { } private fun onHasSettings() { - packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) processQueuedPackets() startMqttClientProxy() serviceBroadcasts.broadcastConnection() sendAnalytics() reportConnection() + packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) } private fun handleConfigComplete(configCompleteId: Int) { - Timber.d( - "handleConfigComplete called with id=$configCompleteId, configOnly=$configOnlyNonce, nodeInfo=$nodeInfoNonce", - ) + Timber.d("[configCompleteId]: ${configCompleteId.toPIIString()}") when (configCompleteId) { configOnlyNonce -> handleConfigOnlyComplete() nodeInfoNonce -> handleNodeInfoComplete() diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index 432e24d60..68b830542 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -24,6 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeInfo +import org.meshtastic.core.model.util.toPIIString import org.meshtastic.core.service.ServiceRepository import timber.log.Timber import javax.inject.Inject @@ -50,7 +51,7 @@ constructor( } fun broadcastNodeChange(info: NodeInfo) { - Timber.d("Broadcasting node change $info") + Timber.d("Broadcasting node change ${info.user?.toPIIString()}") val intent = Intent(MeshService.ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info) explicitBroadcast(intent) } diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index b368e1510..160491ebe 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -115,7 +115,7 @@ constructor( } fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { - Timber.d("queueStatus ${queueStatus.toOneLineString()}") + Timber.d("[queueStatus] ${queueStatus.toOneLineString()}") val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) } if (success && isFull) return // Queue is full, wait for free != 0 if (requestId != 0) { @@ -184,7 +184,7 @@ constructor( if (connectionStateHolder.getState() != ConnectionState.CONNECTED) throw RadioNotConnectedException() sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) } catch (ex: Exception) { - Timber.e("sendToRadio error:", ex) + Timber.e(ex, "sendToRadio error: ${ex.message}") future.complete(false) } return future diff --git a/core/model/detekt-baseline.xml b/core/model/detekt-baseline.xml index 3cc9a334d..f459aeba8 100644 --- a/core/model/detekt-baseline.xml +++ b/core/model/detekt-baseline.xml @@ -30,5 +30,6 @@ MagicNumber:ChannelSet.kt$960 SwallowedException:ChannelSet.kt$ex: Throwable TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable + TooManyFunctions:Extensions.kt$org.meshtastic.core.model.util.Extensions.kt diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt index f72273522..4886f333f 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -44,6 +44,13 @@ fun ConfigProtos.Config.toOneLineString(): String { .replace('\n', ' ') } +fun MeshProtos.MeshPacket.toOneLineString(): String { + val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys + return this.toString() + .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } + .replace('\n', ' ') +} + fun MeshProtos.toOneLineString(): String { val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys return this.toString() diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 7bb215118..ac0126f43 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -238,7 +238,6 @@ internal fun DebugItem( val messageAnnotatedString = rememberAnnotatedLogMessage(log, searchText) Text( text = messageAnnotatedString, - softWrap = false, style = TextStyle( fontSize = if (isSelected) 12.sp else 9.sp, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 1380b1e00..6d93f02f3 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -267,6 +267,7 @@ constructor( /** Transform the input [MeshLog] by enhancing the raw message with annotations. */ private fun annotateMeshLogMessage(meshLog: MeshLog): String = when (meshLog.message_type) { + "LogRecord" -> meshLog.fromRadio.logRecord.toString().replace("\\n\"", "\"") "Packet" -> meshLog.meshPacket?.let { packet -> annotatePacketLog(packet) } ?: meshLog.raw_message "NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.num) }