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 bdf9be6f5..60aac0452 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -52,10 +52,13 @@ import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.asDeviceVersion import org.meshtastic.core.datastore.UiPreferencesDataSource +import org.meshtastic.core.model.TracerouteMapAvailability +import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.service.IMeshService import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceRepository +import org.meshtastic.core.service.TracerouteResponse import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.client_notification import org.meshtastic.core.ui.component.ScrollToTopEvent @@ -152,6 +155,14 @@ constructor( private val _currentAlert: MutableStateFlow = 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, @@ -248,7 +259,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 85e0043ef..1847d37c2 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 f3fae7c9c..0372df12e 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 @@ -927,11 +929,12 @@ class MeshService : Service() { Portnums.PortNum.TRACEROUTE_APP_VALUE -> { Timber.d("Received TRACEROUTE_APP from $fromId") + val routeDiscovery = packet.fullRouteDiscovery val full = packet.getFullTracerouteResponse(::getUserName) if (full != null) { val requestId = packet.decoded.requestId val start = tracerouteStartTimes.remove(requestId) - val response = + val responseText = if (start != null) { val elapsedMs = System.currentTimeMillis() - start val seconds = elapsedMs / 1000.0 @@ -940,7 +943,19 @@ class MeshService : Service() { } else { full } - serviceRepository.setTracerouteResponse(response) + val destination = + routeDiscovery?.routeList?.firstOrNull() + ?: routeDiscovery?.routeBackList?.lastOrNull() + ?: 0 + serviceRepository.setTracerouteResponse( + TracerouteResponse( + message = responseText, + destinationNodeNum = destination, + requestId = requestId, + forwardRoute = routeDiscovery?.routeList.orEmpty(), + returnRoute = routeDiscovery?.routeBackList.orEmpty(), + ), + ) } } 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 8c3121155..50ddfed94 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -106,9 +106,11 @@ import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.toMessageRes import org.meshtastic.core.navigation.ConnectionsRoutes import org.meshtastic.core.navigation.ContactsRoutes import org.meshtastic.core.navigation.MapRoutes +import org.meshtastic.core.navigation.NodeDetailRoutes import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoutes @@ -117,6 +119,7 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.app_too_old import org.meshtastic.core.strings.bottom_nav_settings import org.meshtastic.core.strings.client_notification +import org.meshtastic.core.strings.close import org.meshtastic.core.strings.compromised_keys import org.meshtastic.core.strings.connected import org.meshtastic.core.strings.connecting @@ -133,6 +136,7 @@ import org.meshtastic.core.strings.okay import org.meshtastic.core.strings.should_update import org.meshtastic.core.strings.should_update_firmware import org.meshtastic.core.strings.traceroute +import org.meshtastic.core.strings.view_on_map import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.SimpleAlertDialog @@ -239,16 +243,49 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode } val traceRouteResponse by uIViewModel.tracerouteResponse.observeAsState() - traceRouteResponse?.let { response -> + var tracerouteMapError by remember { mutableStateOf(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 + uIViewModel.clearTracerouteResponse() + } + }, + dismissText = stringResource(Res.string.okay), + onDismiss = { + uIViewModel.clearTracerouteResponse() + dismissedTracerouteRequestId = null + }, + ) + } + tracerouteMapError?.let { res -> SimpleAlertDialog( title = Res.string.traceroute, - text = { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text(text = annotateTraceroute(response)) - } - }, - dismissText = stringResource(Res.string.okay), - onDismiss = { uIViewModel.clearTracerouteResponse() }, + text = { Text(text = stringResource(res)) }, + dismissText = stringResource(Res.string.close), + onDismiss = { tracerouteMapError = null }, ) } val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) 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 309db0618..e27d2d3d8 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,35 @@ fun MeshProtos.MeshPacket.getTracerouteResponse(getUser: (nodeNum: Int) -> Strin fun MeshProtos.MeshPacket.getFullTracerouteResponse(getUser: (nodeNum: Int) -> String): String? = fullRouteDiscovery ?.takeIf { it.routeList.isNotEmpty() && it.routeBackList.isNotEmpty() } ?.getTracerouteResponse(getUser) + +enum class TracerouteMapAvailability { + Ok, + MissingEndpoints, + NoMappableNodes, +} + +fun evaluateTracerouteMapAvailability( + forwardRoute: List, + returnRoute: List, + positionedNodeNums: Set, +): TracerouteMapAvailability { + val endpoints = + listOfNotNull( + forwardRoute.firstOrNull(), + forwardRoute.lastOrNull(), + returnRoute.firstOrNull(), + returnRoute.lastOrNull(), + ) + .distinct() + val missingEndpoint = endpoints.any { !positionedNodeNums.contains(it) } + if (missingEndpoint) return TracerouteMapAvailability.MissingEndpoints + val relatedNodeNums = (forwardRoute + returnRoute).toSet() + val hasAnyMappable = relatedNodeNums.any { positionedNodeNums.contains(it) } + return if (hasAnyMappable) TracerouteMapAvailability.Ok else TracerouteMapAvailability.NoMappableNodes +} + +fun TracerouteMapAvailability.toMessageRes(): StringResource? = when (this) { + TracerouteMapAvailability.Ok -> null + TracerouteMapAvailability.MissingEndpoints -> Res.string.traceroute_endpoint_missing + TracerouteMapAvailability.NoMappableNodes -> Res.string.traceroute_map_no_data +} 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 7032d4354..2d4b4c062 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 08864a28c..fa7162d1c 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 c5d07fdbd..977e66783 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -405,6 +405,12 @@ %1$d hops 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 @@ -1030,8 +1036,8 @@ 1 hour %1$d hours - - + + Compass Open Compass Distance: %1$s 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 c72c0b2e4..f15d1fb82 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,13 @@ val MeshtasticAlt = Color(0xFF2C2D3C) val HyperlinkBlue = Color(0xFF43C3B0) val AnnotationColor = Color(0xFF039BE5) +object TracerouteColors { + // High-contrast pair that stays legible on light/dark tiles and for most color-blind users. + // Use partial alpha so polylines 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) 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 3c1464d06..d0bc42061 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 @@ -120,6 +122,7 @@ import org.meshtastic.core.strings.waypoint_delete import org.meshtastic.core.strings.you import org.meshtastic.core.ui.component.BasicListItem import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.map.cluster.RadiusMarkerClusterer import org.meshtastic.feature.map.component.CacheLayout @@ -128,6 +131,7 @@ import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.model.CustomTileSource import org.meshtastic.feature.map.model.MarkerWithLabel +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint @@ -148,14 +152,19 @@ import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Polygon +import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.infowindow.InfoWindow import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay import timber.log.Timber import java.io.File import java.text.DateFormat +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin -@Composable -private fun MapView.UpdateMarkers( +private fun MapView.updateMarkers( nodeMarkers: List, waypointMarkers: List, nodeClusterer: RadiusMarkerClusterer, @@ -218,7 +227,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable -fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) { +fun MapView( + mapViewModel: MapViewModel = hiltViewModel(), + navigateToNodeDetails: (Int) -> Unit, + tracerouteOverlay: TracerouteOverlay? = null, + onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, +) { var mapFilterExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() @@ -320,6 +334,59 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) + val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } } + val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } + val nodesForMarkers = + if (tracerouteOverlay != null) { + nodes.filter { overlayNodeNums.contains(it.num) } + } else { + nodes + } + val tracerouteForwardPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.forwardRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + val tracerouteReturnPoints = + remember(tracerouteOverlay, nodeLookup) { + tracerouteOverlay?.returnRoute?.mapNotNull { + nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) } + } ?: emptyList() + } + LaunchedEffect(tracerouteOverlay, nodesForMarkers) { + if (tracerouteOverlay != null) { + onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size) + } + } + val tracerouteHeadingReferencePoints = + remember(tracerouteForwardPoints, tracerouteReturnPoints) { + when { + tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints + tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints + else -> emptyList() + } + } + val tracerouteForwardOffsetPoints = + remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { + offsetPolyline( + points = tracerouteForwardPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = tracerouteHeadingReferencePoints, + sideMultiplier = 1.0, + ) + } + val tracerouteReturnOffsetPoints = + remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { + offsetPolyline( + points = tracerouteReturnPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = tracerouteHeadingReferencePoints, + sideMultiplier = -1.0, + ) + } + val traceroutePolylines = remember { mutableStateListOf() } + var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } val markerIcon = remember { AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24) @@ -331,7 +398,12 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val displayUnits = mapViewModel.config.display.units val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> - if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { + if ( + mapFilterStateValue.onlyFavorites && + !node.isFavorite && + !overlayNodeNums.contains(node.num) && + !node.equals(ourNode) + ) { return@mapNotNull null } @@ -424,7 +496,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: mapViewModel.getUser(id).longName } - @Composable @Suppress("MagicNumber") fun MapView.onWaypointChanged(waypoints: Collection): List { val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) @@ -459,7 +530,10 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: MarkerWithLabel(this, label, emoji).apply { id = "${pt.id}" title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" - snippet = "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr" + snippet = + "[$time] ${pt.description} " + + com.meshtastic.core.strings.getString(Res.string.expires) + + ": $expireTimeStr" position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) setVisible(false) // This seems to be always false, was this intended? setOnLongClickListener { @@ -509,7 +583,52 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: invalidate() } - with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } + fun MapView.updateTracerouteOverlay(forwardPoints: List, 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, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }), + ) + } + returnPoints + .takeIf { it.size >= 2 } + ?.let { points -> + traceroutePolylines.add( + buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }), + ) + } + overlays.addAll(traceroutePolylines) + invalidate() + } + + LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { + if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() + if (allPoints.isNotEmpty()) { + if (allPoints.size == 1) { + map.controller.setCenter(allPoints.first()) + map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM) + } else { + map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true) + } + hasCenteredTraceroute = true + } + } fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } @@ -587,7 +706,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } }, modifier = Modifier.fillMaxSize(), - update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict + update = { mapView -> + mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints) + with(mapView) { + updateMarkers( + onNodesChanged(nodesForMarkers), + onWaypointChanged(waypoints.values), + nodeClusterer, + ) + } + mapView.drawOverlays() + }, // Renamed map to mapView to avoid conflict ) if (downloadRegionBoundingBox != null) { CacheLayout( @@ -943,3 +1072,54 @@ private fun MapsDialog( } } } + +private const val EARTH_RADIUS_METERS = 6_371_000.0 +private const val TRACEROUTE_OFFSET_METERS = 100.0 +private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0 +private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5 + +private fun Double.toRad(): Double = Math.toRadians(this) + +private fun bearingRad(from: GeoPoint, to: GeoPoint): Double { + val lat1 = from.latitude.toRad() + val lat2 = to.latitude.toRad() + val dLon = (to.longitude - from.longitude).toRad() + return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)) +} + +private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint { + val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS + val lat1 = latitude.toRad() + val lon1 = longitude.toRad() + val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad)) + val lon2 = + lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2)) + return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2)) +} + +private fun offsetPolyline( + points: List, + 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 dbf9b2df7..4b0833853 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 @@ -107,6 +108,7 @@ import org.meshtastic.core.strings.speed import org.meshtastic.core.strings.timestamp import org.meshtastic.core.strings.track_point import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.feature.map.component.ClusterItemsListDialog import org.meshtastic.feature.map.component.CustomMapLayersSheet @@ -116,6 +118,7 @@ import org.meshtastic.feature.map.component.MapControlsOverlay import org.meshtastic.feature.map.component.NodeClusterMarkers import org.meshtastic.feature.map.component.WaypointMarkers import org.meshtastic.feature.map.model.NodeClusterItem +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint @@ -123,10 +126,14 @@ import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint import timber.log.Timber import java.text.DateFormat +import kotlin.math.abs +import kotlin.math.max private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f private const val DEG_D = 1e-7 private const val HEADING_DEG = 1e-5 +private const val TRACEROUTE_OFFSET_METERS = 100.0 +private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 @Suppress("CyclomaticComplexMethod", "LongMethod") @OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @@ -136,6 +143,8 @@ fun MapView( navigateToNodeDetails: (Int) -> Unit, focusedNodeNum: Int? = null, nodeTracks: List? = null, + tracerouteOverlay: TracerouteOverlay? = null, + onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -253,6 +262,7 @@ fun MapView( .collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } val filteredNodes = allNodes @@ -263,8 +273,20 @@ fun MapView( node.num == ourNodeInfo?.num } + val displayNodes = + if (tracerouteOverlay != null) { + allNodes.filter { overlayNodeNums.contains(it.num) } + } else { + filteredNodes + } + LaunchedEffect(tracerouteOverlay, displayNodes) { + if (tracerouteOverlay != null) { + onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size) + } + } + val nodeClusterItems = - filteredNodes.map { node -> + displayNodes.map { node -> val latLng = LatLng(node.position.latitudeI * DEG_D, node.position.longitudeI * DEG_D) NodeClusterItem( node = node, @@ -287,6 +309,43 @@ fun MapView( true -> ComposeMapColorScheme.DARK else -> ComposeMapColorScheme.LIGHT } + val tracerouteForwardPoints = + remember(tracerouteOverlay, displayNodes) { + val nodeLookup = displayNodes.associateBy { it.num } + tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + } + val tracerouteReturnPoints = + remember(tracerouteOverlay, displayNodes) { + val nodeLookup = displayNodes.associateBy { it.num } + tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList() + } + val tracerouteHeadingReferencePoints = + remember(tracerouteForwardPoints, tracerouteReturnPoints) { + when { + tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints + tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints + else -> emptyList() + } + } + val tracerouteForwardOffsetPoints = + remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) { + offsetPolyline( + points = tracerouteForwardPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = tracerouteHeadingReferencePoints, + sideMultiplier = 1.0, + ) + } + val tracerouteReturnOffsetPoints = + remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) { + offsetPolyline( + points = tracerouteReturnPoints, + offsetMeters = TRACEROUTE_OFFSET_METERS, + headingReferencePoints = tracerouteHeadingReferencePoints, + sideMultiplier = -1.0, + ) + } + var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) } var showLayersBottomSheet by remember { mutableStateOf(false) } @@ -329,6 +388,26 @@ fun MapView( window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } + LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) { + if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect + val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct() + if (allPoints.isNotEmpty()) { + val cameraUpdate = + if (allPoints.size == 1) { + CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f)) + } else { + val bounds = LatLngBounds.builder() + allPoints.forEach { bounds.include(it) } + CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX) + } + try { + cameraPositionState.animate(cameraUpdate) + hasCenteredTraceroute = true + } catch (e: IllegalStateException) { + Timber.d("Error centering traceroute overlay: ${e.message}") + } + } + } Scaffold { paddingValues -> Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { @@ -367,6 +446,25 @@ fun MapView( } } + if (tracerouteForwardPoints.size >= 2) { + Polyline( + points = tracerouteForwardOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.OutgoingRoute, + width = 9f, + zIndex = 1.5f, + ) + } + if (tracerouteReturnPoints.size >= 2) { + Polyline( + points = tracerouteReturnOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.ReturnRoute, + width = 7f, + zIndex = 1.4f, + ) + } + if (nodeTracks != null && focusedNodeNum != null) { val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter val timeFilteredPositions = @@ -449,6 +547,25 @@ fun MapView( ) } + if (tracerouteForwardPoints.size >= 2) { + Polyline( + points = tracerouteForwardOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.OutgoingRoute, + width = 9f, + zIndex = 2f, + ) + } + if (tracerouteReturnPoints.size >= 2) { + Polyline( + points = tracerouteReturnOffsetPoints, + jointType = JointType.ROUND, + color = TracerouteColors.ReturnRoute, + width = 7f, + zIndex = 1.5f, + ) + } + WaypointMarkers( displayableWaypoints = displayableWaypoints, mapFilterState = mapFilterState, @@ -696,3 +813,33 @@ internal fun Position.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.l private fun Node.toLatLng(): LatLng? = this.position.toLatLng() private fun Waypoint.toLatLng(): LatLng = LatLng(this.latitudeI * DEG_D, this.longitudeI * DEG_D) + +private fun offsetPolyline( + points: List, + 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) + } +} 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 000000000..3e803c641 --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/model/TracerouteOverlay.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.map.model + +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() +} diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index ae51361bc..f9e00c072 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/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 000000000..2a35798f3 --- /dev/null +++ b/feature/node/src/fdroid/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp + +internal object TracerouteMapOverlayInsets { + val overlayAlignment: Alignment = Alignment.BottomEnd + val overlayPadding: PaddingValues = PaddingValues(end = 16.dp, bottom = 16.dp) + val contentHorizontalAlignment: Alignment.Horizontal = Alignment.End +} 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 000000000..ad5d33784 --- /dev/null +++ b/feature/node/src/google/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapOverlayInsets.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp + +internal object TracerouteMapOverlayInsets { + val overlayAlignment: Alignment = Alignment.BottomCenter + val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp) + val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally +} 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 004637957..0f5427bbe 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,12 +47,15 @@ import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.TracerouteMapAvailability +import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.navigation.NodesRoutes import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.fallback_node_name import org.meshtastic.core.ui.util.toPosition +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.model.MetricsState import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.MeshProtos @@ -92,6 +95,8 @@ constructor( private var jobs: Job? = null + private val tracerouteOverlayCache = MutableStateFlow>(emptyMap()) + private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } @@ -118,6 +123,60 @@ constructor( fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) } + fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? { + val cached = tracerouteOverlayCache.value[requestId] + if (cached != null) return cached + + val overlay = + serviceRepository.tracerouteResponse.value + ?.takeIf { it.requestId == requestId } + ?.let { response -> + TracerouteOverlay( + requestId = response.requestId, + forwardRoute = response.forwardRoute, + returnRoute = response.returnRoute, + ) + } + ?.takeIf { it.hasRoutes } + + if (overlay != null) { + tracerouteOverlayCache.update { it + (requestId to overlay) } + } + + return overlay + } + + fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() + + fun tracerouteMapAvailability(forwardRoute: List, 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 -> + 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 6d7ade487..7be45b9b4 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 @@ -51,7 +52,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -61,17 +61,21 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse +import org.meshtastic.core.model.toMessageRes import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.close import org.meshtastic.core.strings.delete import org.meshtastic.core.strings.routing_error_no_response import org.meshtastic.core.strings.traceroute import org.meshtastic.core.strings.traceroute_diff import org.meshtastic.core.strings.traceroute_direct import org.meshtastic.core.strings.traceroute_hops +import org.meshtastic.core.strings.view_on_map import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD @@ -80,10 +84,13 @@ import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusYellow +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.MeshProtos import java.text.DateFormat +private data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?) + @OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod") @Composable @@ -91,22 +98,25 @@ fun TracerouteLogScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit, + onViewOnMap: (requestId: Int) -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) } fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } - var showDialog by remember { mutableStateOf(null) } + var showDialog by remember { mutableStateOf(null) } + var errorMessageRes by remember { mutableStateOf(null) } - if (showDialog != null) { - val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown - SimpleAlertDialog( - title = Res.string.traceroute, - text = { SelectionContainer { Text(text = message) } }, - onDismiss = { showDialog = null }, - ) - } + TracerouteLogDialogs( + dialog = showDialog, + errorMessageRes = errorMessageRes, + viewModel = viewModel, + onViewOnMap = onViewOnMap, + onShowErrorMessageRes = { errorMessageRes = it }, + onDismissDialog = { showDialog = null }, + onDismissError = { errorMessageRes = null }, + ) Scaffold( topBar = { @@ -154,6 +164,14 @@ fun TracerouteLogScreen( res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) } } } + val overlay = + route?.let { + TracerouteOverlay( + requestId = log.fromRadio.packet.id, + forwardRoute = it.routeList, + returnRoute = it.routeBackList, + ) + } Box { TracerouteItem( @@ -161,14 +179,18 @@ fun TracerouteLogScreen( text = "$time - $text", modifier = Modifier.combinedClickable(onLongClick = { expanded = true }) { - if (tracerouteDetailsAnnotated != null) { - showDialog = tracerouteDetailsAnnotated - } else if (result != null) { - // Fallback for results that couldn't be fully annotated but have basic info - val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername) - if (basicInfo != null) { - showDialog = AnnotatedString(basicInfo) - } + val dialogMessage = + tracerouteDetailsAnnotated + ?: result?.fromRadio?.packet?.getTracerouteResponse(::getUsername)?.let { + AnnotatedString(it) + } + dialogMessage?.let { + showDialog = + TracerouteDialog( + message = it, + requestId = log.fromRadio.packet.id, + overlay = overlay, + ) } }, ) @@ -184,6 +206,44 @@ fun TracerouteLogScreen( } } +@Composable +private fun TracerouteLogDialogs( + dialog: TracerouteDialog?, + errorMessageRes: StringResource?, + viewModel: MetricsViewModel, + onViewOnMap: (requestId: Int) -> Unit, + onShowErrorMessageRes: (StringResource) -> Unit, + onDismissDialog: () -> Unit, + onDismissError: () -> Unit, +) { + dialog?.let { dialogState -> + SimpleAlertDialog( + title = Res.string.traceroute, + text = { SelectionContainer { Text(text = dialogState.message) } }, + confirmText = stringResource(Res.string.view_on_map), + onConfirm = { + val availability = + viewModel.tracerouteMapAvailability( + forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(), + returnRoute = dialogState.overlay?.returnRoute.orEmpty(), + ) + availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId) + onDismissDialog() + }, + onDismiss = onDismissDialog, + ) + } + + errorMessageRes?.let { res -> + SimpleAlertDialog( + title = Res.string.traceroute, + text = { Text(text = stringResource(res)) }, + dismissText = stringResource(Res.string.close), + onDismiss = onDismissError, + ) + } +} + @Composable private fun DeleteItem(onClick: () -> Unit) { DropdownMenuItem( @@ -205,13 +265,12 @@ private fun DeleteItem(onClick: () -> Unit) { @Composable private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) { Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute)) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, style = MaterialTheme.typography.bodyLarge) + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute)) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = text, style = MaterialTheme.typography.bodyLarge) + } } } } 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 000000000..25f728666 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Route +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.fullRouteDiscovery +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.traceroute +import org.meshtastic.core.strings.traceroute_outgoing_route +import org.meshtastic.core.strings.traceroute_return_route +import org.meshtastic.core.strings.traceroute_showing_nodes +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.theme.TracerouteColors +import org.meshtastic.feature.map.MapView +import org.meshtastic.feature.map.model.TracerouteOverlay + +@Composable +fun TracerouteMapScreen( + metricsViewModel: MetricsViewModel = hiltViewModel(), + requestId: Int, + onNavigateUp: () -> Unit, +) { + val state by metricsViewModel.state.collectAsStateWithLifecycle() + val nodeTitle = state.node?.user?.longName ?: stringResource(Res.string.traceroute) + val routeDiscovery = + state.tracerouteResults + .find { it.fromRadio.packet.decoded.requestId == requestId } + ?.fromRadio + ?.packet + ?.fullRouteDiscovery + val overlayFromLogs = + remember(routeDiscovery) { + routeDiscovery?.let { + TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList) + } + } + val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) } + val overlay = overlayFromLogs ?: overlayFromService + var tracerouteNodesShown by remember { mutableStateOf(0) } + var tracerouteNodesTotal by remember { mutableStateOf(0) } + LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() } + + Scaffold( + topBar = { + MainAppBar( + title = nodeTitle, + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + MapView( + navigateToNodeDetails = {}, + tracerouteOverlay = overlay, + onTracerouteMappableCountChanged = { shown, total -> + tracerouteNodesShown = shown + tracerouteNodesTotal = total + }, + ) + Column( + modifier = + Modifier.align(TracerouteMapOverlayInsets.overlayAlignment) + .padding(TracerouteMapOverlayInsets.overlayPadding), + horizontalAlignment = TracerouteMapOverlayInsets.contentHorizontalAlignment, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal) + TracerouteLegend() + } + } + } +} + +@Composable +private fun TracerouteLegend(modifier: Modifier = Modifier) { + Card(modifier = modifier) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + LegendRow( + color = TracerouteColors.OutgoingRoute, + label = stringResource(Res.string.traceroute_outgoing_route), + ) + LegendRow(color = TracerouteColors.ReturnRoute, label = stringResource(Res.string.traceroute_return_route)) + } + } +} + +@Composable +private fun TracerouteNodeCount(modifier: Modifier = Modifier, shown: Int, total: Int) { + Card(modifier = modifier) { + Text( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + text = stringResource(Res.string.traceroute_showing_nodes, shown, total), + style = MaterialTheme.typography.labelMedium, + ) + } +} + +@Composable +private fun LegendRow(color: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Route, + contentDescription = null, + tint = color, + modifier = Modifier.padding(end = 8.dp).size(18.dp), + ) + Text(text = label, style = MaterialTheme.typography.labelMedium) + } +}