mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)
This commit is contained in:
parent
56332f4d77
commit
520fa717a9
71 changed files with 3464 additions and 2169 deletions
|
|
@ -1,24 +1,41 @@
|
|||
# `:feature:map`
|
||||
|
||||
## Overview
|
||||
The `:feature:map` module provides the mapping interface for the application. It supports multiple map providers and displays node positions, tracks, and waypoints.
|
||||
The `:feature:map` module provides the mapping interface for the application. Map rendering is decomposed into three focused `CompositionLocal` provider contracts, each with per-flavor implementations in `:app`.
|
||||
|
||||
## Key Components
|
||||
## Architecture
|
||||
|
||||
### 1. `MapScreen`
|
||||
The main mapping interface. It integrates with flavor-specific map implementations (Google Maps for `google`, OpenStreetMap for `fdroid`).
|
||||
### Provider Contracts (in `core:ui/commonMain`)
|
||||
|
||||
### 2. `BaseMapViewModel`
|
||||
The base logic for managing map state, node markers, and camera positions.
|
||||
| Contract | Purpose | Implementations |
|
||||
|---|---|---|
|
||||
| `MapViewProvider` | Main map (nodes, waypoints, controls) | `GoogleMapViewProvider`, `FdroidMapViewProvider` |
|
||||
| `NodeTrackMapProvider` | Per-node GPS track overlay (embedded in `PositionLogScreen`) | Google: `NodeTrackMap` → `MapView(GoogleMapMode.NodeTrack)`, F-Droid: `NodeTrackMap` → `NodeTrackOsmMap` |
|
||||
| `TracerouteMapProvider` | Traceroute route visualization | Google: `TracerouteMap` → `MapView(GoogleMapMode.Traceroute)`, F-Droid: `TracerouteMap` → `TracerouteOsmMap` |
|
||||
|
||||
All providers are injected via `CompositionLocal` in `MainActivity.kt` and consumed by feature modules without direct dependency on Google Maps or osmdroid.
|
||||
|
||||
### 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).
|
||||
|
||||
### Key Data Types
|
||||
|
||||
- **`TracerouteOverlay`** (`core:model/commonMain`) — Pure data class representing traceroute route segments. Extracted from `feature:map` for cross-module reuse.
|
||||
- **`TracerouteNodeSelection`** (`feature:map/commonMain`) — Data class modeling node selection results during traceroute visualization.
|
||||
- **`GeoConstants`** (`core:model/commonMain`) — Centralized geographic constants (`DEG_D`, `HEADING_DEG`, `EARTH_RADIUS_METERS`).
|
||||
|
||||
## Map Providers
|
||||
|
||||
- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK.
|
||||
- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source mapping experience.
|
||||
- **Google Maps (`google` flavor)**: Uses Google Play Services Maps SDK. Implementations in `app/src/google/kotlin/org/meshtastic/app/map/`.
|
||||
- **OpenStreetMap (`fdroid` flavor)**: Uses `osmdroid` for a fully open-source experience. Implementations in `app/src/fdroid/kotlin/org/meshtastic/app/map/`.
|
||||
|
||||
## Features
|
||||
- **Live Node Tracking**: Real-time position updates for nodes on the mesh.
|
||||
- **Waypoints**: Create and share points of interest.
|
||||
- **Per-Node Track Overlay**: Embedded map in `PositionLogScreen` showing a node's GPS track history.
|
||||
- **Traceroute Visualization**: Dedicated map view showing route segments between mesh nodes.
|
||||
- **Offline Maps**: Support for pre-downloaded map tiles (via `osmdroid`).
|
||||
|
||||
## Module dependency graph
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ fun MapScreen(
|
|||
) { paddingValues ->
|
||||
LocalMapViewProvider.current?.MapView(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
viewModel = viewModel,
|
||||
navigateToNodeDetails = navigateToNodeDetails,
|
||||
waypointId = waypointId,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.nowSeconds
|
|||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
|
|
@ -41,7 +42,6 @@ import org.meshtastic.core.resources.one_day
|
|||
import org.meshtastic.core.resources.one_hour
|
||||
import org.meshtastic.core.resources.two_days
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
|
|
@ -194,16 +194,42 @@ open class BaseMapViewModel(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a [TracerouteOverlay]'s node nums into displayable [Node] instances.
|
||||
*
|
||||
* @property overlayNodeNums All unique node nums referenced by the traceroute.
|
||||
* @property nodesForMarkers Nodes to render as map markers (with snapshot positions when available).
|
||||
* @property nodeLookup Node-num-keyed map for polyline coordinate resolution.
|
||||
*/
|
||||
data class TracerouteNodeSelection(
|
||||
val overlayNodeNums: Set<Int>,
|
||||
val nodesForMarkers: List<Node>,
|
||||
val nodeLookup: Map<Int, Node>,
|
||||
)
|
||||
|
||||
/** Convenience extension that delegates to [tracerouteNodeSelection] using the VM's [getNodeOrFallback]. */
|
||||
fun BaseMapViewModel.tracerouteNodeSelection(
|
||||
tracerouteOverlay: TracerouteOverlay?,
|
||||
tracerouteNodePositions: Map<Int, Position>,
|
||||
nodes: List<Node>,
|
||||
): TracerouteNodeSelection = tracerouteNodeSelection(
|
||||
tracerouteOverlay = tracerouteOverlay,
|
||||
tracerouteNodePositions = tracerouteNodePositions,
|
||||
nodes = nodes,
|
||||
getNodeOrFallback = ::getNodeOrFallback,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves traceroute overlay node nums into displayable [Node] instances. Snapshot positions (recorded at traceroute
|
||||
* time) take priority over live positions from the node database.
|
||||
*
|
||||
* @param getNodeOrFallback Provides a [Node] for a given num, falling back to a stub if not in the DB.
|
||||
*/
|
||||
fun tracerouteNodeSelection(
|
||||
tracerouteOverlay: TracerouteOverlay?,
|
||||
tracerouteNodePositions: Map<Int, Position>,
|
||||
nodes: List<Node>,
|
||||
getNodeOrFallback: (Int) -> Node,
|
||||
): TracerouteNodeSelection {
|
||||
val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet()
|
||||
val tracerouteSnapshotNodes =
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-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.map.model
|
||||
|
||||
data class TracerouteOverlay(
|
||||
val requestId: Int,
|
||||
val forwardRoute: List<Int> = emptyList(),
|
||||
val returnRoute: List<Int> = emptyList(),
|
||||
) {
|
||||
val relatedNodeNums: Set<Int> = (forwardRoute + returnRoute).toSet()
|
||||
|
||||
val hasRoutes: Boolean
|
||||
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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.map
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
class LastHeardFilterTest {
|
||||
|
||||
@Test
|
||||
fun fromSeconds_knownValues() {
|
||||
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(0L))
|
||||
assertEquals(LastHeardFilter.OneHour, LastHeardFilter.fromSeconds(3600L))
|
||||
assertEquals(LastHeardFilter.EightHours, LastHeardFilter.fromSeconds(28800L))
|
||||
assertEquals(LastHeardFilter.OneDay, LastHeardFilter.fromSeconds(86400L))
|
||||
assertEquals(LastHeardFilter.TwoDays, LastHeardFilter.fromSeconds(172800L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromSeconds_unknownValue_defaultsToAny() {
|
||||
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(9999L))
|
||||
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(-1L))
|
||||
assertEquals(LastHeardFilter.Any, LastHeardFilter.fromSeconds(Long.MAX_VALUE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seconds_matchExpectedValues() {
|
||||
assertEquals(0L, LastHeardFilter.Any.seconds)
|
||||
assertEquals(3600L, LastHeardFilter.OneHour.seconds)
|
||||
assertEquals(28800L, LastHeardFilter.EightHours.seconds)
|
||||
assertEquals(86400L, LastHeardFilter.OneDay.seconds)
|
||||
assertEquals(172800L, LastHeardFilter.TwoDays.seconds)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* 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.map
|
||||
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import org.meshtastic.proto.Position
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TracerouteNodeSelectionTest {
|
||||
|
||||
private fun nodeWithPosition(num: Int, latI: Int = num * 100000, lonI: Int = num * 200000): Node =
|
||||
Node(num = num, position = Position(latitude_i = latI, longitude_i = lonI))
|
||||
|
||||
private fun nodeWithoutPosition(num: Int): Node = Node(num = num, position = Position())
|
||||
|
||||
private val defaultGetNodeOrFallback: (Int) -> Node = { num -> Node(num = num) }
|
||||
|
||||
// ---- Null overlay (no traceroute active) ----
|
||||
|
||||
@Test
|
||||
fun nullOverlay_returnsAllNodesUnfiltered() {
|
||||
val nodes = listOf(nodeWithPosition(1), nodeWithPosition(2), nodeWithPosition(3))
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = null,
|
||||
tracerouteNodePositions = emptyMap(),
|
||||
nodes = nodes,
|
||||
getNodeOrFallback = defaultGetNodeOrFallback,
|
||||
)
|
||||
|
||||
assertEquals(emptySet(), result.overlayNodeNums)
|
||||
assertEquals(3, result.nodesForMarkers.size)
|
||||
assertEquals(nodes.map { it.num }.toSet(), result.nodesForMarkers.map { it.num }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullOverlay_nodeLookupContainsOnlyNodesWithValidPositions() {
|
||||
val nodes = listOf(nodeWithPosition(1), nodeWithoutPosition(2), nodeWithPosition(3))
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = null,
|
||||
tracerouteNodePositions = emptyMap(),
|
||||
nodes = nodes,
|
||||
getNodeOrFallback = defaultGetNodeOrFallback,
|
||||
)
|
||||
|
||||
// nodeLookup filters to validPosition nodes when no snapshot
|
||||
assertEquals(setOf(1, 3), result.nodeLookup.keys)
|
||||
}
|
||||
|
||||
// ---- Overlay with snapshot positions ----
|
||||
|
||||
@Test
|
||||
fun overlayWithSnapshot_usesSnapshotPositions() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(20, 10))
|
||||
val snapshotPositions =
|
||||
mapOf(
|
||||
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
|
||||
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
|
||||
)
|
||||
val liveNodes =
|
||||
listOf(
|
||||
nodeWithPosition(10, latI = 100000000, lonI = -100000000),
|
||||
nodeWithPosition(20, latI = 200000000, lonI = -200000000),
|
||||
nodeWithPosition(30),
|
||||
)
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = snapshotPositions,
|
||||
nodes = liveNodes,
|
||||
getNodeOrFallback = { num -> liveNodes.find { it.num == num } ?: Node(num = num) },
|
||||
)
|
||||
|
||||
// Should use snapshot positions, not live ones
|
||||
assertEquals(setOf(10, 20), result.overlayNodeNums)
|
||||
assertEquals(2, result.nodesForMarkers.size)
|
||||
assertEquals(400000000, result.nodesForMarkers.first { it.num == 10 }.position.latitude_i)
|
||||
assertEquals(410000000, result.nodesForMarkers.first { it.num == 20 }.position.latitude_i)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayWithSnapshot_nodeLookupUsesSnapshotNodes() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
|
||||
val snapshotPositions =
|
||||
mapOf(
|
||||
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
|
||||
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
|
||||
)
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = snapshotPositions,
|
||||
nodes = emptyList(),
|
||||
getNodeOrFallback = { num -> Node(num = num) },
|
||||
)
|
||||
|
||||
assertEquals(2, result.nodeLookup.size)
|
||||
assertEquals(400000000, result.nodeLookup[10]?.position?.latitude_i)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayWithSnapshot_filtersToOverlayNodes() {
|
||||
// Snapshot has node 30 which is NOT in the overlay routes
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
|
||||
val snapshotPositions =
|
||||
mapOf(
|
||||
10 to Position(latitude_i = 400000000, longitude_i = -700000000),
|
||||
20 to Position(latitude_i = 410000000, longitude_i = -710000000),
|
||||
30 to Position(latitude_i = 420000000, longitude_i = -720000000),
|
||||
)
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = snapshotPositions,
|
||||
nodes = emptyList(),
|
||||
getNodeOrFallback = { num -> Node(num = num) },
|
||||
)
|
||||
|
||||
// nodesForMarkers should only contain nodes in the overlay (10, 20), not 30
|
||||
assertEquals(setOf(10, 20), result.nodesForMarkers.map { it.num }.toSet())
|
||||
// but nodeLookup has all snapshot nodes (for polyline drawing)
|
||||
assertEquals(3, result.nodeLookup.size)
|
||||
}
|
||||
|
||||
// ---- Overlay without snapshot positions (live fallback) ----
|
||||
|
||||
@Test
|
||||
fun overlayWithoutSnapshot_filtersLiveNodesToOverlayNums() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20), returnRoute = listOf(30))
|
||||
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20), nodeWithPosition(30), nodeWithPosition(40))
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = emptyMap(),
|
||||
nodes = liveNodes,
|
||||
getNodeOrFallback = defaultGetNodeOrFallback,
|
||||
)
|
||||
|
||||
assertEquals(setOf(10, 20, 30), result.overlayNodeNums)
|
||||
assertEquals(setOf(10, 20, 30), result.nodesForMarkers.map { it.num }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overlayWithoutSnapshot_nodeLookupFiltersToValidPositions() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10, 20))
|
||||
val liveNodes = listOf(nodeWithPosition(10), nodeWithoutPosition(20))
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = emptyMap(),
|
||||
nodes = liveNodes,
|
||||
getNodeOrFallback = defaultGetNodeOrFallback,
|
||||
)
|
||||
|
||||
// nodeLookup only includes nodes with validPosition
|
||||
assertEquals(setOf(10), result.nodeLookup.keys)
|
||||
}
|
||||
|
||||
// ---- Edge cases ----
|
||||
|
||||
@Test
|
||||
fun emptyOverlayRoutes_yieldsEmptySelection() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = emptyList(), returnRoute = emptyList())
|
||||
val liveNodes = listOf(nodeWithPosition(10), nodeWithPosition(20))
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = emptyMap(),
|
||||
nodes = liveNodes,
|
||||
getNodeOrFallback = defaultGetNodeOrFallback,
|
||||
)
|
||||
|
||||
assertTrue(result.overlayNodeNums.isEmpty())
|
||||
assertTrue(result.nodesForMarkers.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getNodeOrFallback_usedForSnapshotNodeLookup() {
|
||||
val overlay = TracerouteOverlay(requestId = 1, forwardRoute = listOf(10))
|
||||
val snapshotPositions = mapOf(10 to Position(latitude_i = 400000000, longitude_i = -700000000))
|
||||
var lookupCalledWith: Int? = null
|
||||
val result =
|
||||
tracerouteNodeSelection(
|
||||
tracerouteOverlay = overlay,
|
||||
tracerouteNodePositions = snapshotPositions,
|
||||
nodes = emptyList(),
|
||||
getNodeOrFallback = { num ->
|
||||
lookupCalledWith = num
|
||||
Node(num = num)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(10, lookupCalledWith)
|
||||
assertEquals(1, result.nodesForMarkers.size)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.meshtastic.feature.map.model
|
||||
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue