Fix/#2701 NodeId annotation in debug panel (#2709)

This commit is contained in:
DaneEvans
2025-08-14 22:19:14 +10:00
committed by GitHub
parent 7ec0d2c1a0
commit 380f41dc5c

View File

@@ -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<SearchMatch> = 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<SearchMatch> = 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<DebugViewModel.UiMeshLog>) {
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<DebugViewModel.UiMeshLog>): List<SearchMatch> {
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<DebugViewModel.UiMeshLog>,
filterTexts: List<String>,
filterMode: FilterMode
): List<DebugViewModel.UiMeshLog> {
filterMode: FilterMode,
): List<DebugViewModel.UiMeshLog> {
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<ImmutableList<UiMeshLog>> = meshLogRepository.getAllLogs()
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf())
val meshLog: StateFlow<ImmutableList<UiMeshLog>> =
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<String?>(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<MeshLog>) = 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<MeshLog>) = 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
}