From 380f41dc5c78e42fda400192c679cef825b2f59a Mon Sep 17 00:00:00 2001 From: DaneEvans Date: Thu, 14 Aug 2025 22:19:14 +1000 Subject: [PATCH] Fix/#2701 NodeId annotation in debug panel (#2709) --- .../geeksville/mesh/model/DebugViewModel.kt | 319 +++++++++--------- 1 file changed, 163 insertions(+), 156 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt index d0aa7e13c..459657434 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt @@ -20,64 +20,54 @@ package com.geeksville.mesh.model import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.AdminProtos +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.Portnums.PortNum +import com.geeksville.mesh.StoreAndForwardProtos +import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.entity.MeshLog +import com.geeksville.mesh.repository.datastore.RadioConfigRepository +import com.geeksville.mesh.ui.debug.FilterMode +import com.google.protobuf.InvalidProtocolBufferException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date import java.util.Locale import javax.inject.Inject -import com.geeksville.mesh.Portnums.PortNum -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import com.geeksville.mesh.repository.datastore.RadioConfigRepository -import com.google.protobuf.InvalidProtocolBufferException -import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.TelemetryProtos -import com.geeksville.mesh.AdminProtos -import com.geeksville.mesh.PaxcountProtos -import com.geeksville.mesh.StoreAndForwardProtos -import com.geeksville.mesh.ui.debug.FilterMode -data class SearchMatch( - val logIndex: Int, - val start: Int, - val end: Int, - val field: String -) +data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) data class SearchState( val searchText: String = "", val currentMatchIndex: Int = -1, val allMatches: List = emptyList(), - val hasMatches: Boolean = false + val hasMatches: Boolean = false, ) // --- Search and Filter Managers --- class LogSearchManager { - data class SearchMatch( - val logIndex: Int, - val start: Int, - val end: Int, - val field: String - ) + data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String) data class SearchState( val searchText: String = "", val currentMatchIndex: Int = -1, val allMatches: List = emptyList(), - val hasMatches: Boolean = false + val hasMatches: Boolean = false, ) private val _searchText = MutableStateFlow("") @@ -119,35 +109,46 @@ class LogSearchManager { fun updateMatches(searchText: String, filteredLogs: List) { val matches = findSearchMatches(searchText, filteredLogs) val hasMatches = matches.isNotEmpty() - _searchState.value = _searchState.value.copy( - searchText = searchText, - allMatches = matches, - hasMatches = hasMatches, - currentMatchIndex = if (hasMatches) _currentMatchIndex.value.coerceIn(0, matches.lastIndex) else -1 - ) + _searchState.value = + _searchState.value.copy( + searchText = searchText, + allMatches = matches, + hasMatches = hasMatches, + currentMatchIndex = if (hasMatches) _currentMatchIndex.value.coerceIn(0, matches.lastIndex) else -1, + ) } fun findSearchMatches(searchText: String, filteredLogs: List): List { if (searchText.isEmpty()) { return emptyList() } - return filteredLogs.flatMapIndexed { logIndex, log -> - searchText.split(" ").flatMap { term -> - val escapedTerm = Regex.escape(term) - val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE) - val messageMatches = regex.findAll(log.logMessage) - .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "message") } - val typeMatches = regex.findAll(log.messageType) - .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "type") } - val dateMatches = regex.findAll(log.formattedReceivedDate) - .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "date") } - val decodedPayloadMatches = log.decodedPayload?.let { decoded -> - regex.findAll(decoded) - .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "decodedPayload") } - } ?: emptySequence() - messageMatches + typeMatches + dateMatches + decodedPayloadMatches + return filteredLogs + .flatMapIndexed { logIndex, log -> + searchText.split(" ").flatMap { term -> + val escapedTerm = Regex.escape(term) + val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE) + val messageMatches = + regex.findAll(log.logMessage).map { match -> + SearchMatch(logIndex, match.range.first, match.range.last, "message") + } + val typeMatches = + regex.findAll(log.messageType).map { match -> + SearchMatch(logIndex, match.range.first, match.range.last, "type") + } + val dateMatches = + regex.findAll(log.formattedReceivedDate).map { match -> + SearchMatch(logIndex, match.range.first, match.range.last, "date") + } + val decodedPayloadMatches = + log.decodedPayload?.let { decoded -> + regex.findAll(decoded).map { match -> + SearchMatch(logIndex, match.range.first, match.range.last, "decodedPayload") + } + } ?: emptySequence() + messageMatches + typeMatches + dateMatches + decodedPayloadMatches + } } - }.sortedBy { it.start } + .sortedBy { it.start } } } @@ -169,23 +170,25 @@ class LogFilterManager { fun filterLogs( logs: List, filterTexts: List, - filterMode: FilterMode - ): List { + filterMode: FilterMode, + ): List { if (filterTexts.isEmpty()) return logs return logs.filter { log -> when (filterMode) { - FilterMode.OR -> filterTexts.any { filterText -> - log.logMessage.contains(filterText, ignoreCase = true) || - log.messageType.contains(filterText, ignoreCase = true) || - log.formattedReceivedDate.contains(filterText, ignoreCase = true) || - (log.decodedPayload?.contains(filterText, ignoreCase = true) == true) - } - FilterMode.AND -> filterTexts.all { filterText -> - log.logMessage.contains(filterText, ignoreCase = true) || - log.messageType.contains(filterText, ignoreCase = true) || - log.formattedReceivedDate.contains(filterText, ignoreCase = true) || - (log.decodedPayload?.contains(filterText, ignoreCase = true) == true) - } + FilterMode.OR -> + filterTexts.any { filterText -> + log.logMessage.contains(filterText, ignoreCase = true) || + log.messageType.contains(filterText, ignoreCase = true) || + log.formattedReceivedDate.contains(filterText, ignoreCase = true) || + (log.decodedPayload?.contains(filterText, ignoreCase = true) == true) + } + FilterMode.AND -> + filterTexts.all { filterText -> + log.logMessage.contains(filterText, ignoreCase = true) || + log.messageType.contains(filterText, ignoreCase = true) || + log.formattedReceivedDate.contains(filterText, ignoreCase = true) || + (log.decodedPayload?.contains(filterText, ignoreCase = true) == true) + } } } } @@ -195,24 +198,38 @@ private const val HEX_FORMAT = "%02x" @Suppress("TooManyFunctions") @HiltViewModel -class DebugViewModel @Inject constructor( +class DebugViewModel +@Inject +constructor( private val meshLogRepository: MeshLogRepository, private val radioConfigRepository: RadioConfigRepository, -) : ViewModel(), Logging { +) : ViewModel(), + Logging { - val meshLog: StateFlow> = meshLogRepository.getAllLogs() - .map(::toUiState) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) + val meshLog: StateFlow> = + meshLogRepository + .getAllLogs() + .map(::toUiState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) // --- Managers --- val searchManager = LogSearchManager() val filterManager = LogFilterManager() - val searchText get() = searchManager.searchText - val currentMatchIndex get() = searchManager.currentMatchIndex - val searchState get() = searchManager.searchState - val filterTexts get() = filterManager.filterTexts - val filteredLogs get() = filterManager.filteredLogs + val searchText + get() = searchManager.searchText + + val currentMatchIndex + get() = searchManager.currentMatchIndex + + val searchState + get() = searchManager.searchState + + val filterTexts + get() = filterManager.filterTexts + + val filteredLogs + get() = filterManager.filteredLogs private val _selectedLogId = MutableStateFlow(null) val selectedLogId = _selectedLogId.asStateFlow() @@ -227,9 +244,10 @@ class DebugViewModel @Inject constructor( viewModelScope.launch { combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> searchManager.findSearchMatches(searchText, logs) - }.collect { matches -> - searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value) } + .collect { matches -> + searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value) + } } } @@ -238,32 +256,28 @@ class DebugViewModel @Inject constructor( debug("DebugViewModel cleared") } - private fun toUiState(databaseLogs: List) = databaseLogs.map { log -> - UiMeshLog( - uuid = log.uuid, - messageType = log.message_type, - formattedReceivedDate = TIME_FORMAT.format(log.received_date), - logMessage = annotateMeshLogMessage(log), - decodedPayload = decodePayloadFromMeshLog(log), - ) - }.toImmutableList() - - /** - * Transform the input [MeshLog] by enhancing the raw message with annotations. - */ - private fun annotateMeshLogMessage(meshLog: MeshLog): String { - return when (meshLog.message_type) { - "Packet" -> meshLog.meshPacket?.let { packet -> - annotatePacketLog(packet) - } ?: meshLog.raw_message - "NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo -> - annotateRawMessage(meshLog.raw_message, nodeInfo.num) - } ?: meshLog.raw_message - "MyNodeInfo" -> meshLog.myNodeInfo?.let { nodeInfo -> - annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) - } ?: meshLog.raw_message - else -> meshLog.raw_message + private fun toUiState(databaseLogs: List) = databaseLogs + .map { log -> + UiMeshLog( + uuid = log.uuid, + messageType = log.message_type, + formattedReceivedDate = TIME_FORMAT.format(log.received_date), + logMessage = annotateMeshLogMessage(log), + decodedPayload = decodePayloadFromMeshLog(log), + ) } + .toImmutableList() + + /** Transform the input [MeshLog] by enhancing the raw message with annotations. */ + private fun annotateMeshLogMessage(meshLog: MeshLog): String = when (meshLog.message_type) { + "Packet" -> meshLog.meshPacket?.let { packet -> annotatePacketLog(packet) } ?: meshLog.raw_message + "NodeInfo" -> + meshLog.nodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.num) } + ?: meshLog.raw_message + "MyNodeInfo" -> + meshLog.myNodeInfo?.let { nodeInfo -> annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum) } + ?: meshLog.raw_message + else -> meshLog.raw_message } private fun annotatePacketLog(packet: MeshProtos.MeshPacket): String { @@ -272,24 +286,21 @@ class DebugViewModel @Inject constructor( val decoded = if (hasDecoded) builder.decoded else null if (hasDecoded) builder.clearDecoded() val baseText = builder.build().toString().trimEnd() - val result = if (hasDecoded && decoded != null) { - val decodedText = decoded.toString().trimEnd().prependIndent(" ") - "$baseText\ndecoded {\n$decodedText\n}" - } else { - baseText - } + val result = + if (hasDecoded && decoded != null) { + val decodedText = decoded.toString().trimEnd().prependIndent(" ") + "$baseText\ndecoded {\n$decodedText\n}" + } else { + baseText + } return annotateRawMessage(result, packet.from, packet.to) } - /** - * Annotate the raw message string with the node IDs provided, in hex, if they are present. - */ + /** Annotate the raw message string with the node IDs provided, in hex, if they are present. */ private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String { val msg = StringBuilder(rawMessage) var mutated = false - nodeIds.forEach { nodeId -> - mutated = mutated or msg.annotateNodeId(nodeId) - } + nodeIds.toSet().forEach { nodeId -> mutated = mutated or msg.annotateNodeId(nodeId) } return if (mutated) { return msg.toString() } else { @@ -297,26 +308,26 @@ class DebugViewModel @Inject constructor( } } - /** - * Look for a single node ID integer in the string and annotate it with the hex equivalent - * if found. - */ + /** Look for a single node ID integer in the string and annotate it with the hex equivalent if found. */ private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean { val nodeIdStr = nodeId.toUInt().toString() - indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx -> - insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})") + // Only match if whitespace before and after + val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""") + regex.find(this)?.let { matchResult -> + matchResult.groupValues.let { _ -> + regex.findAll(this).toList().asReversed().forEach { match -> + val idx = match.range.last + 1 + insert(idx, " (${nodeId.asNodeId()})") + } + } return true } return false } - private fun Int.asNodeId(): String { - return "!%08x".format(Locale.getDefault(), this) - } + private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this) - fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { - meshLogRepository.deleteAll() - } + fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() } @Immutable data class UiMeshLog( @@ -345,19 +356,21 @@ class DebugViewModel @Inject constructor( addAll(PortNum.entries.map { it.name }) } - fun setSelectedLogId(id: String?) { _selectedLogId.value = id } + fun setSelectedLogId(id: String?) { + _selectedLogId.value = id + } /** - * Attempts to fully decode the payload of a MeshLog's MeshPacket using the appropriate protobuf definition, - * based on the portnum of the packet. + * Attempts to fully decode the payload of a MeshLog's MeshPacket using the appropriate protobuf definition, based + * on the portnum of the packet. * - * For known portnums, the payload is parsed into its corresponding proto message and returned as a string. - * For text and alert messages, the payload is interpreted as UTF-8 text. - * For unknown portnums, the payload is shown as a hex string. + * For known portnums, the payload is parsed into its corresponding proto message and returned as a string. For text + * and alert messages, the payload is interpreted as UTF-8 text. For unknown portnums, the payload is shown as a hex + * string. * * @param log The MeshLog containing the packet and payload to decode. - * @return A human-readable string representation of the decoded payload, or an error message if decoding fails, - * or null if the log does not contain a decodable packet. + * @return A human-readable string representation of the decoded payload, or an error message if decoding fails, or + * null if the log does not contain a decodable packet. */ private fun decodePayloadFromMeshLog(log: MeshLog): String? { var result: String? = null @@ -367,32 +380,26 @@ class DebugViewModel @Inject constructor( } else { val portnum = packet.decoded.portnumValue val payload = packet.decoded.payload.toByteArray() - result = try { - when (portnum) { - PortNum.TEXT_MESSAGE_APP_VALUE, - PortNum.ALERT_APP_VALUE -> - payload.toString(Charsets.UTF_8) - PortNum.POSITION_APP_VALUE -> - MeshProtos.Position.parseFrom(payload).toString() - PortNum.WAYPOINT_APP_VALUE -> - MeshProtos.Waypoint.parseFrom(payload).toString() - PortNum.NODEINFO_APP_VALUE -> - MeshProtos.User.parseFrom(payload).toString() - PortNum.TELEMETRY_APP_VALUE -> - TelemetryProtos.Telemetry.parseFrom(payload).toString() - PortNum.ROUTING_APP_VALUE -> - MeshProtos.Routing.parseFrom(payload).toString() - PortNum.ADMIN_APP_VALUE -> - AdminProtos.AdminMessage.parseFrom(payload).toString() - PortNum.PAXCOUNTER_APP_VALUE -> - PaxcountProtos.Paxcount.parseFrom(payload).toString() - PortNum.STORE_FORWARD_APP_VALUE -> - StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString() - else -> payload.joinToString(" ") { HEX_FORMAT.format(it) } + result = + try { + when (portnum) { + PortNum.TEXT_MESSAGE_APP_VALUE, + PortNum.ALERT_APP_VALUE, + -> payload.toString(Charsets.UTF_8) + PortNum.POSITION_APP_VALUE -> MeshProtos.Position.parseFrom(payload).toString() + PortNum.WAYPOINT_APP_VALUE -> MeshProtos.Waypoint.parseFrom(payload).toString() + PortNum.NODEINFO_APP_VALUE -> MeshProtos.User.parseFrom(payload).toString() + PortNum.TELEMETRY_APP_VALUE -> TelemetryProtos.Telemetry.parseFrom(payload).toString() + PortNum.ROUTING_APP_VALUE -> MeshProtos.Routing.parseFrom(payload).toString() + PortNum.ADMIN_APP_VALUE -> AdminProtos.AdminMessage.parseFrom(payload).toString() + PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString() + PortNum.STORE_FORWARD_APP_VALUE -> + StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString() + else -> payload.joinToString(" ") { HEX_FORMAT.format(it) } + } + } catch (e: InvalidProtocolBufferException) { + "Failed to decode payload: ${e.message}" } - } catch (e: InvalidProtocolBufferException) { - "Failed to decode payload: ${e.message}" - } } return result }