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
18 changed files with 917 additions and 60 deletions

View File

@@ -52,10 +52,13 @@ import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
@@ -152,6 +155,14 @@ constructor(
private val _currentAlert: MutableStateFlow<AlertData?> = MutableStateFlow(null)
val currentAlert = _currentAlert.asStateFlow()
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
evaluateTracerouteMapAvailability(
forwardRoute = forwardRoute,
returnRoute = returnRoute,
positionedNodeNums =
nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet(),
)
fun showAlert(
title: String,
message: String? = null,
@@ -248,7 +259,7 @@ constructor(
Timber.d("ViewModel cleared")
}
val tracerouteResponse: LiveData<String?>
val tracerouteResponse: LiveData<TracerouteResponse?>
get() = serviceRepository.tracerouteResponse.asLiveData()
fun clearTracerouteResponse() {

View File

@@ -67,6 +67,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
@@ -121,6 +122,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
NodeMapScreen(vm, onNavigateUp = navController::navigateUp)
}
composable<NodeDetailRoutes.TracerouteLog>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteLog>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute",
),
navDeepLink<NodeDetailRoutes.TracerouteLog>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteLog>()
metricsViewModel.setNodeId(args.destNum)
TracerouteLogScreen(
viewModel = metricsViewModel,
onNavigateUp = navController::navigateUp,
onViewOnMap = { requestId ->
navController.navigate(NodeDetailRoutes.TracerouteMap(args.destNum, requestId))
},
)
}
composable<NodeDetailRoutes.TracerouteMap>(
deepLinks =
listOf(
navDeepLink<NodeDetailRoutes.TracerouteMap>(
basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map",
),
navDeepLink<NodeDetailRoutes.TracerouteMap>(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"),
),
) { backStackEntry ->
val parentGraphBackStackEntry =
remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) }
val metricsViewModel = hiltViewModel<MetricsViewModel>(parentGraphBackStackEntry)
val args = backStackEntry.toRoute<NodeDetailRoutes.TracerouteMap>()
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
onNavigateUp = navController::navigateUp,
)
}
NodeDetailRoute.entries.forEach { entry ->
when (entry.routeClass) {
NodeDetailRoutes.DeviceMetrics::class ->
@@ -163,14 +212,6 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
) {
it.destNum
}
NodeDetailRoutes.TracerouteLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.TracerouteLog>(
navController,
entry,
entry.screenComposable,
) {
it.destNum
}
NodeDetailRoutes.HostMetricsLog::class ->
addNodeDetailScreenComposable<NodeDetailRoutes.HostMetricsLog>(
navController,

View File

@@ -80,6 +80,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.toOneLineString
@@ -92,6 +93,7 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected_count
import org.meshtastic.core.strings.connecting
@@ -927,11 +929,12 @@ class MeshService : Service() {
Portnums.PortNum.TRACEROUTE_APP_VALUE -> {
Timber.d("Received TRACEROUTE_APP from $fromId")
val routeDiscovery = packet.fullRouteDiscovery
val full = packet.getFullTracerouteResponse(::getUserName)
if (full != null) {
val requestId = packet.decoded.requestId
val start = tracerouteStartTimes.remove(requestId)
val response =
val responseText =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / 1000.0
@@ -940,7 +943,19 @@ class MeshService : Service() {
} else {
full
}
serviceRepository.setTracerouteResponse(response)
val destination =
routeDiscovery?.routeList?.firstOrNull()
?: routeDiscovery?.routeBackList?.lastOrNull()
?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.routeList.orEmpty(),
returnRoute = routeDiscovery?.routeBackList.orEmpty(),
),
)
}
}

View File

@@ -106,9 +106,11 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.toMessageRes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@@ -117,6 +119,7 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.app_too_old
import org.meshtastic.core.strings.bottom_nav_settings
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
@@ -133,6 +136,7 @@ import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SimpleAlertDialog
@@ -239,16 +243,49 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState()
traceRouteResponse?.let { response ->
var tracerouteMapError by remember { mutableStateOf<StringResource?>(null) }
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
traceRouteResponse
?.takeIf { it.requestId != dismissedTracerouteRequestId }
?.let { response ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response.message))
}
},
confirmText = stringResource(Res.string.view_on_map),
onConfirm = {
val availability =
uIViewModel.tracerouteMapAvailability(
forwardRoute = response.forwardRoute,
returnRoute = response.returnRoute,
)
val errorRes = availability.toMessageRes()
if (errorRes == null) {
dismissedTracerouteRequestId = response.requestId
navController.navigate(
NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId),
)
} else {
tracerouteMapError = errorRes
uIViewModel.clearTracerouteResponse()
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = {
uIViewModel.clearTracerouteResponse()
dismissedTracerouteRequestId = null
},
)
}
tracerouteMapError?.let { res ->
SimpleAlertDialog(
title = Res.string.traceroute,
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(text = annotateTraceroute(response))
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = { uIViewModel.clearTracerouteResponse() },
text = { Text(text = stringResource(res)) },
dismissText = stringResource(Res.string.close),
onDismiss = { tracerouteMapError = null },
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())

View File

@@ -17,6 +17,10 @@
package org.meshtastic.core.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.traceroute_endpoint_missing
import org.meshtastic.core.strings.traceroute_map_no_data
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.RouteDiscovery
import org.meshtastic.proto.Portnums
@@ -28,11 +32,13 @@ val MeshProtos.MeshPacket.fullRouteDiscovery: RouteDiscovery?
runCatching { RouteDiscovery.parseFrom(payload).toBuilder() }
.getOrNull()
?.apply {
val fullRoute = listOf(to) + routeList + from
val destinationId = dest.takeIf { it != 0 } ?: this@fullRouteDiscovery.to
val sourceId = source.takeIf { it != 0 } ?: this@fullRouteDiscovery.from
val fullRoute = listOf(destinationId) + routeList + sourceId
clearRoute()
addAllRoute(fullRoute)
val fullRouteBack = listOf(from) + routeBackList + to
val fullRouteBack = listOf(sourceId) + routeBackList + destinationId
clearRouteBack()
if (hopStart > 0 && snrBackCount > 0) { // otherwise back route is invalid
addAllRouteBack(fullRouteBack)
@@ -85,3 +91,35 @@ fun MeshProtos.MeshPacket.getTracerouteResponse(getUser: (nodeNum: Int) -> Strin
fun MeshProtos.MeshPacket.getFullTracerouteResponse(getUser: (nodeNum: Int) -> String): String? = fullRouteDiscovery
?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() }
?.getTracerouteResponse(getUser)
enum class TracerouteMapAvailability {
Ok,
MissingEndpoints,
NoMappableNodes,
}
fun evaluateTracerouteMapAvailability(
forwardRoute: List<Int>,
returnRoute: List<Int>,
positionedNodeNums: Set<Int>,
): TracerouteMapAvailability {
val endpoints =
listOfNotNull(
forwardRoute.firstOrNull(),
forwardRoute.lastOrNull(),
returnRoute.firstOrNull(),
returnRoute.lastOrNull(),
)
.distinct()
val missingEndpoint = endpoints.any { !positionedNodeNums.contains(it) }
if (missingEndpoint) return TracerouteMapAvailability.MissingEndpoints
val relatedNodeNums = (forwardRoute + returnRoute).toSet()
val hasAnyMappable = relatedNodeNums.any { positionedNodeNums.contains(it) }
return if (hasAnyMappable) TracerouteMapAvailability.Ok else TracerouteMapAvailability.NoMappableNodes
}
fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) {
TracerouteMapAvailability.Ok -> null
TracerouteMapAvailability.MissingEndpoints -> Res.string.traceroute_endpoint_missing
TracerouteMapAvailability.NoMappableNodes -> Res.string.traceroute_map_no_data
}

View File

@@ -78,6 +78,8 @@ object NodeDetailRoutes {
@Serializable data class TracerouteLog(val destNum: Int) : Route
@Serializable data class TracerouteMap(val destNum: Int, val requestId: Int) : Route
@Serializable data class HostMetricsLog(val destNum: Int) : Route
@Serializable data class PaxMetrics(val destNum: Int) : Route

View File

@@ -29,6 +29,17 @@ import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
data class TracerouteResponse(
val message: String,
val destinationNodeNum: Int,
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
val hasOverlay: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
@@ -94,11 +105,11 @@ class ServiceRepository @Inject constructor() {
_meshPacketFlow.emit(packet)
}
private val _tracerouteResponse = MutableStateFlow<String?>(null)
val tracerouteResponse: StateFlow<String?>
private val _tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
val tracerouteResponse: StateFlow<TracerouteResponse?>
get() = _tracerouteResponse
fun setTracerouteResponse(value: String?) {
fun setTracerouteResponse(value: TracerouteResponse?) {
_tracerouteResponse.value = value
}

View File

@@ -405,6 +405,12 @@
<item quantity="other">%1$d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %1$d Hops back %2$d</string>
<string name="traceroute_outgoing_route">Outgoing route</string>
<string name="traceroute_return_route">Return route</string>
<string name="traceroute_endpoint_missing">Cannot show traceroute map because the start or destination node has no position information.</string>
<string name="view_on_map">View on map</string>
<string name="traceroute_map_no_data">This traceroute does not have any mappable nodes yet.</string>
<string name="traceroute_showing_nodes">Showing %1$d/%2$d nodes</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
<string name="one_week">1W</string>
@@ -1030,8 +1036,8 @@
<item quantity="one">1 hour</item>
<item quantity="other">%1$d hours</item>
</plurals>
<!-- Compass -->
<!-- Compass -->
<string name="compass_title">Compass</string>
<string name="open_compass">Open Compass</string>
<string name="compass_distance">Distance: %1$s</string>

View File

@@ -27,6 +27,13 @@ val MeshtasticAlt = Color(0xFF2C2D3C)
val HyperlinkBlue = Color(0xFF43C3B0)
val AnnotationColor = Color(0xFF039BE5)
object TracerouteColors {
// High-contrast pair that stays legible on light/dark tiles and for most color-blind users.
// Use partial alpha so polylines dont overpower markers/tiles.
val OutgoingRoute = Color(0xCCE86A00) // orange @ ~80% opacity
val ReturnRoute = Color(0xCC0081C7) // cyan @ ~80% opacity
}
object IAQColors {
val IAQExcellent = Color(0xFF00E400)
val IAQGood = Color(0xFF92D050)

View File

@@ -18,6 +18,7 @@
package org.meshtastic.feature.map
import android.Manifest // Added for Accompanist
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -66,6 +67,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -120,6 +122,7 @@ import org.meshtastic.core.strings.waypoint_delete
import org.meshtastic.core.strings.you
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer
import org.meshtastic.feature.map.component.CacheLayout
@@ -128,6 +131,7 @@ import org.meshtastic.feature.map.component.EditWaypointDialog
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.Waypoint
import org.meshtastic.proto.copy
import org.meshtastic.proto.waypoint
@@ -148,14 +152,19 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import timber.log.Timber
import java.io.File
import java.text.DateFormat
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
@Composable
private fun MapView.UpdateMarkers(
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
nodeClusterer: RadiusMarkerClusterer,
@@ -218,7 +227,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
tracerouteOverlay: TracerouteOverlay? = null,
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
@@ -320,6 +334,59 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
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 tracerouteForwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val tracerouteReturnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
if (tracerouteOverlay != null) {
onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
}
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
when {
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
else -> emptyList()
}
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteForwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = 1.0,
)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteReturnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = -1.0,
)
}
val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
val markerIcon = remember {
AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24)
@@ -331,7 +398,12 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
val displayUnits = mapViewModel.config.display.units
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
if (
mapFilterStateValue.onlyFavorites &&
!node.isFavorite &&
!overlayNodeNums.contains(node.num) &&
!node.equals(ourNode)
) {
return@mapNotNull null
}
@@ -424,7 +496,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
mapViewModel.getUser(id).longName
}
@Composable
@Suppress("MagicNumber")
fun MapView.onWaypointChanged(waypoints: Collection<Packet>): List<MarkerWithLabel> {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
@@ -459,7 +530,10 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)"
snippet = "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr"
snippet =
"[$time] ${pt.description} " +
com.meshtastic.core.strings.getString(Res.string.expires) +
": $expireTimeStr"
position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7)
setVisible(false) // This seems to be always false, was this intended?
setOnLongClickListener {
@@ -509,7 +583,52 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
invalidate()
}
with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) }
fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
overlays.removeAll(traceroutePolylines)
traceroutePolylines.clear()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
)
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
)
}
overlays.addAll(traceroutePolylines)
invalidate()
}
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
map.controller.setCenter(allPoints.first())
map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
}
hasCenteredTraceroute = true
}
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
@@ -587,7 +706,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails:
}
},
modifier = Modifier.fillMaxSize(),
update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict
update = { mapView ->
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
with(mapView) {
updateMarkers(
onNodesChanged(nodesForMarkers),
onWaypointChanged(waypoints.values),
nodeClusterer,
)
}
mapView.drawOverlays()
}, // Renamed map to mapView to avoid conflict
)
if (downloadRegionBoundingBox != null) {
CacheLayout(
@@ -943,3 +1072,54 @@ private fun MapsDialog(
}
}
}
private const val EARTH_RADIUS_METERS = 6_371_000.0
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private fun Double.toRad(): Double = Math.toRadians(this)
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View File

@@ -74,6 +74,7 @@ import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.maps.android.SphericalUtil
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
@@ -107,6 +108,7 @@ import org.meshtastic.core.strings.speed
import org.meshtastic.core.strings.timestamp
import org.meshtastic.core.strings.track_point
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.feature.map.component.ClusterItemsListDialog
import org.meshtastic.feature.map.component.CustomMapLayersSheet
@@ -116,6 +118,7 @@ import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.NodeClusterMarkers
import org.meshtastic.feature.map.component.WaypointMarkers
import org.meshtastic.feature.map.model.NodeClusterItem
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.MeshProtos.Position
import org.meshtastic.proto.MeshProtos.Waypoint
@@ -123,10 +126,14 @@ import org.meshtastic.proto.copy
import org.meshtastic.proto.waypoint
import timber.log.Timber
import java.text.DateFormat
import kotlin.math.abs
import kotlin.math.max
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
private const val DEG_D = 1e-7
private const val HEADING_DEG = 1e-5
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -136,6 +143,8 @@ fun MapView(
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTracks: List<Position>? = null,
tracerouteOverlay: TracerouteOverlay? = null,
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@@ -253,6 +262,7 @@ 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 filteredNodes =
allNodes
@@ -263,8 +273,20 @@ fun MapView(
node.num == ourNodeInfo?.num
}
val displayNodes =
if (tracerouteOverlay != null) {
allNodes.filter { overlayNodeNums.contains(it.num) }
} else {
filteredNodes
}
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
}
}
val nodeClusterItems =
filteredNodes.map { node ->
displayNodes.map { node ->
val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D)
NodeClusterItem(
node = node,
@@ -287,6 +309,43 @@ fun MapView(
true -> ComposeMapColorScheme.DARK
else -> ComposeMapColorScheme.LIGHT
}
val tracerouteForwardPoints =
remember(tracerouteOverlay, displayNodes) {
val nodeLookup = displayNodes.associateBy { it.num }
tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
}
val tracerouteReturnPoints =
remember(tracerouteOverlay, displayNodes) {
val nodeLookup = displayNodes.associateBy { it.num }
tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
when {
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
else -> emptyList()
}
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteForwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = 1.0,
)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteReturnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = -1.0,
)
}
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
var showLayersBottomSheet by remember { mutableStateOf(false) }
@@ -329,6 +388,26 @@ fun MapView(
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
if (allPoints.isNotEmpty()) {
val cameraUpdate =
if (allPoints.size == 1) {
CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f))
} else {
val bounds = LatLngBounds.builder()
allPoints.forEach { bounds.include(it) }
CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
}
try {
cameraPositionState.animate(cameraUpdate)
hasCenteredTraceroute = true
} catch (e: IllegalStateException) {
Timber.d("Error centering traceroute overlay: ${e.message}")
}
}
}
Scaffold { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
@@ -367,6 +446,25 @@ fun MapView(
}
}
if (tracerouteForwardPoints.size >= 2) {
Polyline(
points = tracerouteForwardOffsetPoints,
jointType = JointType.ROUND,
color = TracerouteColors.OutgoingRoute,
width = 9f,
zIndex = 1.5f,
)
}
if (tracerouteReturnPoints.size >= 2) {
Polyline(
points = tracerouteReturnOffsetPoints,
jointType = JointType.ROUND,
color = TracerouteColors.ReturnRoute,
width = 7f,
zIndex = 1.4f,
)
}
if (nodeTracks != null && focusedNodeNum != null) {
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val timeFilteredPositions =
@@ -449,6 +547,25 @@ fun MapView(
)
}
if (tracerouteForwardPoints.size >= 2) {
Polyline(
points = tracerouteForwardOffsetPoints,
jointType = JointType.ROUND,
color = TracerouteColors.OutgoingRoute,
width = 9f,
zIndex = 2f,
)
}
if (tracerouteReturnPoints.size >= 2) {
Polyline(
points = tracerouteReturnOffsetPoints,
jointType = JointType.ROUND,
color = TracerouteColors.ReturnRoute,
width = 7f,
zIndex = 1.5f,
)
}
WaypointMarkers(
displayableWaypoints = displayableWaypoints,
mapFilterState = mapFilterState,
@@ -696,3 +813,33 @@ internal fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.l
private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D)
private fun offsetPolyline(
points: List<LatLng>,
offsetMeters: Double,
headingReferencePoints: List<LatLng> = points,
sideMultiplier: Double = 1.0,
): List<LatLng> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
SphericalUtil.computeHeading(
headingPoints[headingPoints.lastIndex - 1],
headingPoints[headingPoints.lastIndex],
)
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (90.0 * sideMultiplier)
SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.map.model
data class TracerouteOverlay(
val requestId: Int,
val forwardRoute: List<Int> = emptyList(),
val returnRoute: List<Int> = emptyList(),
) {
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
val hasRoutes: Boolean
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
}

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)
}
}