feat: Traceroute map visualisation (#4002)

This commit is contained in:
Jord 2025-12-16 16:53:28 +00:00 committed by GitHub
parent 24f40b2005
commit 3dbc5108c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 917 additions and 60 deletions

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.core.strings)
implementation(projects.core.ui)
implementation(projects.core.navigation)
implementation(projects.feature.map)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material.iconsExtended)

View file

@ -0,0 +1,28 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomEnd
val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End
}

View file

@ -0,0 +1,28 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
internal object TracerouteMapOverlayInsets {
val overlayAlignment: Alignment = Alignment.BottomCenter
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp)
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally
}

View file

@ -47,12 +47,15 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.MeshProtos
@ -92,6 +95,8 @@ constructor(
private var jobs: Job? = null
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
@ -118,6 +123,60 @@ constructor(
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
val cached = tracerouteOverlayCache.value[requestId]
if (cached != null) return cached
val overlay =
serviceRepository.tracerouteResponse.value
?.takeIf { it.requestId == requestId }
?.let { response ->
TracerouteOverlay(
requestId = response.requestId,
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
}
?.takeIf { it.hasRoutes }
if (overlay != null) {
tracerouteOverlayCache.update { it + (requestId to overlay) }
}
return overlay
}
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums = positionedNodeNums(),
)
fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability =
tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute)
fun positionedNodeNums(): Set<Int> =
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
init {
viewModelScope.launch {
serviceRepository.tracerouteResponse.filterNotNull().collect { response ->
val overlay =
TracerouteOverlay(
requestId = response.requestId,
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
if (overlay.hasRoutes) {
tracerouteOverlayCache.update { it + (response.requestId to overlay) }
}
}
}
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
}

View file

@ -20,6 +20,7 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -51,7 +52,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -61,17 +61,21 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.toMessageRes
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
import org.meshtastic.core.strings.traceroute_direct
import org.meshtastic.core.strings.traceroute_hops
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
@ -80,10 +84,13 @@ import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshProtos
import java.text.DateFormat
private data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?)
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@Composable
@ -91,22 +98,25 @@ fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
onViewOnMap: (requestId: Int) -> Unit = {},
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
if (showDialog != null) {
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = message) } },
onDismiss = { showDialog = null },
)
}
TracerouteLogDialogs(
dialog = showDialog,
errorMessageRes = errorMessageRes,
viewModel = viewModel,
onViewOnMap = onViewOnMap,
onShowErrorMessageRes = { errorMessageRes = it },
onDismissDialog = { showDialog = null },
onDismissError = { errorMessageRes = null },
)
Scaffold(
topBar = {
@ -154,6 +164,14 @@ fun TracerouteLogScreen(
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
}
}
val overlay =
route?.let {
TracerouteOverlay(
requestId = log.fromRadio.packet.id,
forwardRoute = it.routeList,
returnRoute = it.routeBackList,
)
}
Box {
TracerouteItem(
@ -161,14 +179,18 @@ fun TracerouteLogScreen(
text = "$time - $text",
modifier =
Modifier.combinedClickable(onLongClick = { expanded = true }) {
if (tracerouteDetailsAnnotated != null) {
showDialog = tracerouteDetailsAnnotated
} else if (result != null) {
// Fallback for results that couldn't be fully annotated but have basic info
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
if (basicInfo != null) {
showDialog = AnnotatedString(basicInfo)
}
val dialogMessage =
tracerouteDetailsAnnotated
?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let {
AnnotatedString(it)
}
dialogMessage?.let {
showDialog =
TracerouteDialog(
message = it,
requestId = log.fromRadio.packet.id,
overlay = overlay,
)
}
},
)
@ -184,6 +206,44 @@ fun TracerouteLogScreen(
}
}
@Composable
private fun TracerouteLogDialogs(
dialog: TracerouteDialog?,
errorMessageRes: StringResource?,
viewModel: MetricsViewModel,
onViewOnMap: (requestId: Int) -> Unit,
onShowErrorMessageRes: (StringResource) -> Unit,
onDismissDialog: () -> Unit,
onDismissError: () -> Unit,
) {
dialog?.let { dialogState ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = dialogState.message) } },
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val availability =
viewModel.tracerouteMapAvailability(
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
)
availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId)
onDismissDialog()
},
onDismiss = onDismissDialog,
)
}
errorMessageRes?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = onDismissError,
)
}
}
@Composable
private fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
@ -205,13 +265,12 @@ private fun DeleteItem(onClick: () -> Unit) {
@Composable
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
}
}

View file

@ -0,0 +1,158 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Route
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_outgoing_route
import org.meshtastic.core.strings.traceroute_return_route
import org.meshtastic.core.strings.traceroute_showing_nodes
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.feature.map.MapView
import org.meshtastic.feature.map.model.TracerouteOverlay
@Composable
fun TracerouteMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
requestId: Int,
onNavigateUp: () -> Unit,
) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val nodeTitle = state.node?.user?.longName ?: stringResource(Res.string.traceroute)
val routeDiscovery =
state.tracerouteResults
.find { it.fromRadio.packet.decoded.requestId == requestId }
?.fromRadio
?.packet
?.fullRouteDiscovery
val overlayFromLogs =
remember(routeDiscovery) {
routeDiscovery?.let {
TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList)
}
}
val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) }
val overlay = overlayFromLogs ?: overlayFromService
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() }
Scaffold(
topBar = {
MainAppBar(
title = nodeTitle,
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MapView(
navigateToNodeDetails = {},
tracerouteOverlay = overlay,
onTracerouteMappableCountChanged = { shown, total ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
)
Column(
modifier =
Modifier.align(TracerouteMapOverlayInsets.overlayAlignment)
.padding(TracerouteMapOverlayInsets.overlayPadding),
horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)
TracerouteLegend()
}
}
}
}
@Composable
private fun TracerouteLegend(modifier: Modifier = Modifier) {
Card(modifier = modifier) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
LegendRow(
color = TracerouteColors.OutgoingRoute,
label = stringResource(Res.string.traceroute_outgoing_route),
)
LegendRow(color = TracerouteColors.ReturnRoute, label = stringResource(Res.string.traceroute_return_route))
}
}
}
@Composable
private fun TracerouteNodeCount(modifier: Modifier = Modifier, shown: Int, total: Int) {
Card(modifier = modifier) {
Text(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
text = stringResource(Res.string.traceroute_showing_nodes, shown, total),
style = MaterialTheme.typography.labelMedium,
)
}
}
@Composable
private fun LegendRow(color: Color, label: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Route,
contentDescription = null,
tint = color,
modifier = Modifier.padding(end = 8.dp).size(18.dp),
)
Text(text = label, style = MaterialTheme.typography.labelMedium)
}
}