From 3e09ac5437cf51177b55561566738dc5e04ffad9 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 8 Dec 2025 21:47:13 +0000 Subject: [PATCH 01/21] Add traceroute map view improvements --- .gitignore | 1 + .../java/com/geeksville/mesh/model/UIState.kt | 3 +- .../mesh/navigation/NodesNavigation.kt | 57 ++++++-- .../geeksville/mesh/service/MeshService.kt | 19 ++- .../main/java/com/geeksville/mesh/ui/Main.kt | 14 +- .../org/meshtastic/core/navigation/Routes.kt | 2 + .../core/service/ServiceRepository.kt | 17 ++- .../composeResources/values/strings.xml | 6 +- .../org/meshtastic/feature/map/MapView.kt | 118 ++++++++++++++-- .../org/meshtastic/feature/map/MapView.kt | 86 +++++++++++- .../feature/map/model/TracerouteOverlay.kt | 34 +++++ feature/node/build.gradle.kts | 1 + .../feature/node/metrics/TracerouteLog.kt | 52 ++++--- .../node/metrics/TracerouteMapScreen.kt | 130 ++++++++++++++++++ gradle.properties | 2 +- 15 files changed, 492 insertions(+), 50 deletions(-) create mode 100644 feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt diff --git a/.gitignore b/.gitignore index c3468500e0..d0b5ac12cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.iml .gradle +/.gradle-home /local.properties .DS_Store **/build/** diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index da04eecd49..d344a19da9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -55,6 +55,7 @@ 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 @@ -246,7 +247,7 @@ constructor( Timber.d("ViewModel cleared") } - val tracerouteResponse: LiveData + val tracerouteResponse: LiveData get() = serviceRepository.tracerouteResponse.asLiveData() fun clearTracerouteResponse() { diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index 85e0043ef4..1847d37c22 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -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) { @@ -121,6 +122,54 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo NodeMapScreen(vm, onNavigateUp = navController::navigateUp) } + composable( + deepLinks = + listOf( + navDeepLink( + basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute", + ), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/traceroute"), + ), + ) { backStackEntry -> + val parentGraphBackStackEntry = + remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } + val metricsViewModel = hiltViewModel(parentGraphBackStackEntry) + + val args = backStackEntry.toRoute() + metricsViewModel.setNodeId(args.destNum) + + TracerouteLogScreen( + viewModel = metricsViewModel, + onNavigateUp = navController::navigateUp, + onViewOnMap = { requestId -> + navController.navigate(NodeDetailRoutes.TracerouteMap(args.destNum, requestId)) + }, + ) + } + + composable( + deepLinks = + listOf( + navDeepLink( + basePath = "$DEEP_LINK_BASE_URI/node/{destNum}/traceroute_map", + ), + navDeepLink(basePath = "$DEEP_LINK_BASE_URI/node/traceroute_map"), + ), + ) { backStackEntry -> + val parentGraphBackStackEntry = + remember(backStackEntry) { navController.getBackStackEntry(NodesRoutes.NodeDetailGraph::class) } + val metricsViewModel = hiltViewModel(parentGraphBackStackEntry) + + val args = backStackEntry.toRoute() + 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( - navController, - entry, - entry.screenComposable, - ) { - it.destNum - } NodeDetailRoutes.HostMetricsLog::class -> addNodeDetailScreenComposable( navController, diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 4a23cdf1f2..850a5333e7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -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 @@ -924,11 +926,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 @@ -937,7 +940,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(), + ), + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 41f5023368..ff9d6db0e2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -106,6 +106,7 @@ import org.meshtastic.core.model.DeviceVersion 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 @@ -130,6 +131,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 @@ -241,9 +243,19 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode title = Res.string.traceroute, text = { Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text(text = annotateTraceroute(response)) + Text(text = annotateTraceroute(response.message)) } }, + confirmText = if (response.hasOverlay) stringResource(Res.string.view_on_map) else null, + onConfirm = + response.takeIf { it.hasOverlay }?.let { traceroute -> + { + navController.navigate( + NodeDetailRoutes.TracerouteMap(traceroute.destinationNodeNum, traceroute.requestId), + ) + uIViewModel.clearTracerouteResponse() + } + }, dismissText = stringResource(Res.string.okay), onDismiss = { uIViewModel.clearTracerouteResponse() }, ) diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index cb592d958c..d587e8cdc1 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -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 diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 08864a28c5..fa7162d1ca 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -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 = emptyList(), + val returnRoute: List = 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(null) - val tracerouteResponse: StateFlow + private val _tracerouteResponse = MutableStateFlow(null) + val tracerouteResponse: StateFlow get() = _tracerouteResponse - fun setTracerouteResponse(value: String?) { + fun setTracerouteResponse(value: TracerouteResponse?) { _tracerouteResponse.value = value } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 03d63617ad..c49f577830 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -406,6 +406,10 @@ %1$d hops Hops towards %1$d Hops back %2$d + Outgoing route + Return route + View on map + This traceroute does not have any mappable nodes yet. 24H 48H 1W @@ -1006,4 +1010,4 @@ You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware. Chirpy says, "Keep your ladder handy!" Chirpy - \ No newline at end of file + diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 3c1464d061..d4c8bcf5e2 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -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 @@ -128,6 +130,9 @@ 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.TracerouteOutgoingColor +import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.feature.map.model.TracerouteReturnColor import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint @@ -148,13 +153,13 @@ 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 -@Composable private fun MapView.UpdateMarkers( nodeMarkers: List, waypointMarkers: List, @@ -218,7 +223,11 @@ 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, +) { var mapFilterExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() @@ -320,6 +329,28 @@ 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() + } + val traceroutePolylines = remember { mutableStateListOf() } + 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 +362,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 +460,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: mapViewModel.getUser(id).longName } - @Composable @Suppress("MagicNumber") fun MapView.onWaypointChanged(waypoints: Collection): List { val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) @@ -456,15 +491,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}" } - 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" - position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) - setVisible(false) // This seems to be always false, was this intended? - setOnLongClickListener { - showMarkerLongPressDialog(pt.id) - true + MarkerWithLabel(this, label, emoji).apply { + id = "${pt.id}" + title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" + 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 { + showMarkerLongPressDialog(pt.id) + true } } } @@ -509,7 +546,52 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: invalidate() } - with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } + fun MapView.updateTracerouteOverlay(forwardPoints: List, returnPoints: List) { + overlays.removeAll(traceroutePolylines) + traceroutePolylines.clear() + + fun buildPolyline(points: List, 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, TracerouteOutgoingColor.toArgb(), with(density) { 6.dp.toPx() }), + ) + } + returnPoints + .takeIf { it.size >= 2 } + ?.let { points -> + traceroutePolylines.add( + buildPolyline(points, TracerouteReturnColor.toArgb(), with(density) { 5.dp.toPx() }), + ) + } + overlays.addAll(traceroutePolylines) + invalidate() + } + + LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { + if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints) + if (allPoints.isNotEmpty()) { + if (allPoints.size == 1) { + map.controller.setCenter(allPoints.first()) + map.controller.setZoom(13.0) + } else { + map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints), true) + } + hasCenteredTraceroute = true + } + } fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } @@ -587,7 +669,13 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } }, modifier = Modifier.fillMaxSize(), - update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict + update = { mapView -> + mapView.updateTracerouteOverlay(tracerouteForwardPoints, tracerouteReturnPoints) + with(mapView) { + UpdateMarkers(onNodesChanged(nodesForMarkers), onWaypointChanged(waypoints.values), nodeClusterer) + } + mapView.drawOverlays() + }, // Renamed map to mapView to avoid conflict ) if (downloadRegionBoundingBox != null) { CacheLayout( diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index dbf9b2df78..bad1d1bfd0 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -116,6 +116,9 @@ 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.TracerouteOutgoingColor +import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.feature.map.model.TracerouteReturnColor import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint @@ -123,6 +126,7 @@ import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint import timber.log.Timber import java.text.DateFormat +import kotlin.math.max private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f private const val DEG_D = 1e-7 @@ -136,6 +140,7 @@ fun MapView( navigateToNodeDetails: (Int) -> Unit, focusedNodeNum: Int? = null, nodeTracks: List? = null, + tracerouteOverlay: TracerouteOverlay? = null, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -253,6 +258,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 +269,17 @@ fun MapView( node.num == ourNodeInfo?.num } + val overlayNodes = allNodes.filter { overlayNodeNums.contains(it.num) } + + val displayNodes = + if (tracerouteOverlay != null) { + overlayNodes.ifEmpty { filteredNodes } + } else { + filteredNodes + } + 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 +302,17 @@ 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() + } + var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } var showLayersBottomSheet by remember { mutableStateOf(false) } @@ -329,6 +355,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(), 120) + } + 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 +413,25 @@ fun MapView( } } + if (tracerouteForwardPoints.size >= 2) { + Polyline( + points = tracerouteForwardPoints, + jointType = JointType.ROUND, + color = TracerouteOutgoingColor, + width = 9f, + zIndex = 1.5f, + ) + } + if (tracerouteReturnPoints.size >= 2) { + Polyline( + points = tracerouteReturnPoints, + jointType = JointType.ROUND, + color = TracerouteReturnColor, + width = 7f, + zIndex = 1.4f, + ) + } + if (nodeTracks != null && focusedNodeNum != null) { val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter val timeFilteredPositions = @@ -449,6 +514,25 @@ fun MapView( ) } + if (tracerouteForwardPoints.size >= 2) { + Polyline( + points = tracerouteForwardPoints, + jointType = JointType.ROUND, + color = TracerouteOutgoingColor, + width = 9f, + zIndex = 2f, + ) + } + if (tracerouteReturnPoints.size >= 2) { + Polyline( + points = tracerouteReturnPoints, + jointType = JointType.ROUND, + color = TracerouteReturnColor, + width = 7f, + zIndex = 1.5f, + ) + } + WaypointMarkers( displayableWaypoints = displayableWaypoints, mapFilterState = mapFilterState, diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt new file mode 100644 index 0000000000..17d8fa6113 --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt @@ -0,0 +1,34 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.map.model + +import androidx.compose.ui.graphics.Color + +data class TracerouteOverlay( + val requestId: Int, + val forwardRoute: List = emptyList(), + val returnRoute: List = emptyList(), +) { + val relatedNodeNums: Set = (forwardRoute + returnRoute).toSet() + + val hasRoutes: Boolean + get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() +} + +val TracerouteOutgoingColor = Color(0xFFD32F2F) +val TracerouteReturnColor = Color(0xFF1976D2) diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index ae51361bc7..f9e00c072b 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -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) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 6d7ade487f..4ba63df40d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -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 @@ -72,6 +73,7 @@ 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 @@ -91,19 +93,28 @@ 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(null) } + data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val canShowOnMap: Boolean) + + var showDialog by remember { mutableStateOf(null) } if (showDialog != null) { - val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown + val dialogState = showDialog + val message = dialogState?.message ?: AnnotatedString("") // Should not be null if dialog is shown SimpleAlertDialog( title = Res.string.traceroute, text = { SelectionContainer { Text(text = message) } }, + confirmText = if (dialogState?.canShowOnMap == true) stringResource(Res.string.view_on_map) else null, + onConfirm = { + dialogState?.let { onViewOnMap(it.requestId) } + showDialog = null + }, onDismiss = { showDialog = null }, ) } @@ -154,6 +165,7 @@ fun TracerouteLogScreen( res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) } } } + val canShowOnMap = result != null Box { TracerouteItem( @@ -161,14 +173,17 @@ 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) } + if (dialogMessage != null) { + showDialog = + TracerouteDialog( + message = dialogMessage, + requestId = log.fromRadio.packet.id, + canShowOnMap = canShowOnMap, + ) } }, ) @@ -203,15 +218,18 @@ private fun DeleteItem(onClick: () -> Unit) { } @Composable -private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) { +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) + } } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt new file mode 100644 index 0000000000..10ccae688e --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -0,0 +1,130 @@ +/* + * 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 . + */ + +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.getValue +import androidx.compose.runtime.remember +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.strings.Res +import org.meshtastic.core.strings.traceroute +import org.meshtastic.core.strings.traceroute_map_no_data +import org.meshtastic.core.strings.traceroute_outgoing_route +import org.meshtastic.core.strings.traceroute_return_route +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.feature.map.MapView +import org.meshtastic.feature.map.model.TracerouteOutgoingColor +import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.feature.map.model.TracerouteReturnColor + +@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 overlay = + remember(routeDiscovery) { + routeDiscovery?.let { + TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList) + } + } + + Scaffold( + topBar = { + MainAppBar( + title = nodeTitle, + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) + }, + ) { paddingValues -> + if (overlay?.hasRoutes != true) { + Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.traceroute_map_no_data), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else { + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + MapView(navigateToNodeDetails = {}, tracerouteOverlay = overlay) + TracerouteLegend(modifier = Modifier.align(Alignment.BottomStart).padding(16.dp)) + } + } + } +} + +@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 = TracerouteOutgoingColor, label = stringResource(Res.string.traceroute_outgoing_route)) + LegendRow(color = TracerouteReturnColor, label = stringResource(Res.string.traceroute_return_route)) + } + } +} + +@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) + } +} diff --git a/gradle.properties b/gradle.properties index 92bc26c594..eceeaff380 100644 --- a/gradle.properties +++ b/gradle.properties @@ -60,4 +60,4 @@ org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true dependency.analysis.print.build.health=true ksp.incremental=true -ksp.incremental.classpath=true \ No newline at end of file +ksp.incremental.classpath=true From 14ce83af5cd8607f3404165b4506f51d105f54a6 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 05:06:37 +0000 Subject: [PATCH 02/21] Filter traceroute map to overlay nodes on Google map --- .../src/google/kotlin/org/meshtastic/feature/map/MapView.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index bad1d1bfd0..659168dfbf 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -269,11 +269,9 @@ fun MapView( node.num == ourNodeInfo?.num } - val overlayNodes = allNodes.filter { overlayNodeNums.contains(it.num) } - val displayNodes = if (tracerouteOverlay != null) { - overlayNodes.ifEmpty { filteredNodes } + allNodes.filter { overlayNodeNums.contains(it.num) } } else { filteredNodes } From 95d6f4b0d42a22fa5cd2cc509920f8f626065614 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 05:18:55 +0000 Subject: [PATCH 03/21] Use traceroute response overlay when map log data missing --- .../feature/node/metrics/MetricsViewModel.kt | 11 +++++++++++ .../feature/node/metrics/TracerouteMapScreen.kt | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 004637957b..ed8f541995 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -55,6 +55,7 @@ import org.meshtastic.core.strings.fallback_node_name import org.meshtastic.core.ui.util.toPosition import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.TimeFrame +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos.MeshPacket import org.meshtastic.proto.Portnums @@ -118,6 +119,16 @@ constructor( fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { + val response = serviceRepository.tracerouteResponse.value ?: return null + if (response.requestId != requestId) return null + return TracerouteOverlay( + requestId = response.requestId, + forwardRoute = response.forwardRoute, + returnRoute = response.returnRoute, + ).takeIf { it.hasRoutes } + } + fun clearPosition() = viewModelScope.launch(dispatchers.io) { destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 10ccae688e..49398452c5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -67,12 +67,14 @@ fun TracerouteMapScreen( ?.fromRadio ?.packet ?.fullRouteDiscovery - val overlay = + 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 Scaffold( topBar = { From ed45b1738279816423a891a4fe6315cd958398ab Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 06:17:23 +0000 Subject: [PATCH 04/21] Preserve traceroute overlay when opening map from popup --- app/src/main/java/com/geeksville/mesh/ui/Main.kt | 1 - .../org/meshtastic/feature/node/metrics/MetricsViewModel.kt | 2 ++ .../meshtastic/feature/node/metrics/TracerouteMapScreen.kt | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 3eaa4648ad..2df448bf7c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -256,7 +256,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode navController.navigate( NodeDetailRoutes.TracerouteMap(traceroute.destinationNodeNum, traceroute.requestId), ) - uIViewModel.clearTracerouteResponse() } }, dismissText = stringResource(Res.string.okay), diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index ed8f541995..009267c0bf 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -129,6 +129,8 @@ constructor( ).takeIf { it.hasRoutes } } + fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() + fun clearPosition() = viewModelScope.launch(dispatchers.io) { destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 49398452c5..bf2170ab54 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,6 +76,9 @@ fun TracerouteMapScreen( } val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) } val overlay = overlayFromLogs ?: overlayFromService + LaunchedEffect(Unit) { + metricsViewModel.clearTracerouteResponse() + } Scaffold( topBar = { From 34259a2b20dfe6216e4ce16f4077f990058bbda7 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 06:28:46 +0000 Subject: [PATCH 05/21] Cache traceroute overlays for popup map --- .../main/java/com/geeksville/mesh/ui/Main.kt | 4 ++- .../org/meshtastic/feature/map/MapView.kt | 31 +++++++++++-------- .../feature/node/metrics/MetricsViewModel.kt | 27 ++++++++++++++-- .../feature/node/metrics/TracerouteLog.kt | 15 ++++----- .../node/metrics/TracerouteMapScreen.kt | 8 ++--- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 2df448bf7c..148b88fdaf 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -251,7 +251,9 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode }, confirmText = if (response.hasOverlay) stringResource(Res.string.view_on_map) else null, onConfirm = - response.takeIf { it.hasOverlay }?.let { traceroute -> + response + .takeIf { it.hasOverlay } + ?.let { traceroute -> { navController.navigate( NodeDetailRoutes.TracerouteMap(traceroute.destinationNodeNum, traceroute.requestId), diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index d4c8bcf5e2..5483c57c9e 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -160,7 +160,7 @@ import timber.log.Timber import java.io.File import java.text.DateFormat -private fun MapView.UpdateMarkers( +private fun MapView.updateMarkers( nodeMarkers: List, waypointMarkers: List, nodeClusterer: RadiusMarkerClusterer, @@ -491,17 +491,18 @@ fun MapView( else -> "${timeLeft / 86_400_000} day${if (timeLeft / 86_400_000 != 1L) "s" else ""}" } - MarkerWithLabel(this, label, emoji).apply { - id = "${pt.id}" - title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" - 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 { - showMarkerLongPressDialog(pt.id) - true + MarkerWithLabel(this, label, emoji).apply { + id = "${pt.id}" + title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" + 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 { + showMarkerLongPressDialog(pt.id) + true } } } @@ -672,7 +673,11 @@ fun MapView( update = { mapView -> mapView.updateTracerouteOverlay(tracerouteForwardPoints, tracerouteReturnPoints) with(mapView) { - UpdateMarkers(onNodesChanged(nodesForMarkers), onWaypointChanged(waypoints.values), nodeClusterer) + updateMarkers( + onNodesChanged(nodesForMarkers), + onWaypointChanged(waypoints.values), + nodeClusterer, + ) } mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 009267c0bf..82c06f8805 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -53,9 +53,9 @@ 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.feature.map.model.TracerouteOverlay import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos.MeshPacket import org.meshtastic.proto.Portnums @@ -93,6 +93,8 @@ constructor( private var jobs: Job? = null + private val tracerouteOverlayCache = MutableStateFlow>(emptyMap()) + private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } @@ -120,17 +122,38 @@ constructor( fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { + tracerouteOverlayCache.value[requestId]?.let { + return it + } val response = serviceRepository.tracerouteResponse.value ?: return null if (response.requestId != requestId) return null return TracerouteOverlay( requestId = response.requestId, forwardRoute = response.forwardRoute, returnRoute = response.returnRoute, - ).takeIf { it.hasRoutes } + ) + .takeIf { it.hasRoutes } + ?.also { overlay -> tracerouteOverlayCache.update { it + (requestId to overlay) } } } fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() + 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) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 4ba63df40d..ae1aeed768 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -173,10 +173,11 @@ fun TracerouteLogScreen( text = "$time - $text", modifier = Modifier.combinedClickable(onLongClick = { expanded = true }) { - val dialogMessage = tracerouteDetailsAnnotated - ?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let { - AnnotatedString(it) - } + val dialogMessage = + tracerouteDetailsAnnotated + ?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let { + AnnotatedString(it) + } if (dialogMessage != null) { showDialog = TracerouteDialog( @@ -218,11 +219,7 @@ private fun DeleteItem(onClick: () -> Unit) { } @Composable -private fun TracerouteItem( - icon: ImageVector, - text: String, - modifier: Modifier = Modifier, -) { +private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) { Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index bf2170ab54..b286b1e180 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -32,8 +32,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,13 +42,13 @@ 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_map_no_data import org.meshtastic.core.strings.traceroute_outgoing_route import org.meshtastic.core.strings.traceroute_return_route import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.feature.map.MapView import org.meshtastic.feature.map.model.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay @@ -76,9 +76,7 @@ fun TracerouteMapScreen( } val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) } val overlay = overlayFromLogs ?: overlayFromService - LaunchedEffect(Unit) { - metricsViewModel.clearTracerouteResponse() - } + LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() } Scaffold( topBar = { From da3de9ff02f7be413cfcdbdb4c058e192f66f265 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 06:48:51 +0000 Subject: [PATCH 06/21] Soften traceroute colors with transparency --- .../org/meshtastic/feature/map/model/TracerouteOverlay.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt index 17d8fa6113..6832e2f272 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt @@ -30,5 +30,7 @@ data class TracerouteOverlay( get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() } -val TracerouteOutgoingColor = Color(0xFFD32F2F) -val TracerouteReturnColor = Color(0xFF1976D2) +// High-contrast pair that stays legible on light/dark tiles and for most color-blind users. +// Use partial alpha so polylines don’t overpower markers/tiles. +val TracerouteOutgoingColor = Color(0xCCE86A00) // orange @ ~80% opacity +val TracerouteReturnColor = Color(0xCC0081C7) // cyan @ ~80% opacity From 7e9429b47ddcc6cee1d4caf44089f7877a785a67 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 9 Dec 2025 08:32:46 +0000 Subject: [PATCH 07/21] Adjust traceroute map offsets and lines --- .../org/meshtastic/feature/map/MapView.kt | 82 ++++++++++++++++++- .../org/meshtastic/feature/map/MapView.kt | 67 ++++++++++++++- 2 files changed, 144 insertions(+), 5 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 5483c57c9e..269c92c81d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -159,6 +159,11 @@ 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 private fun MapView.updateMarkers( nodeMarkers: List, @@ -349,6 +354,32 @@ fun MapView( nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } } ?: 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, + ) + } val traceroutePolylines = remember { mutableStateListOf() } var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } @@ -671,7 +702,7 @@ fun MapView( }, modifier = Modifier.fillMaxSize(), update = { mapView -> - mapView.updateTracerouteOverlay(tracerouteForwardPoints, tracerouteReturnPoints) + mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints) with(mapView) { updateMarkers( onNodesChanged(nodesForMarkers), @@ -1036,3 +1067,52 @@ private fun MapsDialog( } } } + +private const val EARTH_RADIUS_METERS = 6_371_000.0 +private const val TRACEROUTE_OFFSET_METERS = 100.0 + +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, + offsetMeters: Double, + headingReferencePoints: List = points, + sideMultiplier: Double = 1.0, +): List { + 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)) + } +} diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 659168dfbf..cff2082080 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -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 @@ -126,11 +127,13 @@ 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 @Suppress("CyclomaticComplexMethod", "LongMethod") @OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -310,6 +313,32 @@ fun MapView( 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) } @@ -413,7 +442,7 @@ fun MapView( if (tracerouteForwardPoints.size >= 2) { Polyline( - points = tracerouteForwardPoints, + points = tracerouteForwardOffsetPoints, jointType = JointType.ROUND, color = TracerouteOutgoingColor, width = 9f, @@ -422,7 +451,7 @@ fun MapView( } if (tracerouteReturnPoints.size >= 2) { Polyline( - points = tracerouteReturnPoints, + points = tracerouteReturnOffsetPoints, jointType = JointType.ROUND, color = TracerouteReturnColor, width = 7f, @@ -514,7 +543,7 @@ fun MapView( if (tracerouteForwardPoints.size >= 2) { Polyline( - points = tracerouteForwardPoints, + points = tracerouteForwardOffsetPoints, jointType = JointType.ROUND, color = TracerouteOutgoingColor, width = 9f, @@ -523,7 +552,7 @@ fun MapView( } if (tracerouteReturnPoints.size >= 2) { Polyline( - points = tracerouteReturnPoints, + points = tracerouteReturnOffsetPoints, jointType = JointType.ROUND, color = TracerouteReturnColor, width = 7f, @@ -778,3 +807,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, + offsetMeters: Double, + headingReferencePoints: List = points, + sideMultiplier: Double = 1.0, +): List { + 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) + } +} From b5ae42789b111183c381292416418ec80b621f48 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Thu, 11 Dec 2025 21:52:50 +0000 Subject: [PATCH 08/21] Interpolate traceroute nodes without locations and show missing endpoints --- .gitignore | 1 + .../composeResources/values/strings.xml | 1 + .../org/meshtastic/feature/map/MapView.kt | 120 ++++++++++++-- .../org/meshtastic/feature/map/MapView.kt | 152 ++++++++++++++---- 4 files changed, 232 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index d0b5ac12cd..8e5ac0ea45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ .gradle /.gradle-home +/.gradle-local /local.properties .DS_Store **/build/** diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index e9493cdc32..e6cce6923e 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -407,6 +407,7 @@ Hops towards %1$d Hops back %2$d Outgoing route Return route + Start or destination node has no location; showing available hops only. View on map This traceroute does not have any mappable nodes yet. 24H diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 269c92c81d..1b894db95c 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -118,6 +118,7 @@ import org.meshtastic.core.strings.only_favorites import org.meshtastic.core.strings.show_precision_circle import org.meshtastic.core.strings.show_waypoints import org.meshtastic.core.strings.toggle_my_position +import org.meshtastic.core.strings.traceroute_endpoint_missing import org.meshtastic.core.strings.waypoint_delete import org.meshtastic.core.strings.you import org.meshtastic.core.ui.component.BasicListItem @@ -133,6 +134,7 @@ import org.meshtastic.feature.map.model.MarkerWithLabel import org.meshtastic.feature.map.model.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.map.model.TracerouteReturnColor +import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint @@ -163,6 +165,7 @@ import kotlin.math.abs import kotlin.math.asin import kotlin.math.atan2 import kotlin.math.cos +import kotlin.math.roundToInt import kotlin.math.sin private fun MapView.updateMarkers( @@ -333,27 +336,45 @@ fun MapView( } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val nodesWithPosition = remember(nodes) { nodes.filter { it.validPosition != null } } val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } } + val nodeLookup = remember(nodes) { nodes.associateBy { it.num } } + val knownPositions = + remember(nodesWithPosition) { nodesWithPosition.associate { it.num to GeoPoint(it.latitude, it.longitude) } } val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } + val tracerouteForwardRoutePoints = + remember(tracerouteOverlay, nodeLookup, knownPositions) { + buildTracerouteRoutePoints( + route = tracerouteOverlay?.forwardRoute ?: emptyList(), + nodeLookup = nodeLookup, + knownPositions = knownPositions, + ) + } val nodesForMarkers = if (tracerouteOverlay != null) { - nodes.filter { overlayNodeNums.contains(it.num) } + tracerouteDisplayNodes.map { it.node } } else { - nodes + nodesWithPosition } - val tracerouteForwardPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.forwardRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() + val tracerouteReturnRoutePoints = + remember(tracerouteOverlay, nodeLookup, knownPositions) { + buildTracerouteRoutePoints( + route = tracerouteOverlay?.returnRoute ?: emptyList(), + nodeLookup = nodeLookup, + knownPositions = knownPositions, + ) } - val tracerouteReturnPoints = - remember(tracerouteOverlay, nodeLookup) { - tracerouteOverlay?.returnRoute?.mapNotNull { - nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } - } ?: emptyList() + val tracerouteDisplayNodes = + remember(tracerouteForwardRoutePoints, tracerouteReturnRoutePoints) { + (tracerouteForwardRoutePoints + tracerouteReturnRoutePoints) + .groupBy { it.node.num } + .values + .map { points -> points.firstOrNull { !it.isEstimated } ?: points.first() } } + val tracerouteForwardPoints = + remember(tracerouteForwardRoutePoints) { tracerouteForwardRoutePoints.map { it.geoPoint } } + val tracerouteReturnPoints = + remember(tracerouteReturnRoutePoints) { tracerouteReturnRoutePoints.map { it.geoPoint } } val tracerouteHeadingReferencePoints = remember(tracerouteForwardPoints, tracerouteReturnPoints) { when { @@ -362,6 +383,14 @@ fun MapView( else -> emptyList() } } + val tracerouteEndpointsMissing = + remember(tracerouteOverlay, tracerouteForwardRoutePoints) { + val startId = tracerouteOverlay?.forwardRoute?.firstOrNull() + val endId = tracerouteOverlay?.forwardRoute?.lastOrNull() + val hasStart = tracerouteForwardRoutePoints.any { it.node.num == startId && !it.isEstimated } + val hasEnd = tracerouteForwardRoutePoints.any { it.node.num == endId && !it.isEstimated } + startId != null && endId != null && (!hasStart || !hasEnd) + } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { offsetPolyline( @@ -437,6 +466,15 @@ fun MapView( true } } + if (tracerouteEndpointsMissing) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.traceroute_endpoint_missing), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } @@ -1090,6 +1128,62 @@ private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoP return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) } +private data class TracerouteRoutePoint(val node: Node, val geoPoint: GeoPoint, val isEstimated: Boolean) + +private fun buildTracerouteRoutePoints( + route: List, + nodeLookup: Map, + knownPositions: Map, +): List { + if (route.isEmpty()) return emptyList() + + val knownIndices = + route.mapIndexedNotNull { index, nodeNum -> + knownPositions[nodeNum]?.let { + index to TracerouteRoutePoint(nodeLookup[nodeNum] ?: Node(num = nodeNum), it, false) + } + } + + return route.mapIndexedNotNull { index, nodeNum -> + val existingKnown = knownPositions[nodeNum] + if (existingKnown != null) { + val node = nodeLookup[nodeNum] ?: Node(num = nodeNum) + return@mapIndexedNotNull TracerouteRoutePoint(node, existingKnown, false) + } + + val prevKnown = knownIndices.lastOrNull { it.first < index }?.second + val nextKnown = knownIndices.firstOrNull { it.first > index }?.second + + if (prevKnown != null && nextKnown != null) { + val prevIndex = route.indexOf(prevKnown.node.num) + val nextIndex = route.indexOf(nextKnown.node.num) + if (nextIndex == prevIndex) return@mapIndexedNotNull null + val fraction = (index - prevIndex).toDouble() / (nextIndex - prevIndex).toDouble() + val lat = + prevKnown.geoPoint.latitude + (nextKnown.geoPoint.latitude - prevKnown.geoPoint.latitude) * fraction + val lon = + prevKnown.geoPoint.longitude + (nextKnown.geoPoint.longitude - prevKnown.geoPoint.longitude) * fraction + val interpolated = GeoPoint(lat, lon) + val baseNode = nodeLookup[nodeNum] ?: Node(num = nodeNum) + val nodeWithPosition = + if (baseNode.validPosition != null) { + baseNode + } else { + baseNode.copy(position = interpolated.toPosition(time = baseNode.position.time)) + } + TracerouteRoutePoint(nodeWithPosition, interpolated, true) + } else { + null + } + } +} + +private fun GeoPoint.toPosition(time: Int = 0): Position = Position.newBuilder() + .setLatitudeI((latitude / DEG_D).roundToInt()) + .setLongitudeI((longitude / DEG_D).roundToInt()) + .setTime(time) + .build() + private fun offsetPolyline( points: List, offsetMeters: Double, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index cff2082080..046389a397 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -106,6 +106,7 @@ import org.meshtastic.core.strings.position import org.meshtastic.core.strings.sats import org.meshtastic.core.strings.speed import org.meshtastic.core.strings.timestamp +import org.meshtastic.core.strings.traceroute_endpoint_missing import org.meshtastic.core.strings.track_point import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.util.formatPositionTime @@ -129,6 +130,7 @@ import timber.log.Timber import java.text.DateFormat import kotlin.math.abs import kotlin.math.max +import kotlin.math.roundToInt private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f private const val DEG_D = 1e-7 @@ -255,16 +257,12 @@ fun MapView( } } - val allNodes by - mapViewModel.nodes - .map { nodes -> nodes.filter { node -> node.validPosition != null } } - .collectAsStateWithLifecycle(listOf()) + val allNodes by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) + val nodesWithPosition = remember(allNodes) { allNodes.filter { node -> node.validPosition != null } } 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 + nodesWithPosition .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } .filter { node -> mapFilterState.lastHeardFilter.seconds == 0L || @@ -272,22 +270,57 @@ fun MapView( node.num == ourNodeInfo?.num } - val displayNodes = - if (tracerouteOverlay != null) { - allNodes.filter { overlayNodeNums.contains(it.num) } - } else { - filteredNodes + val tracerouteNodeLookup = remember(allNodes) { allNodes.associateBy { it.num } } + val tracerouteKnownPositions = + remember(nodesWithPosition) { + nodesWithPosition.associate { + it.num to LatLng(it.position.latitudeI * DEG_D, it.position.longitudeI * DEG_D) + } + } + val tracerouteForwardRoutePoints = + remember(tracerouteOverlay, tracerouteNodeLookup, tracerouteKnownPositions) { + buildTracerouteRoutePoints( + route = tracerouteOverlay?.forwardRoute ?: emptyList(), + nodeLookup = tracerouteNodeLookup, + knownPositions = tracerouteKnownPositions, + ) + } + val tracerouteReturnRoutePoints = + remember(tracerouteOverlay, tracerouteNodeLookup, tracerouteKnownPositions) { + buildTracerouteRoutePoints( + route = tracerouteOverlay?.returnRoute ?: emptyList(), + nodeLookup = tracerouteNodeLookup, + knownPositions = tracerouteKnownPositions, + ) + } + val tracerouteDisplayNodes = + remember(tracerouteForwardRoutePoints, tracerouteReturnRoutePoints) { + (tracerouteForwardRoutePoints + tracerouteReturnRoutePoints) + .groupBy { it.node.num } + .values + .map { pointsForNode -> pointsForNode.firstOrNull { !it.isEstimated } ?: pointsForNode.first() } } val nodeClusterItems = - displayNodes.map { node -> - val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.longName}", - ) + if (tracerouteOverlay != null) { + tracerouteDisplayNodes.map { point -> + NodeClusterItem( + node = point.node, + nodePosition = point.latLng, + nodeTitle = "${point.node.user.shortName} ${formatAgo(point.node.position.time)}", + nodeSnippet = point.node.user.longName, + ) + } + } else { + filteredNodes.map { node -> + val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) + NodeClusterItem( + node = node, + nodePosition = latLng, + nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}", + nodeSnippet = "${node.user.longName}", + ) + } } val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val theme by mapViewModel.theme.collectAsStateWithLifecycle() @@ -304,15 +337,8 @@ fun MapView( 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() - } + remember(tracerouteForwardRoutePoints) { tracerouteForwardRoutePoints.map { it.latLng } } + val tracerouteReturnPoints = remember(tracerouteReturnRoutePoints) { tracerouteReturnRoutePoints.map { it.latLng } } val tracerouteHeadingReferencePoints = remember(tracerouteForwardPoints, tracerouteReturnPoints) { when { @@ -321,6 +347,14 @@ fun MapView( else -> emptyList() } } + val tracerouteEndpointsMissing = + remember(tracerouteOverlay, tracerouteForwardRoutePoints) { + val startId = tracerouteOverlay?.forwardRoute?.firstOrNull() + val endId = tracerouteOverlay?.forwardRoute?.lastOrNull() + val hasStart = tracerouteForwardRoutePoints.any { it.node.num == startId && !it.isEstimated } + val hasEnd = tracerouteForwardRoutePoints.any { it.node.num == endId && !it.isEstimated } + startId != null && endId != null && (!hasStart || !hasEnd) + } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { offsetPolyline( @@ -599,6 +633,16 @@ fun MapView( } } + if (tracerouteEndpointsMissing) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(Res.string.traceroute_endpoint_missing), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + ScaleBar( cameraPositionState = cameraPositionState, modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), @@ -808,6 +852,56 @@ private fun Node.toLatLng(): LatLng? = this.position.toLatLng() private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) +private data class TracerouteRoutePoint(val node: Node, val latLng: LatLng, val isEstimated: Boolean) + +private fun buildTracerouteRoutePoints( + route: List, + nodeLookup: Map, + knownPositions: Map, +): List { + if (route.isEmpty()) return emptyList() + + val indexedKnown = + route.mapIndexedNotNull { index, nodeNum -> + val latLng = knownPositions[nodeNum] + latLng?.let { index to TracerouteRoutePoint(nodeLookup[nodeNum] ?: Node(num = nodeNum), it, false) } + } + + return route.mapIndexedNotNull { index, nodeNum -> + val existingKnown = knownPositions[nodeNum] + if (existingKnown != null) { + val node = nodeLookup[nodeNum] ?: Node(num = nodeNum) + return@mapIndexedNotNull TracerouteRoutePoint(node, existingKnown, false) + } + + val previousKnown = indexedKnown.lastOrNull { it.first < index }?.second + val nextKnown = indexedKnown.firstOrNull { it.first > index }?.second + if (previousKnown != null && nextKnown != null) { + val prevIndex = route.indexOf(previousKnown.node.num) + val nextIndex = route.indexOf(nextKnown.node.num) + if (nextIndex == prevIndex) return@mapIndexedNotNull null + val fraction = (index - prevIndex).toDouble() / (nextIndex - prevIndex).toDouble() + val interpolatedLatLng = SphericalUtil.interpolate(previousKnown.latLng, nextKnown.latLng, fraction) + val baseNode = nodeLookup[nodeNum] ?: Node(num = nodeNum) + val nodeWithEstimatedPosition = + if (baseNode.validPosition != null) { + baseNode + } else { + baseNode.copy(position = interpolatedLatLng.toPosition(time = baseNode.position.time)) + } + TracerouteRoutePoint(nodeWithEstimatedPosition, interpolatedLatLng, true) + } else { + null + } + } +} + +private fun LatLng.toPosition(time: Int = 0): Position = Position.newBuilder() + .setLatitudeI((latitude / DEG_D).roundToInt()) + .setLongitudeI((longitude / DEG_D).roundToInt()) + .setTime(time) + .build() + private fun offsetPolyline( points: List, offsetMeters: Double, From 3f11b4017c59bc3592a3a1b3a27ae2d997bd2ba3 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Thu, 11 Dec 2025 22:03:18 +0000 Subject: [PATCH 09/21] Revert traceroute interpolation and estimated node styling --- .../composeResources/values/strings.xml | 1 - .../org/meshtastic/feature/map/MapView.kt | 120 ++------------ .../org/meshtastic/feature/map/MapView.kt | 152 ++++-------------- 3 files changed, 42 insertions(+), 231 deletions(-) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index e6cce6923e..e9493cdc32 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -407,7 +407,6 @@ Hops towards %1$d Hops back %2$d Outgoing route Return route - Start or destination node has no location; showing available hops only. View on map This traceroute does not have any mappable nodes yet. 24H diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 1b894db95c..269c92c81d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -118,7 +118,6 @@ import org.meshtastic.core.strings.only_favorites import org.meshtastic.core.strings.show_precision_circle import org.meshtastic.core.strings.show_waypoints import org.meshtastic.core.strings.toggle_my_position -import org.meshtastic.core.strings.traceroute_endpoint_missing import org.meshtastic.core.strings.waypoint_delete import org.meshtastic.core.strings.you import org.meshtastic.core.ui.component.BasicListItem @@ -134,7 +133,6 @@ import org.meshtastic.feature.map.model.MarkerWithLabel import org.meshtastic.feature.map.model.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.map.model.TracerouteReturnColor -import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint @@ -165,7 +163,6 @@ import kotlin.math.abs import kotlin.math.asin import kotlin.math.atan2 import kotlin.math.cos -import kotlin.math.roundToInt import kotlin.math.sin private fun MapView.updateMarkers( @@ -336,45 +333,27 @@ fun MapView( } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val nodesWithPosition = remember(nodes) { nodes.filter { it.validPosition != null } } val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val nodeLookup = remember(nodes) { nodes.associateBy { it.num } } - val knownPositions = - remember(nodesWithPosition) { nodesWithPosition.associate { it.num to GeoPoint(it.latitude, it.longitude) } } + val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } } val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } - val tracerouteForwardRoutePoints = - remember(tracerouteOverlay, nodeLookup, knownPositions) { - buildTracerouteRoutePoints( - route = tracerouteOverlay?.forwardRoute ?: emptyList(), - nodeLookup = nodeLookup, - knownPositions = knownPositions, - ) - } val nodesForMarkers = if (tracerouteOverlay != null) { - tracerouteDisplayNodes.map { it.node } + nodes.filter { overlayNodeNums.contains(it.num) } } else { - nodesWithPosition - } - val tracerouteReturnRoutePoints = - remember(tracerouteOverlay, nodeLookup, knownPositions) { - buildTracerouteRoutePoints( - route = tracerouteOverlay?.returnRoute ?: emptyList(), - nodeLookup = nodeLookup, - knownPositions = knownPositions, - ) - } - val tracerouteDisplayNodes = - remember(tracerouteForwardRoutePoints, tracerouteReturnRoutePoints) { - (tracerouteForwardRoutePoints + tracerouteReturnRoutePoints) - .groupBy { it.node.num } - .values - .map { points -> points.firstOrNull { !it.isEstimated } ?: points.first() } + nodes } val tracerouteForwardPoints = - remember(tracerouteForwardRoutePoints) { tracerouteForwardRoutePoints.map { it.geoPoint } } + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.forwardRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } val tracerouteReturnPoints = - remember(tracerouteReturnRoutePoints) { tracerouteReturnRoutePoints.map { it.geoPoint } } + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.returnRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } val tracerouteHeadingReferencePoints = remember(tracerouteForwardPoints, tracerouteReturnPoints) { when { @@ -383,14 +362,6 @@ fun MapView( else -> emptyList() } } - val tracerouteEndpointsMissing = - remember(tracerouteOverlay, tracerouteForwardRoutePoints) { - val startId = tracerouteOverlay?.forwardRoute?.firstOrNull() - val endId = tracerouteOverlay?.forwardRoute?.lastOrNull() - val hasStart = tracerouteForwardRoutePoints.any { it.node.num == startId && !it.isEstimated } - val hasEnd = tracerouteForwardRoutePoints.any { it.node.num == endId && !it.isEstimated } - startId != null && endId != null && (!hasStart || !hasEnd) - } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { offsetPolyline( @@ -466,15 +437,6 @@ fun MapView( true } } - if (tracerouteEndpointsMissing) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(Res.string.traceroute_endpoint_missing), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } } } @@ -1128,62 +1090,6 @@ private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoP return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) } -private data class TracerouteRoutePoint(val node: Node, val geoPoint: GeoPoint, val isEstimated: Boolean) - -private fun buildTracerouteRoutePoints( - route: List, - nodeLookup: Map, - knownPositions: Map, -): List { - if (route.isEmpty()) return emptyList() - - val knownIndices = - route.mapIndexedNotNull { index, nodeNum -> - knownPositions[nodeNum]?.let { - index to TracerouteRoutePoint(nodeLookup[nodeNum] ?: Node(num = nodeNum), it, false) - } - } - - return route.mapIndexedNotNull { index, nodeNum -> - val existingKnown = knownPositions[nodeNum] - if (existingKnown != null) { - val node = nodeLookup[nodeNum] ?: Node(num = nodeNum) - return@mapIndexedNotNull TracerouteRoutePoint(node, existingKnown, false) - } - - val prevKnown = knownIndices.lastOrNull { it.first < index }?.second - val nextKnown = knownIndices.firstOrNull { it.first > index }?.second - - if (prevKnown != null && nextKnown != null) { - val prevIndex = route.indexOf(prevKnown.node.num) - val nextIndex = route.indexOf(nextKnown.node.num) - if (nextIndex == prevIndex) return@mapIndexedNotNull null - val fraction = (index - prevIndex).toDouble() / (nextIndex - prevIndex).toDouble() - val lat = - prevKnown.geoPoint.latitude + (nextKnown.geoPoint.latitude - prevKnown.geoPoint.latitude) * fraction - val lon = - prevKnown.geoPoint.longitude + (nextKnown.geoPoint.longitude - prevKnown.geoPoint.longitude) * fraction - val interpolated = GeoPoint(lat, lon) - val baseNode = nodeLookup[nodeNum] ?: Node(num = nodeNum) - val nodeWithPosition = - if (baseNode.validPosition != null) { - baseNode - } else { - baseNode.copy(position = interpolated.toPosition(time = baseNode.position.time)) - } - TracerouteRoutePoint(nodeWithPosition, interpolated, true) - } else { - null - } - } -} - -private fun GeoPoint.toPosition(time: Int = 0): Position = Position.newBuilder() - .setLatitudeI((latitude / DEG_D).roundToInt()) - .setLongitudeI((longitude / DEG_D).roundToInt()) - .setTime(time) - .build() - private fun offsetPolyline( points: List, offsetMeters: Double, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 046389a397..cff2082080 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -106,7 +106,6 @@ import org.meshtastic.core.strings.position import org.meshtastic.core.strings.sats import org.meshtastic.core.strings.speed import org.meshtastic.core.strings.timestamp -import org.meshtastic.core.strings.traceroute_endpoint_missing import org.meshtastic.core.strings.track_point import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.util.formatPositionTime @@ -130,7 +129,6 @@ import timber.log.Timber import java.text.DateFormat import kotlin.math.abs import kotlin.math.max -import kotlin.math.roundToInt private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f private const val DEG_D = 1e-7 @@ -257,12 +255,16 @@ fun MapView( } } - val allNodes by mapViewModel.nodes.collectAsStateWithLifecycle(listOf()) - val nodesWithPosition = remember(allNodes) { allNodes.filter { node -> node.validPosition != null } } + val allNodes by + mapViewModel.nodes + .map { nodes -> nodes.filter { node -> node.validPosition != null } } + .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 = - nodesWithPosition + allNodes .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } .filter { node -> mapFilterState.lastHeardFilter.seconds == 0L || @@ -270,57 +272,22 @@ fun MapView( node.num == ourNodeInfo?.num } - val tracerouteNodeLookup = remember(allNodes) { allNodes.associateBy { it.num } } - val tracerouteKnownPositions = - remember(nodesWithPosition) { - nodesWithPosition.associate { - it.num to LatLng(it.position.latitudeI * DEG_D, it.position.longitudeI * DEG_D) - } - } - val tracerouteForwardRoutePoints = - remember(tracerouteOverlay, tracerouteNodeLookup, tracerouteKnownPositions) { - buildTracerouteRoutePoints( - route = tracerouteOverlay?.forwardRoute ?: emptyList(), - nodeLookup = tracerouteNodeLookup, - knownPositions = tracerouteKnownPositions, - ) - } - val tracerouteReturnRoutePoints = - remember(tracerouteOverlay, tracerouteNodeLookup, tracerouteKnownPositions) { - buildTracerouteRoutePoints( - route = tracerouteOverlay?.returnRoute ?: emptyList(), - nodeLookup = tracerouteNodeLookup, - knownPositions = tracerouteKnownPositions, - ) - } - val tracerouteDisplayNodes = - remember(tracerouteForwardRoutePoints, tracerouteReturnRoutePoints) { - (tracerouteForwardRoutePoints + tracerouteReturnRoutePoints) - .groupBy { it.node.num } - .values - .map { pointsForNode -> pointsForNode.firstOrNull { !it.isEstimated } ?: pointsForNode.first() } + val displayNodes = + if (tracerouteOverlay != null) { + allNodes.filter { overlayNodeNums.contains(it.num) } + } else { + filteredNodes } val nodeClusterItems = - if (tracerouteOverlay != null) { - tracerouteDisplayNodes.map { point -> - NodeClusterItem( - node = point.node, - nodePosition = point.latLng, - nodeTitle = "${point.node.user.shortName} ${formatAgo(point.node.position.time)}", - nodeSnippet = point.node.user.longName, - ) - } - } else { - filteredNodes.map { node -> - val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) - NodeClusterItem( - node = node, - nodePosition = latLng, - nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}", - nodeSnippet = "${node.user.longName}", - ) - } + displayNodes.map { node -> + val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) + NodeClusterItem( + node = node, + nodePosition = latLng, + nodeTitle = "${node.user.shortName} ${formatAgo(node.position.time)}", + nodeSnippet = "${node.user.longName}", + ) } val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val theme by mapViewModel.theme.collectAsStateWithLifecycle() @@ -337,8 +304,15 @@ fun MapView( else -> ComposeMapColorScheme.LIGHT } val tracerouteForwardPoints = - remember(tracerouteForwardRoutePoints) { tracerouteForwardRoutePoints.map { it.latLng } } - val tracerouteReturnPoints = remember(tracerouteReturnRoutePoints) { tracerouteReturnRoutePoints.map { it.latLng } } + 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 { @@ -347,14 +321,6 @@ fun MapView( else -> emptyList() } } - val tracerouteEndpointsMissing = - remember(tracerouteOverlay, tracerouteForwardRoutePoints) { - val startId = tracerouteOverlay?.forwardRoute?.firstOrNull() - val endId = tracerouteOverlay?.forwardRoute?.lastOrNull() - val hasStart = tracerouteForwardRoutePoints.any { it.node.num == startId && !it.isEstimated } - val hasEnd = tracerouteForwardRoutePoints.any { it.node.num == endId && !it.isEstimated } - startId != null && endId != null && (!hasStart || !hasEnd) - } val tracerouteForwardOffsetPoints = remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { offsetPolyline( @@ -633,16 +599,6 @@ fun MapView( } } - if (tracerouteEndpointsMissing) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = stringResource(Res.string.traceroute_endpoint_missing), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - ScaleBar( cameraPositionState = cameraPositionState, modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp), @@ -852,56 +808,6 @@ private fun Node.toLatLng(): LatLng? = this.position.toLatLng() private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) -private data class TracerouteRoutePoint(val node: Node, val latLng: LatLng, val isEstimated: Boolean) - -private fun buildTracerouteRoutePoints( - route: List, - nodeLookup: Map, - knownPositions: Map, -): List { - if (route.isEmpty()) return emptyList() - - val indexedKnown = - route.mapIndexedNotNull { index, nodeNum -> - val latLng = knownPositions[nodeNum] - latLng?.let { index to TracerouteRoutePoint(nodeLookup[nodeNum] ?: Node(num = nodeNum), it, false) } - } - - return route.mapIndexedNotNull { index, nodeNum -> - val existingKnown = knownPositions[nodeNum] - if (existingKnown != null) { - val node = nodeLookup[nodeNum] ?: Node(num = nodeNum) - return@mapIndexedNotNull TracerouteRoutePoint(node, existingKnown, false) - } - - val previousKnown = indexedKnown.lastOrNull { it.first < index }?.second - val nextKnown = indexedKnown.firstOrNull { it.first > index }?.second - if (previousKnown != null && nextKnown != null) { - val prevIndex = route.indexOf(previousKnown.node.num) - val nextIndex = route.indexOf(nextKnown.node.num) - if (nextIndex == prevIndex) return@mapIndexedNotNull null - val fraction = (index - prevIndex).toDouble() / (nextIndex - prevIndex).toDouble() - val interpolatedLatLng = SphericalUtil.interpolate(previousKnown.latLng, nextKnown.latLng, fraction) - val baseNode = nodeLookup[nodeNum] ?: Node(num = nodeNum) - val nodeWithEstimatedPosition = - if (baseNode.validPosition != null) { - baseNode - } else { - baseNode.copy(position = interpolatedLatLng.toPosition(time = baseNode.position.time)) - } - TracerouteRoutePoint(nodeWithEstimatedPosition, interpolatedLatLng, true) - } else { - null - } - } -} - -private fun LatLng.toPosition(time: Int = 0): Position = Position.newBuilder() - .setLatitudeI((latitude / DEG_D).roundToInt()) - .setLongitudeI((longitude / DEG_D).roundToInt()) - .setTime(time) - .build() - private fun offsetPolyline( points: List, offsetMeters: Double, From 885faed2cc22c2cc61465899ab86835921b1c264 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 12 Dec 2025 11:05:21 +0000 Subject: [PATCH 10/21] Unify traceroute map checks and show node count Centralize map availability evaluation and use it from both traceroute dialogs before navigating. Add a "Showing X/Y nodes" indicator for traceroute maps (Google + F-Droid). --- .../java/com/geeksville/mesh/model/UIState.kt | 10 +++++ .../main/java/com/geeksville/mesh/ui/Main.kt | 39 +++++++++++----- .../meshtastic/core/model/RouteDiscovery.kt | 43 +++++++++++++++++- .../composeResources/values/strings.xml | 2 + .../org/meshtastic/feature/map/MapView.kt | 6 +++ .../org/meshtastic/feature/map/MapView.kt | 6 +++ .../feature/node/metrics/MetricsViewModel.kt | 15 +++++++ .../feature/node/metrics/TracerouteLog.kt | 45 ++++++++++++++++--- .../node/metrics/TracerouteMapScreen.kt | 44 ++++++++++++------ 9 files changed, 177 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index d344a19da9..a43bfd24e6 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -51,6 +51,8 @@ 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 @@ -151,6 +153,14 @@ constructor( private val _currentAlert: MutableStateFlow = MutableStateFlow(null) val currentAlert = _currentAlert.asStateFlow() + fun tracerouteMapAvailability(forwardRoute: List, returnRoute: List): 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, diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 148b88fdaf..86fb1976e2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -106,6 +106,7 @@ 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 @@ -118,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 @@ -241,6 +243,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode } val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState() + var tracerouteMapError by remember { mutableStateOf(null) } traceRouteResponse?.let { response -> SimpleAlertDialog( title = Res.string.traceroute, @@ -249,21 +252,35 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode Text(text = annotateTraceroute(response.message)) } }, - confirmText = if (response.hasOverlay) stringResource(Res.string.view_on_map) else null, - onConfirm = - response - .takeIf { it.hasOverlay } - ?.let { traceroute -> - { - navController.navigate( - NodeDetailRoutes.TracerouteMap(traceroute.destinationNodeNum, traceroute.requestId), - ) - } - }, + 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) { + navController.navigate( + NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId), + ) + } else { + tracerouteMapError = errorRes + } + uIViewModel.clearTracerouteResponse() + }, dismissText = stringResource(Res.string.okay), onDismiss = { uIViewModel.clearTracerouteResponse() }, ) } + tracerouteMapError?.let { res -> + SimpleAlertDialog( + title = Res.string.traceroute, + text = { Text(text = stringResource(res)) }, + dismissText = stringResource(Res.string.close), + onDismiss = { tracerouteMapError = null }, + ) + } val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt index 309db06186..d404adcd72 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt @@ -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,36 @@ 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, + returnRoute: List, + positionedNodeNums: Set, +): TracerouteMapAvailability { + if (forwardRoute.isEmpty() && returnRoute.isEmpty()) return TracerouteMapAvailability.NoMappableNodes + 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 +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index e9493cdc32..6bd1069cc0 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -407,8 +407,10 @@ Hops towards %1$d Hops back %2$d Outgoing route Return route + Cannot show traceroute map because the start or destination node has no position information. View on map This traceroute does not have any mappable nodes yet. + Showing %1$d/%2$d nodes 24H 48H 1W diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 269c92c81d..85acf080db 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -232,6 +232,7 @@ fun MapView( mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit, tracerouteOverlay: TracerouteOverlay? = null, + onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { var mapFilterExpanded by remember { mutableStateOf(false) } @@ -354,6 +355,11 @@ fun MapView( 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 { diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index cff2082080..65d5d04dc9 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -144,6 +144,7 @@ fun MapView( focusedNodeNum: Int? = null, nodeTracks: List? = null, tracerouteOverlay: TracerouteOverlay? = null, + onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -278,6 +279,11 @@ fun MapView( } else { filteredNodes } + LaunchedEffect(tracerouteOverlay, displayNodes) { + if (tracerouteOverlay != null) { + onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) + } + } val nodeClusterItems = displayNodes.map { node -> diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 82c06f8805..4e8d6799fa 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -47,6 +47,8 @@ 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 @@ -138,6 +140,19 @@ constructor( fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() + fun tracerouteMapAvailability(forwardRoute: List, returnRoute: List): TracerouteMapAvailability = + evaluateTracerouteMapAvailability( + forwardRoute = forwardRoute, + returnRoute = returnRoute, + positionedNodeNums = positionedNodeNums(), + ) + + fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability = + tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute) + + fun positionedNodeNums(): Set = + nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet() + init { viewModelScope.launch { serviceRepository.tracerouteResponse.filterNotNull().collect { response -> diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index ae1aeed768..11478b5189 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -52,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 @@ -62,11 +61,14 @@ 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 @@ -82,6 +84,7 @@ 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 @@ -100,9 +103,10 @@ fun TracerouteLogScreen( fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } - data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val canShowOnMap: Boolean) + data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?) var showDialog by remember { mutableStateOf(null) } + var errorMessageRes by remember { mutableStateOf(null) } if (showDialog != null) { val dialogState = showDialog @@ -110,14 +114,34 @@ fun TracerouteLogScreen( SimpleAlertDialog( title = Res.string.traceroute, text = { SelectionContainer { Text(text = message) } }, - confirmText = if (dialogState?.canShowOnMap == true) stringResource(Res.string.view_on_map) else null, + confirmText = stringResource(Res.string.view_on_map), onConfirm = { - dialogState?.let { onViewOnMap(it.requestId) } - showDialog = null + dialogState?.let { dialog -> + val availability = + viewModel.tracerouteMapAvailability( + forwardRoute = dialog.overlay?.forwardRoute.orEmpty(), + returnRoute = dialog.overlay?.returnRoute.orEmpty(), + ) + val errorRes = availability.toMessageRes() + if (errorRes == null) { + onViewOnMap(dialog.requestId) + } else { + errorMessageRes = errorRes + } + showDialog = null + } }, onDismiss = { showDialog = null }, ) } + errorMessageRes?.let { res -> + SimpleAlertDialog( + title = Res.string.traceroute, + text = { Text(text = stringResource(res)) }, + dismissText = stringResource(Res.string.close), + onDismiss = { errorMessageRes = null }, + ) + } Scaffold( topBar = { @@ -165,7 +189,14 @@ fun TracerouteLogScreen( res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) } } } - val canShowOnMap = result != null + val overlay = + route?.let { + TracerouteOverlay( + requestId = log.fromRadio.packet.id, + forwardRoute = it.routeList, + returnRoute = it.routeBackList, + ) + } Box { TracerouteItem( @@ -183,7 +214,7 @@ fun TracerouteLogScreen( TracerouteDialog( message = dialogMessage, requestId = log.fromRadio.packet.id, - canShowOnMap = canShowOnMap, + overlay = overlay, ) } }, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index b286b1e180..3c6b7e5a17 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -34,7 +34,9 @@ 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 @@ -45,9 +47,9 @@ 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_map_no_data 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.feature.map.MapView import org.meshtastic.feature.map.model.TracerouteOutgoingColor @@ -76,6 +78,8 @@ fun TracerouteMapScreen( } 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( @@ -91,18 +95,21 @@ fun TracerouteMapScreen( ) }, ) { paddingValues -> - if (overlay?.hasRoutes != true) { - Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) { - Text( - text = stringResource(Res.string.traceroute_map_no_data), - style = MaterialTheme.typography.bodyLarge, - ) - } - } else { - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { - MapView(navigateToNodeDetails = {}, tracerouteOverlay = overlay) - TracerouteLegend(modifier = Modifier.align(Alignment.BottomStart).padding(16.dp)) - } + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + MapView( + navigateToNodeDetails = {}, + tracerouteOverlay = overlay, + onTracerouteMappableCountChanged = { shown, total -> + tracerouteNodesShown = shown + tracerouteNodesTotal = total + }, + ) + TracerouteLegend(modifier = Modifier.align(Alignment.BottomStart).padding(16.dp)) + TracerouteNodeCount( + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + shown = tracerouteNodesShown, + total = tracerouteNodesTotal, + ) } } } @@ -120,6 +127,17 @@ private fun TracerouteLegend(modifier: Modifier = Modifier) { } } +@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) { From 832554df928db90e874184bcc72668a8b0c01ca2 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 12 Dec 2025 21:47:23 +0000 Subject: [PATCH 11/21] Handle dismissed traceroute requests and clear response on dialog dismiss --- app/src/main/java/com/geeksville/mesh/ui/Main.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 86fb1976e2..cc6afe1f34 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -244,7 +244,8 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState() var tracerouteMapError by remember { mutableStateOf(null) } - traceRouteResponse?.let { response -> + var dismissedTracerouteRequestId by remember { mutableStateOf(null) } + traceRouteResponse?.takeIf { it.requestId != dismissedTracerouteRequestId }?.let { response -> SimpleAlertDialog( title = Res.string.traceroute, text = { @@ -261,16 +262,20 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode ) val errorRes = availability.toMessageRes() if (errorRes == null) { + dismissedTracerouteRequestId = response.requestId navController.navigate( NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId), ) } else { tracerouteMapError = errorRes + uIViewModel.clearTracerouteResponse() } - uIViewModel.clearTracerouteResponse() }, dismissText = stringResource(Res.string.okay), - onDismiss = { uIViewModel.clearTracerouteResponse() }, + onDismiss = { + uIViewModel.clearTracerouteResponse() + dismissedTracerouteRequestId = null + }, ) } tracerouteMapError?.let { res -> From b993b794eccfc3e03ed9e34e39da8d048d6f2d1b Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 12 Dec 2025 22:00:50 +0000 Subject: [PATCH 12/21] Fix traceroute popup map data and overlay layout Keep traceroute response available until the map screen consumes it so 'View on map' from the completion popup doesn't render an empty traceroute overlay. Also stack the traceroute legend and node count at bottom-center (with per-flavor insets) to avoid Google watermark/zoom controls. --- .../main/java/com/geeksville/mesh/ui/Main.kt | 66 ++++++++++--------- .../metrics/TracerouteMapOverlayInsets.kt | 25 +++++++ .../metrics/TracerouteMapOverlayInsets.kt | 25 +++++++ .../node/metrics/TracerouteMapScreen.kt | 14 ++-- 4 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt create mode 100644 feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index cc6afe1f34..3a6562b70d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -245,39 +245,41 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState() var tracerouteMapError by remember { mutableStateOf(null) } var dismissedTracerouteRequestId by remember { mutableStateOf(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 + 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() - } - }, - dismissText = stringResource(Res.string.okay), - onDismiss = { - uIViewModel.clearTracerouteResponse() - dismissedTracerouteRequestId = null - }, - ) - } + dismissedTracerouteRequestId = null + }, + ) + } tracerouteMapError?.let { res -> SimpleAlertDialog( title = Res.string.traceroute, diff --git a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt new file mode 100644 index 0000000000..dbb8c97fb6 --- /dev/null +++ b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.dp + +internal object TracerouteMapOverlayInsets { + val bottomCenter: PaddingValues = PaddingValues(bottom = 16.dp) +} diff --git a/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt b/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt new file mode 100644 index 0000000000..dbb8c97fb6 --- /dev/null +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.dp + +internal object TracerouteMapOverlayInsets { + val bottomCenter: PaddingValues = PaddingValues(bottom = 16.dp) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 3c6b7e5a17..f3732f0f51 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -104,12 +104,14 @@ fun TracerouteMapScreen( tracerouteNodesTotal = total }, ) - TracerouteLegend(modifier = Modifier.align(Alignment.BottomStart).padding(16.dp)) - TracerouteNodeCount( - modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), - shown = tracerouteNodesShown, - total = tracerouteNodesTotal, - ) + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(TracerouteMapOverlayInsets.bottomCenter), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal) + TracerouteLegend() + } } } } From be3fd2cf0fd6d8ddaabb34afe54b1e946005d3e4 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Sat, 13 Dec 2025 02:31:01 +0000 Subject: [PATCH 13/21] Tweak traceroute map overlays and F-Droid zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Put traceroute legend + node count bottom-right on F-Droid (Google stays bottom-center). - Zoom the F-Droid traceroute fit slightly further out so routes aren’t framed too tightly. --- .../fdroid/kotlin/org/meshtastic/feature/map/MapView.kt | 8 +++++--- .../feature/node/metrics/TracerouteMapOverlayInsets.kt | 5 ++++- .../feature/node/metrics/TracerouteMapOverlayInsets.kt | 5 ++++- .../feature/node/metrics/TracerouteMapScreen.kt | 6 ++++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 85acf080db..35229435b8 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -619,13 +619,13 @@ fun MapView( LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect - val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints) + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() if (allPoints.isNotEmpty()) { if (allPoints.size == 1) { map.controller.setCenter(allPoints.first()) - map.controller.setZoom(13.0) + map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) } else { - map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints), true) + map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true) } hasCenteredTraceroute = true } @@ -1076,6 +1076,8 @@ 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) diff --git a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt index dbb8c97fb6..2a35798f38 100644 --- a/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt +++ b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -18,8 +18,11 @@ 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 bottomCenter: PaddingValues = PaddingValues(bottom = 16.dp) + val overlayAlignment: Alignment = Alignment.BottomEnd + val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp) + val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End } diff --git a/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt b/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt index dbb8c97fb6..ad5d337842 100644 --- a/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -18,8 +18,11 @@ 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 bottomCenter: PaddingValues = PaddingValues(bottom = 16.dp) + val overlayAlignment: Alignment = Alignment.BottomCenter + val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp) + val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index f3732f0f51..d7ee455c9c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -105,8 +105,10 @@ fun TracerouteMapScreen( }, ) Column( - modifier = Modifier.align(Alignment.BottomCenter).padding(TracerouteMapOverlayInsets.bottomCenter), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.align(TracerouteMapOverlayInsets.overlayAlignment) + .padding(TracerouteMapOverlayInsets.overlayPadding), + horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment, verticalArrangement = Arrangement.spacedBy(8.dp), ) { TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal) From ffc67a9630db0c45c04b79e395eded6b30db2f1e Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Sun, 14 Dec 2025 15:08:05 +0000 Subject: [PATCH 14/21] fix: simplify dialog handling in TracerouteLogScreen --- .../feature/node/metrics/TracerouteLog.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 11478b5189..c07a25d6c3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -108,28 +108,24 @@ fun TracerouteLogScreen( var showDialog by remember { mutableStateOf(null) } var errorMessageRes by remember { mutableStateOf(null) } - if (showDialog != null) { - val dialogState = showDialog - val message = dialogState?.message ?: AnnotatedString("") // Should not be null if dialog is shown + showDialog?.let { dialog -> SimpleAlertDialog( title = Res.string.traceroute, - text = { SelectionContainer { Text(text = message) } }, + text = { SelectionContainer { Text(text = dialog.message) } }, confirmText = stringResource(Res.string.view_on_map), onConfirm = { - dialogState?.let { dialog -> - val availability = - viewModel.tracerouteMapAvailability( - forwardRoute = dialog.overlay?.forwardRoute.orEmpty(), - returnRoute = dialog.overlay?.returnRoute.orEmpty(), - ) - val errorRes = availability.toMessageRes() - if (errorRes == null) { - onViewOnMap(dialog.requestId) - } else { - errorMessageRes = errorRes - } - showDialog = null + val availability = + viewModel.tracerouteMapAvailability( + forwardRoute = dialog.overlay?.forwardRoute.orEmpty(), + returnRoute = dialog.overlay?.returnRoute.orEmpty(), + ) + val errorRes = availability.toMessageRes() + if (errorRes == null) { + onViewOnMap(dialog.requestId) + } else { + errorMessageRes = errorRes } + showDialog = null }, onDismiss = { showDialog = null }, ) From ad2d3d1d8fbeded23aa0fb94bd1ac7aa581af23f Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Sun, 14 Dec 2025 15:16:11 +0000 Subject: [PATCH 15/21] fix(map): update traceroute bounds padding constant --- .../src/google/kotlin/org/meshtastic/feature/map/MapView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 65d5d04dc9..c06326a9b9 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -134,6 +134,7 @@ 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) @@ -398,7 +399,7 @@ fun MapView( } else { val bounds = LatLngBounds.builder() allPoints.forEach { bounds.include(it) } - CameraUpdateFactory.newLatLngBounds(bounds.build(), 120) + CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) } try { cameraPositionState.animate(cameraUpdate) From 533d2102e7f8e3d141b939b0574d24cc4712d65a Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 15 Dec 2025 21:17:49 +0000 Subject: [PATCH 16/21] Revert gradle.properties change --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index eceeaff380..92bc26c594 100644 --- a/gradle.properties +++ b/gradle.properties @@ -60,4 +60,4 @@ org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true dependency.analysis.print.build.health=true ksp.incremental=true -ksp.incremental.classpath=true +ksp.incremental.classpath=true \ No newline at end of file From 5ef8b11407306ab21cbec55df19dc7b19efbff8f Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 15 Dec 2025 21:18:11 +0000 Subject: [PATCH 17/21] Revert local Gradle ignore entries --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8e5ac0ea45..c3468500e0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,6 @@ *.iml .gradle -/.gradle-home -/.gradle-local /local.properties .DS_Store **/build/** From 9b2cdb8d07f95db9bf4da46e22c25bea874b6a08 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 15 Dec 2025 21:20:55 +0000 Subject: [PATCH 18/21] Move traceroute colors to core UI theme --- .../kotlin/org/meshtastic/core/ui/theme/CustomColors.kt | 5 +++++ .../fdroid/kotlin/org/meshtastic/feature/map/MapView.kt | 4 ++-- .../google/kotlin/org/meshtastic/feature/map/MapView.kt | 4 ++-- .../org/meshtastic/feature/map/model/TracerouteOverlay.kt | 7 ------- .../meshtastic/feature/node/metrics/TracerouteMapScreen.kt | 4 ++-- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index c72c0b2e41..2fa683acfa 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -27,6 +27,11 @@ val MeshtasticAlt = Color(0xFF2C2D3C) val HyperlinkBlue = Color(0xFF43C3B0) val AnnotationColor = Color(0xFF039BE5) +// High-contrast pair that stays legible on light/dark tiles and for most color-blind users. +// Use partial alpha so polylines don’t overpower markers/tiles. +val TracerouteOutgoingColor = Color(0xCCE86A00) // orange @ ~80% opacity +val TracerouteReturnColor = Color(0xCC0081C7) // cyan @ ~80% opacity + object IAQColors { val IAQExcellent = Color(0xFF00E400) val IAQGood = Color(0xFF92D050) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 35229435b8..4d3c3e526f 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -122,6 +122,8 @@ 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.TracerouteOutgoingColor +import org.meshtastic.core.ui.theme.TracerouteReturnColor import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer import org.meshtastic.feature.map.component.CacheLayout @@ -130,9 +132,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.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.feature.map.model.TracerouteReturnColor import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index c06326a9b9..ae11f0b05b 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -108,6 +108,8 @@ 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.TracerouteOutgoingColor +import org.meshtastic.core.ui.theme.TracerouteReturnColor import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.feature.map.component.ClusterItemsListDialog import org.meshtastic.feature.map.component.CustomMapLayersSheet @@ -117,9 +119,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.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.feature.map.model.TracerouteReturnColor import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt index 6832e2f272..3e803c6413 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt @@ -17,8 +17,6 @@ package org.meshtastic.feature.map.model -import androidx.compose.ui.graphics.Color - data class TracerouteOverlay( val requestId: Int, val forwardRoute: List = emptyList(), @@ -29,8 +27,3 @@ data class TracerouteOverlay( val hasRoutes: Boolean get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() } - -// High-contrast pair that stays legible on light/dark tiles and for most color-blind users. -// Use partial alpha so polylines don’t overpower markers/tiles. -val TracerouteOutgoingColor = Color(0xCCE86A00) // orange @ ~80% opacity -val TracerouteReturnColor = Color(0xCC0081C7) // cyan @ ~80% opacity diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index d7ee455c9c..16e0817bda 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -51,10 +51,10 @@ 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.TracerouteOutgoingColor +import org.meshtastic.core.ui.theme.TracerouteReturnColor import org.meshtastic.feature.map.MapView -import org.meshtastic.feature.map.model.TracerouteOutgoingColor import org.meshtastic.feature.map.model.TracerouteOverlay -import org.meshtastic.feature.map.model.TracerouteReturnColor @Composable fun TracerouteMapScreen( From a5bf17953219b672f4c3872270d6b89427274cb8 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 15 Dec 2025 21:31:26 +0000 Subject: [PATCH 19/21] Refactor traceroute colors into TracerouteColors --- .../org/meshtastic/core/ui/theme/CustomColors.kt | 10 ++++++---- .../kotlin/org/meshtastic/feature/map/MapView.kt | 7 +++---- .../kotlin/org/meshtastic/feature/map/MapView.kt | 11 +++++------ .../feature/node/metrics/TracerouteMapScreen.kt | 10 ++++++---- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 2fa683acfa..f15d1fb826 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -27,10 +27,12 @@ val MeshtasticAlt = Color(0xFF2C2D3C) val HyperlinkBlue = Color(0xFF43C3B0) val AnnotationColor = Color(0xFF039BE5) -// High-contrast pair that stays legible on light/dark tiles and for most color-blind users. -// Use partial alpha so polylines don’t overpower markers/tiles. -val TracerouteOutgoingColor = Color(0xCCE86A00) // orange @ ~80% opacity -val TracerouteReturnColor = Color(0xCC0081C7) // cyan @ ~80% opacity +object TracerouteColors { + // High-contrast pair that stays legible on light/dark tiles and for most color-blind users. + // Use partial alpha so polylines don’t overpower markers/tiles. + val OutgoingRoute = Color(0xCCE86A00) // orange @ ~80% opacity + val ReturnRoute = Color(0xCC0081C7) // cyan @ ~80% opacity +} object IAQColors { val IAQExcellent = Color(0xFF00E400) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 4d3c3e526f..d0bc420611 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -122,8 +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.TracerouteOutgoingColor -import org.meshtastic.core.ui.theme.TracerouteReturnColor +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 @@ -603,14 +602,14 @@ fun MapView( .takeIf { it.size >= 2 } ?.let { points -> traceroutePolylines.add( - buildPolyline(points, TracerouteOutgoingColor.toArgb(), with(density) { 6.dp.toPx() }), + buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }), ) } returnPoints .takeIf { it.size >= 2 } ?.let { points -> traceroutePolylines.add( - buildPolyline(points, TracerouteReturnColor.toArgb(), with(density) { 5.dp.toPx() }), + buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }), ) } overlays.addAll(traceroutePolylines) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index ae11f0b05b..4b08338533 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -108,8 +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.TracerouteOutgoingColor -import org.meshtastic.core.ui.theme.TracerouteReturnColor +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 @@ -451,7 +450,7 @@ fun MapView( Polyline( points = tracerouteForwardOffsetPoints, jointType = JointType.ROUND, - color = TracerouteOutgoingColor, + color = TracerouteColors.OutgoingRoute, width = 9f, zIndex = 1.5f, ) @@ -460,7 +459,7 @@ fun MapView( Polyline( points = tracerouteReturnOffsetPoints, jointType = JointType.ROUND, - color = TracerouteReturnColor, + color = TracerouteColors.ReturnRoute, width = 7f, zIndex = 1.4f, ) @@ -552,7 +551,7 @@ fun MapView( Polyline( points = tracerouteForwardOffsetPoints, jointType = JointType.ROUND, - color = TracerouteOutgoingColor, + color = TracerouteColors.OutgoingRoute, width = 9f, zIndex = 2f, ) @@ -561,7 +560,7 @@ fun MapView( Polyline( points = tracerouteReturnOffsetPoints, jointType = JointType.ROUND, - color = TracerouteReturnColor, + color = TracerouteColors.ReturnRoute, width = 7f, zIndex = 1.5f, ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 16e0817bda..25f728666f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -51,8 +51,7 @@ 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.TracerouteOutgoingColor -import org.meshtastic.core.ui.theme.TracerouteReturnColor +import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.feature.map.MapView import org.meshtastic.feature.map.model.TracerouteOverlay @@ -125,8 +124,11 @@ private fun TracerouteLegend(modifier: Modifier = Modifier) { modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - LegendRow(color = TracerouteOutgoingColor, label = stringResource(Res.string.traceroute_outgoing_route)) - LegendRow(color = TracerouteReturnColor, label = stringResource(Res.string.traceroute_return_route)) + LegendRow( + color = TracerouteColors.OutgoingRoute, + label = stringResource(Res.string.traceroute_outgoing_route), + ) + LegendRow(color = TracerouteColors.ReturnRoute, label = stringResource(Res.string.traceroute_return_route)) } } } From 137b106e3cc4f08c7fda69bdd8dcbe5c06efe42a Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 16 Dec 2025 16:06:52 +0000 Subject: [PATCH 20/21] Fix detekt ReturnCount in traceroute map availability --- .../src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt index d404adcd72..e27d2d3d8b 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/RouteDiscovery.kt @@ -103,7 +103,6 @@ fun evaluateTracerouteMapAvailability( returnRoute: List, positionedNodeNums: Set, ): TracerouteMapAvailability { - if (forwardRoute.isEmpty() && returnRoute.isEmpty()) return TracerouteMapAvailability.NoMappableNodes val endpoints = listOfNotNull( forwardRoute.firstOrNull(), From fd716287c55e331218d7d155b0f771a7666a5f57 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Tue, 16 Dec 2025 16:27:19 +0000 Subject: [PATCH 21/21] Fix detekt complexity and return count --- .../feature/node/metrics/MetricsViewModel.kt | 30 ++++--- .../feature/node/metrics/TracerouteLog.kt | 85 +++++++++++-------- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 4e8d6799fa..0f5427bbeb 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -124,18 +124,26 @@ constructor( fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { - tracerouteOverlayCache.value[requestId]?.let { - return it + 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) } } - val response = serviceRepository.tracerouteResponse.value ?: return null - if (response.requestId != requestId) return null - return TracerouteOverlay( - requestId = response.requestId, - forwardRoute = response.forwardRoute, - returnRoute = response.returnRoute, - ) - .takeIf { it.hasRoutes } - ?.also { overlay -> tracerouteOverlayCache.update { it + (requestId to overlay) } } + + return overlay } fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index c07a25d6c3..7be45b9b4e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -89,6 +89,8 @@ 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 @@ -103,41 +105,18 @@ fun TracerouteLogScreen( fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } - data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?) - var showDialog by remember { mutableStateOf(null) } var errorMessageRes by remember { mutableStateOf(null) } - showDialog?.let { dialog -> - SimpleAlertDialog( - title = Res.string.traceroute, - text = { SelectionContainer { Text(text = dialog.message) } }, - confirmText = stringResource(Res.string.view_on_map), - onConfirm = { - val availability = - viewModel.tracerouteMapAvailability( - forwardRoute = dialog.overlay?.forwardRoute.orEmpty(), - returnRoute = dialog.overlay?.returnRoute.orEmpty(), - ) - val errorRes = availability.toMessageRes() - if (errorRes == null) { - onViewOnMap(dialog.requestId) - } else { - errorMessageRes = errorRes - } - showDialog = null - }, - onDismiss = { showDialog = null }, - ) - } - errorMessageRes?.let { res -> - SimpleAlertDialog( - title = Res.string.traceroute, - text = { Text(text = stringResource(res)) }, - dismissText = stringResource(Res.string.close), - onDismiss = { errorMessageRes = null }, - ) - } + TracerouteLogDialogs( + dialog = showDialog, + errorMessageRes = errorMessageRes, + viewModel = viewModel, + onViewOnMap = onViewOnMap, + onShowErrorMessageRes = { errorMessageRes = it }, + onDismissDialog = { showDialog = null }, + onDismissError = { errorMessageRes = null }, + ) Scaffold( topBar = { @@ -205,10 +184,10 @@ fun TracerouteLogScreen( ?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) } - if (dialogMessage != null) { + dialogMessage?.let { showDialog = TracerouteDialog( - message = dialogMessage, + message = it, requestId = log.fromRadio.packet.id, overlay = overlay, ) @@ -227,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(