mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix(node): show the real traceroute map on desktop (#7425)
This commit is contained in:
1 parent
2c33852f93
commit
c82cf1be66
15 files changed
+208
-256
No files matched your search
@@ -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>()
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }</ID>
|
||||
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }</ID>
|
||||
<ID>CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@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<@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< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> 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< @Composable ( destNum: Int, positions: List<Position>, modifier: Modifier, selectedPositionTime: Int?, onPositionSelected: ((Int) -> Unit)?, showAttribution: Boolean, ) -> Unit, > { { _, _, _, _, _, _ -> 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< @Composable ( tracerouteOverlay: TracerouteOverlay?, tracerouteNodePositions: Map<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<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> { { _, _, _, _ -> PlaceholderScreen("Traceroute Map") } }</ID>
|
||||
<ID>CompositionLocalAllowlist:MapViewProvider.kt:val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }</ID>
|
||||
<ID>FunctionTypeModifierSpacing:Theme.kt:@Composable()</ID>
|
||||
<ID>LambdaParameterInRestartableEffect:EmojiPickerDialog.kt:onCategoryChanged: (Int) -> Unit</ID>
|
||||
|
||||
-31
@@ -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)") }
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
-31
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
+1
-40
@@ -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
|
||||
}
|
||||
File renamed without changes.
+9
-7
@@ -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>,
|
||||
|
||||
+186
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user