refactor(map): polish — DRY coordinate helpers, tighter visibility, dead code removal, null safety

- Extract toGeoPositionOrNull() into MapConstants.kt, replacing 8 duplicated
  coordinate-conversion patterns across GeoJsonConverters, TracerouteLayers,
  TracerouteMap, NodeTrackMap, InlineMap, and MapScreen
- Extract typedFeatureCollection() helper to centralize the single unavoidable
  UNCHECKED_CAST, eliminating 9 scattered @Suppress annotations
- Fix hardcoded style URI in InlineMap — now uses MapStyle.OpenStreetMap.toBaseStyle()
- Tighten visibility: internal on MapButton, NodeTrackLayers, TracerouteLayers;
  private on BaseMapViewModel.nodes
- Fix null safety: replace waypoint!!.id with safe mapNotNull pattern
- Remove dead code: getUser(), myId (BaseMapViewModel); mapStyleId, applicationId,
  setDestNum, mapPrefs (NodeMapViewModel)
- Remove redundant empty onFrame={} in MaplibreMapContent
- Rename COORDINATE_PRECISION to FORMAT_DECIMAL_FACTOR in EditWaypointDialog
- Update stale KDoc on BaseMapViewModel and MapButton; add KDoc on FeatureMapModule,
  LayerType, MapLayerItem, MapNavigation.mapGraph
- Add 11 new tests: toGeoPositionOrNull (4), typedFeatureCollection (1),
  convertIntToEmoji fallback (1), combined filters (1), MapStyle.toBaseStyle (3),
  MapStyle defaults (1)
This commit is contained in:
James Rich
2026-04-13 13:36:04 -05:00
parent 5ad55fcbce
commit b554ed0e5b
19 changed files with 211 additions and 123 deletions

View File

@@ -47,9 +47,7 @@ import org.meshtastic.proto.Waypoint
/**
* Shared base ViewModel for the map feature, providing node data, waypoints, map filter preferences, and traceroute
* overlay state.
*
* Platform-specific map ViewModels (fdroid/google) extend this to add flavor-specific map provider logic.
* overlay state. [MapViewModel] extends this with camera persistence and map style management.
*/
@Suppress("TooManyFunctions")
open class BaseMapViewModel(
@@ -66,14 +64,12 @@ open class BaseMapViewModel(
val myNodeNum
get() = myNodeInfo.value?.myNodeNum
val myId = nodeRepository.myId
val isConnected =
radioController.connectionState
.map { it is org.meshtastic.core.model.ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
val nodes: StateFlow<List<Node>> =
private val nodes: StateFlow<List<Node>> =
nodeRepository
.getNodes()
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
@@ -89,8 +85,8 @@ open class BaseMapViewModel(
.getWaypoints()
.mapLatest { list ->
list
.filter { it.waypoint != null }
.associateBy { packet -> packet.waypoint!!.id }
.mapNotNull { packet -> packet.waypoint?.let { wpt -> wpt.id to packet } }
.toMap()
.filterValues {
val expire = it.waypoint?.expire ?: 0
expire == 0 || expire.toLong() > nowSeconds
@@ -142,9 +138,6 @@ open class BaseMapViewModel(
mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
open fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) }

View File

@@ -49,7 +49,7 @@ import org.meshtastic.feature.map.component.MapFilterDropdown
import org.meshtastic.feature.map.component.MapStyleSelector
import org.meshtastic.feature.map.component.MaplibreMapContent
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val WAYPOINT_ZOOM = 15.0
@@ -118,13 +118,8 @@ fun MapScreen(
val wpId = selectedWaypointId ?: return@LaunchedEffect
val packet = waypoints[wpId] ?: return@LaunchedEffect
val wpt = packet.waypoint ?: return@LaunchedEffect
val lat = (wpt.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (wpt.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) {
cameraState.animateTo(
CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = WAYPOINT_ZOOM),
)
}
val geoPos = toGeoPositionOrNull(wpt.latitude_i, wpt.longitude_i) ?: return@LaunchedEffect
cameraState.animateTo(CameraPosition(target = geoPos, zoom = WAYPOINT_ZOOM))
}
@Suppress("ViewModelForwarding")

View File

@@ -55,8 +55,8 @@ import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val MAX_NAME_LENGTH = 29
private const val MAX_DESCRIPTION_LENGTH = 99
private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin
private const val COORDINATE_PRECISION = 1_000_000L
private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin (📍) — same as PIN_EMOJI in GeoJsonConverters
private const val FORMAT_DECIMAL_FACTOR = 1_000_000L
/**
* Dialog for creating or editing a waypoint on the map.
@@ -181,7 +181,7 @@ private fun Double.formatCoord(): String {
val negative = this < 0
val absVal = abs(this)
val wholePart = absVal.toLong()
val fracPart = ((absVal - wholePart) * COORDINATE_PRECISION + 0.5).toLong()
val fracPart = ((absVal - wholePart) * FORMAT_DECIMAL_FACTOR + 0.5).toLong()
val fracStr = fracPart.toString().padStart(6, '0')
return "${if (negative) "-" else ""}$wholePart.$fracStr"
}

View File

@@ -32,14 +32,13 @@ import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.precisionBitsToMeters
import org.maplibre.spatialk.geojson.Position as GeoPosition
import org.meshtastic.feature.map.util.toGeoPositionOrNull
private const val DEFAULT_ZOOM = 15.0
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f
@@ -55,27 +54,19 @@ private const val METERS_PER_PIXEL_ZOOM15 = 4.773
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val position = node.validPosition ?: return
val lat = (position.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (position.longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) return
val geoPos = toGeoPositionOrNull(position.latitude_i, position.longitude_i) ?: return
key(node.num) {
val cameraState =
rememberCameraState(
firstPosition =
CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM),
)
val cameraState = rememberCameraState(firstPosition = CameraPosition(target = geoPos, zoom = DEFAULT_ZOOM))
val nodeFeature =
remember(node.num, lat, lng) {
FeatureCollection(
listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)),
)
remember(node.num, geoPos) {
FeatureCollection(listOf(Feature(geometry = Point(geoPos), properties = null)))
}
MaplibreMap(
modifier = modifier,
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"),
baseStyle = MapStyle.OpenStreetMap.toBaseStyle(),
cameraState = cameraState,
options =
MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled),

View File

@@ -24,12 +24,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
/**
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
* across both Google and F-Droid flavors.
*/
/** A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance. */
@Composable
fun MapButton(
internal fun MapButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,

View File

@@ -125,7 +125,6 @@ fun MaplibreMapContent(
},
onMapLoadFinished = onMapLoadFinished,
onMapLoadFailed = onMapLoadFailed,
onFrame = {},
) {
// --- Terrain hillshade overlay ---
if (showHillshade) {

View File

@@ -44,7 +44,7 @@ private const val SELECTED_OPACITY = 0.9f
* and OSMDroid Polyline overlay implementations.
*/
@Composable
fun NodeTrackLayers(
internal fun NodeTrackLayers(
positions: List<org.meshtastic.proto.Position>,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,

View File

@@ -30,9 +30,8 @@ import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACK_ZOOM = 13.0
private const val BOUNDS_PADDING_DP = 48
@@ -53,13 +52,7 @@ fun NodeTrackMap(
onPositionSelected: ((Int) -> Unit)? = null,
) {
val geoPositions =
remember(positions) {
positions.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
}
remember(positions) { positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) } }
val center = remember(geoPositions) { geoPositions.firstOrNull() }
@@ -69,8 +62,8 @@ fun NodeTrackMap(
val lats = geoPositions.map { it.latitude }
val lngs = geoPositions.map { it.longitude }
BoundingBox(
southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()),
northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()),
)
}
@@ -78,7 +71,7 @@ fun NodeTrackMap(
rememberCameraState(
firstPosition =
CameraPosition(
target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0),
target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0),
zoom = DEFAULT_TRACK_ZOOM,
),
)

View File

@@ -40,7 +40,8 @@ import org.maplibre.spatialk.geojson.LineString
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.meshtastic.feature.map.util.typedFeatureCollection
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val ForwardRouteColor = Color(0xFF4CAF50)
@@ -54,7 +55,7 @@ private const val ROUTE_OPACITY = 0.8f
* polyline implementations.
*/
@Composable
fun TracerouteLayers(
internal fun TracerouteLayers(
overlay: TracerouteOverlay?,
nodePositions: Map<Int, org.meshtastic.proto.Position>,
nodes: Map<Int, Node>,
@@ -130,9 +131,7 @@ private fun buildTracerouteGeoJson(
): TracerouteGeoJsonData {
fun nodeToGeoPosition(nodeNum: Int): GeoPosition? {
val pos = nodePositions[nodeNum] ?: return null
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat)
return toGeoPositionOrNull(pos.latitude_i, pos.longitude_i)
}
// Build forward route line
@@ -144,11 +143,9 @@ private fun buildTracerouteGeoJson(
geometry = LineString(forwardCoords),
properties = buildJsonObject { put("direction", "forward") },
)
@Suppress("UNCHECKED_CAST")
FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
typedFeatureCollection(listOf(feature))
} else {
@Suppress("UNCHECKED_CAST")
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
typedFeatureCollection(emptyList<Feature<LineString, JsonObject>>())
}
// Build return route line
@@ -160,11 +157,9 @@ private fun buildTracerouteGeoJson(
geometry = LineString(returnCoords),
properties = buildJsonObject { put("direction", "return") },
)
@Suppress("UNCHECKED_CAST")
FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
typedFeatureCollection(listOf(feature))
} else {
@Suppress("UNCHECKED_CAST")
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
typedFeatureCollection(emptyList<Feature<LineString, JsonObject>>())
}
// Build hop marker points
@@ -185,10 +180,9 @@ private fun buildTracerouteGeoJson(
)
}
@Suppress("UNCHECKED_CAST")
return TracerouteGeoJsonData(
forwardLine = forwardLine,
returnLine = returnLine,
hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection<Point, JsonObject>,
hopFeatures = typedFeatureCollection(hopFeatures),
)
}

View File

@@ -32,9 +32,8 @@ import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.COORDINATE_SCALE
import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
private const val BOUNDS_PADDING_DP = 64
@@ -60,11 +59,7 @@ fun TracerouteMap(
) {
val geoPositions =
remember(tracerouteNodePositions) {
tracerouteNodePositions.values.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat != 0.0 || lng != 0.0) GeoPosition(longitude = lng, latitude = lat) else null
}
tracerouteNodePositions.values.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) }
}
val center = remember(geoPositions) { geoPositions.firstOrNull() }
@@ -75,8 +70,8 @@ fun TracerouteMap(
val lats = geoPositions.map { it.latitude }
val lngs = geoPositions.map { it.longitude }
BoundingBox(
southwest = GeoPosition(longitude = lngs.min(), latitude = lats.min()),
northeast = GeoPosition(longitude = lngs.max(), latitude = lats.max()),
southwest = org.maplibre.spatialk.geojson.Position(longitude = lngs.min(), latitude = lats.min()),
northeast = org.maplibre.spatialk.geojson.Position(longitude = lngs.max(), latitude = lats.max()),
)
}
@@ -84,7 +79,7 @@ fun TracerouteMap(
rememberCameraState(
firstPosition =
CameraPosition(
target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0),
target = center ?: org.maplibre.spatialk.geojson.Position(longitude = 0.0, latitude = 0.0),
zoom = DEFAULT_TRACEROUTE_ZOOM,
),
)

View File

@@ -19,6 +19,7 @@ package org.meshtastic.feature.map.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
/** Koin module for the map feature. Scans [org.meshtastic.feature.map] for annotated dependencies. */
@Module
@ComponentScan("org.meshtastic.feature.map")
class FeatureMapModule

View File

@@ -18,11 +18,13 @@ package org.meshtastic.feature.map.model
import kotlin.uuid.Uuid
/** Supported custom overlay layer formats. */
enum class LayerType {
KML,
GEOJSON,
}
/** A user-importable map overlay layer (KML or GeoJSON file). */
data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,

View File

@@ -25,6 +25,7 @@ import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.MapViewModel
/** Registers the map feature's navigation entries into a Navigation 3 [EntryProviderScope]. */
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoute.Map> { args ->
val viewModel = koinViewModel<MapViewModel>()

View File

@@ -28,9 +28,7 @@ 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.common.BuildConfigProvider
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
@@ -43,8 +41,6 @@ class NodeMapViewModel(
savedStateHandle: SavedStateHandle,
nodeRepository: NodeRepository,
meshLogRepository: MeshLogRepository,
buildConfigProvider: BuildConfigProvider,
private val mapPrefs: MapPrefs,
) : ViewModel() {
private val destNumFromRoute = savedStateHandle.get<Int>("destNum")
private val manualDestNum = MutableStateFlow<Int?>(null)
@@ -52,18 +48,12 @@ class NodeMapViewModel(
private val destNumFlow =
combine(MutableStateFlow(destNumFromRoute), manualDestNum) { route, manual -> manual ?: route ?: 0 }
fun setDestNum(num: Int) {
manualDestNum.value = num
}
val node =
destNumFlow
.flatMapLatest { destNum -> nodeRepository.nodeDBbyNum.mapLatest { it[destNum] } }
.distinctUntilChanged()
.stateInWhileSubscribed(initialValue = null)
val applicationId = buildConfigProvider.applicationId
private val ourNodeNumFlow = nodeRepository.myNodeInfo.map { it?.myNodeNum }.distinctUntilChanged()
val positionLogs: StateFlow<List<Position>> =
@@ -84,7 +74,4 @@ class NodeMapViewModel(
}
}
.stateInWhileSubscribed(initialValue = emptyList())
val mapStyleId: Int
get() = mapPrefs.mapStyle.value
}

View File

@@ -21,11 +21,11 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Geometry
import org.maplibre.spatialk.geojson.LineString
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val MIN_PRECISION_BITS = 10
private const val MAX_PRECISION_BITS = 19
@@ -35,9 +35,7 @@ fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): Feature
val features =
nodes.mapNotNull { node ->
val pos = node.validPosition ?: return@mapNotNull null
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) return@mapNotNull null
val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
val colors = node.colors
val props = buildJsonObject {
@@ -57,11 +55,10 @@ fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): Feature
put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0))
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
Feature(geometry = Point(geoPos), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
return typedFeatureCollection(features)
}
/** Convert waypoints to a GeoJSON [FeatureCollection]. */
@@ -69,9 +66,7 @@ fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollec
val features =
waypoints.values.mapNotNull { packet ->
val waypoint = packet.waypoint ?: return@mapNotNull null
val lat = (waypoint.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (waypoint.longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) return@mapNotNull null
val geoPos = toGeoPositionOrNull(waypoint.latitude_i, waypoint.longitude_i) ?: return@mapNotNull null
val emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI
@@ -85,21 +80,15 @@ fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollec
put("expire", waypoint.expire)
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
Feature(geometry = Point(geoPos), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
return typedFeatureCollection(features)
}
/** Convert position history to a GeoJSON [LineString] for track rendering. */
fun positionsToLineString(positions: List<org.meshtastic.proto.Position>): FeatureCollection<LineString, JsonObject> {
val coords =
positions.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat)
}
val coords = positions.mapNotNull { pos -> toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) }
if (coords.size < 2) return FeatureCollection(emptyList())
@@ -107,17 +96,14 @@ fun positionsToLineString(positions: List<org.meshtastic.proto.Position>): Featu
val feature = Feature(geometry = LineString(coords), properties = props)
@Suppress("UNCHECKED_CAST")
return FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
return typedFeatureCollection(listOf(feature))
}
/** Convert position history to individual point features with time metadata. */
fun positionsToPointFeatures(positions: List<org.meshtastic.proto.Position>): FeatureCollection<Point, JsonObject> {
val features =
positions.mapNotNull { pos ->
val lat = (pos.latitude_i ?: 0) * COORDINATE_SCALE
val lng = (pos.longitude_i ?: 0) * COORDINATE_SCALE
if (lat == 0.0 && lng == 0.0) return@mapNotNull null
val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
val props = buildJsonObject {
put("time", (pos.time ?: 0).toString())
@@ -126,11 +112,10 @@ fun positionsToPointFeatures(positions: List<org.meshtastic.proto.Position>): Fe
put("sats_in_view", pos.sats_in_view ?: 0)
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
Feature(geometry = Point(geoPos), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
return typedFeatureCollection(features)
}
/** Approximate meters of positional uncertainty from precision_bits (10-19). */
@@ -149,7 +134,16 @@ fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) {
else -> 0.0
}
private const val PIN_EMOJI = "\uD83D\uDCCD"
private const val PIN_EMOJI = "\uD83D\uDCCD" // U+1F4CD Round Pushpin — same as DEFAULT_EMOJI in EditWaypointDialog
/**
* Wraps [FeatureCollection] constructor with an unchecked cast to the desired type parameters. Centralizes the single
* unavoidable cast required by the spatialk GeoJSON API.
*/
@Suppress("UNCHECKED_CAST")
internal fun <G : Geometry, P> typedFeatureCollection(features: List<Feature<G, P>>): FeatureCollection<G, P> =
FeatureCollection(features) as FeatureCollection<G, P>
private const val BMP_MAX = 0xFFFF
private const val SUPPLEMENTARY_OFFSET = 0x10000
private const val HALF_SHIFT = 10

View File

@@ -16,5 +16,17 @@
*/
package org.meshtastic.feature.map.util
import org.maplibre.spatialk.geojson.Position as GeoPosition
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
const val COORDINATE_SCALE = 1e-7
/**
* Convert Meshtastic integer microdegree coordinates to a [GeoPosition], returning `null` if both latitude and
* longitude are zero (indicating no valid position).
*/
fun toGeoPositionOrNull(latI: Int?, lngI: Int?): GeoPosition? {
val lat = (latI ?: 0) * COORDINATE_SCALE
val lng = (lngI ?: 0) * COORDINATE_SCALE
return if (lat == 0.0 && lng == 0.0) null else GeoPosition(longitude = lng, latitude = lat)
}

View File

@@ -276,6 +276,36 @@ class BaseMapViewModelTest {
}
}
@Test
fun filteredNodes_combinedFavoritesAndLastHeard_filtersCorrectly() = runTest(testDispatcher) {
val now = nowSeconds.toInt()
val myNodeNum = 1
nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum))
val nodes =
listOf(
nodeWithPosition(myNodeNum, lastHeard = now), // my node — always visible
nodeWithPosition(2, isFavorite = true, lastHeard = now), // favorite + recent
nodeWithPosition(3, isFavorite = true, lastHeard = now - 7200), // favorite + stale
nodeWithPosition(4, isFavorite = false, lastHeard = now), // non-favorite + recent
)
nodeRepository.setNodes(nodes)
// Enable both filters
viewModel.toggleOnlyFavorites()
viewModel.setLastHeardFilter(LastHeardFilter.OneHour)
viewModel.filteredNodes.test {
val result = expectMostRecentItem()
val nodeNums = result.map { it.num }.toSet()
// My node always visible, favorite+recent visible, favorite+stale filtered, non-favorite filtered
assertTrue(myNodeNum in nodeNums, "My node should always be visible")
assertTrue(2 in nodeNums, "Favorite + recent node should be visible")
assertFalse(3 in nodeNums, "Favorite + stale node should be filtered out by lastHeard")
assertFalse(4 in nodeNums, "Non-favorite node should be filtered out by favorites filter")
cancelAndIgnoreRemainingEvents()
}
}
// ---- getNodeOrFallback ----
@Test

View File

@@ -0,0 +1,47 @@
/*
* 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.model
import org.maplibre.compose.style.BaseStyle
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class MapStyleTest {
@Test
fun toBaseStyle_returnsUriWithCorrectStyleUri() {
for (style in MapStyle.entries) {
val baseStyle = style.toBaseStyle()
assertIs<BaseStyle.Uri>(baseStyle)
assertEquals(style.styleUri, baseStyle.uri)
}
}
@Test
fun allStyles_haveNonBlankUri() {
for (style in MapStyle.entries) {
assert(style.styleUri.isNotBlank()) { "${style.name} has a blank styleUri" }
}
}
@Test
fun openStreetMap_isDefault() {
// Verify OpenStreetMap is the first entry (used as default throughout the app)
assertEquals(MapStyle.OpenStreetMap, MapStyle.entries.first())
}
}

View File

@@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.map.util
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Position
@@ -23,6 +25,8 @@ import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@Suppress("MagicNumber")
@@ -272,4 +276,57 @@ class GeoJsonConvertersTest {
val result = convertIntToEmoji(0xFFFF)
assertEquals(1, result.length)
}
@Test
fun convertIntToEmoji_negativeCodepoint_returnsNonEmptyString() {
// Negative code points wrap around in char conversion but should not crash
val result = convertIntToEmoji(-1)
assertTrue(result.isNotEmpty(), "Should return a non-empty string even for invalid code points")
}
// --- toGeoPositionOrNull ---
@Test
fun toGeoPositionOrNull_validCoords_returnsGeoPosition() {
val result = toGeoPositionOrNull(400000000, -740000000)
assertNotNull(result)
assertEquals(40.0, result.latitude, 0.001)
assertEquals(-74.0, result.longitude, 0.001)
}
@Test
fun toGeoPositionOrNull_zeroCoords_returnsNull() {
val result = toGeoPositionOrNull(0, 0)
assertNull(result)
}
@Test
fun toGeoPositionOrNull_nullCoords_returnsNull() {
val result = toGeoPositionOrNull(null, null)
assertNull(result)
}
@Test
fun toGeoPositionOrNull_onlyLatNull_treatedAsZero() {
// null lat = 0, non-zero lng -> lat=0.0 && lng!=0.0 -> not both zero -> returns position
val result = toGeoPositionOrNull(null, 100000000)
assertNotNull(result)
assertEquals(0.0, result.latitude, 0.001)
assertEquals(10.0, result.longitude, 0.001)
}
// --- typedFeatureCollection ---
@Test
fun typedFeatureCollection_preservesFeatures() {
val features =
listOf(
Feature(
geometry = Point(org.maplibre.spatialk.geojson.Position(longitude = 1.0, latitude = 2.0)),
properties = null,
),
)
val result = typedFeatureCollection(features)
assertEquals(1, result.features.size)
}
}