mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
a2763bdfeb
commit
598cae564e
86 changed files with 1653 additions and 8333 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue