diff --git a/androidApp/detekt-baseline.xml b/androidApp/detekt-baseline.xml
index c1fd804fb9..69c4894c05 100644
--- a/androidApp/detekt-baseline.xml
+++ b/androidApp/detekt-baseline.xml
@@ -24,7 +24,6 @@
MatchingDeclarationName:MapView.kt:GoogleMapMode
ModifierMissing:DownloadButton.kt:@Composable fun DownloadButton
ModifierMissing:Main.kt:@Composable fun MainScreen
- ModifierMissing:NodeMapScreen.kt:@Composable fun NodeMapScreen
ModifierMissing:WaypointMarkers.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun WaypointMarkers
ReturnCount:GooglePlatformAnalytics.kt:GooglePlatformAnalytics.CrashlyticsLogWriter$override fun log
ReturnCount:MapViewModel.kt:MapViewModel$fun getTileProvider: TileProvider?
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
deleted file mode 100644
index 3e854399ce..0000000000
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright (c) 2026 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.app.map.node
-
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.meshtastic.app.map.androidCustomRasterBasemaps
-import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.feature.map.maplibre.MapLibreNodeTrackMap
-import org.meshtastic.feature.map.node.NodeMapViewModel
-
-@Composable
-fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
- val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
-
- Scaffold(
- topBar = {
- MainAppBar(
- title = node?.user?.long_name ?: "",
- ourNode = null,
- showNodeChip = false,
- canNavigateUp = true,
- onNavigateUp = onNavigateUp,
- actions = {},
- onClickChip = {},
- )
- },
- ) { paddingValues ->
- // Only once the node has resolved: 0 is not a node, and a placeholder paints the track in the wrong
- // colour with no head chip.
- node?.let { resolved ->
- MapLibreNodeTrackMap(
- destNum = resolved.num,
- positions = positions,
- modifier = Modifier.fillMaxSize().padding(paddingValues),
- customBasemaps = { androidCustomRasterBasemaps() },
- )
- }
- }
-}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
deleted file mode 100644
index 19ce8cff75..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright (c) 2026 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.app.map.node
-
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.meshtastic.app.map.GoogleMapMode
-import org.meshtastic.app.map.MapView
-import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.feature.map.node.NodeMapViewModel
-
-@Composable
-fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
- val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
-
- Scaffold(
- topBar = {
- MainAppBar(
- title = node?.user?.long_name ?: "",
- ourNode = null,
- showNodeChip = false,
- canNavigateUp = true,
- onNavigateUp = onNavigateUp,
- actions = {},
- onClickChip = {},
- )
- },
- ) { paddingValues ->
- MapView(
- modifier = Modifier.fillMaxSize().padding(paddingValues),
- mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
- )
- }
-}
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 4036bca174..3572943137 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -53,7 +53,6 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
-import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.node.component.InlineMap
@@ -92,12 +91,10 @@ import org.meshtastic.core.ui.util.LocalNfcEmulatorProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
-import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
-import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.accentColorOrNull
import org.meshtastic.core.ui.util.brandHighlightOrNull
import org.meshtastic.core.ui.util.brandPalette
@@ -109,9 +106,6 @@ import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.layers.MapLayersManager
import org.meshtastic.feature.map.layers.toPickedMapFile
-import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.meshtastic.feature.node.metrics.MetricsViewModel
-import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : AppCompatActivity() {
private val model: UIViewModel by viewModel()
@@ -283,24 +277,6 @@ class MainActivity : AppCompatActivity() {
{ userLat, userLon, nodes, modifier ->
org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier)
},
- LocalNodeMapScreenProvider provides
- { destNum, onNavigateUp ->
- val vm = koinViewModel()
- vm.setDestNum(destNum)
- org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
- },
- LocalTracerouteMapScreenProvider provides
- { destNum, requestId, logUuid, onNavigateUp ->
- val metricsViewModel = koinViewModel { parametersOf(destNum) }
- metricsViewModel.setNodeId(destNum)
-
- TracerouteMapScreen(
- metricsViewModel = metricsViewModel,
- requestId = requestId,
- logUuid = logUuid,
- onNavigateUp = onNavigateUp,
- )
- },
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
val viewModel = koinViewModel()
diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml
index fd2d325511..8c44ce91a6 100644
--- a/core/ui/detekt-baseline.xml
+++ b/core/ui/detekt-baseline.xml
@@ -23,11 +23,9 @@
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } }
- CompositionLocalAllowlist:LocalNodeMapScreenProvider.kt:/** * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeMapScreenProvider = compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } }
- CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded * inside another screen layout (e.g. the position-log adaptive layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded * instance collapses it; a full-screen one does not. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } }
+ CompositionLocalAllowlist:LocalNodeTrackMapProvider.kt:/** * Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions]. * It has no Scaffold or AppBar, so it can be embedded inside another screen layout (e.g. the position-log adaptive * layout). * * Supports optional synchronized selection: * - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When * non-null, the map should visually highlight the corresponding marker and center the camera on it. * - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so * the host can synchronize the card list. * - [showAttribution]: whether the credit opens with the map or stays collapsed behind its own button. The embedded * instance collapses it; a full-screen one does not. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalNodeTrackMapProvider = compositionLocalOf< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> PlaceholderScreen("Position Track Map") } }
CompositionLocalAllowlist:LocalTracerouteMapOverlayInsetsProvider.kt:val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() }
CompositionLocalAllowlist:LocalTracerouteMapProvider.kt:/** * Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a * traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location * tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s * scaffold. * * On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen]. * * Parameters: * - `tracerouteOverlay`: The overlay with forward/return route node nums. * - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes. * - `onMappableCountChanged`: Callback with (shown, total) node counts. * - `modifier`: Compose modifier for the map. */ @Suppress("Wrapping") val LocalTracerouteMapProvider = compositionLocalOf< @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map<Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }
- CompositionLocalAllowlist:LocalTracerouteMapScreenProvider.kt:/** * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available * yet, it falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalTracerouteMapScreenProvider = compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }
CompositionLocalAllowlist:MapViewProvider.kt:val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }
FunctionTypeModifierSpacing:Theme.kt:@Composable()
LambdaParameterInRestartableEffect:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
deleted file mode 100644
index 7e54003a52..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2026 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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import org.meshtastic.core.ui.component.PlaceholderScreen
-
-/**
- * Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
- * targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalNodeMapScreenProvider =
- compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
- { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
index 8319ec11e5..6f1ea6d7c4 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
@@ -24,8 +24,8 @@ import org.meshtastic.proto.Position
/**
* Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions].
- * Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded
- * inside another screen layout (e.g. the position-log adaptive layout).
+ * It has no Scaffold or AppBar, so it can be embedded inside another screen layout (e.g. the position-log adaptive
+ * layout).
*
* Supports optional synchronized selection:
* - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
deleted file mode 100644
index 26eb02b7e8..0000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright (c) 2026 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.core.ui.util
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.compositionLocalOf
-import org.meshtastic.core.ui.component.PlaceholderScreen
-
-/**
- * Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available
- * yet, it falls back to a [PlaceholderScreen].
- */
-@Suppress("Wrapping")
-val LocalTracerouteMapScreenProvider =
- compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> {
- { _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
- }
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 4530b31116..3d9fe0a654 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -17,6 +17,7 @@
package org.meshtastic.desktop
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -105,7 +106,9 @@ import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
+import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
+import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
import org.meshtastic.core.ui.util.rememberOpenUrl
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
@@ -471,6 +474,9 @@ private fun ApplicationScope.MeshtasticWindow(
{ overlay, nodePositions, onMappableCountChanged, modifier ->
DesktopTracerouteMap(overlay, nodePositions, onMappableCountChanged, modifier)
},
+ // Clear of the MapLibre logo and attribution row along the bottom edge.
+ LocalTracerouteMapOverlayInsetsProvider provides
+ TracerouteMapOverlayInsets(overlayPadding = PaddingValues(bottom = 48.dp)),
) {
AppTheme(darkTheme = isDarkTheme) { DesktopMainScreen(uiViewModel, multiBackstack) }
}
diff --git a/docs/en/user/node-metrics.md b/docs/en/user/node-metrics.md
index afe552ffe8..9d10a36a2b 100644
--- a/docs/en/user/node-metrics.md
+++ b/docs/en/user/node-metrics.md
@@ -2,7 +2,7 @@
title: Node Metrics
parent: User Guide
nav_order: 5
-last_updated: 2026-09-09
+last_updated: 2026-09-28
description: Telemetry dashboards for each mesh node — device health, environment sensors, air quality, signal quality, power, traceroute, and position history.
aliases:
- metrics
@@ -152,7 +152,7 @@ Traceroute shows the path a message takes through the mesh:
### Reading Traceroute Results
-A traceroute is a round trip, so each saved result carries a hop count in each direction — **Forward Hops** and **Return Hops** — and the **Round Trip** time in seconds. A result marked **Direct** reached the target with no relay in between. Tap a result to read the route traced toward the destination and the route traced back to you, with the SNR of every hop. On Android that view offers **View on map**, which draws the same path, as long as the start and destination nodes have both shared a position.
+A traceroute is a round trip, so each saved result carries a hop count in each direction, **Forward Hops** and **Return Hops**, plus the **Round Trip** time in seconds. A result marked **Direct** reached the target with no relay in between. Tap a result to read the route traced toward the destination and the route traced back to you, with the SNR of every hop. That view offers **View on map**, which draws the same path, as long as the start and destination nodes have both shared a position.
A result marked **No Response** means the target never answered. It may be out of range, asleep, or configured not to reply. Wait for the 30-second cooldown to clear and try again; if it keeps failing, send a direct message first to confirm the node is reachable at all.
diff --git a/feature/map/README.md b/feature/map/README.md
index 3010765b84..a8d20a9d22 100644
--- a/feature/map/README.md
+++ b/feature/map/README.md
@@ -23,7 +23,7 @@ All providers are injected via `CompositionLocal` — in `MainActivity.kt` on An
### Shared ViewModels (in `commonMain`)
- **`BaseMapViewModel`** — Core contract for all map state management, node markers, camera positions, and traceroute node selection logic (`TracerouteNodeSelection`, `tracerouteNodeSelection()`).
-- **`NodeMapViewModel`** — Shared logic for per-node map views (track display, position history).
+- **`NodeMapViewModel`**: resolves a node number to its `Node` for the Google flavor's embedded node-track map.
### Shared Logic (in `commonMain`)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
index 6204dc5ecf..e8d219ba2a 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapViewModel.kt
@@ -19,31 +19,16 @@ package org.meshtastic.feature.map.node
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.toList
import org.koin.core.annotation.KoinViewModel
-import org.meshtastic.core.model.MeshLog
-import org.meshtastic.core.repository.MapPrefs
-import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Position
@KoinViewModel
-class NodeMapViewModel(
- savedStateHandle: SavedStateHandle,
- nodeRepository: NodeRepository,
- meshLogRepository: MeshLogRepository,
- private val mapPrefs: MapPrefs,
-) : ViewModel() {
+class NodeMapViewModel(savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository) : ViewModel() {
private val destNumFromRoute = savedStateHandle.get("destNum")
private val manualDestNum = MutableStateFlow(null)
@@ -59,28 +44,4 @@ class NodeMapViewModel(
.flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = null)
-
- private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
-
- val positionLogs: StateFlow> =
- combine(ourNodeNumFlow, destNumFlow) { ourNodeNum, destNum ->
- if (destNum == ourNodeNum) MeshLog.NODE_NUM_LOCAL else destNum
- }
- .distinctUntilChanged()
- .flatMapLatest { logId ->
- meshLogRepository.getMeshPacketsFrom(logId, PortNum.POSITION_APP.value).map { packets ->
- packets
- .mapNotNull { it.toPosition() }
- .asFlow()
- .distinctUntilChanged { old, new ->
- old.time == new.time ||
- (old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
- }
- .toList()
- }
- }
- .stateInWhileSubscribed(initialValue = emptyList())
-
- val mapStyleId: Int
- get() = mapPrefs.mapStyle.value
}
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
similarity index 100%
rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index 6d5a618794..ee312702b4 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -69,6 +69,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
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -128,12 +129,14 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
}
entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
- val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
- tracerouteMapScreen(
- args.destNum,
- args.requestId,
- args.logUuid,
- dropUnlessResumed { backStack.removeLastOrNull() },
+ val metricsViewModel = koinViewModel { parametersOf(args.destNum) }
+ metricsViewModel.setNodeId(args.destNum)
+
+ TracerouteMapScreen(
+ metricsViewModel = metricsViewModel,
+ requestId = args.requestId,
+ logUuid = args.logUuid,
+ onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
@@ -188,7 +191,6 @@ private inline fun EntryProviderScope.addNodeDetailS
}
}
-/** Expect declaration for the platform-specific traceroute map screen. */
enum class NodeDetailScreen(
val title: StringResource,
val routeClass: KClass,
diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt
new file mode 100644
index 0000000000..e175bec006
--- /dev/null
+++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapRouteTest.kt
@@ -0,0 +1,186 @@
+/*
+ * Copyright (c) 2026 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.navigation
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.koin.compose.KoinIsolatedContext
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.koinApplication
+import org.koin.dsl.module
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.navigation.NodeDetailRoute
+import org.meshtastic.core.repository.FileService
+import org.meshtastic.core.repository.MeshLogRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.TracerouteResponseProvider
+import org.meshtastic.core.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.traceroute_showing_nodes
+import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.util.AlertManager
+import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
+import org.meshtastic.feature.node.detail.NodeDetailUiState
+import org.meshtastic.feature.node.detail.NodeRequestActions
+import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
+import org.meshtastic.feature.node.metrics.MetricsViewModel
+import org.meshtastic.feature.node.model.MetricsState
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class)
+class TracerouteMapRouteTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun tracerouteMapRouteRendersTheMapScreenAroundTheHostMap() = runComposeUiTest {
+ val route = NodeDetailRoute.TracerouteMap(destNum = NODE_NUM, requestId = REQUEST_ID)
+ var factoryDestNum: Int? = null
+ val koinApp = koinApplication {
+ modules(
+ module {
+ viewModel { params ->
+ createMetricsViewModel(destNum = params.get().also { factoryDestNum = it })
+ }
+ },
+ )
+ }
+ val viewModelStoreOwner = TestViewModelStoreOwner()
+ val lifecycleOwner = ResumedLifecycleOwner()
+ try {
+ setContent {
+ val backStack = remember { NavBackStack().apply { add(route) } }
+ KoinIsolatedContext(context = koinApp) {
+ CompositionLocalProvider(
+ LocalViewModelStoreOwner provides viewModelStoreOwner,
+ LocalLifecycleOwner provides lifecycleOwner,
+ LocalTracerouteMapProvider provides
+ { _, _, onMappableCountChanged, modifier ->
+ LaunchedEffect(Unit) { onMappableCountChanged(SHOWN_NODES, TOTAL_NODES) }
+ Text(text = FAKE_MAP, modifier = modifier)
+ },
+ ) {
+ MaterialTheme {
+ MeshtasticNavDisplay(
+ backStack = backStack,
+ entryProvider = entryProvider { nodeDetailGraph(backStack) },
+ )
+ }
+ }
+ }
+ }
+
+ onNodeWithText(PLACEHOLDER).assertDoesNotExist()
+ onNodeWithText(FAKE_MAP).assertExists()
+ onNodeWithText(NODE_NAME).assertExists()
+ onNodeWithText(getString(Res.string.traceroute_showing_nodes, SHOWN_NODES, TOTAL_NODES)).assertExists()
+ assertEquals(NODE_NUM, factoryDestNum)
+ } finally {
+ viewModelStoreOwner.viewModelStore.clear()
+ koinApp.close()
+ }
+ }
+
+ private fun createMetricsViewModel(destNum: Int): MetricsViewModel {
+ val tracerouteResponseProvider: TracerouteResponseProvider = mock()
+ every { tracerouteResponseProvider.tracerouteResponse } returns MutableStateFlow(null)
+ every { tracerouteResponseProvider.clearTracerouteResponse() } returns Unit
+ val nodeRequestActions: NodeRequestActions = mock()
+ every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null)
+ every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap())
+ val nodeRepository: NodeRepository = mock()
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ val node = Node(num = NODE_NUM, user = User.Builder().also { wb -> wb.long_name = NODE_NAME }.build())
+ val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
+ every { getNodeDetailsUseCase(NODE_NUM) } returns
+ flowOf(NodeDetailUiState(node = node, metricsState = MetricsState(node = node)))
+
+ return MetricsViewModel(
+ destNum = destNum,
+ dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher),
+ meshLogRepository = mock(),
+ tracerouteResponseProvider = tracerouteResponseProvider,
+ nodeRepository = nodeRepository,
+ tracerouteSnapshotRepository = mock(),
+ nodeRequestActions = nodeRequestActions,
+ alertManager = mock(),
+ getNodeDetailsUseCase = getNodeDetailsUseCase,
+ fileService = mock(),
+ )
+ }
+
+ private class TestViewModelStoreOwner : ViewModelStoreOwner {
+ override val viewModelStore = ViewModelStore()
+ }
+
+ private class ResumedLifecycleOwner : LifecycleOwner {
+ override val lifecycle: LifecycleRegistry =
+ LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED }
+ }
+
+ private companion object {
+ const val NODE_NUM = 1234
+ const val NODE_NAME = "Route target"
+ const val REQUEST_ID = 42
+ const val SHOWN_NODES = 2
+ const val TOTAL_NODES = 3
+ const val FAKE_MAP = "host traceroute map"
+ const val PLACEHOLDER = "Traceroute Map"
+ }
+}