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 048c74db13
commit 2cf4e8f80e
86 changed files with 1653 additions and 8321 deletions

View File

@@ -277,11 +277,6 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
@@ -294,10 +289,6 @@ dependencies {
googleImplementation(libs.firebase.analytics)
googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)

View File

@@ -1,39 +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.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
)
}
}

View File

@@ -1,21 +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.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()

View File

@@ -1,80 +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.app.map
import android.content.Context
import android.util.TypedValue
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.log2
import kotlin.math.pow
private const val DEGREES_IN_CIRCLE = 360.0
private const val METERS_PER_DEGREE_LATITUDE = 111320.0
private const val ZOOM_ADJUSTMENT_FACTOR = 0.8
/**
* Calculates the zoom level required to fit the entire [BoundingBox] inside the map view.
*
* @return The zoom level as a Double value.
*/
fun BoundingBox.requiredZoomLevel(): Double {
val topLeft = GeoPoint(this.latNorth, this.lonWest)
val bottomRight = GeoPoint(this.latSouth, this.lonEast)
val latLonWidth = topLeft.distanceToAsDouble(GeoPoint(topLeft.latitude, bottomRight.longitude))
val latLonHeight = topLeft.distanceToAsDouble(GeoPoint(bottomRight.latitude, topLeft.longitude))
val requiredLatZoom = log2(DEGREES_IN_CIRCLE / (latLonHeight / METERS_PER_DEGREE_LATITUDE))
val requiredLonZoom = log2(DEGREES_IN_CIRCLE / (latLonWidth / METERS_PER_DEGREE_LATITUDE))
return maxOf(requiredLatZoom, requiredLonZoom) * ZOOM_ADJUSTMENT_FACTOR
}
/**
* Creates a new bounding box with adjusted dimensions based on the provided [zoomFactor].
*
* @return A new [BoundingBox] with added [zoomFactor]. Example:
* ```
* // Setting the zoom level directly using setZoom()
* map.setZoom(14.0)
* val boundingBoxZoom14 = map.boundingBox
*
* // Using zoomIn() results the equivalent BoundingBox with setZoom(15.0)
* val boundingBoxZoom15 = boundingBoxZoom14.zoomIn(1.0)
* ```
*/
fun BoundingBox.zoomIn(zoomFactor: Double): BoundingBox {
val center = GeoPoint((latNorth + latSouth) / 2, (lonWest + lonEast) / 2)
val latDiff = latNorth - latSouth
val lonDiff = lonEast - lonWest
val newLatDiff = latDiff / (2.0.pow(zoomFactor))
val newLonDiff = lonDiff / (2.0.pow(zoomFactor))
return BoundingBox(
center.latitude + newLatDiff / 2,
center.longitude + newLonDiff / 2,
center.latitude - newLatDiff / 2,
center.longitude - newLonDiff / 2,
)
}
// Converts SP to pixels.
fun Context.spToPx(sp: Float): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics).toInt()
// Converts DP to pixels.
fun Context.dpToPx(dp: Float): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics).toInt()

View File

@@ -1,968 +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.app.map
import android.Manifest
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
import org.meshtastic.core.resources.map_cache_tiles
import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
import org.meshtastic.core.resources.map_purge_success
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.cachemanager.CacheManager
import org.osmdroid.tileprovider.modules.SqliteArchiveTileWriter
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicyException
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
nodeClusterer: RadiusMarkerClusterer,
) {
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(waypointMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
nodeClusterer.invalidate()
}
private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) -> Unit) =
object : CacheManager.CacheManagerCallback {
override fun onTaskComplete() {
onTaskComplete()
}
override fun onTaskFailed(errors: Int) {
onTaskFailed(errors)
}
override fun updateProgress(progress: Int, currentZoomLevel: Int, zoomMin: Int, zoomMax: Int) {
// NOOP since we are using the build in UI
}
override fun downloadStarted() {
// NOOP since we are using the build in UI
}
override fun setPossibleTilesInArea(total: Int) {
// NOOP since we are using the build in UI
}
}
/**
* Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user
* interactions for map manipulation, filtering, and offline caching.
*
* @param mapViewModel The [MapViewModel] providing data and state for the map.
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit,
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
var cacheEstimate by remember { mutableStateOf("") }
var zoomLevelMin by remember { mutableDoubleStateOf(0.0) }
var zoomLevelMax by remember { mutableDoubleStateOf(0.0) }
var downloadRegionBoundingBox: BoundingBox? by remember { mutableStateOf(null) }
var myLocationOverlay: MyLocationNewOverlay? by remember { mutableStateOf(null) }
var showDownloadButton: Boolean by remember { mutableStateOf(false) }
var showEditWaypointDialog by remember { mutableStateOf<Waypoint?>(null) }
var showCacheManagerDialog by remember { mutableStateOf(false) }
var showCurrentCacheInfo by remember { mutableStateOf(false) }
var showPurgeTileSourceDialog by remember { mutableStateOf(false) }
var showMapStyleDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val density = LocalDensity.current
val haptic = LocalHapticFeedback.current
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
// Accompanist permissions state for location
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
fun loadOnlineTileSourceBase(): ITileSource {
val id = mapViewModel.mapStyleId
Logger.d { "mapStyleId from prefs: $id" }
return CustomTileSource.getTileSource(id).also {
zoomLevelMax = it.maximumZoomLevel.toDouble()
showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false
}
}
val initialCameraView = remember {
val nodes = mapViewModel.nodes.value
val nodesWithPosition = nodes.filter { it.validPosition != null }
val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) }
BoundingBox.fromGeoPoints(geoPoints)
}
val map =
rememberMapViewWithLifecycle(
applicationId = mapViewModel.applicationId,
box = initialCameraView,
tileSource = loadOnlineTileSourceBase(),
)
val nodeClusterer = remember { RadiusMarkerClusterer(context) }
fun MapView.toggleMyLocation() {
if (context.gpsDisabled()) {
Logger.d { "Telling user we need location turned on for MyLocationNewOverlay" }
scope.launch { context.showToast(Res.string.location_disabled) }
return
}
Logger.d { "user clicked MyLocationNewOverlay ${myLocationOverlay == null}" }
if (myLocationOverlay == null) {
myLocationOverlay =
MyLocationNewOverlay(this).apply {
enableMyLocation()
enableFollowLocation()
getBitmapFromVectorDrawable(context, R.drawable.ic_map_location_dot)?.let {
setPersonIcon(it)
setPersonAnchor(0.5f, 0.5f)
}
getBitmapFromVectorDrawable(context, R.drawable.ic_map_navigation)?.let {
setDirectionIcon(it)
setDirectionAnchor(0.5f, 0.5f)
}
}
overlays.add(myLocationOverlay)
} else {
myLocationOverlay?.apply {
disableMyLocation()
disableFollowLocation()
}
overlays.remove(myLocationOverlay)
myLocationOverlay = null
}
}
// Effect to toggle MyLocation after permission is granted
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
map.toggleMyLocation()
triggerLocationToggleAfterPermission = false
}
}
// Keep screen on while location tracking is active
LaunchedEffect(myLocationOverlay) {
val activity = context as? android.app.Activity ?: return@LaunchedEffect
if (myLocationOverlay != null) {
activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
val myId by mapViewModel.myId.collectAsStateWithLifecycle()
LaunchedEffect(selectedWaypointId, waypoints) {
if (selectedWaypointId != null && waypoints.containsKey(selectedWaypointId)) {
waypoints[selectedWaypointId]?.waypoint?.let { pt ->
val geoPoint = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
map.controller.setCenter(geoPoint)
map.controller.setZoom(WAYPOINT_ZOOM)
}
}
}
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
if (
mapFilterStateValue.lastHeardFilter.seconds != 0L &&
(nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
node.num != ourNode?.num
) {
return@mapNotNull null
}
val (p, u) = node.position to node.user
val nodePosition = GeoPoint(node.latitude, node.longitude)
MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply {
id = u.id
title = u.long_name
snippet =
getString(
Res.string.map_node_popup_details,
node.gpsString(),
formatAgo(node.lastHeard),
formatAgo(p.time),
if (node.batteryStr != "") node.batteryStr else "?",
)
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
ourNode.bearing(node)?.let { bearing ->
subDescription = getString(Res.string.map_subDescription, bearing, dist)
}
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
icon = markerIcon
setNodeColors(node.colors)
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
setPrecisionBits(p.precision_bits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
true
}
}
}
}
fun showDeleteMarkerDialog(waypoint: Waypoint) {
val builder = MaterialAlertDialogBuilder(context)
builder.setTitle(getString(Res.string.waypoint_delete))
builder.setNeutralButton(getString(Res.string.cancel)) { _, _ ->
Logger.d { "User canceled marker delete dialog" }
}
builder.setNegativeButton(getString(Res.string.delete_for_me)) { _, _ ->
Logger.d { "User deleted waypoint ${waypoint.id} for me" }
mapViewModel.deleteWaypoint(waypoint.id)
}
if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ ->
Logger.d { "User deleted waypoint ${waypoint.id} for everyone" }
mapViewModel.sendWaypoint(waypoint.copy(expire = 1))
mapViewModel.deleteWaypoint(waypoint.id)
}
}
val dialog = builder.show()
for (
button in
setOf(
androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL,
androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE,
androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE,
)
) {
with(dialog.getButton(button)) {
textSize = 12F
isAllCaps = false
}
}
}
fun showMarkerLongPressDialog(id: Int) {
performHapticFeedback()
Logger.d { "marker long pressed id=$id" }
val waypoint = waypoints[id]?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) {
getString(Res.string.you)
} else {
mapViewModel.getUser(id).long_name
}
@Suppress("MagicNumber")
fun MapView.onWaypointChanged(waypoints: Collection<DataPacket>, selectedWaypointId: Int?): List<MarkerWithLabel> {
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt())
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val now = nowMillis
val expireTimeMillis = pt.expire * 1000L
val expireTimeStr =
when {
pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
MarkerWithLabel(this, label, emoji).apply {
id = "${pt.id}"
title = "${pt.name} (${getUsername(waypoint.from)}$lock)"
snippet = "[$time] ${pt.description} " + getString(Res.string.expires) + ": $expireTimeStr"
position = GeoPoint((pt.latitude_i ?: 0) * 1e-7, (pt.longitude_i ?: 0) * 1e-7)
if (selectedWaypointId == pt.id) {
showInfoWindow()
}
setOnLongClickListener {
showMarkerLongPressDialog(pt.id)
true
}
}
}
}
val mapEventsReceiver =
object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint): Boolean {
InfoWindow.closeAllInfoWindowsOn(map)
return true
}
override fun longPressHelper(p: GeoPoint): Boolean {
performHapticFeedback()
val enabled = isConnected && downloadRegionBoundingBox == null
if (enabled) {
showEditWaypointDialog =
Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt())
}
return true
}
}
fun MapView.drawOverlays() {
if (overlays.none { it is MapEventsOverlay }) {
overlays.add(0, MapEventsOverlay(mapEventsReceiver))
}
if (myLocationOverlay != null && overlays.none { it is MyLocationNewOverlay }) {
overlays.add(myLocationOverlay)
}
if (overlays.none { it is RadiusMarkerClusterer }) {
overlays.add(nodeClusterer)
}
addCopyright()
addScaleBarOverlay(density)
createLatLongGrid(false)
invalidate()
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
zoomLevelMin = minOf(zoomLevelDouble, zoomLevelMax)
downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor)
val polygon =
Polygon().apply {
points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) }
}
overlays.add(polygon)
invalidate()
val tileCount: Int =
CacheManager(this)
.possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt())
cacheEstimate = getString(Res.string.map_cache_tiles, tileCount)
}
val boxOverlayListener =
object : MapListener {
override fun onScroll(event: ScrollEvent): Boolean {
if (downloadRegionBoundingBox != null) {
event.source.generateBoxOverlay()
}
return true
}
override fun onZoom(event: ZoomEvent): Boolean = false
}
fun startDownload() {
val boundingBox = downloadRegionBoundingBox ?: return
try {
val outputName = buildString {
append(Configuration.getInstance().osmdroidBasePath.absolutePath)
append(File.separator)
append("mainFile.sqlite")
}
val writer = SqliteArchiveTileWriter(outputName)
val cacheManager = CacheManager(map, writer)
cacheManager.downloadAreaAsync(
context,
boundingBox,
zoomLevelMin.toInt(),
zoomLevelMax.toInt(),
cacheManagerCallback(
onTaskComplete = {
scope.launch { context.showToast(Res.string.map_download_complete) }
writer.onDetach()
},
onTaskFailed = { errors ->
scope.launch { context.showToast(Res.string.map_download_errors, errors) }
writer.onDetach()
},
),
)
} catch (ex: TileSourcePolicyException) {
Logger.d { "Tile source does not allow archiving: ${ex.message}" }
} catch (ex: Exception) {
Logger.d { "Tile source exception: ${ex.message}" }
}
}
Scaffold(
modifier = modifier,
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
AndroidView(
factory = {
map.apply {
setDestroyMode(false)
addMapListener(boxOverlayListener)
}
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
with(mapView) {
updateMarkers(
onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
nodeClusterer,
)
}
mapView.drawOverlays()
}, // Renamed map to mapView to avoid conflict
)
if (downloadRegionBoundingBox != null) {
CacheLayout(
cacheEstimate = cacheEstimate,
onExecuteJob = { startDownload() },
onCancelDownload = {
downloadRegionBoundingBox = null
map.overlays.removeAll { it is Polygon }
map.invalidate()
},
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { mapFilterExpanded = true },
filterDropdownContent = {
FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
mapFilterState = mapFilterState,
mapViewModel = mapViewModel,
)
},
mapTypeContent = {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { showMapStyleDialog = true },
)
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
},
)
}
}
}
if (showMapStyleDialog) {
MapStyleDialog(
selectedMapStyle = mapViewModel.mapStyleId,
onDismiss = { showMapStyleDialog = false },
onSelectMapStyle = {
mapViewModel.mapStyleId = it
map.setTileSource(loadOnlineTileSourceBase())
},
)
}
if (showCacheManagerDialog) {
CacheManagerDialog(
onClickOption = { option ->
when (option) {
CacheManagerOption.CurrentCacheSize -> {
scope.launch { context.showToast(Res.string.calculating) }
showCurrentCacheInfo = true
}
CacheManagerOption.DownloadRegion -> map.generateBoxOverlay()
CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true
CacheManagerOption.Cancel -> Unit
}
showCacheManagerDialog = false
},
onDismiss = { showCacheManagerDialog = false },
)
}
if (showCurrentCacheInfo) {
CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false })
}
if (showPurgeTileSourceDialog) {
PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false })
}
if (showEditWaypointDialog != null) {
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
onSendClicked = { waypoint ->
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0
val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
mapViewModel.sendWaypoint(
waypoint.copy(
id = newId,
name = newName,
expire = newExpire,
locked_to = newLockedTo,
icon = newIcon,
),
)
},
onDeleteClicked = { waypoint ->
Logger.d { "User clicked delete waypoint ${waypoint.id}" }
showEditWaypointDialog = null
showDeleteMarkerDialog(waypoint)
},
onDismissRequest = {
Logger.d { "User clicked cancel marker edit dialog" }
showEditWaypointDialog = null
},
)
}
}
/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
@Composable
private fun FdroidMainMapFilterDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapFilterState: MapFilterState,
mapViewModel: MapViewModel,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
MapsDialog(onDismiss = onDismiss) {
CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
ListItem(
text = style,
trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null,
onClick = {
selected.value = index
onSelectMapStyle(index)
onDismiss()
},
)
}
}
}
private enum class CacheManagerOption(val label: StringResource) {
CurrentCacheSize(label = Res.string.map_cache_size),
DownloadRegion(label = Res.string.map_download_region),
ClearTiles(label = Res.string.map_clear_tiles),
Cancel(label = Res.string.cancel),
}
@Composable
private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) {
MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) {
CacheManagerOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickOption(option)
onDismiss()
}
}
}
}
@Composable
private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
val (cacheCapacity, currentCacheUsage) =
remember(mapView) {
val cacheManager = CacheManager(mapView)
cacheManager.cacheCapacity() to cacheManager.currentCacheUsage()
}
MapsDialog(
title = stringResource(Res.string.map_cache_manager),
onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) {
Text(
modifier = Modifier.padding(16.dp),
text =
stringResource(
Res.string.map_cache_info,
cacheCapacity / (1024.0 * 1024.0),
currentCacheUsage / (1024.0 * 1024.0),
),
)
}
}
@Composable
private fun PurgeTileSourceDialog(onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val cache = SqlTileWriterExt()
val sourceList by derivedStateOf { cache.sources.map { it.source as String } }
val selected = remember { mutableStateListOf<Int>() }
MapsDialog(
title = stringResource(Res.string.map_tile_source),
positiveButton = {
TextButton(
enabled = selected.isNotEmpty(),
onClick = {
selected.forEach { selectedIndex ->
val source = sourceList[selectedIndex]
scope.launch {
context.showToast(
if (cache.purgeCache(source)) {
getString(Res.string.map_purge_success, source)
} else {
getString(Res.string.map_purge_fail)
},
)
}
}
onDismiss()
},
) {
Text(text = stringResource(Res.string.clear))
}
},
negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } },
onDismiss = onDismiss,
) {
sourceList.forEachIndexed { index, source ->
val isSelected = selected.contains(index)
BasicListItem(
text = source,
trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) },
onClick = {
if (isSelected) {
selected.remove(index)
} else {
selected.add(index)
}
},
) {}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MapsDialog(
title: String? = null,
onDismiss: () -> Unit,
positiveButton: (@Composable () -> Unit)? = null,
negativeButton: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
modifier = Modifier.wrapContentWidth().wrapContentHeight(),
shape = MaterialTheme.shapes.large,
color = AlertDialogDefaults.containerColor,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column {
title?.let {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
text = it,
style = MaterialTheme.typography.titleLarge,
)
}
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() }
if (positiveButton != null || negativeButton != null) {
Row(Modifier.align(Alignment.End)) {
positiveButton?.invoke()
negativeButton?.invoke()
}
}
}
}
}
}
private const val WAYPOINT_ZOOM = 15.0

View File

@@ -1,145 +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.app.map
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import org.meshtastic.app.R
import org.meshtastic.proto.Position
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.CopyrightOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.ScaleBarOverlay
import org.osmdroid.views.overlay.advancedpolyline.MonochromaticPaintList
import org.osmdroid.views.overlay.gridlines.LatLonGridlineOverlay2
/** Adds copyright to map depending on what source is showing */
fun MapView.addCopyright() {
if (overlays.none { it is CopyrightOverlay }) {
val copyrightNotice: String = tileProvider.tileSource.copyrightNotice ?: return
val copyrightOverlay = CopyrightOverlay(context)
copyrightOverlay.setCopyrightNotice(copyrightNotice)
overlays.add(copyrightOverlay)
}
}
/**
* Create LatLong Grid line overlay
*
* @param enabled: turn on/off gridlines
*/
fun MapView.createLatLongGrid(enabled: Boolean) {
val latLongGridOverlay = LatLonGridlineOverlay2()
latLongGridOverlay.isEnabled = enabled
if (latLongGridOverlay.isEnabled) {
val textPaint =
Paint().apply {
textSize = 40f
color = Color.GRAY
isAntiAlias = true
isFakeBoldText = true
textAlign = Paint.Align.CENTER
}
latLongGridOverlay.textPaint = textPaint
latLongGridOverlay.setBackgroundColor(Color.TRANSPARENT)
latLongGridOverlay.setLineWidth(3.0f)
latLongGridOverlay.setLineColor(Color.GRAY)
overlays.add(latLongGridOverlay)
}
}
fun MapView.addScaleBarOverlay(density: Density) {
if (overlays.none { it is ScaleBarOverlay }) {
val scaleBarOverlay =
ScaleBarOverlay(this).apply {
setAlignBottom(true)
with(density) {
setScaleBarOffset(15.dp.toPx().toInt(), 40.dp.toPx().toInt())
setTextSize(12.sp.toPx())
}
textPaint.apply {
isAntiAlias = true
typeface = Typeface.DEFAULT_BOLD
}
}
overlays.add(scaleBarOverlay)
}
}
fun MapView.addPolyline(density: Density, geoPoints: List<GeoPoint>, onClick: () -> Unit): Polyline {
val polyline =
Polyline(this).apply {
val borderPaint =
Paint().apply {
color = Color.BLACK
isAntiAlias = true
strokeWidth = with(density) { 10.dp.toPx() }
style = Paint.Style.STROKE
strokeJoin = Paint.Join.ROUND
strokeCap = Paint.Cap.ROUND
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
}
outlinePaintLists.add(MonochromaticPaintList(borderPaint))
val fillPaint =
Paint().apply {
color = Color.WHITE
isAntiAlias = true
strokeWidth = with(density) { 6.dp.toPx() }
style = Paint.Style.FILL_AND_STROKE
strokeJoin = Paint.Join.ROUND
strokeCap = Paint.Cap.ROUND
pathEffect = DashPathEffect(floatArrayOf(80f, 60f), 0f)
}
outlinePaintLists.add(MonochromaticPaintList(fillPaint))
setPoints(geoPoints)
setOnClickListener { _, _, _ ->
onClick()
true
}
}
overlays.add(polyline)
return polyline
}
fun MapView.addPositionMarkers(positions: List<Position>, onClick: (Int) -> Unit): List<Marker> {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
val markers =
positions.map { pos ->
Marker(this).apply {
icon = navIcon
rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
setOnMarkerClickListener { _, _ ->
onClick(pos.time)
true
}
}
}
overlays.addAll(markers)
return markers
}

View File

@@ -1,67 +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.app.map
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.LocalConfig
@Suppress("LongParameterList")
@KoinViewModel
class MapViewModel(
mapPrefs: MapPrefs,
packetRepository: PacketRepository,
nodeRepository: NodeRepository,
radioController: RadioController,
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
fun setWaypointId(id: Int?) {
if (_selectedWaypointId.value != id) {
_selectedWaypointId.value = id
}
}
var mapStyleId: Int
get() = mapPrefs.mapStyle.value
set(value) {
mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val config
get() = localConfig.value
val applicationId = buildConfigProvider.applicationId
}

View File

@@ -1,136 +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.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
@Suppress("MagicNumber")
@Composable
fun rememberMapViewWithLifecycle(
applicationId: String,
box: BoundingBox,
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
): MapView {
val zoom =
if (box.requiredZoomLevel().isFinite()) {
(box.requiredZoomLevel() - 0.5).coerceAtLeast(MIN_ZOOM_LEVEL)
} else {
DEFAULT_ZOOM_LEVEL
}
val center = GeoPoint(box.centerLatitude, box.centerLongitude)
return rememberMapViewWithLifecycle(
applicationId = applicationId,
zoomLevel = zoom,
mapCenter = center,
tileSource = tileSource,
)
}
@Suppress("LongMethod")
@Composable
internal fun rememberMapViewWithLifecycle(
applicationId: String,
zoomLevel: Double = MIN_ZOOM_LEVEL,
mapCenter: GeoPoint = GeoPoint(0.0, 0.0),
tileSource: ITileSource = TileSourceFactory.DEFAULT_TILE_SOURCE,
): MapView {
var savedZoom by rememberSaveable { mutableDoubleStateOf(zoomLevel) }
var savedCenter by
rememberSaveable(
stateSaver =
Saver(
save = { mapOf("latitude" to it.latitude, "longitude" to it.longitude) },
restore = { GeoPoint(it["latitude"] ?: 0.0, it["longitude"] ?: .0) },
),
) {
mutableStateOf(mapCenter)
}
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
clipToOutline = true
// Required to get online tiles
Configuration.getInstance().userAgentValue = applicationId
setTileSource(tileSource)
isVerticalMapRepetitionEnabled = false // disables map repetition
setMultiTouchControls(true)
val bounds = overlayManager.tilesOverlay.bounds // bounds scrollable map
setScrollableAreaLimitLatitude(bounds.actualNorth, bounds.actualSouth, 0)
// scales the map tiles to the display density of the screen
isTilesScaledToDpi = true
// sets the minimum zoom level (the furthest out you can zoom)
minZoomLevel = MIN_ZOOM_LEVEL
maxZoomLevel = MAX_ZOOM_LEVEL
// Disables default +/- button for zooming
zoomController.setVisibility(CustomZoomButtonsController.Visibility.SHOW_AND_FADEOUT)
controller.setZoom(savedZoom)
controller.setCenter(savedCenter)
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
mapView.onResume()
}
Lifecycle.Event.ON_STOP -> {
savedCenter = mapView.projection.currentCenter
savedZoom = mapView.zoomLevelDouble
}
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}

View File

@@ -1,111 +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.app.map
import android.database.Cursor
import org.meshtastic.core.common.util.nowMillis
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
import org.osmdroid.tileprovider.modules.SqlTileWriter
/**
* Extended the sqlite tile writer to have some additional query functions. A this point it's unclear if there is a need
* to put these with the osmdroid-android library, thus they were put here as more of an example.
*
* created on 12/21/2016.
*
* @author Alex O'Ree
* @since 5.6.2
*/
class SqlTileWriterExt : SqlTileWriter() {
fun select(rows: Int, offset: Int): Cursor? = this.db?.rawQuery(
"select " +
DatabaseFileArchive.COLUMN_KEY +
"," +
COLUMN_EXPIRES +
"," +
DatabaseFileArchive.COLUMN_PROVIDER +
" from " +
DatabaseFileArchive.TABLE +
" limit ? offset ?",
arrayOf(rows.toString() + "", offset.toString() + ""),
)
/**
* gets all the tiles sources that we have tiles for in the cache database and their counts
*
* @return
*/
val sources: List<SourceCount>
get() {
val db = db
val ret: MutableList<SourceCount> = ArrayList()
if (db == null) {
return ret
}
var cur: Cursor? = null
try {
cur =
db.rawQuery(
"select " +
DatabaseFileArchive.COLUMN_PROVIDER +
",count(*) " +
",min(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
",max(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
",sum(length(" +
DatabaseFileArchive.COLUMN_TILE +
")) " +
"from " +
DatabaseFileArchive.TABLE +
" " +
"group by " +
DatabaseFileArchive.COLUMN_PROVIDER,
null,
)
while (cur.moveToNext()) {
val c = SourceCount()
c.source = cur.getString(0)
c.rowCount = cur.getLong(1)
c.sizeMin = cur.getLong(2)
c.sizeMax = cur.getLong(3)
c.sizeTotal = cur.getLong(4)
c.sizeAvg = c.sizeTotal / c.rowCount
ret.add(c)
}
} catch (e: Exception) {
catchException(e)
} finally {
cur?.close()
}
return ret
}
val rowCountExpired: Long
get() = getRowCount("$COLUMN_EXPIRES<?", arrayOf(nowMillis.toString()))
class SourceCount {
var rowCount: Long = 0
var source: String? = null
var sizeTotal: Long = 0
var sizeMin: Long = 0
var sizeMax: Long = 0
var sizeAvg: Long = 0
}
}

View File

@@ -1,94 +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.app.map.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.map_select_download_region
import org.meshtastic.core.resources.map_start_download
import org.meshtastic.core.resources.map_tile_download_estimate
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CacheLayout(
cacheEstimate: String,
onExecuteJob: () -> Unit,
onCancelDownload: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxWidth()
.wrapContentHeight()
.background(color = MaterialTheme.colorScheme.background)
.padding(8.dp),
) {
Text(
text = stringResource(Res.string.map_select_download_region),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.map_tile_download_estimate) + " " + cacheEstimate,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
Button(onClick = onCancelDownload, modifier = Modifier.weight(1f)) {
Text(text = stringResource(Res.string.cancel), color = MaterialTheme.colorScheme.onPrimary)
}
Button(onClick = onExecuteJob, modifier = Modifier.weight(1f)) {
Text(text = stringResource(Res.string.map_start_download), color = MaterialTheme.colorScheme.onPrimary)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun CacheLayoutPreview() {
CacheLayout(cacheEstimate = "100 tiles", onExecuteJob = {}, onCancelDownload = {})
}

View File

@@ -1,65 +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.app.map.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.ui.icon.Download
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
AnimatedVisibility(
visible = enabled,
enter =
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
exit =
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing),
),
) {
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
Icon(
imageVector = MeshtasticIcons.Download,
contentDescription = stringResource(Res.string.map_download_region),
modifier = Modifier.scale(1.25f),
)
}
}
}
// @Preview(showBackground = true)
// @Composable
// private fun DownloadButtonPreview() {
// DownloadButton(true, onClick = {})
// }

View File

@@ -1,357 +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.app.map.component
import android.app.DatePickerDialog
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@Suppress("MagicNumber")
val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
val context = LocalContext.current
val tz = systemTimeZone
// Determine locale-specific date format
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
// Check if 24-hour format is preferred
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
val currentInstant =
remember(waypointInput.expire) {
val expire = waypointInput.expire
if (expire != 0 && expire != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(expire.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
// State to hold selected date and time
var selectedDate by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
var selectedTime by
remember(currentInstant) {
mutableStateOf(
if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
},
)
}
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
shape = RoundedCornerShape(16.dp),
text = {
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(title),
style =
MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
EditTextPreference(
title = stringResource(Res.string.name),
value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(name = it) },
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(emoji)),
modifier =
Modifier.background(MaterialTheme.colorScheme.background, CircleShape)
.padding(4.dp),
fontSize = 24.sp,
color = Color.Unspecified.copy(alpha = 1f),
)
}
},
)
EditTextPreference(
title = stringResource(Res.string.description),
value = waypointInput.description,
maxSize = 99,
enabled = true,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {}),
onValueChanged = { waypointInput = waypointInput.copy(description = it) },
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Text(stringResource(Res.string.locked))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.locked_to != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
val newLdt =
LocalDateTime(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
hour = ldt.hour,
minute = ldt.minute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
ldt.month.ordinal,
ldt.day,
)
val timePickerDialog =
android.app.TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
val newLdt =
LocalDateTime(
year = ldt.year,
month = ldt.month,
day = ldt.day,
hour = selectedHour,
minute = selectedMinute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.hour,
ldt.minute,
is24Hour,
)
Row(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Text(stringResource(Res.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
} else {
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
},
)
}
if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedDate,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedTime,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
},
confirmButton = {
FlowRow(
modifier = modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.Center,
) {
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(Res.string.cancel))
}
if (waypoint.id != 0) {
Button(
modifier = modifier.weight(1f),
onClick = { onDeleteClicked(waypointInput) },
enabled = !(waypointInput.name.isNullOrEmpty()),
) {
Text(stringResource(Res.string.delete))
}
}
Button(modifier = modifier.weight(1f), onClick = { onSendClicked(waypointInput) }, enabled = true) {
Text(stringResource(Res.string.send))
}
}
},
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) {
showEmojiPickerView = false
waypointInput = waypointInput.copy(icon = it.codePointAt(0))
}
}
}
@Preview(showBackground = true)
@Composable
@Suppress("MagicNumber")
private fun EditWaypointFormPreview() {
AppTheme {
EditWaypointDialog(
waypoint =
Waypoint(
id = 123,
name = "Test 123",
description = "This is only a test",
icon = 128169,
expire = (nowSeconds.toInt() + 8 * 3600),
),
onSendClicked = {},
onDeleteClicked = {},
onDismissRequest = {},
)
}
}

View File

@@ -1,208 +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.app.map.model
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
@Suppress("UnusedPrivateProperty")
class CustomTileSource {
companion object {
val OPENWEATHER_RADAR =
OnlineTileSourceAuth(
"Open Weather Map",
1,
22,
256,
".png",
arrayOf("https://tile.openweathermap.org/map/"),
"Openweathermap",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
"precipitation",
"",
)
private val ESRI_IMAGERY =
object :
OnlineTileSourceBase(
"ESRI World Overview",
1,
20,
256,
".jpg",
arrayOf("https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"),
"Esri, Maxar, Earthstar Geographics, and the GIS User Community",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val ESRI_WORLD_TOPO =
object :
OnlineTileSourceBase(
"ESRI World TOPO",
1,
20,
256,
".jpg",
arrayOf("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/"),
"Esri, HERE, Garmin, FAO, NOAA, USGS, © OpenStreetMap contributors, and the GIS User Community ",
TileSourcePolicy(
4,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val USGS_HYDRO_CACHE =
object :
OnlineTileSourceBase(
"USGS Hydro Cache",
0,
18,
256,
"",
arrayOf("https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/"),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
private val USGS_SHADED_RELIEF =
object :
OnlineTileSourceBase(
"USGS Shaded Relief Only",
0,
18,
256,
"",
arrayOf(
"https://basemap.nationalmap.gov/arcgis/rest/services/USGSShadedReliefOnly/MapServer/tile/",
),
"USGS",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
override fun getTileURLString(pMapTileIndex: Long): String = baseUrl +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex) +
"/" +
MapTileIndex.getX(pMapTileIndex) +
mImageFilenameEnding
)
}
/** WMS TILE SERVER More research is required to get this to function correctly with overlays */
val NOAA_RADAR_WMS =
NOAAWmsTileSource(
"Recent Weather Radar",
arrayOf(
"https://new.nowcoast.noaa.gov/arcgis/services/nowcoast/" +
"radar_meteo_imagery_nexrad_time/MapServer/WmsServer?",
),
"1",
"1.1.0",
"",
"EPSG%3A3857",
"",
"image/png",
)
/** =============================================================================================== */
private val MAPNIK: OnlineTileSourceBase = TileSourceFactory.MAPNIK
private val USGS_TOPO: OnlineTileSourceBase = TileSourceFactory.USGS_TOPO
private val OPEN_TOPO: OnlineTileSourceBase = TileSourceFactory.OpenTopo
private val USGS_SAT: OnlineTileSourceBase = TileSourceFactory.USGS_SAT
private val SEAMAP: OnlineTileSourceBase = TileSourceFactory.OPEN_SEAMAP
val DEFAULT_TILE_SOURCE: OnlineTileSourceBase = TileSourceFactory.DEFAULT_TILE_SOURCE
/** Source for each available [ITileSource] and their display names. */
val mTileSources: Map<ITileSource, String> =
mapOf(
MAPNIK to "OpenStreetMap",
USGS_TOPO to "USGS TOPO",
OPEN_TOPO to "Open TOPO",
ESRI_WORLD_TOPO to "ESRI World TOPO",
USGS_SAT to "USGS Satellite",
ESRI_IMAGERY to "ESRI World Overview",
)
fun getTileSource(index: Int): ITileSource = mTileSources.keys.elementAtOrNull(index) ?: DEFAULT_TILE_SOURCE
fun getTileSource(aName: String): ITileSource {
for (tileSource: ITileSource in mTileSources.keys) {
if (tileSource.name().equals(aName)) {
return tileSource
}
}
throw IllegalArgumentException("No such tile source: $aName")
}
}
}

View File

@@ -1,138 +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.app.map.model
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.MotionEvent
import org.meshtastic.app.map.dpToPx
import org.meshtastic.app.map.spToPx
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
class MarkerWithLabel(mapView: MapView?, label: String, emoji: String? = null) : Marker(mapView) {
companion object {
private const val LABEL_CORNER_RADIUS_DP = 4f
private const val LABEL_Y_OFFSET_DP = 34f
private const val FONT_SIZE_SP = 14f
private const val EMOJI_FONT_SIZE_SP = 20f
}
private val labelYOffsetPx by lazy { mapView?.context?.dpToPx(LABEL_Y_OFFSET_DP) ?: 100 }
private val labelCornerRadiusPx by lazy { mapView?.context?.dpToPx(LABEL_CORNER_RADIUS_DP) ?: 12 }
private var nodeColor: Int = Color.GRAY
fun setNodeColors(colors: Pair<Int, Int>) {
nodeColor = colors.second
}
private var precisionBits: Int? = null
fun setPrecisionBits(bits: Int) {
precisionBits = bits
}
@Suppress("MagicNumber")
private fun getPrecisionMeters(): Double? = when (precisionBits) {
10 -> 23345.484932
11 -> 11672.7369
12 -> 5836.36288
13 -> 2918.175876
14 -> 1459.0823719999053
15 -> 729.53562
16 -> 364.7622
17 -> 182.375556
18 -> 91.182212
19 -> 45.58554
else -> null
}
private var onLongClickListener: (() -> Boolean)? = null
fun setOnLongClickListener(listener: () -> Boolean) {
onLongClickListener = listener
}
private val mLabel = label
private val mEmoji = emoji
private val textPaint =
Paint().apply {
textSize = mapView?.context?.spToPx(FONT_SIZE_SP)?.toFloat() ?: 40f
color = Color.DKGRAY
isAntiAlias = true
isFakeBoldText = true
textAlign = Paint.Align.CENTER
}
private val emojiPaint =
Paint().apply {
textSize = mapView?.context?.spToPx(EMOJI_FONT_SIZE_SP)?.toFloat() ?: 80f
isAntiAlias = true
textAlign = Paint.Align.CENTER
}
private val bgPaint = Paint().apply { color = Color.WHITE }
private fun getTextBackgroundSize(text: String, x: Float, y: Float): RectF {
val fontMetrics = textPaint.fontMetrics
val halfTextLength = textPaint.measureText(text) / 2 + 3
return RectF((x - halfTextLength), (y + fontMetrics.top), (x + halfTextLength), (y + fontMetrics.bottom))
}
override fun onLongPress(event: MotionEvent?, mapView: MapView?): Boolean {
val touched = hitTest(event, mapView)
if (touched && this.id != null) {
return onLongClickListener?.invoke() ?: super.onLongPress(event, mapView)
}
return super.onLongPress(event, mapView)
}
@Suppress("MagicNumber")
override fun draw(c: Canvas, osmv: MapView?, shadow: Boolean) {
super.draw(c, osmv, false)
val p = mPositionPixels
val bgRect = getTextBackgroundSize(mLabel, p.x.toFloat(), (p.y - labelYOffsetPx.toFloat()))
bgRect.inset(-8F, -2F)
if (mLabel.isNotEmpty()) {
c.drawRoundRect(bgRect, labelCornerRadiusPx.toFloat(), labelCornerRadiusPx.toFloat(), bgPaint)
c.drawText(mLabel, (p.x - 0F), (p.y - labelYOffsetPx.toFloat()), textPaint)
}
mEmoji?.let { c.drawText(it, (p.x - 0f), (p.y - 30f), emojiPaint) }
getPrecisionMeters()?.let { radius ->
val polygon =
Polygon(osmv).apply {
points = Polygon.pointsAsCircle(position, radius)
fillPaint.apply {
color = nodeColor
alpha = 48
}
outlinePaint.apply {
color = nodeColor
alpha = 64
}
}
polygon.draw(c, osmv, false)
}
}
}

View File

@@ -1,160 +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.app.map.model
import android.content.res.Resources
import co.touchlab.kermit.Logger
import org.osmdroid.api.IMapView
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
import kotlin.math.atan
import kotlin.math.pow
import kotlin.math.sinh
open class NOAAWmsTileSource(
aName: String,
aBaseUrl: Array<String>,
layername: String,
version: String,
time: String?,
srs: String,
style: String?,
format: String,
) : OnlineTileSourceBase(
aName,
0,
5,
256,
"png",
aBaseUrl,
"",
TileSourcePolicy(
2,
TileSourcePolicy.FLAG_NO_BULK or
TileSourcePolicy.FLAG_NO_PREVENTIVE or
TileSourcePolicy.FLAG_USER_AGENT_MEANINGFUL or
TileSourcePolicy.FLAG_USER_AGENT_NORMALIZED,
),
) {
// array indexes for array to hold bounding boxes.
private val minX = 0
private val maxX = 1
private val minY = 2
private val maxY = 3
// Web Mercator n/w corner of the map.
private val tileOrigin = doubleArrayOf(-20037508.34789244, 20037508.34789244)
// array indexes for that data
private val origX = 0
private val origY = 1 // "
// Size of square world map in meters, using WebMerc projection.
private val mapSize = 20037508.34789244 * 2
private var layer = ""
private var version = "1.1.0"
private var srs = "EPSG%3A3857" // used by geo server
private var format = ""
private var time = ""
private var style: String? = null
private var forceHttps = false
private var forceHttp = false
init {
Logger.withTag(IMapView.LOGTAG).i { "WMS support is BETA. Please report any issues" }
layer = layername
this.version = version
this.srs = srs
this.style = style
this.format = format
if (time != null) this.time = time
}
private fun tile2lon(x: Int, z: Int): Double = x / 2.0.pow(z.toDouble()) * 360.0 - 180
private fun tile2lat(y: Int, z: Int): Double {
val n = Math.PI - 2.0 * Math.PI * y / 2.0.pow(z.toDouble())
return Math.toDegrees(atan(sinh(n)))
}
// Return a web Mercator bounding box given tile x/y indexes and a zoom
// level.
private fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
val tileSize = mapSize / 2.0.pow(zoom.toDouble())
val minx = tileOrigin[origX] + x * tileSize
val maxx = tileOrigin[origX] + (x + 1) * tileSize
val miny = tileOrigin[origY] - (y + 1) * tileSize
val maxy = tileOrigin[origY] - y * tileSize
val bbox = DoubleArray(4)
bbox[minX] = minx
bbox[minY] = miny
bbox[maxX] = maxx
bbox[maxY] = maxy
return bbox
}
fun isForceHttps(): Boolean = forceHttps
fun setForceHttps(forceHttps: Boolean) {
this.forceHttps = forceHttps
}
fun isForceHttp(): Boolean = forceHttp
fun setForceHttp(forceHttp: Boolean) {
this.forceHttp = forceHttp
}
override fun getTileURLString(pMapTileIndex: Long): String? {
var baseUrl = baseUrl
if (forceHttps) baseUrl = baseUrl.replace("http://", "https://")
if (forceHttp) baseUrl = baseUrl.replace("https://", "http://")
val sb = StringBuilder(baseUrl)
if (!baseUrl.endsWith("&")) sb.append("service=WMS")
sb.append("&request=GetMap")
sb.append("&version=").append(version)
sb.append("&layers=").append(layer)
if (style != null) sb.append("&styles=").append(style)
sb.append("&format=").append(format)
sb.append("&transparent=true")
sb.append("&height=").append(Resources.getSystem().displayMetrics.heightPixels)
sb.append("&width=").append(Resources.getSystem().displayMetrics.widthPixels)
sb.append("&srs=").append(srs)
sb.append("&size=").append(getSize())
sb.append("&bbox=")
val bbox =
getBoundingBox(
MapTileIndex.getX(pMapTileIndex),
MapTileIndex.getY(pMapTileIndex),
MapTileIndex.getZoom(pMapTileIndex),
)
sb.append(bbox[minX]).append(",")
sb.append(bbox[minY]).append(",")
sb.append(bbox[maxX]).append(",")
sb.append(bbox[maxY])
Logger.withTag(IMapView.LOGTAG).i { sb.toString() }
return sb.toString()
}
private fun getSize(): String {
val height = Resources.getSystem().displayMetrics.heightPixels
val width = Resources.getSystem().displayMetrics.widthPixels
return "$width,$height"
}
}

View File

@@ -1,65 +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.app.map.model
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourcePolicy
import org.osmdroid.util.MapTileIndex
@Suppress("LongParameterList")
open class OnlineTileSourceAuth(
name: String,
zoomLevel: Int,
zoomMaxLevel: Int,
tileSizePixels: Int,
imageFileNameEnding: String,
baseUrl: Array<String>,
pCopyright: String,
tileSourcePolicy: TileSourcePolicy,
layerName: String?,
apiKey: String,
) : OnlineTileSourceBase(
name,
zoomLevel,
zoomMaxLevel,
tileSizePixels,
imageFileNameEnding,
baseUrl,
pCopyright,
tileSourcePolicy,
) {
private var layerName = ""
private var apiKey = ""
init {
if (layerName != null) {
this.layerName = layerName
}
this.apiKey = apiKey
}
override fun getTileURLString(pMapTileIndex: Long): String = "$baseUrl$layerName/" +
(
MapTileIndex.getZoom(pMapTileIndex).toString() +
"/" +
MapTileIndex.getX(pMapTileIndex).toString() +
"/" +
MapTileIndex.getY(pMapTileIndex).toString()
) +
mImageFilenameEnding +
"?appId=$apiKey"
}

View File

@@ -1,50 +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.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
* ([NodeTrackOsmMap]).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
)
}

View File

@@ -1,162 +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.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.roundToInt
/**
* A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional
* markers for each historical position.
*
* Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
* from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
* minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider
* so users can adjust the time range directly from the map.
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*
* Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
* location tracking. It is designed to be embedded inside the position-log adaptive layout.
*/
@Composable
fun NodeTrackOsmMap(
positions: List<Position>,
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val filteredPositions =
remember(positions, lastHeardTrackFilter) {
positions.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
}
val geoPoints =
remember(filteredPositions) {
filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
}
val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(mapStyleId),
)
var filterMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
AndroidView(
modifier = Modifier.matchParentSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
// Center on selected position
if (selectedPositionTime != null) {
val selected = filteredPositions.find { it.time == selectedPositionTime }
if (selected != null) {
val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D)
map.controller.animateTo(point)
}
}
},
)
// Track filter controls overlay
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { filterMenuExpanded = true },
filterDropdownContent = {
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
},
)
}
}

View File

@@ -1,41 +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.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
* ([TracerouteOsmMap]).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View File

@@ -1,288 +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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.app.map.traceroute
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
/**
* A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and
* forward/return offset polylines with auto-centering camera.
*
* Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
* map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
*/
@Composable
fun TracerouteOsmMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val context = LocalContext.current
val density = LocalDensity.current
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
// Resolve which nodes to display for the traceroute
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val displayNodes = tracerouteSelection.nodesForMarkers
val nodeLookup = tracerouteSelection.nodeLookup
// Report mappable count
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
}
}
// Compute polyline GeoPoints from node positions
val forwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val returnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
// Compute offset polylines for visual separation
val headingReferencePoints =
remember(forwardPoints, returnPoints) {
when {
forwardPoints.size >= 2 -> forwardPoints
returnPoints.size >= 2 -> returnPoints
else -> emptyList()
}
}
val forwardOffsetPoints =
remember(forwardPoints, headingReferencePoints) {
offsetPolyline(
points = forwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = 1.0,
)
}
val returnOffsetPoints =
remember(returnPoints, headingReferencePoints) {
offsetPolyline(
points = returnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = -1.0,
)
}
// Camera auto-center
var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
// Build initial camera from all traceroute points
val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
val initialCameraView =
remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = mapViewModel.applicationId,
box = initialCameraView ?: BoundingBox(),
tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
)
// Center camera on traceroute bounds
LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
mapView.controller.setCenter(allPoints.first())
mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
mapView.zoomToBoundingBox(
BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
true,
)
}
hasCentered = true
}
}
AndroidView(
modifier = modifier,
factory = { mapView.apply { setDestroyMode(false) } },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
// Render traceroute polylines
buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
// Render simple node markers
displayNodes.forEach { node ->
val position = GeoPoint(node.latitude, node.longitude)
val marker =
MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
.apply {
id = node.user.id
title = node.user.long_name
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
this.position = position
icon = markerIcon
setNodeColors(node.colors)
}
map.overlays.add(marker)
}
map.invalidate()
},
)
}
private fun buildTraceroutePolylines(
forwardPoints: List<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
}
return polylines
}
// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
private fun Double.toRad(): Double = this * PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View File

@@ -1,64 +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.app.node.component
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import org.meshtastic.core.model.Node
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val context = androidx.compose.ui.platform.LocalContext.current
val map = remember {
MapView(context).apply {
layoutParams =
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
// Default osmdroid tile source.
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(false)
controller.setZoom(15.0)
}
}
LaunchedEffect(node.num) {
val point = GeoPoint(node.latitude, node.longitude)
map.overlays.clear()
val marker =
Marker(map).apply {
position = point
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
map.overlays.add(marker)
map.controller.animateTo(point)
}
AndroidView(factory = { map }, modifier = modifier)
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets(
overlayAlignment = Alignment.BottomEnd,
overlayPadding = PaddingValues(end = 16.dp, bottom = 16.dp),
contentHorizontalAlignment = Alignment.End,
)

View File

@@ -17,7 +17,6 @@
package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class])
@Module(includes = [GoogleNetworkModule::class])
class FlavorModule

View File

@@ -1,21 +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.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()

View File

@@ -1,39 +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.app.map
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
)
}
}

View File

@@ -1,139 +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.app.map
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import co.touchlab.kermit.Logger
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.LocationSettingsRequest
import com.google.android.gms.location.Priority
private const val INTERVAL_MILLIS = 10000L
@Suppress("LongMethod")
@Composable
fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
val context = LocalContext.current
var localHasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED,
)
}
val requestLocationPermissionLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
localHasPermission = isGranted
// Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
// onPermissionResult
// if permission is granted. If not granted, immediately report false.
if (!isGranted) {
onPermissionResult(false)
}
}
val locationSettingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
Logger.d { "Location settings changed by user." }
// User has enabled location services or improved accuracy.
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
} else {
Logger.d { "Location settings change cancelled by user." }
// User chose not to change settings. The permission itself is still granted,
// but the experience might be degraded. For the purpose of enabling map features,
// we consider this as success if the core permission is there.
// If stricter handling is needed (e.g., block feature if settings not optimal),
// this logic might change.
onPermissionResult(localHasPermission)
}
}
LaunchedEffect(Unit) {
// Initial permission check
when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
PackageManager.PERMISSION_GRANTED -> {
if (!localHasPermission) {
localHasPermission = true
}
// If permission is already granted, proceed to check location settings.
// The LaunchedEffect(localHasPermission) will handle this.
// No need to call onPermissionResult(true) here yet, let settings check complete.
}
else -> {
// Request permission if not granted. The launcher's callback will update localHasPermission.
requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
}
LaunchedEffect(localHasPermission) {
// Handles logic after permission status is known/updated
if (localHasPermission) {
// Permission is granted, now check location settings
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
val client = LocationServices.getSettingsClient(context)
val task = client.checkLocationSettings(builder.build())
task.addOnSuccessListener {
Logger.d { "Location settings are satisfied." }
onPermissionResult(true) // Permission granted and settings are good
}
task.addOnFailureListener { exception ->
if (exception is ResolvableApiException) {
try {
val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
locationSettingsLauncher.launch(intentSenderRequest)
// Result of this launch will be handled by locationSettingsLauncher's callback
} catch (sendEx: ActivityNotFoundException) {
Logger.d { "Error launching location settings resolution ${sendEx.message}." }
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
}
} else {
Logger.d { "Location settings are not satisfiable.${exception.message}" }
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
}
}
} else {
// If permission is not granted, report false.
// This case is primarily handled by the requestLocationPermissionLauncher's callback
// if the initial state was denied, or if user denies it.
onPermissionResult(false)
}
}
}

View File

@@ -1,65 +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.app.map
import android.database.sqlite.SQLiteDatabase
import com.google.android.gms.maps.model.Tile
import com.google.android.gms.maps.model.TileProvider
import java.io.File
class MBTilesProvider(private val file: File) :
TileProvider,
AutoCloseable {
private var database: SQLiteDatabase? = null
init {
openDatabase()
}
private fun openDatabase() {
if (database == null && file.exists()) {
database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
}
}
override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
val db = database ?: return null
var tile: Tile? = null
// Convert Google Maps y coordinate to standard TMS y coordinate
val tmsY = (1 shl zoom) - 1 - y
val cursor =
db.rawQuery(
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
)
if (cursor.moveToFirst()) {
val tileData = cursor.getBlob(0)
tile = Tile(256, 256, tileData)
}
cursor.close()
return tile ?: TileProvider.NO_TILE
}
override fun close() {
database?.close()
database = null
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,676 +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.app.map
import android.app.Application
import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.MalformedURLException
import java.net.URL
import kotlin.uuid.Uuid
private const val TILE_SIZE = 256
@Serializable
data class MapCameraPosition(
val targetLat: Double,
val targetLng: Double,
val zoom: Float,
val tilt: Float,
val bearing: Float,
)
@Suppress("TooManyFunctions", "LongParameterList")
@KoinViewModel
class MapViewModel(
private val application: Application,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
fun setWaypointId(id: Int?) {
if (_selectedWaypointId.value != id) {
_selectedWaypointId.value = id
if (id != null) {
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(id) }
wpMap[id]?.let { packet ->
val waypoint = packet.waypoint!!
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
}
}
}
}
}
private val targetLatLng =
googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }
?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?: ourNodeInfo.value?.position?.toLatLng()
?: LatLng(0.0, 0.0)
val cameraPositionState =
CameraPositionState(
position =
CameraPosition(
targetLatLng,
googleMapsPrefs.cameraZoom.value,
googleMapsPrefs.cameraTilt.value,
googleMapsPrefs.cameraBearing.value,
),
)
val theme: StateFlow<Int> = uiPrefs.theme
private val _errorFlow = MutableSharedFlow<String>()
val errorFlow: SharedFlow<String> = _errorFlow.asSharedFlow()
val customTileProviderConfigs: StateFlow<List<CustomTileProviderConfig>> =
customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
private val _selectedCustomTileProviderUrl = MutableStateFlow<String?>(null)
val selectedCustomTileProviderUrl: StateFlow<String?> = _selectedCustomTileProviderUrl.asStateFlow()
private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
val selectedGoogleMapType: StateFlow<MapType> = _selectedGoogleMapType.asStateFlow()
val displayUnits =
radioConfigRepository.deviceProfileFlow
.mapNotNull { it.config?.display?.units }
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
viewModelScope.launch {
if (
name.isBlank() ||
(urlTemplate.isBlank() && localUri == null) ||
(localUri == null && !isValidTileUrlTemplate(urlTemplate))
) {
_errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.")
return@launch
}
if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
_errorFlow.emit("Custom tile provider with name '$name' already exists.")
return@launch
}
var finalLocalUri = localUri
if (localUri != null) {
try {
val uri = Uri.parse(localUri)
val extension = "mbtiles"
val finalFileName = "mbtiles_${Uuid.random()}.$extension"
val copiedUri = copyFileToInternalStorage(uri, finalFileName)
if (copiedUri != null) {
finalLocalUri = copiedUri.toString()
} else {
_errorFlow.emit("Failed to copy MBTiles file to internal storage.")
return@launch
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error processing local URI" }
_errorFlow.emit("Error processing local URI for MBTiles.")
return@launch
}
}
val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
customTileProviderRepository.addCustomTileProvider(newConfig)
}
}
fun updateCustomTileProvider(configToUpdate: CustomTileProviderConfig) {
viewModelScope.launch {
if (
configToUpdate.name.isBlank() ||
(configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
(configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
) {
_errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
return@launch
}
val existingConfigs = customTileProviderConfigs.value
if (
existingConfigs.any {
it.id != configToUpdate.id && it.name.equals(configToUpdate.name, ignoreCase = true)
}
) {
_errorFlow.emit("Another custom tile provider with name '${configToUpdate.name}' already exists.")
return@launch
}
customTileProviderRepository.updateCustomTileProvider(configToUpdate)
val originalConfig = customTileProviderRepository.getCustomTileProviderById(configToUpdate.id)
if (
_selectedCustomTileProviderUrl.value != null &&
originalConfig?.urlTemplate == _selectedCustomTileProviderUrl.value
) {
// No change needed if URL didn't change, or handle if it did
} else if (originalConfig != null && _selectedCustomTileProviderUrl.value != originalConfig.urlTemplate) {
val currentlySelectedConfig =
customTileProviderConfigs.value.find { it.urlTemplate == _selectedCustomTileProviderUrl.value }
if (currentlySelectedConfig?.id == configToUpdate.id) {
_selectedCustomTileProviderUrl.value = configToUpdate.urlTemplate
}
}
}
}
fun removeCustomTileProvider(configId: String) {
viewModelScope.launch {
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
customTileProviderRepository.deleteCustomTileProvider(configId)
if (configToRemove != null) {
if (
_selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
_selectedCustomTileProviderUrl.value == configToRemove.localUri
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
if (configToRemove.localUri != null) {
val uri = Uri.parse(configToRemove.localUri)
deleteFileToInternalStorage(uri)
}
}
}
}
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
return
}
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.setSelectedCustomTileUrl(null)
googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
if (config == null) {
(currentTileProvider as? MBTilesProvider)?.close()
currentTileProvider = null
return null
}
val selectedUrl = config.localUri ?: config.urlTemplate
if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
return currentTileProvider
}
// Close previous if it was a local provider
(currentTileProvider as? MBTilesProvider)?.close()
val newProvider =
if (config.isLocal) {
val uri = Uri.parse(config.localUri)
val file =
try {
uri.toFile()
} catch (e: Exception) {
File(uri.path ?: "")
}
if (file.exists()) {
MBTilesProvider(file)
} else {
Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
null
}
} else {
val urlString = config.urlTemplate
if (!isValidTileUrlTemplate(urlString)) {
Logger.withTag("MapViewModel")
.e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
null
} else {
object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
val subdomains = listOf("a", "b", "c")
val subdomain = subdomains[(x + y) % subdomains.size]
val formattedUrl =
urlString
.replace("{s}", subdomain, ignoreCase = true)
.replace("{z}", zoom.toString(), ignoreCase = true)
.replace("{x}", x.toString(), ignoreCase = true)
.replace("{y}", y.toString(), ignoreCase = true)
return try {
URL(formattedUrl)
} catch (e: MalformedURLException) {
Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
null
}
}
}
}
}
currentTileProvider = newProvider
return newProvider
}
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
urlTemplate.contains("{x}", ignoreCase = true) &&
urlTemplate.contains("{y}", ignoreCase = true)
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
init {
viewModelScope.launch {
customTileProviderRepository.getCustomTileProviders().first()
loadPersistedMapType()
}
loadPersistedLayers()
selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
wpMap[wpId]?.let { packet ->
val waypoint = packet.waypoint!!
val latLng = LatLng((waypoint.latitude_i ?: 0) / 1e7, (waypoint.longitude_i ?: 0) / 1e7)
cameraPositionState.position = CameraPosition.fromLatLngZoom(latLng, 15f)
}
}
}
}
fun saveCameraPosition(cameraPosition: CameraPosition) {
viewModelScope.launch {
googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
}
}
private fun loadPersistedMapType() {
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
customTileProviderConfigs.value.any { it.urlTemplate == savedCustomUrl } &&
isValidTileUrlTemplate(savedCustomUrl)
) {
_selectedCustomTileProviderUrl.value = savedCustomUrl
_selectedGoogleMapType.value =
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
googleMapsPrefs.setSelectedCustomTileUrl(null)
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
}
private fun loadPersistedLayers() {
viewModelScope.launch(Dispatchers.IO) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems = persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
}
} else {
null
}
}
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
val id = parts[0]
val name = parts[1]
val uri = Uri.parse(parts[2])
MapLayerItem(
id = id,
name = name,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = LayerType.KML,
isNetwork = true,
)
} else {
null
}
} catch (e: Exception) {
null
}
}
_mapLayers.value = loadedItems + networkItems
if (_mapLayers.value.isNotEmpty()) {
Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
}
}
} else {
Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" }
_mapLayers.value = emptyList()
}
}
}
fun addMapLayer(uri: Uri, fileName: String?) {
viewModelScope.launch {
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
val extension =
fileName?.substringAfterLast('.', "")?.lowercase()
?: application.contentResolver.getType(uri)?.split('/')?.last()
val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
val geoJsonExtensions = listOf("geojson", "json")
val layerType =
when (extension) {
in kmlExtensions -> LayerType.KML
in geoJsonExtensions -> LayerType.GEOJSON
else -> null
}
if (layerType == null) {
Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension")
return@launch
}
val finalFileName =
if (fileName != null) {
"$layerName.$extension"
} else {
"layer_${Uuid.random()}.$extension"
}
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
if (localFileUri != null) {
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
_mapLayers.value = _mapLayers.value + newItem
} else {
Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.")
}
}
}
fun addNetworkMapLayer(name: String, url: String) {
viewModelScope.launch {
if (name.isBlank() || url.isBlank()) {
_errorFlow.emit("Invalid name or URL for network layer.")
return@launch
}
try {
val uri = Uri.parse(url)
if (uri.scheme != "http" && uri.scheme != "https") {
_errorFlow.emit("URL must be http or https.")
return@launch
}
val path = uri.path?.lowercase() ?: ""
val layerType =
when {
path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
else -> LayerType.KML // Default to KML
}
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
if (!directory.exists()) {
directory.mkdirs()
}
val outputFile = File(directory, fileName)
val outputStream = FileOutputStream(outputFile)
inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } }
Uri.fromFile(outputFile)
} catch (e: IOException) {
Logger.withTag("MapViewModel").e(e) { "Error copying file to internal storage" }
null
}
}
fun toggleLayerVisibility(layerId: String) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =
_mapLayers.value.map {
if (it.id == layerId) {
toggledLayer = it.copy(isVisible = !it.isVisible)
toggledLayer
} else {
it
}
}
_mapLayers.value = updatedLayers
toggledLayer?.let {
if (it.isVisible) {
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
googleMapsPrefs.setNetworkMapLayers(
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
)
} else {
deleteFileToInternalStorage(uri)
}
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
fun refreshMapLayer(layerId: String) {
viewModelScope.launch {
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
// By resetting the layer data in the UI (implied by just refreshing),
// we trigger a reload in the Composable.
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
}
}
fun refreshAllVisibleNetworkLayers() {
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
val file = uri.toFile()
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error deleting file from internal storage" }
}
}
}
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val url = java.net.URL(uriToLoad.toString())
java.io.BufferedInputStream(url.openStream())
} else {
application.contentResolver.openInputStream(uriToLoad)
}
} catch (e: Exception) {
Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
null
}
}
}
override fun onCleared() {
super.onCleared()
(currentTileProvider as? MBTilesProvider)?.close()
}
override fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
}
enum class LayerType {
KML,
GEOJSON,
}
data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uri: Uri? = null,
val isVisible: Boolean = true,
val layerType: LayerType,
val isNetwork: Boolean = false,
val isRefreshing: Boolean = false,
)

View File

@@ -1,76 +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.app.map.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes_at_this_location
import org.meshtastic.core.resources.okay
import org.meshtastic.core.ui.component.NodeChip
@Composable
fun ClusterItemsListDialog(
items: List<NodeClusterItem>,
onDismiss: () -> Unit,
onItemClick: (NodeClusterItem) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringResource(Res.string.nodes_at_this_location)) },
text = {
// Use a LazyColumn for potentially long lists of items
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(items, key = { it.node.num }) { item ->
ClusterDialogListItem(item = item, onClick = { onItemClick(item) })
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.okay)) } },
)
}
@Composable
private fun ClusterDialogListItem(item: NodeClusterItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
ListItem(
leadingContent = { NodeChip(node = item.node) },
headlineContent = { Text(item.nodeTitle) },
supportingContent = {
if (item.nodeSnippet.isNotBlank()) {
Text(item.nodeSnippet)
}
},
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp), // Add some padding around list items
)
}

View File

@@ -1,216 +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.app.map.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapLayerItem
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
import org.meshtastic.core.resources.add_network_layer
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_layer
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.network_layer_url_hint
import org.meshtastic.core.resources.no_map_layers_loaded
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.remove_layer
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomMapLayersSheet(
mapLayers: List<MapLayerItem>,
onToggleVisibility: (String) -> Unit,
onRemoveLayer: (String) -> Unit,
onAddLayerClicked: () -> Unit,
onRefreshLayer: (String) -> Unit,
onAddNetworkLayer: (String, String) -> Unit,
) {
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
modifier = Modifier.padding(16.dp),
text = stringResource(Res.string.manage_map_layers),
style = MaterialTheme.typography.headlineSmall,
)
HorizontalDivider()
}
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
text = stringResource(Res.string.map_layer_formats),
style = MaterialTheme.typography.bodySmall,
)
}
if (mapLayers.isEmpty()) {
item {
Text(
modifier = Modifier.padding(16.dp),
text = stringResource(Res.string.no_map_layers_loaded),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(mapLayers, key = { it.id }) { layer ->
ListItem(
headlineContent = { Text(layer.name) },
trailingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (layer.isNetwork) {
if (layer.isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(4.dp),
strokeWidth = 2.dp,
)
} else {
IconButton(onClick = { onRefreshLayer(layer.id) }) {
Icon(
imageVector = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
)
}
}
}
IconToggleButton(
checked = layer.isVisible,
onCheckedChange = { onToggleVisibility(layer.id) },
) {
Icon(
imageVector =
if (layer.isVisible) {
MeshtasticIcons.Visibility
} else {
MeshtasticIcons.VisibilityOff
},
contentDescription =
stringResource(
if (layer.isVisible) {
Res.string.hide_layer
} else {
Res.string.show_layer
},
),
)
}
IconButton(onClick = { onRemoveLayer(layer.id) }) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.remove_layer),
)
}
}
},
)
HorizontalDivider()
}
}
item {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
Text(stringResource(Res.string.add_layer))
}
Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
Text(stringResource(Res.string.add_network_layer))
}
}
}
}
if (showAddNetworkLayerDialog) {
AddNetworkLayerDialog(
onDismiss = { showAddNetworkLayerDialog = false },
onConfirm = { name, url ->
onAddNetworkLayer(name, url)
showAddNetworkLayerDialog = false
},
)
}
}
@Composable
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.add_network_layer),
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text(stringResource(Res.string.url)) },
placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
onConfirm = { onConfirm(name, url) },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}

View File

@@ -1,324 +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.app.map.component
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
import org.meshtastic.core.resources.add_local_mbtiles_file
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete_custom_tile_source
import org.meshtastic.core.resources.edit_custom_tile_source
import org.meshtastic.core.resources.local_mbtiles_file
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.name_cannot_be_empty
import org.meshtastic.core.resources.no_custom_tile_sources_found
import org.meshtastic.core.resources.provider_name_exists
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.url_cannot_be_empty
import org.meshtastic.core.resources.url_must_contain_placeholders
import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.Edit
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.showToast
@Suppress("LongMethod")
@Composable
fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
var editingConfig by remember { mutableStateOf<CustomTileProviderConfig?>(null) }
var showEditDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
val mbtilesPickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val fileName = uri.getFileName(context)
val baseName = fileName.substringBeforeLast('.')
mapViewModel.addCustomTileProvider(
name = baseName,
urlTemplate = "", // Empty for local
localUri = uri.toString(),
)
}
}
}
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
if (showEditDialog) {
AddEditCustomTileProviderDialog(
config = editingConfig,
onDismiss = { showEditDialog = false },
onSave = { name, url ->
if (editingConfig == null) { // Adding new
mapViewModel.addCustomTileProvider(name, url)
} else { // Editing existing
mapViewModel.updateCustomTileProvider(editingConfig!!.copy(name = name, urlTemplate = url))
}
showEditDialog = false
},
mapViewModel = mapViewModel,
)
}
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
text = stringResource(Res.string.manage_custom_tile_sources),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp),
)
HorizontalDivider()
}
if (customTileProviders.isEmpty()) {
item {
Text(
text = stringResource(Res.string.no_custom_tile_sources_found),
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
items(customTileProviders, key = { it.id }) { config ->
ListItem(
headlineContent = { Text(config.name) },
supportingContent = {
if (config.isLocal) {
Text(
stringResource(Res.string.local_mbtiles_file),
style = MaterialTheme.typography.bodySmall,
)
} else {
Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
}
},
trailingContent = {
Row {
IconButton(
onClick = {
editingConfig = config
showEditDialog = true
},
) {
Icon(
MeshtasticIcons.Edit,
contentDescription = stringResource(Res.string.edit_custom_tile_source),
)
}
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
Icon(
MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete_custom_tile_source),
)
}
}
},
)
HorizontalDivider()
}
}
item {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
editingConfig = null
showEditDialog = true
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.add_custom_tile_source))
}
Button(
onClick = {
val intent =
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
mbtilesPickerLauncher.launch(intent)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.add_local_mbtiles_file))
}
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun AddEditCustomTileProviderDialog(
config: CustomTileProviderConfig?,
onDismiss: () -> Unit,
onSave: (String, String) -> Unit,
mapViewModel: MapViewModel,
) {
var name by rememberSaveable { mutableStateOf(config?.name ?: "") }
var url by rememberSaveable { mutableStateOf(config?.urlTemplate ?: "") }
var nameError by remember { mutableStateOf<String?>(null) }
var urlError by remember { mutableStateOf<String?>(null) }
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val emptyNameError = stringResource(Res.string.name_cannot_be_empty)
val providerNameExistsError = stringResource(Res.string.provider_name_exists)
val urlCannotBeEmptyError = stringResource(Res.string.url_cannot_be_empty)
val urlMustContainPlaceholdersError = stringResource(Res.string.url_must_contain_placeholders)
fun validateAndSave() {
val currentNameError =
validateName(name, customTileProviders, config?.id, emptyNameError, providerNameExistsError)
val currentUrlError = validateUrl(url, urlCannotBeEmptyError, urlMustContainPlaceholdersError)
nameError = currentNameError
urlError = currentUrlError
if (currentNameError == null && currentUrlError == null) {
onSave(name, url)
}
}
MeshtasticDialog(
onDismiss = onDismiss,
title =
if (config == null) {
stringResource(Res.string.add_custom_tile_source)
} else {
stringResource(Res.string.edit_custom_tile_source)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = {
name = it
nameError = null
},
label = { Text(stringResource(Res.string.name)) },
isError = nameError != null,
supportingText = { nameError?.let { Text(it) } },
singleLine = true,
)
OutlinedTextField(
value = url,
onValueChange = {
url = it
urlError = null
},
label = { Text(stringResource(Res.string.url_template)) },
isError = urlError != null,
supportingText = {
if (urlError != null) {
Text(urlError!!)
} else {
Text(stringResource(Res.string.url_template_hint))
}
},
singleLine = false,
maxLines = 2,
)
}
},
onConfirm = { validateAndSave() },
confirmTextRes = Res.string.save,
dismissTextRes = Res.string.cancel,
)
}
private fun validateName(
name: String,
providers: List<CustomTileProviderConfig>,
currentId: String?,
emptyNameError: String,
nameExistsError: String,
): String? = if (name.isBlank()) {
emptyNameError
} else if (providers.any { it.name.equals(name, ignoreCase = true) && it.id != currentId }) {
nameExistsError
} else {
null
}
private fun validateUrl(url: String, emptyUrlError: String, mustContainPlaceholdersError: String): String? =
if (url.isBlank()) {
emptyUrlError
} else if (
!url.contains("{z}", ignoreCase = true) ||
!url.contains("{x}", ignoreCase = true) ||
!url.contains("{y}", ignoreCase = true)
) {
mustContainPlaceholdersError
} else {
null
}
private fun android.net.Uri.getFileName(context: android.content.Context): String {
var name = this.lastPathSegment ?: "mbtiles_file"
if (this.scheme == "content") {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
name = cursor.getString(displayNameIndex)
}
}
}
}
return name
}

View File

@@ -1,375 +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.app.map.component
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.widget.DatePicker
import android.widget.TimePicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.atTime
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.date
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.description
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.locked
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.send
import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.icon.CalendarMonth
import org.meshtastic.core.ui.icon.Lock
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@Composable
fun EditWaypointDialog(
waypoint: Waypoint,
onSendClicked: (Waypoint) -> Unit,
onDeleteClicked: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
var waypointInput by remember { mutableStateOf(waypoint) }
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
val defaultEmoji = 0x1F4CD // 📍 Round Pushpin
val currentEmojiCodepoint = if ((waypointInput.icon ?: 0) == 0) defaultEmoji else waypointInput.icon!!
var showEmojiPickerView by remember { mutableStateOf(false) }
val context = LocalContext.current
val tz = systemTimeZone
// Initialize date and time states from waypointInput.expire
var selectedDateString by remember { mutableStateOf("") }
var selectedTimeString by remember { mutableStateOf("") }
var isExpiryEnabled by remember {
mutableStateOf((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE)
}
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
dateFormat.timeZone = java.util.TimeZone.getDefault()
timeFormat.timeZone = java.util.TimeZone.getDefault()
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
val expireValue = waypointInput.expire ?: 0
if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong())
val date = java.util.Date(instant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
selectedDateString = ""
selectedTimeString = ""
}
}
if (!showEmojiPickerView) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = waypointInput.name ?: "",
onValueChange = { waypointInput = waypointInput.copy(name = it.take(29)) },
label = { Text(stringResource(Res.string.name)) },
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
text = String(Character.toChars(currentEmojiCodepoint)),
modifier =
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
.padding(6.dp),
fontSize = 20.sp,
)
}
},
)
Spacer(modifier = Modifier.size(8.dp))
OutlinedTextField(
value = waypointInput.description ?: "",
onValueChange = { waypointInput = waypointInput.copy(description = it.take(99)) },
label = { Text(stringResource(Res.string.description)) },
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { /* Handle next/done focus */ }),
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 3,
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(Res.string.locked))
}
Switch(
checked = (waypointInput.locked_to ?: 0) != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(Res.string.expires))
}
Switch(
checked = isExpiryEnabled,
onCheckedChange = { checked ->
isExpiryEnabled = checked
if (checked) {
val expireValue = waypointInput.expire ?: 0
// Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
val futureInstant = kotlin.time.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
},
)
}
if (isExpiryEnabled) {
val currentInstant =
(waypointInput.expire ?: 0).let {
if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
val currentLdt =
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
val newLdt =
LocalDate(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
)
.atTime(
hour = currentLdt.hour,
minute = currentLdt.minute,
second = currentLdt.second,
nanosecond = currentLdt.nanosecond,
)
waypointInput =
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
ldt.month.number - 1,
ldt.day,
)
val timePickerDialog =
TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
val currentLdt =
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
val newLdt =
LocalDate(
year = currentLdt.year,
month = currentLdt.month,
day = currentLdt.day,
)
.atTime(
hour = selectedHour,
minute = selectedMinute,
second = currentLdt.second,
nanosecond = currentLdt.nanosecond,
)
waypointInput =
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.hour,
ldt.minute,
android.text.format.DateFormat.is24HourFormat(context),
)
Spacer(modifier = Modifier.size(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedDateString,
style = MaterialTheme.typography.bodyMedium,
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedTimeString,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End,
) {
if (waypoint.id != 0) {
TextButton(
onClick = { onDeleteClicked(waypointInput) },
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
}
}
Spacer(modifier = Modifier.weight(1f)) // Pushes delete to left and cancel/send to right
TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) {
Text(stringResource(Res.string.cancel))
}
Button(
onClick = { onSendClicked(waypointInput) },
enabled = (waypointInput.name ?: "").isNotBlank(),
) {
Text(stringResource(Res.string.send))
}
}
},
dismissButton = null, // Using custom buttons in confirmButton Row
modifier = modifier,
)
} else {
EmojiPickerDialog(onDismiss = { showEmojiPickerView = false }) { selectedEmoji ->
showEmojiPickerView = false
waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0))
}
}
}

View File

@@ -1,160 +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.app.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@Composable
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = stringResource(Res.string.only_favorites),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_precision_circle)) },
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
},
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}

View File

@@ -1,114 +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.app.map.component
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.maps.android.compose.MapType
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.map_type_hybrid
import org.meshtastic.core.resources.map_type_normal
import org.meshtastic.core.resources.map_type_satellite
import org.meshtastic.core.resources.map_type_terrain
import org.meshtastic.core.resources.selected_map_type
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Suppress("LongMethod")
@Composable
internal fun MapTypeDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapViewModel: MapViewModel,
onManageCustomTileProvidersClicked: () -> Unit,
) {
val customTileProviders by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val selectedCustomUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
val googleMapTypes =
listOf(
stringResource(Res.string.map_type_normal) to MapType.NORMAL,
stringResource(Res.string.map_type_satellite) to MapType.SATELLITE,
stringResource(Res.string.map_type_terrain) to MapType.TERRAIN,
stringResource(Res.string.map_type_hybrid) to MapType.HYBRID,
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
googleMapTypes.forEach { (name, type) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
mapViewModel.setSelectedGoogleMapType(type)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
if (customTileProviders.isNotEmpty()) {
HorizontalDivider()
customTileProviders.forEach { config ->
DropdownMenuItem(
text = { Text(config.name) },
onClick = {
mapViewModel.selectCustomTileProvider(config)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == config.urlTemplate) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
}
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
onClick = {
onManageCustomTileProvidersClicked()
onDismissRequest()
},
)
}
}

View File

@@ -1,96 +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.app.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.compose.LocalSavedStateRegistryOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.google.maps.android.clustering.Cluster
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
mapFilterState: BaseMapViewModel.MapFilterState,
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
// Workaround for https://github.com/googlemaps/android-maps-compose/issues/858
// The maps clustering library creates an internal ComposeView to snapshot markers.
// If that view is not attached to the hierarchy (which it often isn't during rendering),
// it fails to find the Lifecycle and SavedState owners. We propagate them to the root view
// so the internal snapshot view can find them when walking up the tree.
LaunchedEffect(view, lifecycleOwner, savedStateRegistryOwner) {
val root = view.rootView
if (root.findViewTreeLifecycleOwner() == null) {
root.setViewTreeLifecycleOwner(lifecycleOwner)
}
if (root.findViewTreeSavedStateRegistryOwner() == null) {
root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
}
}
Clustering(
items = nodeClusterItems,
onClusterClick = onClusterClick,
onClusterItemInfoWindowClick = { item ->
navigateToNodeDetails(item.node.num)
false
},
clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
onClusterManager = { clusterManager ->
(clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
},
clusterItemDecoration = { clusterItem ->
if (mapFilterState.showPrecisionCircle) {
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = clusterItem.position,
radius = precisionMeters,
fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(clusterItem.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
}
}
}
// Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
},
)
}

View File

@@ -1,68 +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.app.map.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
@Composable
fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) {
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(node) {
if ((nowSeconds - node.lastHeard) <= 5) {
launch {
animatedProgress.snapTo(0f)
animatedProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
}
}
}
Box(
modifier =
modifier.drawWithContent {
drawContent()
if (animatedProgress.value > 0 && animatedProgress.value < 1f) {
val alpha = (1f - animatedProgress.value) * 0.3f
drawRoundRect(
size = size,
cornerRadius = CornerRadius(8.dp.toPx()),
color = Color.White.copy(alpha = alpha),
)
}
},
) {
NodeChip(node = node)
}
}

View File

@@ -1,92 +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.app.map.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
mapFilterState: BaseMapViewModel.MapFilterState,
myNodeNum: Int,
isConnected: Boolean,
onEditWaypointRequest: (Waypoint) -> Unit,
selectedWaypointId: Int? = null,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
if (mapFilterState.showWaypoints) {
displayableWaypoints.forEach { waypoint ->
val markerState =
rememberUpdatedMarkerState(
position = LatLng((waypoint.latitude_i ?: 0) * DEG_D, (waypoint.longitude_i ?: 0) * DEG_D),
)
LaunchedEffect(selectedWaypointId) {
if (selectedWaypointId == waypoint.id) {
markerState.showInfoWindow()
}
}
val iconCodePoint = if ((waypoint.icon ?: 0) == 0) PUSHPIN else waypoint.icon!!
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
Marker(
state = markerState,
icon = icon,
title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '),
snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '),
visible = true,
onInfoWindowClick = {
if ((waypoint.locked_to ?: 0) == 0 || waypoint.locked_to == myNodeNum || !isConnected) {
onEditWaypointRequest(waypoint)
} else {
scope.launch { context.showToast(Res.string.locked) }
}
},
)
}
}
}
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin

View File

@@ -1,31 +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.app.map.model
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class CustomTileProviderConfig(
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
val localUri: String? = null,
) {
val isLocal: Boolean
get() = localUri != null
}

View File

@@ -1,26 +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.app.map.model
class CustomTileSource {
companion object {
fun getTileSource(index: Int) {
index
}
}
}

View File

@@ -1,58 +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.app.map.model
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import org.meshtastic.core.model.Node
data class NodeClusterItem(
val node: Node,
val nodePosition: LatLng,
val nodeTitle: String,
val nodeSnippet: String,
val myNodeNum: Int? = null,
) : ClusterItem {
override fun getPosition(): LatLng = nodePosition
override fun getTitle(): String = nodeTitle
override fun getSnippet(): String = nodeSnippet
override fun getZIndex(): Float = when {
node.num == myNodeNum -> 5.0f // My node is always highest
node.isFavorite -> 5.0f // Favorites are equally high priority
else -> 4.0f
}
fun getPrecisionMeters(): Double? {
val precisionMap =
mapOf(
10 to 23345.484932,
11 to 11672.7369,
12 to 5836.36288,
13 to 2918.175876,
14 to 1459.0823719999053,
15 to 729.53562,
16 to 364.7622,
17 to 182.375556,
18 to 91.182212,
19 to 45.58554,
)
return precisionMap[this.node.position.precision_bits ?: 0]
}
}

View File

@@ -1,54 +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.app.map.node
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.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
)
}
}

View File

@@ -1,58 +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.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
* filter).
*
* Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
fun NodeTrackMap(
destNum: Int,
positions: List<Position>,
modifier: Modifier = Modifier,
selectedPositionTime: Int? = null,
onPositionSelected: ((Int) -> Unit)? = null,
) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(
modifier = modifier,
mode =
GoogleMapMode.NodeTrack(
focusedNode = focusedNode,
positions = positions,
selectedPositionTime = selectedPositionTime,
onPositionSelected = onPositionSelected,
),
)
}

View File

@@ -1,44 +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.app.map.prefs.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@Module
@ComponentScan("org.meshtastic.app.map")
class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}

View File

@@ -1,196 +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.app.map.prefs.map
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
interface GoogleMapsPrefs {
val selectedGoogleMapType: StateFlow<String?>
fun setSelectedGoogleMapType(value: String?)
val selectedCustomTileUrl: StateFlow<String?>
fun setSelectedCustomTileUrl(value: String?)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val cameraTargetLat: StateFlow<Double>
fun setCameraTargetLat(value: Double)
val cameraTargetLng: StateFlow<Double>
fun setCameraTargetLng(value: Double)
val cameraZoom: StateFlow<Float>
fun setCameraZoom(value: Float)
val cameraTilt: StateFlow<Float>
fun setCameraTilt(value: Float)
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
@Single
class GoogleMapsPrefsImpl(
@Named("GoogleMapsDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : GoogleMapsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val selectedGoogleMapType: StateFlow<String?> =
dataStore.data
.map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
.stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
override fun setSelectedGoogleMapType(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
} else {
prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
}
}
}
}
override val selectedCustomTileUrl: StateFlow<String?> =
dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setSelectedCustomTileUrl(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
} else {
prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
}
}
}
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
}
override val cameraTargetLat: StateFlow<Double> =
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LAT_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLat(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
}
override val cameraTargetLng: StateFlow<Double> =
dataStore.data
.map {
try {
it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0
} catch (_: ClassCastException) {
it[floatPreferencesKey(KEY_CAMERA_TARGET_LNG_PREF.name)]?.toDouble() ?: 0.0
}
}
.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLng(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
}
override val cameraZoom: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
override fun setCameraZoom(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
}
override val cameraTilt: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraTilt(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
}
override val cameraBearing: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraBearing(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
}
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
}
companion object {
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
}
}

View File

@@ -1,104 +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.app.map.repository
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapTileProviderPrefs
interface CustomTileProviderRepository {
fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>>
suspend fun addCustomTileProvider(config: CustomTileProviderConfig)
suspend fun updateCustomTileProvider(config: CustomTileProviderConfig)
suspend fun deleteCustomTileProvider(configId: String)
suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig?
}
@Single
class CustomTileProviderRepositoryImpl(
private val json: Json,
private val dispatchers: CoroutineDispatchers,
private val mapTileProviderPrefs: MapTileProviderPrefs,
) : CustomTileProviderRepository {
private val customTileProvidersStateFlow = MutableStateFlow<List<CustomTileProviderConfig>>(emptyList())
init {
loadDataFromPrefs()
}
override fun getCustomTileProviders(): Flow<List<CustomTileProviderConfig>> =
customTileProvidersStateFlow.asStateFlow()
override suspend fun addCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value + config
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun updateCustomTileProvider(config: CustomTileProviderConfig) {
val newList = customTileProvidersStateFlow.value.map { if (it.id == config.id) config else it }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun deleteCustomTileProvider(configId: String) {
val newList = customTileProvidersStateFlow.value.filterNot { it.id == configId }
customTileProvidersStateFlow.value = newList
saveDataToPrefs(newList)
}
override suspend fun getCustomTileProviderById(configId: String): CustomTileProviderConfig? =
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error deserializing tile providers" }
customTileProvidersStateFlow.value = emptyList()
}
} else {
customTileProvidersStateFlow.value = emptyList()
}
}
private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}
}
}
}

View File

@@ -1,46 +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.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
* mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View File

@@ -1,85 +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.app.node.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val mapColorScheme =
when (dark) {
true -> ComposeMapColorScheme.DARK
else -> ComposeMapColorScheme.LIGHT
}
key(node.num) {
val location = LatLng(node.latitude, node.longitude)
val cameraState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
}
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = modifier,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = false,
compassEnabled = false,
myLocationButtonEnabled = false,
rotationGesturesEnabled = false,
scrollGesturesEnabled = false,
tiltGesturesEnabled = false,
zoomGesturesEnabled = false,
),
cameraPositionState = cameraState,
) {
val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0)
val latLng = LatLng(node.latitude, node.longitude)
if (precisionMeters > 0) {
Circle(
center = latLng,
radius = precisionMeters,
fillColor = Color(node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(node.colors.second),
strokeWidth = 2f,
)
}
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.node.metrics
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.util.TracerouteMapOverlayInsets
fun getTracerouteMapOverlayInsets(): TracerouteMapOverlayInsets = TracerouteMapOverlayInsets(
overlayAlignment = Alignment.BottomCenter,
overlayPadding = PaddingValues(bottom = 16.dp),
contentHorizontalAlignment = Alignment.CenterHorizontally,
)

View File

@@ -52,9 +52,6 @@ import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.toMeshtasticUri
@@ -68,25 +65,12 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@@ -172,56 +156,6 @@ class MainActivity : ComponentActivity() {
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
org.meshtastic.app.map.node.NodeTrackMap(
destNum,
positions,
modifier,
selectedPositionTime,
onPositionSelected,
)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
},
content = content,
)
}

View File

@@ -28,8 +28,8 @@ import kotlinx.coroutines.CoroutineDispatcher
import org.koin.test.verify.definition
import org.koin.test.verify.injectedParameters
import org.koin.test.verify.verify
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.feature.map.MapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import kotlin.test.Test

View File

@@ -0,0 +1,126 @@
/*
* 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.core.prefs.map
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MapCameraPrefs
@Single
class MapCameraPrefsImpl(
@Named("MapDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MapCameraPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val cameraLat: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_LAT] ?: DEFAULT_LAT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LAT)
override fun setCameraLat(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_LAT] = value } }
}
override val cameraLng: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_LNG] ?: DEFAULT_LNG }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LNG)
override fun setCameraLng(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_LNG] = value } }
}
override val cameraZoom: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_ZOOM] ?: DEFAULT_ZOOM }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_ZOOM)
override fun setCameraZoom(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM] = value } }
}
override val cameraTilt: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_TILT] ?: DEFAULT_TILT }.stateIn(scope, SharingStarted.Eagerly, DEFAULT_TILT)
override fun setCameraTilt(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TILT] = value } }
}
override val cameraBearing: StateFlow<Float> =
dataStore.data
.map { it[KEY_CAMERA_BEARING] ?: DEFAULT_BEARING }
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_BEARING)
override fun setCameraBearing(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING] = value } }
}
override val selectedStyleUri: StateFlow<String> =
dataStore.data
.map { it[KEY_SELECTED_STYLE_URI] ?: DEFAULT_STYLE_URI }
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_STYLE_URI)
override fun setSelectedStyleUri(value: String) {
scope.launch { dataStore.edit { it[KEY_SELECTED_STYLE_URI] = value } }
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } }
}
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS] = value } }
}
companion object {
private const val DEFAULT_LAT = 0.0
private const val DEFAULT_LNG = 0.0
private const val DEFAULT_ZOOM = 7f
private const val DEFAULT_TILT = 0f
private const val DEFAULT_BEARING = 0f
private const val DEFAULT_STYLE_URI = ""
val KEY_CAMERA_LAT = doublePreferencesKey("map_camera_lat")
val KEY_CAMERA_LNG = doublePreferencesKey("map_camera_lng")
val KEY_CAMERA_ZOOM = floatPreferencesKey("map_camera_zoom")
val KEY_CAMERA_TILT = floatPreferencesKey("map_camera_tilt")
val KEY_CAMERA_BEARING = floatPreferencesKey("map_camera_bearing")
val KEY_SELECTED_STYLE_URI = stringPreferencesKey("map_selected_style_uri")
val KEY_HIDDEN_LAYER_URLS = stringSetPreferencesKey("map_hidden_layer_urls")
val KEY_NETWORK_MAP_LAYERS = stringSetPreferencesKey("map_network_layers")
}
}

View File

@@ -167,6 +167,41 @@ interface MapPrefs {
fun setLastHeardTrackFilter(seconds: Long)
}
/** Reactive interface for map camera position persistence. */
interface MapCameraPrefs {
val cameraLat: StateFlow<Double>
fun setCameraLat(value: Double)
val cameraLng: StateFlow<Double>
fun setCameraLng(value: Double)
val cameraZoom: StateFlow<Float>
fun setCameraZoom(value: Float)
val cameraTilt: StateFlow<Float>
fun setCameraTilt(value: Float)
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val selectedStyleUri: StateFlow<String>
fun setSelectedStyleUri(value: String)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
/** Reactive interface for map consent. */
interface MapConsentPrefs {
fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean>
@@ -234,6 +269,7 @@ interface AppPreferences {
val emoji: CustomEmojiPrefs
val ui: UiPrefs
val map: MapPrefs
val mapCamera: MapCameraPrefs
val mapConsent: MapConsentPrefs
val mapTileProvider: MapTileProviderPrefs
val radio: RadioPrefs

View File

@@ -1149,6 +1149,11 @@
<string name="bluetooth_feature_config">Configuration</string>
<string name="bluetooth_feature_config_description">Wirelessly manage your device settings and channels.</string>
<string name="map_style_selection">Map style selection</string>
<string name="map_style_osm">OpenStreetMap</string>
<string name="map_style_satellite">Satellite</string>
<string name="map_style_terrain">Terrain</string>
<string name="map_style_hybrid">Hybrid</string>
<string name="map_style_dark">Dark</string>
<string name="local_stats_battery">Battery: %1$d%</string>
<string name="local_stats_nodes">Nodes: %1$d online / %2$d total</string>

View File

@@ -23,6 +23,7 @@ import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MapCameraPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
@@ -192,6 +193,56 @@ class FakeMapPrefs : MapPrefs {
}
}
class FakeMapCameraPrefs : MapCameraPrefs {
override val cameraLat = MutableStateFlow(0.0)
override fun setCameraLat(value: Double) {
cameraLat.value = value
}
override val cameraLng = MutableStateFlow(0.0)
override fun setCameraLng(value: Double) {
cameraLng.value = value
}
override val cameraZoom = MutableStateFlow(7f)
override fun setCameraZoom(value: Float) {
cameraZoom.value = value
}
override val cameraTilt = MutableStateFlow(0f)
override fun setCameraTilt(value: Float) {
cameraTilt.value = value
}
override val cameraBearing = MutableStateFlow(0f)
override fun setCameraBearing(value: Float) {
cameraBearing.value = value
}
override val selectedStyleUri = MutableStateFlow("")
override fun setSelectedStyleUri(value: String) {
selectedStyleUri.value = value
}
override val hiddenLayerUrls = MutableStateFlow(emptySet<String>())
override fun setHiddenLayerUrls(value: Set<String>) {
hiddenLayerUrls.value = value
}
override val networkMapLayers = MutableStateFlow(emptySet<String>())
override fun setNetworkMapLayers(value: Set<String>) {
networkMapLayers.value = value
}
}
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
@@ -258,6 +309,7 @@ class FakeAppPreferences : AppPreferences {
override val emoji = FakeCustomEmojiPrefs()
override val ui = FakeUiPrefs()
override val map = FakeMapPrefs()
override val mapCamera = FakeMapCameraPrefs()
override val mapConsent = FakeMapConsentPrefs()
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()

View File

@@ -1,24 +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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.Node
val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } }

View File

@@ -1,33 +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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it
* falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalMapMainScreenProvider =
compositionLocalOf<
@Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
> {
{ _, _, _ -> PlaceholderScreen("Map") }
}

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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
* targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalNodeMapScreenProvider =
compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
{ destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
}

View File

@@ -1,50 +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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
import org.meshtastic.proto.Position
/**
* Provides an embeddable position-track map composable that renders a polyline with markers for the given [positions].
* Unlike [LocalNodeMapScreenProvider], this does **not** include a Scaffold or AppBar — it is designed to be embedded
* inside another screen layout (e.g. the position-log adaptive layout).
*
* Supports optional synchronized selection:
* - [selectedPositionTime]: the `Position.time` of the currently selected position (or `null` for no selection). When
* non-null, the map should visually highlight the corresponding marker and center the camera on it.
* - [onPositionSelected]: callback invoked when a position marker is tapped on the map, passing the `Position.time` so
* the host can synchronize the card list.
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalNodeTrackMapProvider =
compositionLocalOf<
@Composable (
destNum: Int,
positions: List<Position>,
modifier: Modifier,
selectedPositionTime: Int?,
onPositionSelected: ((Int) -> Unit)?,
) -> Unit,
> {
{ _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
}

View File

@@ -1,30 +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.core.ui.util
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
data class TracerouteMapOverlayInsets(
val overlayAlignment: Alignment = Alignment.BottomCenter,
val overlayPadding: PaddingValues = PaddingValues(bottom = 16.dp),
val contentHorizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
)
val LocalTracerouteMapOverlayInsetsProvider = compositionLocalOf { TracerouteMapOverlayInsets() }

View File

@@ -1,51 +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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.ui.component.PlaceholderScreen
import org.meshtastic.proto.Position
/**
* Provides an embeddable traceroute map composable that renders node markers and forward/return offset polylines for a
* traceroute result. Unlike [LocalMapViewProvider], this does **not** include a Scaffold, AppBar, waypoints, location
* tracking, custom tiles, or any main-map features — it is designed to be embedded inside `TracerouteMapScreen`'s
* scaffold.
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*
* Parameters:
* - `tracerouteOverlay`: The overlay with forward/return route node nums.
* - `tracerouteNodePositions`: Map of node num to position snapshots for the route nodes.
* - `onMappableCountChanged`: Callback with (shown, total) node counts.
* - `modifier`: Compose modifier for the map.
*/
@Suppress("Wrapping")
val LocalTracerouteMapProvider =
compositionLocalOf<
@Composable (
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (Int, Int) -> Unit,
modifier: Modifier,
) -> Unit,
> {
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
}

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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available
* yet, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalTracerouteMapScreenProvider =
compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> {
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
}

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.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
/**
* Interface for providing a flavored MapView. This allows the map feature to be decoupled from specific map
* implementations (Google Maps vs OSMDroid). Platform implementations create their own ViewModel via Koin.
*/
interface MapViewProvider {
@Composable fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int? = null)
}
val LocalMapViewProvider = compositionLocalOf<MapViewProvider?> { null }

View File

@@ -31,6 +31,8 @@ kotlin {
commonMain.dependencies {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kotlinx.collections.immutable)
api(libs.maplibre.compose)
implementation(libs.maplibre.compose.material3)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)

View File

@@ -1,64 +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 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.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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
@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()
@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 ->
LocalMapViewProvider.current?.MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
}
}

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(
{ backStack.add(NodesRoute.NodeDetail(it)) }, // onClickNodeChip
{ backStack.add(NodesRoute.NodeDetail(it)) }, // 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.app.map.node
package org.meshtastic.feature.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -23,18 +23,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.feature.map.node.NodeMapViewModel
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 NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
fun NodeMapScreen(viewModel: NodeMapViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) {
val node by viewModel.node.collectAsStateWithLifecycle()
val positions by viewModel.positionLogs.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
title = node?.user?.long_name ?: stringResource(Res.string.map),
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@@ -44,11 +54,6 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
NodeTrackOsmMap(
positions = positions,
applicationId = nodeMapViewModel.applicationId,
mapStyleId = nodeMapViewModel.mapStyleId,
modifier = Modifier.fillMaxSize().padding(paddingValues),
)
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()
}
}
}

View File

@@ -43,7 +43,7 @@ import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.feature.map.component.InlineMap
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
@@ -85,7 +85,7 @@ internal fun PositionInlineContent(
private fun PositionMap(node: Node, distance: String?) {
Box(modifier = Modifier.padding(vertical = 4.dp)) {
Surface(shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().height(MAP_HEIGHT_DP.dp)) {
LocalInlineMapProvider.current(node, Modifier.fillMaxSize())
InlineMap(node, Modifier.fillMaxSize())
}
if (distance != null && distance.isNotEmpty()) {
Surface(

View File

@@ -31,8 +31,8 @@ import org.meshtastic.core.resources.position_log
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
import org.meshtastic.feature.map.component.NodeTrackMap
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@@ -41,9 +41,6 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) }
val trackMap = LocalNodeTrackMapProvider.current
val destNum = state.node?.num ?: 0
BaseMetricScreen(
onNavigateUp = onNavigateUp,
telemetryType = null,
@@ -66,7 +63,12 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
},
chartPart = { modifier, selectedX, _, onPointSelected ->
val selectedTime = selectedX?.toInt()
trackMap(destNum, positions, modifier, selectedTime) { time -> onPointSelected(time.toDouble()) }
NodeTrackMap(
positions = positions,
modifier = modifier,
selectedPositionTime = selectedTime,
onPositionSelected = { time -> onPointSelected(time.toDouble()) },
)
},
listPart = { modifier, selectedX, lazyListState, onCardClick ->
LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) {

View File

@@ -52,8 +52,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.feature.map.component.TracerouteMap
import org.meshtastic.proto.Position
@Composable
@@ -102,7 +101,6 @@ private fun TracerouteMapScaffold(
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
var tracerouteNodesTotal by remember { mutableStateOf(0) }
val insets = LocalTracerouteMapOverlayInsetsProvider.current
Scaffold(
topBar = {
MainAppBar(
@@ -117,18 +115,18 @@ private fun TracerouteMapScaffold(
},
) { paddingValues ->
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
LocalTracerouteMapProvider.current(
overlay,
snapshotPositions,
{ shown: Int, total: Int ->
TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
onMappableCountChanged = { shown: Int, total: Int ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
)
Column(
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),
horizontalAlignment = insets.contentHorizontalAlignment,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TracerouteNodeCount(shown = tracerouteNodesShown, total = tracerouteNodesTotal)

View File

@@ -65,6 +65,7 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -142,8 +143,14 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoute.TracerouteMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val tracerouteMapScreen = org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider.current
tracerouteMapScreen(args.destNum, args.requestId, args.logUuid) { backStack.removeLastOrNull() }
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
NodeDetailScreen.entries.forEach { routeInfo ->

View File

@@ -39,8 +39,8 @@ compose-multiplatform-material3 = "1.11.0-alpha06"
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-alpha06"
# Google
maps-compose = "8.3.0"
# MapLibre
maplibre-compose = "0.12.1"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
@@ -64,7 +64,6 @@ firebase-crashlytics-gradle = "3.0.7"
google-services-gradle = "4.4.4"
markdownRenderer = "0.40.2"
okio = "3.17.0"
osmdroid-android = "6.1.20"
spotless = "8.4.0"
wire = "6.2.0"
vico = "3.1.0"
@@ -153,11 +152,9 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", ver
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" }
maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibre-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" }
@@ -225,9 +222,6 @@ kmqtt-common = { module = "io.github.davidepianca98:kmqtt-common", version.ref =
jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
osmbonuspack = { module = "com.github.MKergall:osmbonuspack", version = "6.9.0" }
osmdroid-android = { module = "org.osmdroid:osmdroid-android", version.ref = "osmdroid-android" }
osmdroid-geopackage = { module = "org.osmdroid:osmdroid-geopackage", version.ref = "osmdroid-android" }
kermit = { module = "co.touchlab:kermit", version = "2.1.0" }
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }