mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-13 18:40:18 -04:00
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:
@@ -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) }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -125,7 +125,6 @@ fun MaplibreMapContent(
|
||||
},
|
||||
onMapLoadFinished = onMapLoadFinished,
|
||||
onMapLoadFailed = onMapLoadFailed,
|
||||
onFrame = {},
|
||||
) {
|
||||
// --- Terrain hillshade overlay ---
|
||||
if (showHillshade) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user