fix(node): show the real traceroute map on desktop (#7425)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 12:40:38 +00:00
1 parent 2c33852f93
commit c82cf1be66
15 files changed
+208 -256

No files matched your search

-1
View File
@@ -24,7 +24,6 @@
<ID>MatchingDeclarationName:MapView.kt:GoogleMapMode</ID>
<ID>ModifierMissing:DownloadButton.kt:@Composable fun DownloadButton</ID>
<ID>ModifierMissing:Main.kt:@Composable fun MainScreen</ID>
<ID>ModifierMissing:NodeMapScreen.kt:@Composable fun NodeMapScreen</ID>
<ID>ModifierMissing:WaypointMarkers.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun WaypointMarkers</ID>
<ID>ReturnCount:GooglePlatformAnalytics.kt:GooglePlatformAnalytics.CrashlyticsLogWriter$override fun log</ID>
<ID>ReturnCount:MapViewModel.kt:MapViewModel$fun getTileProvider: TileProvider?</ID>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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() },
)
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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),
)
}
}
@@ -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<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
val viewModel = koinViewModel<SharedMapViewModel>()
+1 -3
View File
@@ -23,11 +23,9 @@
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf&lt;@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }</ID>
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf&lt;@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } }</ID>
<ID>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&lt;@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> { { destNum, _ -> PlaceholderScreen("Node Map ($destNum)") } }</ID>
<ID>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&lt; @Composable ( destNum: Int, positions: List&lt;Position&gt;, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -&gt; Unit)?, showAttribution: Boolean, ) -&gt; Unit, &gt; { { _, _, _, _, _, _ -&gt; PlaceholderScreen("Position Track Map") } }</ID>
<ID>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&lt; @Composable ( destNum: Int, positions: List&lt;Position&gt;, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -&gt; Unit)?, showAttribution: Boolean, ) -&gt; Unit, &gt; { { _, _, _, _, _, _ -&gt; PlaceholderScreen("Position Track Map") } }</ID>
<ID>CompositionLocalAllowlist:LocalTracerouteMapOverlayInsetsProvider.kt:val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() }</ID>
<ID>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&lt; @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map&lt;Int, Position>, onMappableCountChanged: (Int, Int) -> Unit, modifier: Modifier, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
<ID>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&lt;@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
<ID>CompositionLocalAllowlist:MapViewProvider.kt:val LocalMapViewProvider = compositionLocalOf&lt;MapViewProvider?> { null }</ID>
<ID>FunctionTypeModifierSpacing:Theme.kt:@Composable()</ID>
<ID>LambdaParameterInRestartableEffect:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit</ID>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)") }
}
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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") }
}
@@ -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) }
}
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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`)
@@ -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<Int>("destNum")
private val manualDestNum = MutableStateFlow<Int?>(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<List<Position>> =
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
}
@@ -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<NavKey>.nodeDetailGraph(backStack: NavBackStack<NavKey>)
}
entry<NodeDetailRoute.TracerouteMap>(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<MetricsViewModel> { 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 <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
}
}
/** Expect declaration for the platform-specific traceroute map screen. */
enum class NodeDetailScreen(
val title: StringResource,
val routeClass: KClass<out Route>,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Int>().also { factoryDestNum = it })
}
},
)
}
val viewModelStoreOwner = TestViewModelStoreOwner()
val lifecycleOwner = ResumedLifecycleOwner()
try {
setContent {
val backStack = remember { NavBackStack<NavKey>().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<MeshLogRepository>(),
tracerouteResponseProvider = tracerouteResponseProvider,
nodeRepository = nodeRepository,
tracerouteSnapshotRepository = mock<TracerouteSnapshotRepository>(),
nodeRequestActions = nodeRequestActions,
alertManager = mock<AlertManager>(),
getNodeDetailsUseCase = getNodeDetailsUseCase,
fileService = mock<FileService>(),
)
}
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"
}
}