feat(map): replace Google Maps + OSMDroid with unified MapLibre Compose Multiplatform

Replace the dual flavor-specific map implementations (Google Maps for google,
OSMDroid for fdroid) with a single MapLibre Compose Multiplatform implementation
in feature:map/commonMain, eliminating ~8,500 lines of duplicated code.

Key changes:
- Add maplibre-compose v0.12.1 dependency (KMP: Android, Desktop, iOS)
- Create unified MapViewModel with camera persistence via MapCameraPrefs
- Create MapScreen, MaplibreMapContent, NodeTrackLayers, TracerouteLayers,
  InlineMap, NodeTrackMap, TracerouteMap, NodeMapScreen in commonMain
- Create MapStyle enum with predefined OpenFreeMap tile styles
- Create GeoJsonConverters for Node/Waypoint/Position to GeoJSON
- Move TracerouteMapScreen from feature:node/androidMain to commonMain
- Wire navigation to use direct imports instead of CompositionLocal providers
- Delete 61 flavor-specific map files (google + fdroid source sets)
- Remove 8 CompositionLocal map providers from core:ui
- Remove SharedMapViewModel (replaced by new MapViewModel)
- Remove dead google-maps and osmdroid entries from version catalog
- Add MapViewModelTest with 10 test cases in commonTest

Baseline verified: spotlessCheck, detekt, assembleGoogleDebug, allTests all pass.
This commit is contained in:
James Rich 2026-04-12 18:25:15 -05:00
parent a2763bdfeb
commit 598cae564e
86 changed files with 1653 additions and 8333 deletions

View file

@ -1,63 +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
import android.database.sqlite.SQLiteDatabase
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import kotlin.test.assertEquals
@RunWith(RobolectricTestRunner::class)
class MBTilesProviderTest {
@get:Rule val tempFolder = TemporaryFolder()
@Test
fun `getTile translates y coordinate correctly to TMS`() {
val dbFile = tempFolder.newFile("test.mbtiles")
setupMockDatabase(dbFile)
val provider = MBTilesProvider(dbFile)
// Google Maps zoom 1, x=0, y=0
// TMS y = (1 << 1) - 1 - 0 = 1
provider.getTile(0, 0, 1)
// We verify the query was correct by checking the database if we could,
// but here we just ensure it doesn't crash and returns the expected No Tile if missing.
// To truly test, we'd need to insert data.
val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')")
db.close()
val tile = provider.getTile(0, 0, 1)
assertEquals(256, tile?.width)
assertEquals(256, tile?.height)
// Robolectric SQLite might return different blob handling, but let's see.
}
private fun setupMockDatabase(file: File) {
val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY)
db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)")
db.close()
}
}

View file

@ -1,154 +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
import android.app.Application
import androidx.lifecycle.SavedStateHandle
import com.google.android.gms.maps.model.UrlTileProvider
import dev.mokkery.MockMode
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.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.feature.map.model.CustomTileProviderConfig
import org.meshtastic.feature.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.feature.map.repository.CustomTileProviderRepository
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class MapViewModelTest {
private val application = mock<Application>(MockMode.autofill)
private val mapPrefs = mock<MapPrefs>(MockMode.autofill)
private val googleMapsPrefs = mock<GoogleMapsPrefs>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val radioController = FakeRadioController()
private val customTileProviderRepository = mock<CustomTileProviderRepository>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: MapViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
every { mapPrefs.mapStyle } returns MutableStateFlow(0)
every { mapPrefs.showOnlyFavorites } returns MutableStateFlow(false)
every { mapPrefs.showWaypointsOnMap } returns MutableStateFlow(true)
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(true)
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
every { googleMapsPrefs.cameraTargetLat } returns MutableStateFlow(0.0)
every { googleMapsPrefs.cameraTargetLng } returns MutableStateFlow(0.0)
every { googleMapsPrefs.cameraZoom } returns MutableStateFlow(0f)
every { googleMapsPrefs.cameraTilt } returns MutableStateFlow(0f)
every { googleMapsPrefs.cameraBearing } returns MutableStateFlow(0f)
every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null)
every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null)
every { googleMapsPrefs.hiddenLayerUrls } returns MutableStateFlow(emptySet())
every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
every { radioConfigRepository.deviceProfileFlow } returns flowOf(mock(MockMode.autofill))
every { uiPrefs.theme } returns MutableStateFlow(1)
every { packetRepository.getWaypoints() } returns flowOf(emptyList())
viewModel =
MapViewModel(
application,
mapPrefs,
googleMapsPrefs,
nodeRepository,
packetRepository,
radioConfigRepository,
radioController,
customTileProviderRepository,
uiPrefs,
savedStateHandle,
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `getTileProvider returns UrlTileProvider for remote config`() = runTest {
val config =
CustomTileProviderConfig(
name = "OpenStreetMap",
urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
)
val provider = viewModel.getTileProvider(config)
assertTrue(provider is UrlTileProvider)
}
@Test
fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) {
viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson")
advanceUntilIdle()
val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" }
assertEquals(LayerType.GEOJSON, layer?.layerType)
}
@Test
fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) {
viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml")
advanceUntilIdle()
val layer = viewModel.mapLayers.value.find { it.name == "Test KML" }
assertEquals(LayerType.KML, layer?.layerType)
}
@Test
fun `setWaypointId updates value correctly including null`() = runTest(testDispatcher) {
// Set to a valid ID
viewModel.setWaypointId(123)
assertEquals(123, viewModel.selectedWaypointId.value)
// Set to null should clear the selection
viewModel.setWaypointId(null)
assertEquals(null, viewModel.selectedWaypointId.value)
}
}

View file

@ -0,0 +1,105 @@
/*
* 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
import androidx.compose.foundation.layout.Box
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.maplibre.compose.camera.rememberCameraState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.MaplibreMapContent
/**
* Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers,
* waypoints, and overlays.
*
* This replaces the previous flavor-specific Google Maps and OSMDroid implementations with a single cross-platform
* composable.
*/
@Composable
fun MapScreen(
onClickNodeChip: (Int) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: MapViewModel,
waypointId: Int? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val nodesWithPosition by viewModel.nodesWithPosition.collectAsStateWithLifecycle()
val waypoints by viewModel.waypoints.collectAsStateWithLifecycle()
val filterState by viewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val baseStyle by viewModel.baseStyle.collectAsStateWithLifecycle()
LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) }
val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition)
@Suppress("ViewModelForwarding")
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.map),
ourNode = ourNodeInfo,
showNodeChip = ourNodeInfo != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = { onClickNodeChip(it.num) },
)
},
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
MaplibreMapContent(
nodes = nodesWithPosition,
waypoints = waypoints,
baseStyle = baseStyle,
cameraState = cameraState,
myNodeNum = viewModel.myNodeNum,
showWaypoints = filterState.showWaypoints,
showPrecisionCircle = filterState.showPrecisionCircle,
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
onMapLongClick = { position ->
// TODO: open waypoint creation dialog at position
},
modifier = Modifier.fillMaxSize(),
onCameraMoved = { position -> viewModel.saveCameraPosition(position) },
)
MapControlsOverlay(
onToggleFilterMenu = {},
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
bearing = cameraState.position.bearing.toFloat(),
onCompassClick = {},
isLocationTrackingEnabled = false,
onToggleLocationTracking = {},
)
}
}
}

View file

@ -0,0 +1,100 @@
/*
* 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 androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.KoinViewModel
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.style.BaseStyle
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapCameraPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.MapStyle
import org.maplibre.spatialk.geojson.Position as GeoPosition
/**
* Unified map ViewModel replacing the previous Google and F-Droid flavor-specific ViewModels.
*
* Manages camera state persistence, map style selection, and waypoint selection using MapLibre Compose Multiplatform
* types. All map-related state is shared across platforms.
*/
@KoinViewModel
class MapViewModel(
mapPrefs: MapPrefs,
private val mapCameraPrefs: MapCameraPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
/** Currently selected waypoint to focus on map. */
private val selectedWaypointIdInternal = MutableStateFlow<Int?>(savedStateHandle.get<Int?>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = selectedWaypointIdInternal.asStateFlow()
fun setWaypointId(id: Int?) {
selectedWaypointIdInternal.value = id
}
/** Initial camera position restored from persistent preferences. */
val initialCameraPosition: CameraPosition
get() =
CameraPosition(
target =
GeoPosition(longitude = mapCameraPrefs.cameraLng.value, latitude = mapCameraPrefs.cameraLat.value),
zoom = mapCameraPrefs.cameraZoom.value.toDouble(),
tilt = mapCameraPrefs.cameraTilt.value.toDouble(),
bearing = mapCameraPrefs.cameraBearing.value.toDouble(),
)
/** Active map base style. */
val baseStyle: StateFlow<BaseStyle> =
mapCameraPrefs.selectedStyleUri
.map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) }
.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle())
/** Currently selected map style enum index. */
val selectedMapStyle: StateFlow<MapStyle> =
mapCameraPrefs.selectedStyleUri
.map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap }
.stateInWhileSubscribed(MapStyle.OpenStreetMap)
/** Persist camera position to DataStore. */
fun saveCameraPosition(position: CameraPosition) {
mapCameraPrefs.setCameraLat(position.target.latitude)
mapCameraPrefs.setCameraLng(position.target.longitude)
mapCameraPrefs.setCameraZoom(position.zoom.toFloat())
mapCameraPrefs.setCameraTilt(position.tilt.toFloat())
mapCameraPrefs.setCameraBearing(position.bearing.toFloat())
}
/** Select a predefined map style. */
fun selectMapStyle(style: MapStyle) {
mapCameraPrefs.setSelectedStyleUri(style.styleUri)
}
/** Bearing for the compass in degrees. */
val compassBearing: Float
get() = mapCameraPrefs.cameraBearing.value
}

View file

@ -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.feature.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@KoinViewModel
class SharedMapViewModel(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)

View file

@ -0,0 +1,106 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
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.precisionBitsToMeters
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_ZOOM = 15.0
private const val COORDINATE_SCALE = 1e-7
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f
private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f
/**
* A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the
* Google Maps and OSMDroid inline map implementations.
*/
@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
key(node.num) {
val cameraState =
rememberCameraState(
firstPosition =
CameraPosition(target = GeoPosition(longitude = lng, latitude = lat), zoom = DEFAULT_ZOOM),
)
val nodeFeature =
remember(node.num, lat, lng) {
FeatureCollection(
listOf(Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = null)),
)
}
MaplibreMap(
modifier = modifier,
baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/liberty"),
cameraState = cameraState,
options =
MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled),
) {
val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature))
// Node marker dot
CircleLayer(
id = "inline-node-marker",
source = source,
radius = const(8.dp),
color = const(Color(node.colors.second)),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
)
// Precision circle
val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0)
if (precisionMeters > 0) {
CircleLayer(
id = "inline-node-precision",
source = source,
radius = const(40.dp), // visual approximation
color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
strokeWidth = const(1.dp),
strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
)
}
}
}
}

View file

@ -0,0 +1,205 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.dsl.offset
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.ClickResult
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.util.nodesToFeatureCollection
import org.meshtastic.feature.map.util.waypointsToFeatureCollection
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val NodeMarkerColor = Color(0xFF6750A4)
private const val CLUSTER_RADIUS = 50
private const val CLUSTER_MIN_POINTS = 10
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f
private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f
private const val CLUSTER_OPACITY = 0.85f
/**
* Main map content composable using MapLibre Compose Multiplatform.
*
* Renders nodes as clustered markers, waypoints, and optional overlays (position tracks, traceroute routes). Replaces
* both the Google Maps and OSMDroid implementations with a single cross-platform composable.
*/
@Composable
fun MaplibreMapContent(
nodes: List<Node>,
waypoints: Map<Int, DataPacket>,
baseStyle: BaseStyle,
cameraState: CameraState,
myNodeNum: Int?,
showWaypoints: Boolean,
showPrecisionCircle: Boolean,
onNodeClick: (Int) -> Unit,
onMapLongClick: (GeoPosition) -> Unit,
modifier: Modifier = Modifier,
onCameraMoved: (CameraPosition) -> Unit = {},
) {
MaplibreMap(
modifier = modifier,
baseStyle = baseStyle,
cameraState = cameraState,
onMapLongClick = { position, _ ->
onMapLongClick(position)
ClickResult.Consume
},
onFrame = {},
) {
// --- Node markers with clustering ---
NodeMarkerLayers(
nodes = nodes,
myNodeNum = myNodeNum,
showPrecisionCircle = showPrecisionCircle,
onNodeClick = onNodeClick,
)
// --- Waypoint markers ---
if (showWaypoints) {
WaypointMarkerLayers(waypoints = waypoints)
}
}
// Persist camera position when it stops moving
LaunchedEffect(cameraState.isCameraMoving) {
if (!cameraState.isCameraMoving) {
onCameraMoved(cameraState.position)
}
}
}
/** Node markers rendered as clustered circles and symbols using GeoJSON source. */
@Composable
private fun NodeMarkerLayers(
nodes: List<Node>,
myNodeNum: Int?,
showPrecisionCircle: Boolean,
onNodeClick: (Int) -> Unit,
) {
val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
val nodesSource =
rememberGeoJsonSource(
data = GeoJsonData.Features(featureCollection),
options =
GeoJsonOptions(cluster = true, clusterRadius = CLUSTER_RADIUS, clusterMinPoints = CLUSTER_MIN_POINTS),
)
// Cluster circles
CircleLayer(
id = "node-clusters",
source = nodesSource,
filter = feature.has("cluster"),
radius = const(20.dp),
color = const(NodeMarkerColor), // Material primary
opacity = const(CLUSTER_OPACITY),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
)
// Cluster count labels
SymbolLayer(
id = "node-cluster-count",
source = nodesSource,
filter = feature.has("cluster"),
textField = feature["point_count"].asString(),
textColor = const(Color.White),
textSize = const(1.2f.em),
)
// Individual node markers
CircleLayer(
id = "node-markers",
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(8.dp),
color = const(NodeMarkerColor),
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
onClick = { features ->
val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull()
if (nodeNum != null) {
onNodeClick(nodeNum)
ClickResult.Consume
} else {
ClickResult.Pass
}
},
)
// Precision circles
if (showPrecisionCircle) {
CircleLayer(
id = "node-precision",
source = nodesSource,
filter = !feature.has("cluster"),
radius = const(40.dp), // TODO: scale by precision_meters and zoom
color = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)),
strokeWidth = const(1.dp),
strokeColor = const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)),
)
}
}
/** Waypoint markers rendered as symbol layer with emoji icons. */
@Composable
private fun WaypointMarkerLayers(waypoints: Map<Int, DataPacket>) {
val featureCollection = remember(waypoints) { waypointsToFeatureCollection(waypoints) }
val waypointSource = rememberGeoJsonSource(data = GeoJsonData.Features(featureCollection))
// Waypoint emoji labels
SymbolLayer(
id = "waypoint-markers",
source = waypointSource,
textField = feature["emoji"].asString(),
textSize = const(2f.em),
textAllowOverlap = const(true),
iconAllowOverlap = const(true),
)
// Waypoint name labels below emoji
SymbolLayer(
id = "waypoint-labels",
source = waypointSource,
textField = feature["name"].asString(),
textSize = const(1.em),
textOffset = offset(0f.em, 2f.em),
textColor = const(Color.DarkGray),
)
}

View file

@ -0,0 +1,103 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.LineLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.meshtastic.feature.map.util.positionsToLineString
import org.meshtastic.feature.map.util.positionsToPointFeatures
private val TrackColor = Color(0xFF2196F3)
private val SelectedPointColor = Color(0xFFF44336)
private const val TRACK_OPACITY = 0.8f
private const val SELECTED_OPACITY = 0.9f
/**
* Renders a position history track as a line with marker points. Replaces the Google Maps Polyline + MarkerComposable
* and OSMDroid Polyline overlay implementations.
*/
@Composable
fun NodeTrackLayers(
positions: List<org.meshtastic.proto.Position>,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
if (positions.size < 2) return
// Line track source
val lineFeatureCollection = remember(positions) { positionsToLineString(positions) }
val lineSource =
rememberGeoJsonSource(
data = GeoJsonData.Features(lineFeatureCollection),
options = GeoJsonOptions(lineMetrics = true),
)
// Track line with gradient
LineLayer(
id = "node-track-line",
source = lineSource,
width = const(3.dp),
color = const(TrackColor), // Blue
opacity = const(TRACK_OPACITY),
)
// Position marker points
val pointFeatureCollection = remember(positions) { positionsToPointFeatures(positions) }
val pointsSource = rememberGeoJsonSource(data = GeoJsonData.Features(pointFeatureCollection))
CircleLayer(
id = "node-track-points",
source = pointsSource,
radius = const(5.dp),
color = const(TrackColor),
strokeWidth = const(1.dp),
strokeColor = const(Color.White),
onClick = { features ->
val time = features.firstOrNull()?.properties?.get("time")?.toString()?.toIntOrNull()
if (time != null && onPositionSelected != null) {
onPositionSelected(time)
ClickResult.Consume
} else {
ClickResult.Pass
}
},
)
// Highlight selected position
if (selectedPositionTime != null) {
CircleLayer(
id = "node-track-selected",
source = pointsSource,
radius = const(10.dp),
color = const(SelectedPointColor), // Red
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
opacity = const(SELECTED_OPACITY),
)
}
}

View file

@ -0,0 +1,72 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MaplibreMap
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACK_ZOOM = 13.0
private const val COORDINATE_SCALE = 1e-7
/**
* Embeddable position-track map showing a polyline with markers for the given positions.
*
* Supports synchronized selection: [selectedPositionTime] highlights the corresponding marker and [onPositionSelected]
* is called when a marker is tapped, passing the `Position.time` for the host screen to synchronize its card list.
*
* Replaces both the Google Maps and OSMDroid flavor-specific NodeTrackMap implementations.
*/
@Composable
fun NodeTrackMap(
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val center =
remember(positions) {
positions.firstOrNull()?.let { 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
}
}
val cameraState =
rememberCameraState(
firstPosition =
CameraPosition(
target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0),
zoom = DEFAULT_TRACK_ZOOM,
),
)
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
NodeTrackLayers(
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}
}

View file

@ -0,0 +1,193 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.maplibre.compose.expressions.dsl.asString
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.offset
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.LineLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
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.maplibre.spatialk.geojson.Position as GeoPosition
private val ForwardRouteColor = Color(0xFF4CAF50)
private val ReturnRouteColor = Color(0xFFF44336)
private val HopMarkerColor = Color(0xFF9C27B0)
private const val COORDINATE_SCALE = 1e-7
private const val HEX_RADIX = 16
private const val ROUTE_OPACITY = 0.8f
/**
* Renders traceroute forward and return routes with hop markers. Replaces the Google Maps and OSMDroid traceroute
* polyline implementations.
*/
@Composable
fun TracerouteLayers(
overlay: TracerouteOverlay?,
nodePositions: Map<Int, org.meshtastic.proto.Position>,
nodes: Map<Int, Node>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
) {
if (overlay == null) return
// Build route line features
val routeData = remember(overlay, nodePositions) { buildTracerouteGeoJson(overlay, nodePositions, nodes) }
// Report mappable count
val mappableCount = routeData.hopFeatures.features.size
val totalCount = overlay.forwardRoute.size + overlay.returnRoute.size
onMappableCountChanged(mappableCount, totalCount)
// Forward route line
if (routeData.forwardLine.features.isNotEmpty()) {
val forwardSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.forwardLine))
LineLayer(
id = "traceroute-forward",
source = forwardSource,
width = const(3.dp),
color = const(ForwardRouteColor), // Green
opacity = const(ROUTE_OPACITY),
)
}
// Return route line (dashed)
if (routeData.returnLine.features.isNotEmpty()) {
val returnSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.returnLine))
LineLayer(
id = "traceroute-return",
source = returnSource,
width = const(3.dp),
color = const(ReturnRouteColor), // Red
opacity = const(ROUTE_OPACITY),
dasharray = const(listOf(2f, 1f)),
)
}
// Hop markers
if (routeData.hopFeatures.features.isNotEmpty()) {
val hopsSource = rememberGeoJsonSource(data = GeoJsonData.Features(routeData.hopFeatures))
CircleLayer(
id = "traceroute-hops",
source = hopsSource,
radius = const(8.dp),
color = const(HopMarkerColor), // Purple
strokeWidth = const(2.dp),
strokeColor = const(Color.White),
)
SymbolLayer(
id = "traceroute-hop-labels",
source = hopsSource,
textField = feature["short_name"].asString(),
textSize = const(1.em),
textOffset = offset(0f.em, -2f.em),
textColor = const(Color.DarkGray),
)
}
}
private data class TracerouteGeoJsonData(
val forwardLine: FeatureCollection<LineString, JsonObject>,
val returnLine: FeatureCollection<LineString, JsonObject>,
val hopFeatures: FeatureCollection<Point, JsonObject>,
)
private fun buildTracerouteGeoJson(
overlay: TracerouteOverlay,
nodePositions: Map<Int, org.meshtastic.proto.Position>,
nodes: Map<Int, Node>,
): 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)
}
// Build forward route line
val forwardCoords = overlay.forwardRoute.mapNotNull { nodeToGeoPosition(it) }
val forwardLine =
if (forwardCoords.size >= 2) {
val feature =
Feature(
geometry = LineString(forwardCoords),
properties = buildJsonObject { put("direction", "forward") },
)
@Suppress("UNCHECKED_CAST")
FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
} else {
@Suppress("UNCHECKED_CAST")
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
}
// Build return route line
val returnCoords = overlay.returnRoute.mapNotNull { nodeToGeoPosition(it) }
val returnLine =
if (returnCoords.size >= 2) {
val feature =
Feature(
geometry = LineString(returnCoords),
properties = buildJsonObject { put("direction", "return") },
)
@Suppress("UNCHECKED_CAST")
FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
} else {
@Suppress("UNCHECKED_CAST")
FeatureCollection(emptyList<Feature<LineString, JsonObject>>()) as FeatureCollection<LineString, JsonObject>
}
// Build hop marker points
val allNodeNums = overlay.relatedNodeNums
val hopFeatures =
allNodeNums.mapNotNull { nodeNum ->
val geoPos = nodeToGeoPosition(nodeNum) ?: return@mapNotNull null
val node = nodes[nodeNum]
Feature(
geometry = Point(geoPos),
properties =
buildJsonObject {
put("node_num", nodeNum)
put("short_name", node?.user?.short_name ?: nodeNum.toUInt().toString(HEX_RADIX))
put("long_name", node?.user?.long_name ?: "Unknown")
},
)
}
@Suppress("UNCHECKED_CAST")
return TracerouteGeoJsonData(
forwardLine = forwardLine,
returnLine = returnLine,
hopFeatures = FeatureCollection(hopFeatures) as FeatureCollection<Point, JsonObject>,
)
}

View file

@ -0,0 +1,75 @@
/*
* 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.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MaplibreMap
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.proto.Position
import org.maplibre.spatialk.geojson.Position as GeoPosition
private const val DEFAULT_TRACEROUTE_ZOOM = 10.0
private const val COORDINATE_SCALE = 1e-7
/**
* Embeddable traceroute map showing forward/return route polylines with hop markers.
*
* This composable is designed to be embedded inside a parent scaffold (e.g. TracerouteMapScreen). It does NOT include
* its own Scaffold or AppBar.
*
* Replaces both the Google Maps and OSMDroid flavor-specific TracerouteMap implementations.
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
// Center the camera on the first node with a known position.
val center =
remember(tracerouteNodePositions) {
tracerouteNodePositions.values.firstOrNull()?.let { 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
}
}
val cameraState =
rememberCameraState(
firstPosition =
CameraPosition(
target = center ?: GeoPosition(longitude = 0.0, latitude = 0.0),
zoom = DEFAULT_TRACEROUTE_ZOOM,
),
)
MaplibreMap(modifier = modifier, baseStyle = MapStyle.OpenStreetMap.toBaseStyle(), cameraState = cameraState) {
TracerouteLayers(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
nodes = emptyMap(), // Node lookups for short names are best-effort
onMappableCountChanged = onMappableCountChanged,
)
}
}

View file

@ -0,0 +1,52 @@
/*
* 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.jetbrains.compose.resources.StringResource
import org.maplibre.compose.style.BaseStyle
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_dark
import org.meshtastic.core.resources.map_style_hybrid
import org.meshtastic.core.resources.map_style_osm
import org.meshtastic.core.resources.map_style_satellite
import org.meshtastic.core.resources.map_style_terrain
/**
* Predefined map tile styles available in the app.
*
* Uses free tile sources that do not require API keys. Custom XYZ tile URLs and offline sources can be configured
* separately via [MapLayerItem].
*/
enum class MapStyle(val label: StringResource, val styleUri: String) {
/** OpenStreetMap default tiles via OpenFreeMap Liberty style. */
OpenStreetMap(label = Res.string.map_style_osm, styleUri = "https://tiles.openfreemap.org/styles/liberty"),
/** Satellite imagery — uses OpenFreeMap with a raster overlay switcher. */
Satellite(label = Res.string.map_style_satellite, styleUri = "https://tiles.openfreemap.org/styles/liberty"),
/** Terrain style. */
Terrain(label = Res.string.map_style_terrain, styleUri = "https://tiles.openfreemap.org/styles/liberty"),
/** Satellite + labels hybrid. */
Hybrid(label = Res.string.map_style_hybrid, styleUri = "https://tiles.openfreemap.org/styles/liberty"),
/** Dark mode style. */
Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/bright"),
;
fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri)
}

View file

@ -19,16 +19,20 @@ package org.meshtastic.feature.map.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.MapViewModel
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoute.Map> { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalMapMainScreenProvider.current
mapScreen(
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // onClickNodeChip
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails
args.waypointId,
val viewModel = koinViewModel<MapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) },
waypointId = args.waypointId,
)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -14,7 +14,7 @@
* 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
package org.meshtastic.feature.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@ -27,38 +27,33 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.feature.map.component.NodeTrackMap
/**
* Full-screen map showing a single node's position history.
*
* Includes a Scaffold with AppBar showing the node's long name. Replaces both the Google Maps and OSMDroid
* flavor-specific NodeMapScreen implementations.
*/
@Composable
fun MapScreen(
onClickNodeChip: (Int) -> Unit,
navigateToNodeDetails: (Int) -> Unit,
modifier: Modifier = Modifier,
viewModel: SharedMapViewModel,
waypointId: Int? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
fun NodeMapScreen(viewModel: NodeMapViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
val node by viewModel.node.collectAsStateWithLifecycle()
val positions by viewModel.positionLogs.collectAsStateWithLifecycle()
@Suppress("ViewModelForwarding")
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.map),
ourNode = ourNodeInfo,
showNodeChip = ourNodeInfo != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
title = node?.user?.long_name ?: stringResource(Res.string.map),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = { onClickNodeChip(it.num) },
onClickChip = {},
)
},
) { paddingValues ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
NodeTrackMap(positions = positions, modifier = Modifier.fillMaxSize().padding(paddingValues))
}
}

View file

@ -0,0 +1,178 @@
/*
* 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.util
import kotlinx.serialization.json.JsonObject
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.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
/** Meshtastic stores lat/lng as integer microdegrees; multiply by this to get decimal degrees. */
private const val COORDINATE_SCALE = 1e-7
private const val MIN_PRECISION_BITS = 10
private const val MAX_PRECISION_BITS = 19
/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */
fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
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 colors = node.colors
val props = buildJsonObject {
put("node_num", node.num)
put("short_name", node.user.short_name)
put("long_name", node.user.long_name)
put("last_heard", node.lastHeard)
put("is_favorite", node.isFavorite)
put("is_my_node", node.num == myNodeNum)
put("hops_away", node.hopsAway)
put("via_mqtt", node.viaMqtt)
put("snr", node.snr.toDouble())
put("rssi", node.rssi)
put("foreground_color", colors.first)
put("background_color", colors.second)
put("has_precision", (pos.precision_bits ?: 0) in MIN_PRECISION_BITS..MAX_PRECISION_BITS)
put("precision_meters", precisionBitsToMeters(pos.precision_bits ?: 0))
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
}
/** Convert waypoints to a GeoJSON [FeatureCollection]. */
fun waypointsToFeatureCollection(waypoints: Map<Int, DataPacket>): FeatureCollection<Point, JsonObject> {
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 emoji = if (waypoint.icon != 0) convertIntToEmoji(waypoint.icon) else PIN_EMOJI
val props = buildJsonObject {
put("waypoint_id", waypoint.id)
put("name", waypoint.name)
put("description", waypoint.description)
put("emoji", emoji)
put("icon", waypoint.icon)
put("locked_to", waypoint.locked_to)
put("expire", waypoint.expire)
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
}
/** 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)
}
if (coords.size < 2) return FeatureCollection(emptyList())
val props = buildJsonObject { put("point_count", coords.size) }
val feature = Feature(geometry = LineString(coords), properties = props)
@Suppress("UNCHECKED_CAST")
return FeatureCollection(listOf(feature)) as FeatureCollection<LineString, JsonObject>
}
/** 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 props = buildJsonObject {
put("time", pos.time ?: 0)
put("altitude", pos.altitude ?: 0)
put("ground_speed", pos.ground_speed ?: 0)
put("sats_in_view", pos.sats_in_view ?: 0)
}
Feature(geometry = Point(GeoPosition(longitude = lng, latitude = lat)), properties = props)
}
@Suppress("UNCHECKED_CAST")
return FeatureCollection(features) as FeatureCollection<Point, JsonObject>
}
/** Approximate meters of positional uncertainty from precision_bits (10-19). */
@Suppress("MagicNumber")
fun precisionBitsToMeters(precisionBits: Int): Double = when (precisionBits) {
10 -> 5886.0
11 -> 2944.0
12 -> 1472.0
13 -> 736.0
14 -> 368.0
15 -> 184.0
16 -> 92.0
17 -> 46.0
18 -> 23.0
19 -> 11.5
else -> 0.0
}
private const val PIN_EMOJI = "\uD83D\uDCCD"
private const val BMP_MAX = 0xFFFF
private const val SUPPLEMENTARY_OFFSET = 0x10000
private const val HALF_SHIFT = 10
private const val HIGH_SURROGATE_BASE = 0xD800
private const val LOW_SURROGATE_BASE = 0xDC00
private const val SURROGATE_MASK = 0x3FF
/** Convert a Unicode code point integer to its emoji string representation. */
internal fun convertIntToEmoji(codePoint: Int): String = try {
if (codePoint <= BMP_MAX) {
codePoint.toChar().toString()
} else {
val offset = codePoint - SUPPLEMENTARY_OFFSET
val high = (offset shr HALF_SHIFT) + HIGH_SURROGATE_BASE
val low = (offset and SURROGATE_MASK) + LOW_SURROGATE_BASE
buildString {
append(high.toChar())
append(low.toChar())
}
}
} catch (_: Exception) {
PIN_EMOJI
}

View file

@ -0,0 +1,184 @@
/*
* 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 androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
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.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.maplibre.compose.style.BaseStyle
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeMapCameraPrefs
import org.meshtastic.core.testing.FakeMapPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.feature.map.model.MapStyle
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@OptIn(ExperimentalCoroutinesApi::class)
class MapViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var viewModel: MapViewModel
private lateinit var mapCameraPrefs: FakeMapCameraPrefs
private lateinit var mapPrefs: FakeMapPrefs
private val packetRepository: PacketRepository = mock()
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
mapCameraPrefs = FakeMapCameraPrefs()
mapPrefs = FakeMapPrefs()
every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
viewModel = createViewModel()
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()): MapViewModel = MapViewModel(
mapPrefs = mapPrefs,
mapCameraPrefs = mapCameraPrefs,
nodeRepository = FakeNodeRepository(),
packetRepository = packetRepository,
radioController = FakeRadioController(),
savedStateHandle = savedStateHandle,
)
@Test
fun selectedWaypointIdDefaultsToNull() {
assertNull(viewModel.selectedWaypointId.value)
}
@Test
fun selectedWaypointIdRestoredFromSavedState() {
val vm = createViewModel(SavedStateHandle(mapOf("waypointId" to 42)))
assertEquals(42, vm.selectedWaypointId.value)
}
@Test
fun setWaypointIdUpdatesState() {
viewModel.setWaypointId(7)
assertEquals(7, viewModel.selectedWaypointId.value)
viewModel.setWaypointId(null)
assertNull(viewModel.selectedWaypointId.value)
}
@Test
fun initialCameraPositionReflectsPrefs() {
mapCameraPrefs.setCameraLat(51.5)
mapCameraPrefs.setCameraLng(-0.1)
mapCameraPrefs.setCameraZoom(12f)
mapCameraPrefs.setCameraTilt(30f)
mapCameraPrefs.setCameraBearing(45f)
val vm = createViewModel()
val pos = vm.initialCameraPosition
assertEquals(51.5, pos.target.latitude)
assertEquals(-0.1, pos.target.longitude)
assertEquals(12.0, pos.zoom)
assertEquals(30.0, pos.tilt)
assertEquals(45.0, pos.bearing)
}
@Test
fun saveCameraPositionPersistsToPrefs() {
val cameraPosition =
org.maplibre.compose.camera.CameraPosition(
target = org.maplibre.spatialk.geojson.Position(longitude = -122.4, latitude = 37.8),
zoom = 15.0,
tilt = 20.0,
bearing = 90.0,
)
viewModel.saveCameraPosition(cameraPosition)
assertEquals(37.8, mapCameraPrefs.cameraLat.value)
assertEquals(-122.4, mapCameraPrefs.cameraLng.value)
assertEquals(15f, mapCameraPrefs.cameraZoom.value)
assertEquals(20f, mapCameraPrefs.cameraTilt.value)
assertEquals(90f, mapCameraPrefs.cameraBearing.value)
}
@Test
fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) {
viewModel.baseStyle.test {
val style = awaitItem()
assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun selectMapStyleUpdatesBaseStyleAndSelectedMapStyle() = runTest(testDispatcher) {
viewModel.selectedMapStyle.test {
assertEquals(MapStyle.OpenStreetMap, awaitItem())
viewModel.selectMapStyle(MapStyle.Dark)
assertEquals(MapStyle.Dark, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun baseStyleEmitsUriOnStyleChange() = runTest(testDispatcher) {
viewModel.baseStyle.test {
// Initial style
awaitItem()
viewModel.selectMapStyle(MapStyle.Dark)
val darkStyle = awaitItem()
assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun compassBearingReflectsPrefs() {
mapCameraPrefs.setCameraBearing(180f)
val vm = createViewModel()
assertEquals(180f, vm.compassBearing)
}
@Test
fun blankStyleUriFallsBackToOpenStreetMap() = runTest(testDispatcher) {
// selectedStyleUri defaults to "" in FakeMapCameraPrefs
viewModel.baseStyle.test {
val style = awaitItem()
assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style)
cancelAndIgnoreRemainingEvents()
}
}
}