diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0942756c0..d93686e92 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
deleted file mode 100644
index 21c2d4fde..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
+++ /dev/null
@@ -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 .
- */
-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,
- )
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
deleted file mode 100644
index 48b1aa7fc..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ /dev/null
@@ -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 .
- */
-package org.meshtastic.app.map
-
-import org.meshtastic.core.ui.util.MapViewProvider
-
-fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
deleted file mode 100644
index 1243fdc8a..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapUtils.kt
+++ /dev/null
@@ -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 .
- */
-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()
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
deleted file mode 100644
index 657f7ab74..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ /dev/null
@@ -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 .
- */
-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,
- waypointMarkers: List,
- 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(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): List {
- 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, selectedWaypointId: Int?): List {
- 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() }
-
- 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
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
deleted file mode 100644
index 3cc0dbaf0..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
+++ /dev/null
@@ -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 .
- */
-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, 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, onClick: (Int) -> Unit): List {
- 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
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
deleted file mode 100644
index 1ffe68aa1..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ /dev/null
@@ -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 .
- */
-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("waypointId"))
- val selectedWaypointId: StateFlow = _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
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
deleted file mode 100644
index c16d87163..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
+++ /dev/null
@@ -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 .
- */
-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
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
deleted file mode 100644
index 112449d1f..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/SqlTileWriterExt.kt
+++ /dev/null
@@ -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 .
- */
-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
- get() {
- val db = db
- val ret: MutableList = 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
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
deleted file mode 100644
index 986918e06..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/CacheLayout.kt
+++ /dev/null
@@ -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 .
- */
-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 = {})
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
deleted file mode 100644
index 7568d695a..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
+++ /dev/null
@@ -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 .
- */
-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 = {})
-// }
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
deleted file mode 100644
index c41798bf0..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ /dev/null
@@ -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 .
- */
-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 = {},
- )
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
deleted file mode 100644
index de0f8c6c2..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
+++ /dev/null
@@ -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 .
- */
-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 =
- 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")
- }
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
deleted file mode 100644
index da94a7725..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/MarkerWithLabel.kt
+++ /dev/null
@@ -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 .
- */
-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) {
- 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)
- }
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
deleted file mode 100644
index ac438397a..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/NOAAWmsTileSource.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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"
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
deleted file mode 100644
index 3d51133bd..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/model/OnlineTileSourceAuth.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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"
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
deleted file mode 100644
index 77b595d88..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
+++ /dev/null
@@ -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 .
- */
-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,
- modifier: Modifier = Modifier,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
- val vm = koinViewModel()
- vm.setDestNum(destNum)
- NodeTrackOsmMap(
- positions = positions,
- applicationId = vm.applicationId,
- mapStyleId = vm.mapStyleId,
- modifier = modifier,
- selectedPositionTime = selectedPositionTime,
- onPositionSelected = onPositionSelected,
- )
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
deleted file mode 100644
index a6aec4c2d..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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,
- )
- }
- }
- },
- )
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
deleted file mode 100644
index fcf1d47e9..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
+++ /dev/null
@@ -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 .
- */
-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,
- onMappableCountChanged: (shown: Int, total: Int) -> Unit,
- modifier: Modifier = Modifier,
-) {
- TracerouteOsmMap(
- tracerouteOverlay = tracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions,
- onMappableCountChanged = onMappableCountChanged,
- modifier = modifier,
- )
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
deleted file mode 100644
index 55b49154a..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
+++ /dev/null
@@ -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 .
- */
-@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,
- 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,
- returnPoints: List,
- density: androidx.compose.ui.unit.Density,
-): List {
- val polylines = mutableListOf()
-
- fun buildPolyline(points: List, 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,
- offsetMeters: Double,
- headingReferencePoints: List = points,
- sideMultiplier: Double = 1.0,
-): List {
- 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))
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
deleted file mode 100644
index 447765522..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ /dev/null
@@ -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 .
- */
-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)
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
deleted file mode 100644
index d6515eeb7..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
+++ /dev/null
@@ -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 .
- */
-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,
-)
diff --git a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 802f3b150..479dbca1e 100644
--- a/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -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
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
deleted file mode 100644
index 8a441fa70..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/GetMapViewProvider.kt
+++ /dev/null
@@ -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 .
- */
-package org.meshtastic.app.map
-
-import org.meshtastic.core.ui.util.MapViewProvider
-
-fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
deleted file mode 100644
index 940c4ab5a..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
+++ /dev/null
@@ -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 .
- */
-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,
- )
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt b/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
deleted file mode 100644
index 1aa4a7bab..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/LocationHandler.kt
+++ /dev/null
@@ -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 .
- */
-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)
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
deleted file mode 100644
index 6ac756f6b..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/MBTilesProvider.kt
+++ /dev/null
@@ -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 .
- */
-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
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
deleted file mode 100644
index c8f2f3fee..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ /dev/null
@@ -1,1125 +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 .
- */
-@file:Suppress("MagicNumber")
-
-package org.meshtastic.app.map
-
-import android.Manifest
-import android.app.Activity
-import android.content.Intent
-import android.net.Uri
-import android.view.WindowManager
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
-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.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-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.gms.location.LocationCallback
-import com.google.android.gms.location.LocationRequest
-import com.google.android.gms.location.LocationResult
-import com.google.android.gms.location.LocationServices
-import com.google.android.gms.location.Priority
-import com.google.android.gms.maps.CameraUpdateFactory
-import com.google.android.gms.maps.model.CameraPosition
-import com.google.android.gms.maps.model.JointType
-import com.google.android.gms.maps.model.LatLng
-import com.google.android.gms.maps.model.LatLngBounds
-import com.google.maps.android.SphericalUtil
-import com.google.maps.android.compose.CameraPositionState
-import com.google.maps.android.compose.ComposeMapColorScheme
-import com.google.maps.android.compose.GoogleMap
-import com.google.maps.android.compose.MapEffect
-import com.google.maps.android.compose.MapProperties
-import com.google.maps.android.compose.MapType
-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.MarkerInfoWindowComposable
-import com.google.maps.android.compose.Polyline
-import com.google.maps.android.compose.TileOverlay
-import com.google.maps.android.compose.rememberCameraPositionState
-import com.google.maps.android.compose.rememberUpdatedMarkerState
-import com.google.maps.android.compose.widgets.ScaleBar
-import com.google.maps.android.data.Layer
-import com.google.maps.android.data.geojson.GeoJsonLayer
-import com.google.maps.android.data.kml.KmlLayer
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.stringResource
-import org.json.JSONObject
-import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.app.map.component.ClusterItemsListDialog
-import org.meshtastic.app.map.component.CustomMapLayersSheet
-import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
-import org.meshtastic.app.map.component.EditWaypointDialog
-import org.meshtastic.app.map.component.MapFilterDropdown
-import org.meshtastic.app.map.component.MapTypeDropdown
-import org.meshtastic.app.map.component.NodeClusterMarkers
-import org.meshtastic.app.map.component.NodeMapFilterDropdown
-import org.meshtastic.app.map.component.WaypointMarkers
-import org.meshtastic.app.map.model.NodeClusterItem
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.TracerouteOverlay
-import org.meshtastic.core.model.util.GeoConstants.DEG_D
-import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
-import org.meshtastic.core.model.util.metersIn
-import org.meshtastic.core.model.util.mpsToKmph
-import org.meshtastic.core.model.util.mpsToMph
-import org.meshtastic.core.model.util.toString
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.alt
-import org.meshtastic.core.resources.heading
-import org.meshtastic.core.resources.latitude
-import org.meshtastic.core.resources.longitude
-import org.meshtastic.core.resources.manage_map_layers
-import org.meshtastic.core.resources.map_tile_source
-import org.meshtastic.core.resources.position
-import org.meshtastic.core.resources.sats
-import org.meshtastic.core.resources.speed
-import org.meshtastic.core.resources.timestamp
-import org.meshtastic.core.resources.track_point
-import org.meshtastic.core.ui.component.NodeChip
-import org.meshtastic.core.ui.icon.Layers
-import org.meshtastic.core.ui.icon.Map
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.TripOrigin
-import org.meshtastic.core.ui.theme.TracerouteColors
-import org.meshtastic.core.ui.util.formatAgo
-import org.meshtastic.core.ui.util.formatPositionTime
-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.feature.map.tracerouteNodeSelection
-import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
-import org.meshtastic.proto.Position
-import org.meshtastic.proto.Waypoint
-import kotlin.math.abs
-import kotlin.math.max
-
-// region --- Map Mode ---
-
-/**
- * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed
- * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers,
- * controls overlay) is available in every mode.
- */
-sealed interface GoogleMapMode {
- /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */
- data object Main : GoogleMapMode
-
- /** Focused node position track: polyline + gradient markers for historical positions. */
- data class NodeTrack(
- val focusedNode: Node?,
- val positions: List,
- val selectedPositionTime: Int? = null,
- val onPositionSelected: ((Int) -> Unit)? = null,
- ) : GoogleMapMode
-
- /** Traceroute visualization: offset forward/return polylines + hop markers. */
- data class Traceroute(
- val overlay: TracerouteOverlay?,
- val nodePositions: Map,
- val onMappableCountChanged: (shown: Int, total: Int) -> Unit,
- ) : GoogleMapMode
-}
-
-// endregion
-
-private const val TRACEROUTE_OFFSET_METERS = 100.0
-private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
-
-@Suppress("CyclomaticComplexMethod", "LongMethod")
-@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
-@Composable
-fun MapView(
- modifier: Modifier = Modifier,
- mapViewModel: MapViewModel = koinViewModel(),
- navigateToNodeDetails: (Int) -> Unit = {},
- mode: GoogleMapMode = GoogleMapMode.Main,
-) {
- val context = LocalContext.current
- val coroutineScope = rememberCoroutineScope()
- val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
-
- // --- Location permissions ---
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
-
- // --- Location tracking ---
- var isLocationTrackingEnabled by remember { mutableStateOf(false) }
- var followPhoneBearing by remember { mutableStateOf(false) }
-
- LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
- if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
- isLocationTrackingEnabled = true
- triggerLocationToggleAfterPermission = false
- }
- }
-
- // --- File picker for map layers (Main mode) ---
- val filePickerLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == Activity.RESULT_OK) {
- result.data?.data?.let { uri ->
- val fileName = uri.getFileName(context)
- mapViewModel.addMapLayer(uri, fileName)
- }
- }
- }
-
- // --- UI state ---
- var mapFilterMenuExpanded by remember { mutableStateOf(false) }
- val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
- val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
- var editingWaypoint by remember { mutableStateOf(null) }
-
- val selectedGoogleMapType by mapViewModel.selectedGoogleMapType.collectAsStateWithLifecycle()
- val currentCustomTileProviderUrl by mapViewModel.selectedCustomTileProviderUrl.collectAsStateWithLifecycle()
-
- var mapTypeMenuExpanded by remember { mutableStateOf(false) }
- var showCustomTileManagerSheet by remember { mutableStateOf(false) }
-
- // --- Camera ---
- // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
- val cameraPositionState =
- if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
-
- if (mode is GoogleMapMode.Main) {
- LaunchedEffect(cameraPositionState.isMoving) {
- if (!cameraPositionState.isMoving) {
- mapViewModel.saveCameraPosition(cameraPositionState.position)
- }
- }
- }
-
- // --- FusedLocation ---
- val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
- val locationCallback = remember {
- object : LocationCallback() {
- override fun onLocationResult(locationResult: LocationResult) {
- if (isLocationTrackingEnabled) {
- locationResult.lastLocation?.let { location ->
- val latLng = LatLng(location.latitude, location.longitude)
- val cameraUpdate =
- if (followPhoneBearing) {
- val bearing =
- if (location.hasBearing()) {
- location.bearing
- } else {
- cameraPositionState.position.bearing
- }
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(latLng)
- .zoom(cameraPositionState.position.zoom)
- .bearing(bearing)
- .build(),
- )
- } else {
- CameraUpdateFactory.newLatLngZoom(latLng, cameraPositionState.position.zoom)
- }
- coroutineScope.launch {
- try {
- cameraPositionState.animate(cameraUpdate)
- } catch (e: IllegalStateException) {
- Logger.d { "Error animating camera to location: ${e.message}" }
- }
- }
- }
- }
- }
- }
- }
-
- LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
- if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
- val locationRequest =
- LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
- .setMinUpdateIntervalMillis(2000L)
- .build()
- try {
- @Suppress("MissingPermission")
- fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
- Logger.d { "Started location tracking" }
- } catch (e: SecurityException) {
- Logger.d { "Location permission not available: ${e.message}" }
- isLocationTrackingEnabled = false
- }
- } else {
- fusedLocationClient.removeLocationUpdates(locationCallback)
- Logger.d { "Stopped location tracking" }
- }
- }
-
- DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
-
- // --- Node & waypoint data ---
- val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
- val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
- val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
- val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
-
- val filteredNodes =
- allNodes
- .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
- .filter { node ->
- mapFilterState.lastHeardFilter.seconds == 0L ||
- (nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
- node.num == ourNodeInfo?.num
- }
-
- val myNodeNum = mapViewModel.myNodeNum
- val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
- val theme by mapViewModel.theme.collectAsStateWithLifecycle()
- val dark =
- when (theme) {
- AppCompatDelegate.MODE_NIGHT_YES -> true
- AppCompatDelegate.MODE_NIGHT_NO -> false
- AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
- else -> isSystemInDarkTheme()
- }
- val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
-
- // --- Mode-specific data ---
- // Node track: apply time filter
- val sortedTrackPositions =
- if (mode is GoogleMapMode.NodeTrack) {
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- remember(mode.positions, lastHeardTrackFilter) {
- mode.positions
- .filter {
- lastHeardTrackFilter == LastHeardFilter.Any ||
- it.time > nowSeconds - lastHeardTrackFilter.seconds
- }
- .sortedBy { it.time }
- }
- } else {
- emptyList()
- }
-
- // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules
- // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all
- // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops
- // whose positions come from snapshots.
- val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf())
- val tracerouteSelection =
- if (mode is GoogleMapMode.Traceroute) {
- remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = mode.overlay,
- tracerouteNodePositions = mode.nodePositions,
- nodes = allNodesForTraceroute,
- )
- }
- } else {
- null
- }
- val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList()
-
- if (mode is GoogleMapMode.Traceroute) {
- LaunchedEffect(mode.overlay, tracerouteDisplayNodes) {
- if (mode.overlay != null) {
- mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size)
- }
- }
- }
-
- val tracerouteForwardPoints: List =
- if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
- val nodeLookup = tracerouteSelection.nodeLookup
- remember(mode.overlay, nodeLookup) {
- mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
- }
- } else {
- emptyList()
- }
- val tracerouteReturnPoints: List =
- if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
- val nodeLookup = tracerouteSelection.nodeLookup
- remember(mode.overlay, nodeLookup) {
- mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
- }
- } else {
- emptyList()
- }
- val tracerouteHeadingReferencePoints =
- remember(tracerouteForwardPoints, tracerouteReturnPoints) {
- when {
- tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
- tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
- else -> emptyList()
- }
- }
- val tracerouteForwardOffsetPoints =
- remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0)
- }
- val tracerouteReturnOffsetPoints =
- remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0)
- }
-
- // Auto-centering for NodeTrack / Traceroute modes
- var hasCentered by remember(mode) { mutableStateOf(false) }
-
- if (mode is GoogleMapMode.NodeTrack) {
- LaunchedEffect(sortedTrackPositions, hasCentered) {
- if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect
- val points = sortedTrackPositions.map { it.toLatLng() }
- val cameraUpdate =
- if (points.size == 1) {
- CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f))
- } else {
- val bounds = LatLngBounds.builder()
- points.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), 80)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCentered = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering track map: ${e.message}" }
- }
- }
-
- // Animate to selected position marker when card is tapped in the list
- LaunchedEffect(mode.selectedPositionTime) {
- val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect
- val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect
- try {
- cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
- } catch (e: IllegalStateException) {
- Logger.d { "Error animating to selected position: ${e.message}" }
- }
- }
- }
-
- if (mode is GoogleMapMode.Traceroute) {
- LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (mode.overlay == null || hasCentered) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- val cameraUpdate =
- if (allPoints.size == 1) {
- CameraUpdateFactory.newLatLngZoom(
- allPoints.first(),
- max(cameraPositionState.position.zoom, 12f),
- )
- } else {
- val bounds = LatLngBounds.builder()
- allPoints.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCentered = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering traceroute overlay: ${e.message}" }
- }
- }
- }
- }
-
- // --- Tile & layers state ---
- var showLayersBottomSheet by remember { mutableStateOf(false) }
-
- val onAddLayerClicked = {
- val intent =
- Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "*/*"
- val mimeTypes =
- arrayOf(
- "application/vnd.google-earth.kml+xml",
- "application/vnd.google-earth.kmz",
- "application/vnd.geo+json",
- "application/geo+json",
- "application/json",
- )
- putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
- }
- filePickerLauncher.launch(intent)
- }
- val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
- val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
-
- val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
-
- var showClusterItemsDialog by remember { mutableStateOf?>(null) }
-
- // --- Keep screen on while location tracking ---
- LaunchedEffect(isLocationTrackingEnabled) {
- val activity = context as? Activity ?: return@LaunchedEffect
- val window = activity.window
- if (isLocationTrackingEnabled) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
-
- // --- Main UI ---
- val isMainMode = mode is GoogleMapMode.Main
-
- Box(modifier = modifier) {
- GoogleMap(
- mapColorScheme = mapColorScheme,
- modifier = Modifier.fillMaxSize(),
- cameraPositionState = cameraPositionState,
- uiSettings =
- MapUiSettings(
- zoomControlsEnabled = true,
- mapToolbarEnabled = isMainMode,
- compassEnabled = false,
- myLocationButtonEnabled = false,
- rotationGesturesEnabled = true,
- scrollGesturesEnabled = true,
- tiltGesturesEnabled = isMainMode,
- zoomGesturesEnabled = true,
- ),
- properties =
- MapProperties(
- mapType = effectiveGoogleMapType,
- isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
- ),
- onMapLongClick = { latLng ->
- if (isMainMode && isConnected) {
- editingWaypoint =
- Waypoint(
- latitude_i = (latLng.latitude / DEG_D).toInt(),
- longitude_i = (latLng.longitude / DEG_D).toInt(),
- )
- }
- },
- ) {
- // Custom tile overlay (all modes)
- key(currentCustomTileProviderUrl) {
- currentCustomTileProviderUrl?.let { url ->
- val config =
- mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
- it.urlTemplate == url || it.localUri == url
- }
- mapViewModel.getTileProvider(config)?.let { tileProvider ->
- TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
- }
- }
- }
-
- when (mode) {
- is GoogleMapMode.Main ->
- MainMapContent(
- nodeClusterItems =
- filteredNodes.map { node ->
- val latLng =
- LatLng(
- (node.position.latitude_i ?: 0) * DEG_D,
- (node.position.longitude_i ?: 0) * DEG_D,
- )
- NodeClusterItem(
- node = node,
- nodePosition = latLng,
- nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
- nodeSnippet = "${node.user.long_name}",
- myNodeNum = myNodeNum,
- )
- },
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- displayableWaypoints = displayableWaypoints,
- myNodeNum = myNodeNum,
- isConnected = isConnected,
- onEditWaypointRequest = { editingWaypoint = it },
- selectedWaypointId = selectedWaypointId,
- mapLayers = mapLayers,
- mapViewModel = mapViewModel,
- cameraPositionState = cameraPositionState,
- coroutineScope = coroutineScope,
- onShowClusterItemsDialog = { showClusterItemsDialog = it },
- )
-
- is GoogleMapMode.NodeTrack -> {
- val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
- if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
- NodeTrackOverlay(
- focusedNode = mode.focusedNode,
- sortedPositions = sortedTrackPositions,
- displayUnits = displayUnits,
- myNodeNum = myNodeNum,
- selectedPositionTime = mode.selectedPositionTime,
- onPositionSelected = mode.onPositionSelected,
- )
- }
- }
-
- is GoogleMapMode.Traceroute ->
- TracerouteMapContent(
- forwardOffsetPoints = tracerouteForwardOffsetPoints,
- returnOffsetPoints = tracerouteReturnOffsetPoints,
- forwardPointCount = tracerouteForwardPoints.size,
- returnPointCount = tracerouteReturnPoints.size,
- displayNodes = tracerouteDisplayNodes,
- )
- }
- }
-
- // Scale bar
- ScaleBar(
- cameraPositionState = cameraPositionState,
- modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp),
- )
-
- // Waypoint edit dialog (Main mode only)
- if (isMainMode) {
- editingWaypoint?.let { waypointToEdit ->
- EditWaypointDialog(
- waypoint = waypointToEdit,
- onSendClicked = { updatedWp ->
- var finalWp = updatedWp
- if (updatedWp.id == 0) {
- finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
- }
- if ((updatedWp.icon ?: 0) == 0) {
- finalWp = finalWp.copy(icon = 0x1F4CD)
- }
- mapViewModel.sendWaypoint(finalWp)
- editingWaypoint = null
- },
- onDeleteClicked = { wpToDelete ->
- if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
- mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
- }
- mapViewModel.deleteWaypoint(wpToDelete.id)
- editingWaypoint = null
- },
- onDismissRequest = { editingWaypoint = null },
- )
- }
- }
-
- // Controls overlay
- val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
- val showRefresh = visibleNetworkLayers.isNotEmpty()
- val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
-
- MapControlsOverlay(
- modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
- onToggleFilterMenu = { mapFilterMenuExpanded = true },
- filterDropdownContent = {
- if (mode is GoogleMapMode.NodeTrack) {
- NodeMapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = { mapFilterMenuExpanded = false },
- mapViewModel = mapViewModel,
- )
- } else {
- MapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = { mapFilterMenuExpanded = false },
- mapViewModel = mapViewModel,
- )
- }
- },
- mapTypeContent = {
- Box {
- MapButton(
- icon = MeshtasticIcons.Map,
- contentDescription = stringResource(Res.string.map_tile_source),
- onClick = { mapTypeMenuExpanded = true },
- )
- MapTypeDropdown(
- expanded = mapTypeMenuExpanded,
- onDismissRequest = { mapTypeMenuExpanded = false },
- mapViewModel = mapViewModel,
- onManageCustomTileProvidersClicked = {
- mapTypeMenuExpanded = false
- showCustomTileManagerSheet = true
- },
- )
- }
- },
- layersContent = {
- MapButton(
- icon = MeshtasticIcons.Layers,
- contentDescription = stringResource(Res.string.manage_map_layers),
- onClick = { showLayersBottomSheet = true },
- )
- },
- isLocationTrackingEnabled = isLocationTrackingEnabled,
- onToggleLocationTracking = {
- if (locationPermissionsState.allPermissionsGranted) {
- isLocationTrackingEnabled = !isLocationTrackingEnabled
- if (!isLocationTrackingEnabled) {
- followPhoneBearing = false
- }
- } else {
- triggerLocationToggleAfterPermission = true
- locationPermissionsState.launchMultiplePermissionRequest()
- }
- },
- bearing = cameraPositionState.position.bearing,
- onCompassClick = {
- if (isLocationTrackingEnabled) {
- followPhoneBearing = !followPhoneBearing
- } else {
- coroutineScope.launch {
- try {
- val currentPosition = cameraPositionState.position
- val newCameraPosition = CameraPosition.Builder(currentPosition).bearing(0f).build()
- cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
- Logger.d { "Oriented map to north" }
- } catch (e: IllegalStateException) {
- Logger.d { "Error orienting map to north: ${e.message}" }
- }
- }
- }
- },
- followPhoneBearing = followPhoneBearing,
- showRefresh = showRefresh,
- isRefreshing = isRefreshingLayers,
- onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
- )
- }
-
- // --- Bottom sheets & dialogs ---
- if (showLayersBottomSheet) {
- ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
- CustomMapLayersSheet(
- mapLayers = mapLayers,
- onToggleVisibility = onToggleVisibility,
- onRemoveLayer = onRemoveLayer,
- onAddLayerClicked = onAddLayerClicked,
- onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
- onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
- )
- }
- }
- showClusterItemsDialog?.let {
- ClusterItemsListDialog(
- items = it,
- onDismiss = { showClusterItemsDialog = null },
- onItemClick = { item ->
- navigateToNodeDetails(item.node.num)
- showClusterItemsDialog = null
- },
- )
- }
- if (showCustomTileManagerSheet) {
- ModalBottomSheet(onDismissRequest = { showCustomTileManagerSheet = false }) {
- CustomTileProviderManagerSheet(mapViewModel = mapViewModel)
- }
- }
-}
-
-// region --- Main Map Content ---
-
-@Suppress("LongParameterList")
-@OptIn(MapsComposeExperimentalApi::class)
-@Composable
-private fun MainMapContent(
- nodeClusterItems: List,
- mapFilterState: MapFilterState,
- navigateToNodeDetails: (Int) -> Unit,
- displayableWaypoints: List,
- myNodeNum: Int?,
- isConnected: Boolean,
- onEditWaypointRequest: (Waypoint) -> Unit,
- selectedWaypointId: Int?,
- mapLayers: List,
- mapViewModel: MapViewModel,
- cameraPositionState: CameraPositionState,
- coroutineScope: CoroutineScope,
- onShowClusterItemsDialog: (List?) -> Unit,
-) {
- NodeClusterMarkers(
- nodeClusterItems = nodeClusterItems,
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- onClusterClick = { cluster ->
- val items = cluster.items.toList()
- val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
- if (allSameLocation) {
- onShowClusterItemsDialog(items)
- } else {
- val bounds = LatLngBounds.builder()
- cluster.items.forEach { bounds.include(it.position) }
- coroutineScope.launch {
- cameraPositionState.animate(
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(bounds.build().center)
- .zoom(cameraPositionState.position.zoom + 1)
- .build(),
- ),
- )
- }
- Logger.d { "Cluster clicked! $cluster" }
- }
- true
- },
- )
-
- WaypointMarkers(
- displayableWaypoints = displayableWaypoints,
- mapFilterState = mapFilterState,
- myNodeNum = myNodeNum ?: 0,
- isConnected = isConnected,
- onEditWaypointRequest = onEditWaypointRequest,
- selectedWaypointId = selectedWaypointId,
- )
-
- mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
-}
-
-// endregion
-
-// region --- Node Track Overlay ---
-
-/**
- * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
- * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
- * [TripOrigin] dot with an info-window on tap.
- *
- * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
- * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
- */
-@OptIn(MapsComposeExperimentalApi::class)
-@Composable
-@Suppress("LongMethod")
-private fun NodeTrackOverlay(
- focusedNode: Node,
- sortedPositions: List,
- displayUnits: DisplayUnits,
- myNodeNum: Int?,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
- val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
- val activeNodeZIndex = if (isHighPriority) 5f else 4f
- val selectedColor = MaterialTheme.colorScheme.primary
-
- sortedPositions.forEachIndexed { index, position ->
- key(position.time) {
- val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
- val alpha =
- if (sortedPositions.size > 1) {
- index.toFloat() / (sortedPositions.size.toFloat() - 1)
- } else {
- 1f
- }
- val isSelected = position.time == selectedPositionTime
- val color =
- if (isSelected) {
- selectedColor
- } else {
- Color(focusedNode.colors.second).copy(alpha = alpha)
- }
-
- if (index == sortedPositions.lastIndex) {
- MarkerComposable(
- state = markerState,
- zIndex = activeNodeZIndex,
- alpha = if (isHighPriority) 1.0f else 0.9f,
- onClick = {
- onPositionSelected?.invoke(position.time)
- false // Allow default info window behavior
- },
- ) {
- NodeChip(node = focusedNode)
- }
- } else {
- MarkerInfoWindowComposable(
- state = markerState,
- title = stringResource(Res.string.position),
- snippet = formatAgo(position.time),
- zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
- onClick = {
- onPositionSelected?.invoke(position.time)
- false // Allow default info window behavior
- },
- infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
- ) {
- Icon(
- imageVector = MeshtasticIcons.TripOrigin,
- contentDescription = stringResource(Res.string.track_point),
- tint = color,
- modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
- )
- }
- }
- }
- }
-
- // Gradient polyline segments
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = index.toFloat() / (segments.size.toFloat() - 1)
- Polyline(
- points = segmentPoints.map { it.toLatLng() },
- jointType = JointType.ROUND,
- color = Color(focusedNode.colors.second).copy(alpha = alpha),
- width = 8f,
- zIndex = 0.6f,
- )
- }
- }
-}
-
-@Composable
-@Suppress("LongMethod")
-private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
- @Composable
- fun PositionRow(label: String, value: String) {
- Row(modifier = Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
- Text(label, style = MaterialTheme.typography.labelMedium)
- Spacer(modifier = Modifier.width(16.dp))
- Text(value, style = MaterialTheme.typography.labelMedium)
- }
- }
-
- Card {
- Column(modifier = Modifier.padding(8.dp)) {
- PositionRow(
- label = stringResource(Res.string.latitude),
- value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
- )
- PositionRow(
- label = stringResource(Res.string.longitude),
- value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
- )
- PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString())
- PositionRow(
- label = stringResource(Res.string.alt),
- value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
- )
- PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
- PositionRow(
- label = stringResource(Res.string.heading),
- value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
- )
- PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
- }
- }
-}
-
-@Composable
-private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
- val speedInMps = position.ground_speed ?: 0
- val mpsText = "%d m/s".format(speedInMps)
- return if (speedInMps > 10) {
- when (displayUnits) {
- DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
- DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
- else -> mpsText
- }
- } else {
- mpsText
- }
-}
-
-// endregion
-
-// region --- Traceroute Map Content ---
-
-@OptIn(MapsComposeExperimentalApi::class)
-@Composable
-private fun TracerouteMapContent(
- forwardOffsetPoints: List,
- returnOffsetPoints: List,
- forwardPointCount: Int,
- returnPointCount: Int,
- displayNodes: List,
-) {
- if (forwardPointCount >= 2) {
- Polyline(
- points = forwardOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.OutgoingRoute,
- width = 9f,
- zIndex = 3.0f,
- )
- }
- if (returnPointCount >= 2) {
- Polyline(
- points = returnOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.ReturnRoute,
- width = 7f,
- zIndex = 2.5f,
- )
- }
- displayNodes.forEach { node ->
- val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
- MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
- }
-}
-
-private fun offsetPolyline(
- points: List,
- offsetMeters: Double,
- headingReferencePoints: List = points,
- sideMultiplier: Double = 1.0,
-): List {
- 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 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
- headingPoints.lastIndex ->
- SphericalUtil.computeHeading(
- headingPoints[headingPoints.lastIndex - 1],
- headingPoints[headingPoints.lastIndex],
- )
-
- else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
- }
- }
-
- return points.mapIndexed { index, point ->
- val heading = headings[index.coerceIn(0, headings.lastIndex)]
- val perpendicularHeading = heading + (90.0 * sideMultiplier)
- SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
- }
-}
-
-// endregion
-
-// region --- Map Layers ---
-
-@Composable
-private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
- val context = LocalContext.current
- var currentLayer by remember { mutableStateOf(null) }
-
- MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
- currentLayer?.safeRemoveLayerFromMap()
- currentLayer = null
- val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
- val layer =
- try {
- when (layerItem.layerType) {
- LayerType.KML -> KmlLayer(map, inputStream, context)
- LayerType.GEOJSON ->
- GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
- }
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
- null
- }
- layer?.let {
- if (layerItem.isVisible) it.safeAddLayerToMap()
- currentLayer = it
- }
- }
-
- DisposableEffect(layerItem.id) {
- onDispose {
- currentLayer?.safeRemoveLayerFromMap()
- currentLayer = null
- }
- }
-
- LaunchedEffect(layerItem.isVisible) {
- val layer = currentLayer ?: return@LaunchedEffect
- if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap()
- }
-}
-
-private fun Layer.safeRemoveLayerFromMap() {
- try {
- removeLayerFromMap()
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error removing map layer" }
- }
-}
-
-private fun Layer.safeAddLayerToMap() {
- try {
- if (!isLayerOnMap) addLayerToMap()
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error adding map layer" }
- }
-}
-
-// endregion
-
-// region --- Utilities ---
-
-internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
- String(Character.toChars(unicodeCodePoint))
-} catch (e: IllegalArgumentException) {
- Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
- "\uD83D\uDCCD"
-}
-
-@Suppress("NestedBlockDepth")
-fun Uri.getFileName(context: android.content.Context): String {
- var name = this.lastPathSegment ?: "layer_$nowMillis"
- 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
-}
-
-/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
-internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
-
-// endregion
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
deleted file mode 100644
index 70ff4858d..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ /dev/null
@@ -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 .
- */
-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("waypointId"))
- val selectedWaypointId: StateFlow = _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 = uiPrefs.theme
-
- private val _errorFlow = MutableSharedFlow()
- val errorFlow: SharedFlow = _errorFlow.asSharedFlow()
-
- val customTileProviderConfigs: StateFlow> =
- customTileProviderRepository.getCustomTileProviders().stateInWhileSubscribed(initialValue = emptyList())
-
- private val _selectedCustomTileProviderUrl = MutableStateFlow(null)
- val selectedCustomTileProviderUrl: StateFlow = _selectedCustomTileProviderUrl.asStateFlow()
-
- private val _selectedGoogleMapType = MutableStateFlow(MapType.NORMAL)
- val selectedGoogleMapType: StateFlow = _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>(emptyList())
- val mapLayers: StateFlow> = _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,
-)
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
deleted file mode 100644
index 5c5e325ac..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/ClusterItemsListDialog.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
deleted file mode 100644
index fd9272579..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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,
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
deleted file mode 100644
index 8082e40d1..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
+++ /dev/null
@@ -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 .
- */
-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(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(null) }
- var urlError by remember { mutableStateOf(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,
- 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
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
deleted file mode 100644
index 18eb0ac83..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ /dev/null
@@ -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 .
- */
-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))
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
deleted file mode 100644
index d8e29120e..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
+++ /dev/null
@@ -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 .
- */
-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,
- )
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
deleted file mode 100644
index ad4bd58bb..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
+++ /dev/null
@@ -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 .
- */
-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()
- },
- )
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
deleted file mode 100644
index 32e250475..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
+++ /dev/null
@@ -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 .
- */
-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,
- mapFilterState: BaseMapViewModel.MapFilterState,
- navigateToNodeDetails: (Int) -> Unit,
- onClusterClick: (Cluster) -> 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())
- },
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
deleted file mode 100644
index 5403b8c11..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/PulsingNodeChip.kt
+++ /dev/null
@@ -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 .
- */
-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)
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
deleted file mode 100644
index 61cdab9f1..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ /dev/null
@@ -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 .
- */
-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,
- 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
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
deleted file mode 100644
index a28b3b6c1..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileProviderConfig.kt
+++ /dev/null
@@ -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 .
- */
-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
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
deleted file mode 100644
index 4adb7d97d..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/model/CustomTileSource.kt
+++ /dev/null
@@ -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 .
- */
-package org.meshtastic.app.map.model
-
-class CustomTileSource {
-
- companion object {
- fun getTileSource(index: Int) {
- index
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
deleted file mode 100644
index 943d2c826..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt
+++ /dev/null
@@ -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 .
- */
-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]
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
deleted file mode 100644
index fa17fedbf..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ /dev/null
@@ -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 .
- */
-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),
- )
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
deleted file mode 100644
index 2f7244b97..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
+++ /dev/null
@@ -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 .
- */
-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,
- modifier: Modifier = Modifier,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
- val vm = koinViewModel()
- vm.setDestNum(destNum)
- val focusedNode by vm.node.collectAsStateWithLifecycle()
- MapView(
- modifier = modifier,
- mode =
- GoogleMapMode.NodeTrack(
- focusedNode = focusedNode,
- positions = positions,
- selectedPositionTime = selectedPositionTime,
- onPositionSelected = onPositionSelected,
- ),
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
deleted file mode 100644
index e33fb1f8c..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ /dev/null
@@ -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 .
- */
-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 = PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
deleted file mode 100644
index 6cf6091b1..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/map/GoogleMapsPrefs.kt
+++ /dev/null
@@ -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 .
- */
-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
-
- fun setSelectedGoogleMapType(value: String?)
-
- val selectedCustomTileUrl: StateFlow
-
- fun setSelectedCustomTileUrl(value: String?)
-
- val hiddenLayerUrls: StateFlow>
-
- fun setHiddenLayerUrls(value: Set)
-
- val cameraTargetLat: StateFlow
-
- fun setCameraTargetLat(value: Double)
-
- val cameraTargetLng: StateFlow
-
- fun setCameraTargetLng(value: Double)
-
- val cameraZoom: StateFlow
-
- fun setCameraZoom(value: Float)
-
- val cameraTilt: StateFlow
-
- fun setCameraTilt(value: Float)
-
- val cameraBearing: StateFlow
-
- fun setCameraBearing(value: Float)
-
- val networkMapLayers: StateFlow>
-
- fun setNetworkMapLayers(value: Set)
-}
-
-@Single
-class GoogleMapsPrefsImpl(
- @Named("GoogleMapsDataStore") private val dataStore: DataStore,
- dispatchers: CoroutineDispatchers,
-) : GoogleMapsPrefs {
- private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
-
- override val selectedGoogleMapType: StateFlow =
- 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 =
- 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> =
- dataStore.data
- .map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
- .stateIn(scope, SharingStarted.Eagerly, emptySet())
-
- override fun setHiddenLayerUrls(value: Set) {
- scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
- }
-
- override val cameraTargetLat: StateFlow =
- 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 =
- 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 =
- 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 =
- 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 =
- 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> =
- dataStore.data
- .map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
- .stateIn(scope, SharingStarted.Eagerly, emptySet())
-
- override fun setNetworkMapLayers(value: Set) {
- 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")
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt b/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
deleted file mode 100644
index 6840cb17d..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/repository/CustomTileProviderRepository.kt
+++ /dev/null
@@ -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 .
- */
-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>
-
- 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>(emptyList())
-
- init {
- loadDataFromPrefs()
- }
-
- override fun getCustomTileProviders(): Flow> =
- 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>(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error deserializing tile providers" }
- customTileProvidersStateFlow.value = emptyList()
- }
- } else {
- customTileProvidersStateFlow.value = emptyList()
- }
- }
-
- private suspend fun saveDataToPrefs(providers: List) {
- withContext(dispatchers.io) {
- try {
- val jsonString = json.encodeToString(providers)
- mapTileProviderPrefs.setCustomTileProviders(jsonString)
- } catch (e: SerializationException) {
- Logger.e(e) { "Error serializing tile providers" }
- }
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
deleted file mode 100644
index d725537c8..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
+++ /dev/null
@@ -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 .
- */
-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,
- onMappableCountChanged: (shown: Int, total: Int) -> Unit,
- modifier: Modifier = Modifier,
-) {
- MapView(
- modifier = modifier,
- mode =
- GoogleMapMode.Traceroute(
- overlay = tracerouteOverlay,
- nodePositions = tracerouteNodePositions,
- onMappableCountChanged = onMappableCountChanged,
- ),
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
deleted file mode 100644
index c86e7a78c..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ /dev/null
@@ -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 .
- */
-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) }
- }
- }
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt b/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
deleted file mode 100644
index 992edf588..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/node/metrics/TracerouteMapOverlayInsets.kt
+++ /dev/null
@@ -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 .
- */
-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,
-)
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 03549c0b3..c27db822b 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -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()
- vm.setDestNum(destNum)
- org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
- },
- LocalTracerouteMapScreenProvider provides
- { destNum, requestId, logUuid, onNavigateUp ->
- val metricsViewModel = koinViewModel { parametersOf(destNum) }
- metricsViewModel.setNodeId(destNum)
-
- TracerouteMapScreen(
- metricsViewModel = metricsViewModel,
- requestId = requestId,
- logUuid = logUuid,
- onNavigateUp = onNavigateUp,
- )
- },
- LocalMapMainScreenProvider provides
- { onClickNodeChip, navigateToNodeDetails, waypointId ->
- val viewModel = koinViewModel()
- MapScreen(
- viewModel = viewModel,
- onClickNodeChip = onClickNodeChip,
- navigateToNodeDetails = navigateToNodeDetails,
- waypointId = waypointId,
- )
- },
content = content,
)
}
diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
index 7b140cca8..64228d590 100644
--- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
@@ -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
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt
new file mode 100644
index 000000000..b7f27080b
--- /dev/null
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapCameraPrefsImpl.kt
@@ -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 .
+ */
+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,
+ dispatchers: CoroutineDispatchers,
+) : MapCameraPrefs {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
+
+ override val cameraLat: StateFlow =
+ 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 =
+ 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 =
+ 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 =
+ 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 =
+ 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 =
+ 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> =
+ dataStore.data
+ .map { it[KEY_HIDDEN_LAYER_URLS] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS] = value } }
+ }
+
+ override val networkMapLayers: StateFlow> =
+ dataStore.data
+ .map { it[KEY_NETWORK_MAP_LAYERS] ?: emptySet() }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ 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")
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index f5203e3c1..d12b6894f 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -167,6 +167,41 @@ interface MapPrefs {
fun setLastHeardTrackFilter(seconds: Long)
}
+/** Reactive interface for map camera position persistence. */
+interface MapCameraPrefs {
+ val cameraLat: StateFlow
+
+ fun setCameraLat(value: Double)
+
+ val cameraLng: StateFlow
+
+ fun setCameraLng(value: Double)
+
+ val cameraZoom: StateFlow
+
+ fun setCameraZoom(value: Float)
+
+ val cameraTilt: StateFlow
+
+ fun setCameraTilt(value: Float)
+
+ val cameraBearing: StateFlow
+
+ fun setCameraBearing(value: Float)
+
+ val selectedStyleUri: StateFlow
+
+ fun setSelectedStyleUri(value: String)
+
+ val hiddenLayerUrls: StateFlow>
+
+ fun setHiddenLayerUrls(value: Set)
+
+ val networkMapLayers: StateFlow>
+
+ fun setNetworkMapLayers(value: Set)
+}
+
/** Reactive interface for map consent. */
interface MapConsentPrefs {
fun shouldReportLocation(nodeNum: Int?): StateFlow
@@ -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
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 5d7eba25a..682fe8874 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1149,6 +1149,11 @@
Configuration
Wirelessly manage your device settings and channels.
Map style selection
+ OpenStreetMap
+ Satellite
+ Terrain
+ Hybrid
+ Dark
Battery: %1$d%
Nodes: %1$d online / %2$d total
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 2b9f9918f..1a2c1b811 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -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())
+
+ override fun setHiddenLayerUrls(value: Set) {
+ hiddenLayerUrls.value = value
+ }
+
+ override val networkMapLayers = MutableStateFlow(emptySet())
+
+ override fun setNetworkMapLayers(value: Set) {
+ networkMapLayers.value = value
+ }
+}
+
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf>()
@@ -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()
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt
deleted file mode 100644
index e2a3206d1..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt
+++ /dev/null
@@ -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 .
- */
-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> { { _, _ -> } }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
deleted file mode 100644
index 70ed07a2b..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
+++ /dev/null
@@ -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 .
- */
-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") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
deleted file mode 100644
index 7e54003a5..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeMapScreenProvider.kt
+++ /dev/null
@@ -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 .
- */
-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)") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
deleted file mode 100644
index d0901f0f9..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt
+++ /dev/null
@@ -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 .
- */
-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,
- modifier: Modifier,
- selectedPositionTime: Int?,
- onPositionSelected: ((Int) -> Unit)?,
- ) -> Unit,
- > {
- { _, _, _, _, _ -> PlaceholderScreen("Position Track Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt
deleted file mode 100644
index 40b174e8d..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapOverlayInsetsProvider.kt
+++ /dev/null
@@ -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 .
- */
-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() }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
deleted file mode 100644
index 139992c54..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt
+++ /dev/null
@@ -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 .
- */
-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,
- onMappableCountChanged: (Int, Int) -> Unit,
- modifier: Modifier,
- ) -> Unit,
- > {
- { _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
deleted file mode 100644
index 26eb02b7e..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapScreenProvider.kt
+++ /dev/null
@@ -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 .
- */
-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") }
- }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
deleted file mode 100644
index 10d975f3d..000000000
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt
+++ /dev/null
@@ -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 .
- */
-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 { null }
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index db52c350a..943f990a5 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -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)
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
deleted file mode 100644
index 588ca198b..000000000
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
+++ /dev/null
@@ -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 .
- */
-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,
- )
- }
-}
diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
deleted file mode 100644
index 0490e9410..000000000
--- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
+++ /dev/null
@@ -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 .
- */
-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()
- }
-}
diff --git a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
deleted file mode 100644
index 7026e1fb6..000000000
--- a/feature/map/src/androidUnitTestGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
+++ /dev/null
@@ -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 .
- */
-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(MockMode.autofill)
- private val mapPrefs = mock(MockMode.autofill)
- private val googleMapsPrefs = mock(MockMode.autofill)
- private val nodeRepository = FakeNodeRepository()
- private val packetRepository = mock(MockMode.autofill)
- private val radioConfigRepository = mock(MockMode.autofill)
- private val radioController = FakeRadioController()
- private val customTileProviderRepository = mock(MockMode.autofill)
- private val uiPrefs = mock(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)
- }
-}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
new file mode 100644
index 000000000..92291d3f3
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
@@ -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 .
+ */
+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 = {},
+ )
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt
new file mode 100644
index 000000000..f05502127
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -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 .
+ */
+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(savedStateHandle.get("waypointId"))
+ val selectedWaypointId: StateFlow = 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 =
+ 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 =
+ 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
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt
deleted file mode 100644
index bcebdabf6..000000000
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt
+++ /dev/null
@@ -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 .
- */
-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)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt
new file mode 100644
index 000000000..7e9cfa2cd
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt
@@ -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 .
+ */
+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)),
+ )
+ }
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
new file mode 100644
index 000000000..19e65518b
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
@@ -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 .
+ */
+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,
+ waypoints: Map,
+ 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,
+ 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) {
+ 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),
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt
new file mode 100644
index 000000000..94341d419
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackLayers.kt
@@ -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 .
+ */
+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,
+ 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),
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt
new file mode 100644
index 000000000..9c7e1220d
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackMap.kt
@@ -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 .
+ */
+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,
+ 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,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt
new file mode 100644
index 000000000..d87c21602
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt
@@ -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 .
+ */
+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,
+ nodes: Map,
+ 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,
+ val returnLine: FeatureCollection,
+ val hopFeatures: FeatureCollection,
+)
+
+private fun buildTracerouteGeoJson(
+ overlay: TracerouteOverlay,
+ nodePositions: Map,
+ nodes: Map,
+): 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
+ } else {
+ @Suppress("UNCHECKED_CAST")
+ FeatureCollection(emptyList>()) as FeatureCollection
+ }
+
+ // 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
+ } else {
+ @Suppress("UNCHECKED_CAST")
+ FeatureCollection(emptyList>()) as FeatureCollection
+ }
+
+ // 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,
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt
new file mode 100644
index 000000000..e198397c4
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteMap.kt
@@ -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 .
+ */
+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,
+ 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,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt
new file mode 100644
index 000000000..21f063433
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt
@@ -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 .
+ */
+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)
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index 2c0b5e7b8..ee786764c 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -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.mapGraph(backStack: NavBackStack) {
entry { 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()
+ MapScreen(
+ viewModel = viewModel,
+ onClickNodeChip = { backStack.add(NodesRoute.NodeDetail(it)) },
+ navigateToNodeDetails = { backStack.add(NodesRoute.NodeDetail(it)) },
+ waypointId = args.waypointId,
)
}
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
similarity index 59%
rename from app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
index b7795180f..6bae9a23c 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt
@@ -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 .
*/
-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))
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
new file mode 100644
index 000000000..a28c76627
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
@@ -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 .
+ */
+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, myNodeNum: Int? = null): FeatureCollection {
+ 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
+}
+
+/** Convert waypoints to a GeoJSON [FeatureCollection]. */
+fun waypointsToFeatureCollection(waypoints: Map): FeatureCollection {
+ 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
+}
+
+/** Convert position history to a GeoJSON [LineString] for track rendering. */
+fun positionsToLineString(positions: List): FeatureCollection {
+ 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
+}
+
+/** Convert position history to individual point features with time metadata. */
+fun positionsToPointFeatures(positions: List): FeatureCollection {
+ 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
+}
+
+/** 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
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
new file mode 100644
index 000000000..7981ab1df
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -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 .
+ */
+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()
+ }
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 0ab017f7b..951510174 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -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(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index e414ea26d..d97574a6c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -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) {
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
similarity index 91%
rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
rename to feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index 8a4c0d7d5..e3370a792 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -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)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index facb5a9d7..68e09bec5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -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.nodeDetailGraph(
}
entry(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 { parametersOf(args.destNum) }
+ metricsViewModel.setNodeId(args.destNum)
+ TracerouteMapScreen(
+ metricsViewModel = metricsViewModel,
+ requestId = args.requestId,
+ logUuid = args.logUuid,
+ onNavigateUp = { backStack.removeLastOrNull() },
+ )
}
NodeDetailScreen.entries.forEach { routeInfo ->
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 52d30d1ea..39a45757e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }