Traceroute map position snapshots (#4035)

Signed-off-by: Jord <650645+DivineOmega@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jord 2025-12-18 14:14:03 +00:00 committed by GitHub
parent 03fd2bf9ba
commit 9833795864
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1195 additions and 44 deletions

View file

@ -132,6 +132,7 @@ import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.feature.map.model.MarkerWithLabel
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.MeshProtos.Position
import org.meshtastic.proto.MeshProtos.Waypoint
import org.meshtastic.proto.copy
import org.meshtastic.proto.waypoint
@ -231,6 +232,7 @@ fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
@ -334,14 +336,17 @@ fun MapView(
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } }
val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() }
val nodesForMarkers =
if (tracerouteOverlay != null) {
nodes.filter { overlayNodeNums.contains(it.num) }
} else {
nodes
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val overlayNodeNums = tracerouteSelection.overlayNodeNums
val nodeLookup = tracerouteSelection.nodeLookup
val nodesForMarkers = tracerouteSelection.nodesForMarkers
val tracerouteForwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {

View file

@ -144,6 +144,7 @@ fun MapView(
focusedNodeNum: Int? = null,
nodeTracks: List<Position>? = null,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
@ -262,7 +263,14 @@ fun MapView(
.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() }
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, allNodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = allNodes,
)
}
val filteredNodes =
allNodes
@ -275,7 +283,7 @@ fun MapView(
val displayNodes =
if (tracerouteOverlay != null) {
allNodes.filter { overlayNodeNums.contains(it.num) }
tracerouteSelection.nodesForMarkers
} else {
filteredNodes
}

View file

@ -42,6 +42,7 @@ import org.meshtastic.core.strings.one_day
import org.meshtastic.core.strings.one_hour
import org.meshtastic.core.strings.two_days
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.MeshProtos
import timber.log.Timber
import java.util.concurrent.TimeUnit
@ -68,7 +69,7 @@ sealed class LastHeardFilter(val seconds: Long, val label: StringResource) {
@Suppress("TooManyFunctions")
abstract class BaseMapViewModel(
protected val mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
private val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val serviceRepository: ServiceRepository,
) : ViewModel() {
@ -118,6 +119,12 @@ abstract class BaseMapViewModel(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
fun getNodeByNum(nodeNum: Int): Node? = nodeRepository.nodeDBbyNum.value[nodeNum]
fun getUser(nodeNum: Int): MeshProtos.User = nodeRepository.getUser(nodeNum)
fun getNodeOrFallback(nodeNum: Int): Node = getNodeByNum(nodeNum) ?: Node(num = nodeNum, user = getUser(nodeNum))
val isConnected =
serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
@ -196,3 +203,47 @@ abstract class BaseMapViewModel(
),
)
}
data class TracerouteNodeSelection(
val overlayNodeNums: Set<Int>,
val nodesForMarkers: List<Node>,
val nodeLookup: Map<Int, Node>,
)
fun BaseMapViewModel.tracerouteNodeSelection(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, MeshProtos.Position>,
nodes: List<Node>,
): TracerouteNodeSelection {
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
val tracerouteSnapshotNodes =
if (tracerouteOverlay == null || tracerouteNodePositions.isEmpty()) {
emptyList()
} else {
tracerouteNodePositions.map { (nodeNum, position) -> getNodeOrFallback(nodeNum).copy(position = position) }
}
val nodesForMarkers =
if (tracerouteOverlay != null) {
if (tracerouteSnapshotNodes.isNotEmpty()) {
tracerouteSnapshotNodes.filter { overlayNodeNums.contains(it.num) }
} else {
nodes.filter { overlayNodeNums.contains(it.num) }
}
} else {
nodes
}
val nodesForLookup =
if (tracerouteSnapshotNodes.isNotEmpty()) {
tracerouteSnapshotNodes
} else {
nodes.filter { it.validPosition != null }
}
return TracerouteNodeSelection(
overlayNodeNums = overlayNodeNums,
nodesForMarkers = nodesForMarkers,
nodeLookup = nodesForLookup.associateBy { it.num },
)
}

View file

@ -43,6 +43,7 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
@ -87,6 +88,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
) : ViewModel() {
@ -146,6 +148,8 @@ constructor(
return overlay
}
fun tracerouteSnapshotPositions(logUuid: String) = tracerouteSnapshotRepository.getSnapshotPositions(logUuid)
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =

View file

@ -64,6 +64,7 @@ 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.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.toMessageRes
@ -89,7 +90,12 @@ 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?)
private data class TracerouteDialog(
val message: AnnotatedString,
val requestId: Int,
val responseLogUuid: String,
val overlay: TracerouteOverlay?,
)
@OptIn(ExperimentalFoundationApi::class)
@Suppress("LongMethod")
@ -98,7 +104,7 @@ fun TracerouteLogScreen(
modifier: Modifier = Modifier,
viewModel: MetricsViewModel = hiltViewModel(),
onNavigateUp: () -> Unit,
onViewOnMap: (requestId: Int) -> Unit = {},
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> },
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
@ -185,10 +191,12 @@ fun TracerouteLogScreen(
AnnotatedString(it)
}
dialogMessage?.let {
val responseLogUuid = result?.uuid ?: return@combinedClickable
showDialog =
TracerouteDialog(
message = it,
requestId = log.fromRadio.packet.id,
responseLogUuid = responseLogUuid,
overlay = overlay,
)
}
@ -211,23 +219,34 @@ private fun TracerouteLogDialogs(
dialog: TracerouteDialog?,
errorMessageRes: StringResource?,
viewModel: MetricsViewModel,
onViewOnMap: (requestId: Int) -> Unit,
onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit,
onShowErrorMessageRes: (StringResource) -> Unit,
onDismissDialog: () -> Unit,
onDismissError: () -> Unit,
) {
dialog?.let { dialogState ->
val snapshotPositionsFlow =
remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) }
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, MeshProtos.Position>())
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = dialogState.message) } },
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val positionedNodeNums =
if (snapshotPositions.isNotEmpty()) {
snapshotPositions.keys
} else {
viewModel.positionedNodeNums()
}
val availability =
viewModel.tracerouteMapAvailability(
evaluateTracerouteMapAvailability(
forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(),
returnRoute = dialogState.overlay?.returnRoute.orEmpty(),
positionedNodeNums = positionedNodeNums,
)
availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId)
availability.toMessageRes()?.let(onShowErrorMessageRes)
?: onViewOnMap(dialogState.requestId, dialogState.responseLogUuid)
onDismissDialog()
},
onDismiss = onDismissDialog,

View file

@ -43,6 +43,7 @@ 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 kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.strings.Res
@ -54,37 +55,59 @@ 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
import org.meshtastic.proto.MeshProtos
@Composable
fun TracerouteMapScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
requestId: Int,
logUuid: String? = null,
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 snapshotPositions by
remember(logUuid) {
logUuid?.let(metricsViewModel::tracerouteSnapshotPositions)
?: flowOf(emptyMap<Int, MeshProtos.Position>())
}
.collectAsStateWithLifecycle(emptyMap<Int, MeshProtos.Position>())
val tracerouteResult =
if (logUuid != null) {
state.tracerouteResults.find { it.uuid == logUuid }
} else {
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == requestId }
}
val routeDiscovery = tracerouteResult?.fromRadio?.packet?.fullRouteDiscovery
val overlayFromLogs =
remember(routeDiscovery) {
routeDiscovery?.let {
TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList)
}
remember(routeDiscovery, requestId) {
routeDiscovery?.let { TracerouteOverlay(requestId, it.routeList, 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() }
TracerouteMapScaffold(
title = state.node?.user?.longName ?: stringResource(Res.string.traceroute),
overlay = overlay,
snapshotPositions = snapshotPositions,
onNavigateUp = onNavigateUp,
)
}
@Composable
private fun TracerouteMapScaffold(
title: String,
overlay: TracerouteOverlay?,
snapshotPositions: Map<Int, MeshProtos.Position>,
onNavigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
Scaffold(
topBar = {
MainAppBar(
title = nodeTitle,
title = title,
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -94,10 +117,11 @@ fun TracerouteMapScreen(
)
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
MapView(
navigateToNodeDetails = {},
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
onTracerouteMappableCountChanged = { shown, total ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total