feat(map): Site Planner coverage integration — import + estimate (#6136)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-07 18:24:14 -05:00
committed by GitHub
parent e12c4a579d
commit 17501048b4
18 changed files with 1267 additions and 10 deletions

View File

@@ -1450,6 +1450,32 @@ shutdown_on_power_loss
shutdown_warning
signal
signal_quality
### SITE ###
site_planner
site_planner_antenna_gain_dbi
site_planner_antenna_height_meters
site_planner_color_scale
site_planner_estimate
site_planner_estimating
site_planner_failed
site_planner_frequency_mhz
site_planner_high_resolution
site_planner_invalid_latitude
site_planner_invalid_longitude
site_planner_invalid_positive
site_planner_invalid_rx_sensitivity
site_planner_max_range_km
site_planner_rx_height_meters
site_planner_rx_sensitivity_dbm
site_planner_section_display
site_planner_section_receiver
site_planner_section_simulation
site_planner_section_transmitter
site_planner_subtitle
site_planner_tx_power_watts
site_planner_use_current_location
site_planner_use_map_center
site_planner_use_node_location
skip
slot
smart_position

View File

@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = FdroidMapViewProvider()
/** Site Planner (coverage-estimate) is Google-flavor only; the F-Droid map has no overlay-layer support. */
fun sitePlannerAvailable(): Boolean = false

View File

@@ -29,6 +29,35 @@
android:name="android.app.appfunctions.app_metadata"
android:resource="@xml/app_metadata" />
<!--
Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
Planner's "Send to App" share). Google flavor only — it's the flavor that renders map overlays. Merged into
the MainActivity declared in the base manifest. application/json is broad, but the map importer validates the
file extension and ignores anything that isn't a supported layer.
-->
<activity
android:name="org.meshtastic.app.MainActivity"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/geo+json" />
<data android:mimeType="application/vnd.geo+json" />
<data android:mimeType="application/json" />
<data android:mimeType="application/vnd.google-earth.kml+xml" />
<data android:mimeType="application/vnd.google-earth.kmz" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/geo+json" />
<data android:mimeType="application/vnd.geo+json" />
<data android:mimeType="application/json" />
<data android:mimeType="application/vnd.google-earth.kml+xml" />
<data android:mimeType="application/vnd.google-earth.kmz" />
</intent-filter>
</activity>
<!--
AppFunctions 1.0.0-alpha10 discontinued the appfunctions-service artifact whose
manifest used to contribute these two delegate services; apps must now declare

View File

@@ -19,3 +19,6 @@ package org.meshtastic.app.map
import org.meshtastic.core.ui.util.MapViewProvider
fun getMapViewProvider(): MapViewProvider = GoogleMapViewProvider()
/** Site Planner (coverage-estimate) availability — Google flavor only (the flavor that renders map overlays). */
fun sitePlannerAvailable(): Boolean = true

View File

@@ -18,8 +18,10 @@
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.location.Location
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -61,6 +63,7 @@ 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.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -97,6 +100,7 @@ import com.google.maps.android.data.geojson.GeoJsonPolygonStyle
import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
import org.koin.compose.viewmodel.koinViewModel
@@ -119,6 +123,7 @@ 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.primaryChannel
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
@@ -150,14 +155,19 @@ import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.component.EditWaypointDialog
import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.BoundingBox
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import kotlin.coroutines.resume
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.roundToInt
import android.graphics.Color as AndroidColor
@@ -250,6 +260,7 @@ fun MapView(
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val channelSet by mapViewModel.channelSet.collectAsStateWithLifecycle()
var editingWaypoint by remember { mutableStateOf<Waypoint?>(null) }
var geofenceInfoWaypoint by remember { mutableStateOf<Waypoint?>(null) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
@@ -509,6 +520,8 @@ fun MapView(
// --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
// Non-null while the Site Planner estimate dialog/runner is open, holding the initial (prefilled) params.
var sitePlannerInitial by remember { mutableStateOf<SitePlannerParams?>(null) }
val onAddLayerClicked = {
val intent =
@@ -839,6 +852,13 @@ fun MapView(
onClick = { showLayersBottomSheet = true },
)
},
// Google flavor only: hands params to the hosted Site Planner and imports the returned coverage.
onSitePlannerClick =
if (sitePlannerAvailable()) {
{ sitePlannerInitial = ourNodeInfo.toSitePlannerParams(channelSet) }
} else {
null
},
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
when {
@@ -896,6 +916,40 @@ fun MapView(
)
}
}
// Site Planner deep link from a node's detail screen — open the estimate dialog prefilled with that node.
val sitePlannerRequest by mapViewModel.sitePlannerRequest.collectAsStateWithLifecycle()
LaunchedEffect(sitePlannerRequest) {
sitePlannerRequest?.let { node ->
sitePlannerInitial = node.toSitePlannerParams(channelSet)
mapViewModel.consumeSitePlannerRequest()
}
}
sitePlannerInitial?.let { initial ->
// Phone GPS: only when permission is already granted; otherwise the field stays manual.
val onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? =
if (locationPermission.isGranted) {
{ fusedLocationClient.awaitLastLocation()?.let { it.latitude to it.longitude } }
} else {
null
}
// Our connected node's reported position: only when it has a valid fix.
val onUseNodeLocation: (() -> Pair<Double, Double>)? =
ourNodeInfo?.takeIf { it.validPosition != null }?.let { node -> { node.latitude to node.longitude } }
SitePlannerHost(
initialParams = initial,
onDismiss = { sitePlannerInitial = null },
onImport = { name, geoJson, latitude, longitude ->
mapViewModel.addGeoJsonLayer(name, geoJson)
// Recenter on the estimate's transmitter so the freshly-imported coverage is on-screen.
coroutineScope.launch {
cameraPositionState.animate(CameraUpdateFactory.newLatLng(LatLng(latitude, longitude)))
}
},
onRequestCurrentLocation = onRequestCurrentLocation,
onUseNodeLocation = onUseNodeLocation,
onUseMapCenter = { cameraPositionState.position.target.let { it.latitude to it.longitude } },
)
}
showClusterItemsDialog?.let {
ClusterItemsListDialog(
items = it,
@@ -1395,6 +1449,60 @@ fun Uri.getFileName(context: android.content.Context): String {
/** 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)
/**
* Seed Site Planner params from a node (name + position) and, when a radio is connected, from its actual config:
* transmit frequency (from the primary channel), transmit power (dBm→W), and a receiver sensitivity derived from the
* modem preset. Antenna gain/height aren't in the device config, so they keep the planner's stock defaults.
*/
private fun Node?.toSitePlannerParams(channelSet: ChannelSet?): SitePlannerParams {
val lora = channelSet?.lora_config
val freqMhz = channelSet?.primaryChannel?.radioFreq?.toDouble()?.takeIf { it > 0.0 }
return SitePlannerParams(
name = this?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Coverage",
latitude = this?.latitude ?: 0.0,
longitude = this?.longitude ?: 0.0,
txFreqMhz = freqMhz ?: SitePlannerParams.DEFAULT_TX_FREQ_MHZ,
txPowerWatts = dbmToWatts(lora?.tx_power ?: 0) ?: SitePlannerParams.DEFAULT_TX_POWER_WATTS,
rxSensitivityDbm = sensitivityDbmFor(lora?.modem_preset),
)
}
/**
* Meshtastic tx_power is dBm; the planner wants watts. 0 (or less) means "use region max" in firmware — unknown here.
*/
@Suppress("MagicNumber")
private fun dbmToWatts(dbm: Int): Double? = if (dbm <= 0) null else 10.0.pow((dbm - 30).toDouble() / 10.0)
/**
* Approximate receiver sensitivity (dBm) per Meshtastic modem preset, from the Site Planner's own parameters.md table.
* Unmapped presets fall back to the planner's default so the estimate still runs.
*/
@Suppress("MagicNumber")
private fun sensitivityDbmFor(preset: ModemPreset?): Double = when (preset) {
ModemPreset.SHORT_TURBO -> -126.0
ModemPreset.SHORT_FAST -> -129.0
ModemPreset.SHORT_SLOW -> -131.5
ModemPreset.MEDIUM_FAST -> -134.0
ModemPreset.MEDIUM_SLOW -> -136.5
ModemPreset.LONG_FAST -> -139.0
ModemPreset.LONG_MODERATE -> -142.0
ModemPreset.LONG_SLOW -> -144.5
ModemPreset.VERY_LONG_SLOW -> -147.5
else -> SitePlannerParams.DEFAULT_RX_SENSITIVITY_DBM
}
/** One-shot last known location as a suspend call. Guarded by a permission check at the call site. */
@SuppressLint("MissingPermission")
private suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? = suspendCancellableCoroutine { cont ->
// lastLocation can throw SecurityException synchronously if permission is revoked between the compose-time
// isGranted check and this call; treat that as "no location" rather than crashing the estimate flow.
try {
lastLocation.addOnSuccessListener { cont.resume(it) }.addOnFailureListener { cont.resume(null) }
} catch (_: SecurityException) {
cont.resume(null)
}
}
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),

View File

@@ -39,16 +39,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
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.MapFileImportBus
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.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -106,6 +109,17 @@ class MapViewModel(
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
// Site Planner deep link from node detail: MapRoute.Map(sitePlannerNodeNum) → resolve to the node so the map can
// open the estimate dialog pre-filled with its position. Cleared once consumed so it doesn't re-open.
private val pendingSitePlannerNodeNum = MutableStateFlow(savedStateHandle.get<Int>("sitePlannerNodeNum"))
val sitePlannerRequest: StateFlow<Node?> =
combine(pendingSitePlannerNodeNum, nodeRepository.nodeDBbyNum) { num, db -> num?.let { db[it] } }
.stateInWhileSubscribed(initialValue = null)
fun consumeSitePlannerRequest() {
pendingSitePlannerNodeNum.value = null
}
fun setWaypointId(id: Int?) {
if (_selectedWaypointId.value != id) {
_selectedWaypointId.value = id
@@ -362,6 +376,15 @@ class MapViewModel(
}
loadPersistedLayers()
// Import a map file handed to us via an "Open in / Send to Meshtastic" intent (see MapFileImportBus).
viewModelScope.launch {
MapFileImportBus.pending.collect { uri ->
uri ?: return@collect
MapFileImportBus.pending.value = null
addMapLayer(uri, uri.getFileName(application))
}
}
selectedWaypointId.value?.let { wpId ->
viewModelScope.launch {
val wpMap = waypoints.first { it.containsKey(wpId) }
@@ -581,6 +604,37 @@ class MapViewModel(
}
}
/**
* Import a GeoJSON string (e.g. handed back by the Site Planner headless bridge) as a visible local overlay,
* reusing the same internal-storage-backed layer plumbing as file imports.
*/
fun addGeoJsonLayer(name: String, geoJson: String) {
viewModelScope.launch {
val displayName = name.ifBlank { "Coverage" }
val safeFileName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_")
val uri = writeStringToInternalStorage(geoJson, "${safeFileName}_${Uuid.random()}.geojson")
if (uri != null) {
_mapLayers.value =
_mapLayers.value + MapLayerItem(name = displayName, uri = uri, layerType = LayerType.GEOJSON)
} else {
Logger.withTag("MapViewModel").e("Failed to write GeoJSON layer to internal storage.")
}
}
}
private suspend fun writeStringToInternalStorage(content: String, fileName: String): Uri? =
withContext(dispatchers.io) {
try {
val directory = File(application.filesDir, "map_layers").apply { if (!exists()) mkdirs() }
val outputFile = File(directory, fileName)
outputFile.writeText(content)
Uri.fromFile(outputFile)
} catch (e: IOException) {
Logger.withTag("MapViewModel").e(e) { "Error writing GeoJSON to internal storage" }
null
}
}
fun toggleLayerVisibility(layerId: String) {
var toggledLayer: MapLayerItem? = null
val updatedLayers =

View File

@@ -0,0 +1,231 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import co.touchlab.kermit.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.site_planner_estimating
import org.meshtastic.core.resources.site_planner_failed
import org.meshtastic.feature.map.component.SitePlannerParams
import org.meshtastic.feature.map.component.SitePlannerSheet
// The official hosted Site Planner (static PWA on GitHub Pages). The estimate flow loads it headless with
// run=1&bridge=1; the native bridge + full flat query contract it relies on shipped in site-planner #74.
// ponytail: make it a setting if a self-hosted planner is ever needed.
private const val SITE_PLANNER_BASE_URL = "https://site.meshtastic.org"
private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
/**
* Site Planner coverage-estimate flow: an editable [SitePlannerSheet] pre-filled with [initialParams], then a hidden
* headless WebView that loads the planner with `run=1&bridge=1`, waits for it to hand back the styled GeoJSON, and
* imports it via [onImport]. Location shortcuts re-seed the coordinates from the device GPS
* ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center
* ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SitePlannerHost(
initialParams: SitePlannerParams,
onDismiss: () -> Unit,
onImport: (name: String, geoJson: String, latitude: Double, longitude: Double) -> Unit,
onRequestCurrentLocation: (suspend () -> Pair<Double, Double>?)? = null,
onUseNodeLocation: (() -> Pair<Double, Double>)? = null,
onUseMapCenter: (() -> Pair<Double, Double>)? = null,
) {
var params by remember(initialParams) { mutableStateOf(initialParams) }
var running by remember { mutableStateOf<SitePlannerParams?>(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val failedText = stringResource(Res.string.site_planner_failed)
val estimatingText = stringResource(Res.string.site_planner_estimating)
val current = running
if (current == null) {
SitePlannerSheet(
initial = params,
onSubmit = { running = it },
onDismiss = onDismiss,
onUseCurrentLocation =
onRequestCurrentLocation?.let { fetch ->
{
scope.launch {
fetch()?.let { (lat, lon) -> params = params.copy(latitude = lat, longitude = lon) }
}
}
},
onUseNodeLocation =
onUseNodeLocation?.let { node ->
{
val (lat, lon) = node()
params = params.copy(latitude = lat, longitude = lon)
}
},
onUseMapCenter =
onUseMapCenter?.let { center ->
{
val (lat, lon) = center()
params = params.copy(latitude = lat, longitude = lon)
}
},
)
} else {
Dialog(onDismissRequest = { onDismiss() }) {
// The WebView fills the card and runs the sim; the opaque spinner Surface on top hides it, so the flow
// reads as "Estimating coverage…" rather than a browser. It must be attached and non-trivially sized —
// a detached or 0-size WebView can't get a WebGL context, and the planner's autorun waits on map load.
Box(modifier = Modifier.size(280.dp), contentAlignment = Alignment.Center) {
SitePlannerRunner(
url = current.toQueryUrl(SITE_PLANNER_BASE_URL),
onResult = { geoJson ->
onImport(current.name, geoJson, current.latitude, current.longitude)
onDismiss()
},
onError = { detail ->
Logger.withTag("SitePlanner").e { "Coverage estimate failed: $detail" }
Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
onDismiss()
},
// Headless: fully transparent so the WebGL first-paint frame never flashes through, but still
// attached + sized (280dp) so the sim gets a WebGL context. alpha(0) is a compositor property —
// the view stays VISIBLE, so the page's requestAnimationFrame/autorun keep running.
modifier = Modifier.matchParentSize().alpha(0f),
)
Surface(
modifier = Modifier.matchParentSize(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
CircularWavyProgressIndicator()
Text(estimatingText)
TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) }
}
}
}
}
LaunchedEffect(current) {
delay(SITE_PLANNER_TIMEOUT_MS)
Logger.withTag("SitePlanner").w { "Coverage estimate timed out after ${SITE_PLANNER_TIMEOUT_MS}ms" }
Toast.makeText(context, failedText, Toast.LENGTH_SHORT).show()
onDismiss()
}
}
}
/**
* Headless WebView that loads [url] (the planner with `run=1&bridge=1`), exposes a `window.__meshtasticNative` bridge,
* and reports the coverage GeoJSON via [onResult] once the planner posts it. Main-frame load failures go to [onError].
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun SitePlannerRunner(
url: String,
onResult: (String) -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val onResultState by rememberUpdatedState(onResult)
val onErrorState by rememberUpdatedState(onError)
AndroidView(
modifier = modifier,
factory = { context ->
val main = Handler(Looper.getMainLooper())
WebView(context).apply {
setBackgroundColor(android.graphics.Color.TRANSPARENT) // no opaque black backing before first paint
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(
object {
// Called from the planner's JS thread — hop to main before touching Compose state.
@JavascriptInterface fun onCoverage(geoJson: String) = main.post { onResultState(geoJson) }
},
"__meshtasticNative",
)
webViewClient =
object : WebViewClient() {
// Keep the __meshtasticNative bridge exclusive to the planner's own origin: block any
// navigation
// elsewhere (redirect/compromise/open-redirect) so foreign content can never reach
// onCoverage().
// Sub-resource fetches (tiles, XHR) aren't navigations, so this doesn't affect the sim itself.
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val target = request?.url ?: return false
val trusted = Uri.parse(SITE_PLANNER_BASE_URL)
return target.scheme != trusted.scheme || target.host != trusted.host
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
// Only a failed main-frame load is fatal; a stray tile/asset 404 is not.
if (request?.isForMainFrame == true) {
main.post { onErrorState("${error?.errorCode} ${error?.description}") }
}
}
}
loadUrl(url)
}
},
onRelease = WebView::destroy,
)
}

View File

@@ -54,6 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.map.sitePlannerAvailable
import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
@@ -81,6 +82,7 @@ import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
@@ -199,6 +201,7 @@ class MainActivity : AppCompatActivity() {
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalSitePlannerAvailable provides sitePlannerAvailable(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
@@ -262,9 +265,7 @@ class MainActivity : AppCompatActivity() {
val appLinkData: Uri? = intent.data
when (appLinkAction) {
Intent.ACTION_VIEW -> {
appLinkData?.let { handleMeshtasticUri(it) }
}
Intent.ACTION_VIEW -> handleViewIntent(appLinkData)
NfcAdapter.ACTION_NDEF_DISCOVERED -> {
val rawMessages =
@@ -295,12 +296,7 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_MAIN -> {}
Intent.ACTION_SEND -> {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null) {
createShareIntent(text).send()
}
}
Intent.ACTION_SEND -> handleSendIntent(intent)
else -> {
Logger.w { "Unexpected action $appLinkAction" }
@@ -308,12 +304,46 @@ class MainActivity : AppCompatActivity() {
}
}
private fun handleViewIntent(uri: Uri?) {
when {
uri == null -> {}
// A file handed to us via "Open in Meshtastic" (Files, Share Sheet, drag-and-drop) rather than a
// meshtastic:// / https deep link — import it as a map overlay.
uri.scheme == "content" || uri.scheme == "file" -> importMapFile(uri)
else -> handleMeshtasticUri(uri)
}
}
private fun handleSendIntent(intent: Intent) {
val stream = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
when {
stream != null -> importMapFile(stream)
// shared .geojson/.kml file (e.g. Site Planner)
text != null -> createShareIntent(text).send()
}
}
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
/**
* Hand a map file received via an OS "Open in / Send to Meshtastic" intent to the map, then bring the Map tab
* forward so the imported overlay is visible. Only the Google-flavor map consumes this (see [MapFileImportBus]);
* the read grant on [uri] lives as long as this activity, which is long enough for the map to copy the file in.
*/
private fun importMapFile(uri: Uri) {
Logger.d { "Importing shared map file: $uri" }
MapFileImportBus.pending.value = uri
handleMeshtasticUri("$DEEP_LINK_BASE_URI/map".toUri())
}
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent =

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app
import android.net.Uri
import kotlinx.coroutines.flow.MutableStateFlow
/**
* One-slot handoff for a map file (GeoJSON/KML) received via an "Open in / Send to Meshtastic" intent (e.g. the
* Meshtastic Site Planner's "Send to App" share). The shared [MainActivity] receives the intent; only the Google-flavor
* map renders overlays, so its `MapViewModel` drains this and imports the layer while the activity still holds the URI
* read grant.
*
* ponytail: process-global single slot — fine for one shared file at a time; promote to a Koin single if this ever
* needs a second consumer or unit testing.
*/
object MapFileImportBus {
val pending = MutableStateFlow<Uri?>(null)
}

View File

@@ -54,7 +54,7 @@ sealed interface ContactsRoute : Route {
@Serializable
sealed interface MapRoute : Route {
@Serializable data class Map(val waypointId: Int? = null) : MapRoute
@Serializable data class Map(val waypointId: Int? = null, val sitePlannerNodeNum: Int? = null) : MapRoute
}
@Serializable

View File

@@ -1492,6 +1492,32 @@
<string name="shutdown_warning">⚠️ This will SHUTDOWN the node. Physical interaction will be required to turn it back on.</string>
<string name="signal">Signal</string>
<string name="signal_quality">Signal Quality</string>
<!-- SITE -->
<string name="site_planner">Site Planner</string>
<string name="site_planner_antenna_gain_dbi">Antenna gain (dBi)</string>
<string name="site_planner_antenna_height_meters">Antenna height (m)</string>
<string name="site_planner_color_scale">Color palette</string>
<string name="site_planner_estimate">Estimate coverage</string>
<string name="site_planner_estimating">Estimating coverage…</string>
<string name="site_planner_failed">Coverage estimate failed</string>
<string name="site_planner_frequency_mhz">Frequency (MHz)</string>
<string name="site_planner_high_resolution">High-resolution terrain</string>
<string name="site_planner_invalid_latitude">Enter 90 to 90</string>
<string name="site_planner_invalid_longitude">Enter 180 to 180</string>
<string name="site_planner_invalid_positive">Must be greater than 0</string>
<string name="site_planner_invalid_rx_sensitivity">Enter 150 to 30 dBm</string>
<string name="site_planner_max_range_km">Max range (km)</string>
<string name="site_planner_rx_height_meters">Receiver height (m)</string>
<string name="site_planner_rx_sensitivity_dbm">Receiver sensitivity (dBm)</string>
<string name="site_planner_section_display">Display</string>
<string name="site_planner_section_receiver">Receiver</string>
<string name="site_planner_section_simulation">Simulation Options</string>
<string name="site_planner_section_transmitter">Site / Transmitter</string>
<string name="site_planner_subtitle">Estimate RF coverage for a transmitter</string>
<string name="site_planner_tx_power_watts">TX power (W)</string>
<string name="site_planner_use_current_location">Use current location</string>
<string name="site_planner_use_map_center">Use map center</string>
<string name="site_planner_use_node_location">Use current node location</string>
<string name="skip">Skip</string>
<string name="slot">Slot</string>
<string name="smart_position">Smart Position</string>

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.compositionLocalOf
/**
* Whether the Site Planner coverage-estimate flow is available (Google flavor only, backed by the official hosted
* planner). Gates the "Estimate coverage" action on the node detail screen; the map's own control is gated equivalently
* in the Google MapView. Defaults to false so F-Droid/release never surface a dead action.
*
* Flavor-injected feature availability, mirroring the other Local*Provider seams in this package (all baselined for
* this rule); a plain boolean local is the lightest way to gate a commonMain UI action on a flavor capability.
*/
@Suppress("CompositionLocalAllowlist")
val LocalSitePlannerAvailable = compositionLocalOf { false }

View File

@@ -48,6 +48,7 @@ import org.meshtastic.core.resources.one_hour
import org.meshtastic.core.resources.two_days
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
@@ -79,6 +80,12 @@ open class BaseMapViewModel(
val ourNodeInfo = nodeRepository.ourNodeInfo
/**
* Connected radio's channel set (primary-channel frequency + LoRa config); used to prefill a Site Planner estimate.
*/
val channelSet: StateFlow<ChannelSet?> =
radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = null)
val myNodeNum
get() = myNodeInfo.value?.myNodeNum

View File

@@ -35,7 +35,9 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.site_planner
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -72,6 +74,7 @@ fun MapControlsOverlay(
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
onSitePlannerClick: (() -> Unit)? = null,
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
@@ -102,6 +105,15 @@ fun MapControlsOverlay(
// Layers button (flavor-specific)
layersContent()
// Site Planner coverage estimate (optional; provided by the Google flavor)
onSitePlannerClick?.let { onClick ->
MapButton(
icon = MeshtasticIcons.CellTower,
contentDescription = stringResource(Res.string.site_planner),
onClick = onClick,
)
}
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {

View File

@@ -0,0 +1,126 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
/**
* Transmitter parameters for a Meshtastic Site Planner coverage estimate. Maps onto the planner's flat query contract
* (`?lat=&lon=&name=&tx_power=&tx_freq=&tx_height=&tx_gain=`), so [toQueryUrl] can hand a configured, auto-running view
* to the planner without knowing its internal parameter schema.
*/
data class SitePlannerParams(
val name: String,
val latitude: Double,
val longitude: Double,
val txPowerWatts: Double = DEFAULT_TX_POWER_WATTS,
val txFreqMhz: Double = DEFAULT_TX_FREQ_MHZ,
val txHeightMeters: Double = DEFAULT_TX_HEIGHT_METERS,
val txGainDbi: Double = DEFAULT_TX_GAIN_DBI,
val colorScale: String = DEFAULT_COLOR_SCALE,
// Advanced fields — default to the planner's own values, so sending them is behaviorally identical to omitting
// them (they just make the hand-off explicit). Surfaced behind an "Advanced" section in the form.
val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM,
val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS,
val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM,
val highResolution: Boolean = false,
val minDbm: Double = DEFAULT_MIN_DBM,
val maxDbm: Double = DEFAULT_MAX_DBM,
val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY,
) {
/**
* Build the planner URL that prefills these params and auto-runs the simulation (`run=1`), asking the planner to
* hand the result to a native host bridge (`bridge=1`) rather than the share sheet. Advanced params map onto the
* planner's flat query contract (receiver / simulation / display sections).
*/
fun toQueryUrl(baseUrl: String): String {
val query = buildString {
append("lat=").append(latitude)
append("&lon=").append(longitude)
append("&name=").append(encodeQueryComponent(name))
append("&tx_power=").append(txPowerWatts)
append("&tx_freq=").append(txFreqMhz)
append("&tx_height=").append(txHeightMeters)
append("&tx_gain=").append(txGainDbi)
append("&color_scale=").append(encodeQueryComponent(colorScale))
append("&rx_sensitivity=").append(rxSensitivityDbm)
append("&rx_height=").append(rxHeightMeters)
append("&max_range=").append(maxRangeKm)
if (highResolution) append("&high_res=1")
append("&min_dbm=").append(minDbm)
append("&max_dbm=").append(maxDbm)
append("&overlay_transparency=").append(overlayTransparency)
append("&run=1&bridge=1")
}
val separator = if (baseUrl.endsWith("/")) "" else "/"
return "$baseUrl$separator?$query"
}
companion object {
// Meshtastic-typical stock defaults; all editable in the form before submission. Values mirror the planner's
// own factory defaults (src/store.ts defaultParams()) so an untouched form matches a fresh planner session.
const val DEFAULT_TX_POWER_WATTS: Double = 0.1
const val DEFAULT_TX_FREQ_MHZ: Double = 907.0
const val DEFAULT_TX_HEIGHT_METERS: Double = 2.0
const val DEFAULT_TX_GAIN_DBI: Double = 2.0
const val DEFAULT_COLOR_SCALE: String = "plasma"
const val DEFAULT_RX_SENSITIVITY_DBM: Double = -130.0
const val DEFAULT_RX_HEIGHT_METERS: Double = 1.0
const val DEFAULT_MAX_RANGE_KM: Double = 30.0
const val DEFAULT_MIN_DBM: Double = -130.0
const val DEFAULT_MAX_DBM: Double = -80.0
const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50
// Validation ranges mirroring the planner's input constraints (src/components/*.vue).
const val MIN_FREQ_MHZ: Double = 20.0
const val MAX_FREQ_MHZ: Double = 20_000.0
const val MIN_RX_SENSITIVITY_DBM: Double = -150.0
const val MAX_RX_SENSITIVITY_DBM: Double = -30.0
const val MAX_RANGE_STANDARD_KM: Double = 150.0
const val MAX_RANGE_HIGH_RES_KM: Double = 70.0
const val MIN_TRANSPARENCY: Int = 0
const val MAX_TRANSPARENCY: Int = 100
/** Coverage palettes the planner ships (value → label), mirroring its Display.vue picker. */
val COLOR_SCALES: List<Pair<String, String>> =
listOf(
"plasma" to "Plasma",
"viridis" to "Viridis (colorblind-safe)",
"CMRmap" to "CMR map",
"cool" to "Cool",
"turbo" to "Turbo",
"jet" to "Jet",
)
}
}
private const val UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
private val HEX = "0123456789ABCDEF".toCharArray()
private const val BYTE_MASK = 0xFF
private const val ASCII_LIMIT = 0x80
private const val NIBBLE_BITS = 4
private const val NIBBLE_MASK = 0xF
/** Percent-encode a query-component value (UTF-8), so names with spaces/non-ASCII survive the round trip. */
internal fun encodeQueryComponent(value: String): String = buildString {
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and BYTE_MASK
if (code < ASCII_LIMIT && code.toChar() in UNRESERVED) {
append(code.toChar())
} else {
append('%').append(HEX[code shr NIBBLE_BITS]).append(HEX[code and NIBBLE_MASK])
}
}
}

View File

@@ -0,0 +1,438 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@file:Suppress("TooManyFunctions") // small, single-purpose section composables mirroring the planner's panels
package org.meshtastic.feature.map.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
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.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.site_planner
import org.meshtastic.core.resources.site_planner_antenna_gain_dbi
import org.meshtastic.core.resources.site_planner_antenna_height_meters
import org.meshtastic.core.resources.site_planner_color_scale
import org.meshtastic.core.resources.site_planner_estimate
import org.meshtastic.core.resources.site_planner_frequency_mhz
import org.meshtastic.core.resources.site_planner_high_resolution
import org.meshtastic.core.resources.site_planner_invalid_latitude
import org.meshtastic.core.resources.site_planner_invalid_longitude
import org.meshtastic.core.resources.site_planner_invalid_positive
import org.meshtastic.core.resources.site_planner_invalid_rx_sensitivity
import org.meshtastic.core.resources.site_planner_max_range_km
import org.meshtastic.core.resources.site_planner_rx_height_meters
import org.meshtastic.core.resources.site_planner_rx_sensitivity_dbm
import org.meshtastic.core.resources.site_planner_section_display
import org.meshtastic.core.resources.site_planner_section_receiver
import org.meshtastic.core.resources.site_planner_section_simulation
import org.meshtastic.core.resources.site_planner_section_transmitter
import org.meshtastic.core.resources.site_planner_subtitle
import org.meshtastic.core.resources.site_planner_tx_power_watts
import org.meshtastic.core.resources.site_planner_use_current_location
import org.meshtastic.core.resources.site_planner_use_map_center
import org.meshtastic.core.resources.site_planner_use_node_location
import org.meshtastic.core.ui.icon.ExpandLess
import org.meshtastic.core.ui.icon.ExpandMore
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.PinDrop
private const val LAT_LIMIT = 90.0
private const val LON_LIMIT = 180.0
private val SWATCH_WIDTH = 40.dp
private val SWATCH_HEIGHT = 16.dp
/** Editable form state; string-backed so partial/invalid input is preserved while typing. */
@Stable
private class SiteFormState(initial: SitePlannerParams) {
var name by mutableStateOf(initial.name)
var lat by mutableStateOf(initial.latitude.toString())
var lon by mutableStateOf(initial.longitude.toString())
var power by mutableStateOf(initial.txPowerWatts.toString())
var freq by mutableStateOf(initial.txFreqMhz.toString())
var height by mutableStateOf(initial.txHeightMeters.toString())
var gain by mutableStateOf(initial.txGainDbi.toString())
var colorScale by mutableStateOf(initial.colorScale)
// Advanced (coverage-affecting) fields.
var rxSensitivity by mutableStateOf(initial.rxSensitivityDbm.toString())
var rxHeight by mutableStateOf(initial.rxHeightMeters.toString())
var maxRange by mutableStateOf(initial.maxRangeKm.toString())
var highResolution by mutableStateOf(initial.highResolution)
// Validation — computed from the (observable) string fields, so callers just read the booleans.
private val latValue
get() = lat.toDoubleOrNull()
private val lonValue
get() = lon.toDoubleOrNull()
val latBad
get() = latValue.let { it == null || it !in -LAT_LIMIT..LAT_LIMIT }
val lonBad
get() = lonValue.let { it == null || it !in -LON_LIMIT..LON_LIMIT }
val powerBad
get() = (power.toDoubleOrNull() ?: 0.0) <= 0.0
val freqBad
get() = (freq.toDoubleOrNull() ?: 0.0) <= 0.0
val rxSensBad
get() =
rxSensitivity.toDoubleOrNull()?.let {
it !in SitePlannerParams.MIN_RX_SENSITIVITY_DBM..SitePlannerParams.MAX_RX_SENSITIVITY_DBM
} ?: false
// Guard the null-island (0,0) case so an empty ocean run can't be submitted.
val canSubmit
get() = !latBad && !lonBad && !powerBad && !freqBad && !rxSensBad && (latValue != 0.0 || lonValue != 0.0)
}
/**
* Bottom-sheet form for a Site Planner coverage estimate, pre-filled with [initial]. [onSubmit] receives the edited
* params once they validate. Location shortcut chips ([onUseCurrentLocation]/[onUseNodeLocation]/[onUseMapCenter])
* re-seed the coordinate fields when provided, preserving edits to the other fields.
*/
@Composable
fun SitePlannerSheet(
initial: SitePlannerParams,
onSubmit: (SitePlannerParams) -> Unit,
onDismiss: () -> Unit,
onUseCurrentLocation: (() -> Unit)? = null,
onUseNodeLocation: (() -> Unit)? = null,
onUseMapCenter: (() -> Unit)? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val state = remember { SiteFormState(initial) }
// Re-seed only the coordinates when a shortcut changes them; other fields keep the user's edits.
LaunchedEffect(initial.latitude, initial.longitude) {
state.lat = initial.latitude.toString()
state.lon = initial.longitude.toString()
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
SiteFormContent(state, initial, onSubmit, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
}
}
@Composable
private fun SiteFormContent(
state: SiteFormState,
initial: SitePlannerParams,
onSubmit: (SitePlannerParams) -> Unit,
onUseCurrentLocation: (() -> Unit)?,
onUseNodeLocation: (() -> Unit)?,
onUseMapCenter: (() -> Unit)?,
) {
Column(
modifier =
Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.site_planner),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.semantics { heading() },
)
Text(
text = stringResource(Res.string.site_planner_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Sections mirror the planner's own panel order + default-open state (Site Parameters drawer):
// Site / Transmitter (open) → Receiver → Simulation Options → Display (open).
TransmitterSection(state, onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
ReceiverSection(state)
SimulationSection(state)
DisplaySection(state)
Button(
onClick = { onSubmit(buildSubmitParams(state, initial)) },
enabled = state.canSubmit,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(Res.string.site_planner_estimate))
}
}
}
/** A collapsible titled section, matching the planner's `Section` panels. */
@Composable
private fun FormSection(title: String, defaultExpanded: Boolean, content: @Composable ColumnScope.() -> Unit) {
var expanded by remember { mutableStateOf(defaultExpanded) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.semantics { heading() })
Icon(if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore, contentDescription = null)
}
if (expanded) content()
}
}
@Composable
private fun TransmitterSection(
state: SiteFormState,
onUseCurrentLocation: (() -> Unit)?,
onUseNodeLocation: (() -> Unit)?,
onUseMapCenter: (() -> Unit)?,
) {
val posMsg = stringResource(Res.string.site_planner_invalid_positive)
FormSection(stringResource(Res.string.site_planner_section_transmitter), defaultExpanded = true) {
SiteField(state.name, { state.name = it }, Res.string.name, keyboardType = KeyboardType.Text)
LocationChips(onUseCurrentLocation, onUseNodeLocation, onUseMapCenter)
TransmitterFields(
state = state,
latError = if (state.latBad) stringResource(Res.string.site_planner_invalid_latitude) else null,
lonError = if (state.lonBad) stringResource(Res.string.site_planner_invalid_longitude) else null,
powerError = if (state.powerBad) posMsg else null,
freqError = if (state.freqBad) posMsg else null,
)
}
}
@Composable
private fun ReceiverSection(state: SiteFormState) {
FormSection(stringResource(Res.string.site_planner_section_receiver), defaultExpanded = false) {
SiteField(
state.rxSensitivity,
{ state.rxSensitivity = it },
Res.string.site_planner_rx_sensitivity_dbm,
error = if (state.rxSensBad) stringResource(Res.string.site_planner_invalid_rx_sensitivity) else null,
)
SiteField(state.rxHeight, { state.rxHeight = it }, Res.string.site_planner_rx_height_meters)
}
}
@Composable
private fun SimulationSection(state: SiteFormState) {
FormSection(stringResource(Res.string.site_planner_section_simulation), defaultExpanded = false) {
SiteField(state.maxRange, { state.maxRange = it }, Res.string.site_planner_max_range_km)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(Res.string.site_planner_high_resolution))
Switch(checked = state.highResolution, onCheckedChange = { state.highResolution = it })
}
}
}
@Composable
private fun DisplaySection(state: SiteFormState) {
FormSection(stringResource(Res.string.site_planner_section_display), defaultExpanded = true) {
PalettePicker(state.colorScale) { state.colorScale = it }
}
}
/** Quick shortcuts (chips) that re-seed the coordinates from the device, this node, or the map center. */
@Composable
private fun LocationChips(
onUseCurrentLocation: (() -> Unit)?,
onUseNodeLocation: (() -> Unit)?,
onUseMapCenter: (() -> Unit)?,
) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onUseCurrentLocation?.let {
LocationChip(it, MeshtasticIcons.MyLocation, Res.string.site_planner_use_current_location)
}
onUseNodeLocation?.let { LocationChip(it, MeshtasticIcons.PinDrop, Res.string.site_planner_use_node_location) }
onUseMapCenter?.let { LocationChip(it, MeshtasticIcons.Map, Res.string.site_planner_use_map_center) }
}
}
@Composable
private fun LocationChip(onClick: () -> Unit, icon: ImageVector, label: StringResource) {
AssistChip(
onClick = onClick,
leadingIcon = { Icon(icon, null, Modifier.size(AssistChipDefaults.IconSize)) },
label = { Text(stringResource(label)) },
)
}
@Composable
private fun TransmitterFields(
state: SiteFormState,
latError: String?,
lonError: String?,
powerError: String?,
freqError: String?,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SiteField(state.lat, { state.lat = it }, Res.string.latitude, error = latError)
SiteField(state.lon, { state.lon = it }, Res.string.longitude, error = lonError)
SiteField(state.power, { state.power = it }, Res.string.site_planner_tx_power_watts, error = powerError)
SiteField(state.freq, { state.freq = it }, Res.string.site_planner_frequency_mhz, error = freqError)
SiteField(state.height, { state.height = it }, Res.string.site_planner_antenna_height_meters)
SiteField(state.gain, { state.gain = it }, Res.string.site_planner_antenna_gain_dbi)
}
}
@Composable
private fun SiteField(
value: String,
onValueChange: (String) -> Unit,
label: StringResource,
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Number,
error: String? = null,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(stringResource(label)) },
singleLine = true,
isError = error != null,
supportingText = error?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
modifier = modifier.fillMaxWidth(),
)
}
/** Color-scale picker with a gradient swatch for each palette (recognition over recall). */
@Composable
private fun PalettePicker(value: String, onChange: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = SitePlannerParams.COLOR_SCALES.firstOrNull { it.first == value }?.second ?: value,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.site_planner_color_scale)) },
leadingIcon = { PaletteSwatch(value) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
SitePlannerParams.COLOR_SCALES.forEach { (key, label) ->
DropdownMenuItem(
text = { Text(label) },
leadingIcon = { PaletteSwatch(key) },
onClick = {
onChange(key)
expanded = false
},
)
}
}
}
}
@Composable
private fun PaletteSwatch(name: String) {
Box(
Modifier.width(SWATCH_WIDTH)
.height(SWATCH_HEIGHT)
.clip(RoundedCornerShape(4.dp))
.background(Brush.horizontalGradient(paletteStops(name))),
)
}
/** Representative gradient stops per planner palette (approximations of the matplotlib LUTs for a preview swatch). */
@Suppress("MagicNumber")
private fun paletteStops(name: String): List<Color> {
val argb =
when (name) {
"viridis" -> listOf(0xFF440154, 0xFF31688E, 0xFF35B779, 0xFFFDE725)
"CMRmap" -> listOf(0xFF000000, 0xFF3B0F70, 0xFF8C2981, 0xFFDE4968, 0xFFFE9F6D, 0xFFFFFFFF)
"cool" -> listOf(0xFF00FFFF, 0xFFFF00FF)
"turbo" -> listOf(0xFF30123B, 0xFF4585F9, 0xFF1BD0D5, 0xFFA4FC3B, 0xFFFABA39, 0xFFE5460A, 0xFF7A0403)
"jet" -> listOf(0xFF000080, 0xFF0000FF, 0xFF00FFFF, 0xFFFFFF00, 0xFFFF0000, 0xFF800000)
else -> listOf(0xFF0D0887, 0xFF7E03A8, 0xFFCC4778, 0xFFF89540, 0xFFF0F921)
}
return argb.map { Color(it) }
}
/** Build params from the (string) form fields, falling back to the matching [initial] value when a field is invalid. */
private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): SitePlannerParams = SitePlannerParams(
name = state.name.ifBlank { initial.name },
latitude = state.lat.toDoubleOrNull() ?: initial.latitude,
longitude = state.lon.toDoubleOrNull() ?: initial.longitude,
txPowerWatts = state.power.toDoubleOrNull() ?: initial.txPowerWatts,
txFreqMhz = state.freq.toDoubleOrNull() ?: initial.txFreqMhz,
txHeightMeters = state.height.toDoubleOrNull() ?: initial.txHeightMeters,
txGainDbi = state.gain.toDoubleOrNull() ?: initial.txGainDbi,
colorScale = state.colorScale,
rxSensitivityDbm = state.rxSensitivity.toDoubleOrNull() ?: initial.rxSensitivityDbm,
rxHeightMeters = state.rxHeight.toDoubleOrNull() ?: initial.rxHeightMeters,
maxRangeKm = state.maxRange.toDoubleOrNull() ?: initial.maxRangeKm,
highResolution = state.highResolution,
minDbm = initial.minDbm,
maxDbm = initial.maxDbm,
overlayTransparency = initial.overlayTransparency,
)

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SitePlannerParamsTest {
@Test
fun `toQueryUrl emits the planner's flat contract with advanced defaults and run and bridge flags`() {
val url =
SitePlannerParams(
name = "Tower A",
latitude = 51.05,
longitude = -114.07,
txPowerWatts = 0.5,
txFreqMhz = 915.0,
txHeightMeters = 12.0,
txGainDbi = 5.5,
colorScale = "turbo",
)
.toQueryUrl("http://localhost:5173")
// Advanced params carry their (planner-matching) defaults; high_res is omitted while false.
assertEquals(
"http://localhost:5173/?lat=51.05&lon=-114.07&name=Tower%20A" +
"&tx_power=0.5&tx_freq=915.0&tx_height=12.0&tx_gain=5.5&color_scale=turbo" +
"&rx_sensitivity=-130.0&rx_height=1.0&max_range=30.0" +
"&min_dbm=-130.0&max_dbm=-80.0&overlay_transparency=50&run=1&bridge=1",
url,
)
}
@Test
fun `toQueryUrl emits advanced overrides and high_res only when enabled`() {
val url =
SitePlannerParams(
name = "N",
latitude = 1.0,
longitude = 2.0,
rxSensitivityDbm = -139.0,
maxRangeKm = 60.0,
highResolution = true,
)
.toQueryUrl("http://localhost:5173")
assertTrue(url.contains("&rx_sensitivity=-139.0"), url)
assertTrue(url.contains("&max_range=60.0"), url)
assertTrue(url.contains("&high_res=1"), url)
}
@Test
fun `toQueryUrl does not double the slash when the base already ends in one`() {
val url = SitePlannerParams("N", 1.0, 2.0).toQueryUrl("https://planner.example/")
assertTrue(url.startsWith("https://planner.example/?lat=1.0&lon=2.0&name=N"))
}
@Test
fun `encodeQueryComponent percent-encodes spaces and non-ASCII`() {
assertEquals("Tower%20%C3%91%C3%B6r%C3%B0", encodeQueryComponent("Tower Ñörð"))
assertEquals("plain-name_1.0~", encodeQueryComponent("plain-name_1.0~"))
}
}

View File

@@ -38,12 +38,16 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.site_planner_estimate
import org.meshtastic.core.ui.icon.CellTower
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.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
@@ -79,6 +83,24 @@ internal fun PositionInlineContent(
overflow = TextOverflow.Ellipsis,
)
}
// Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated).
if (LocalSitePlannerAvailable.current && node.validPosition != null) {
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { onAction(NodeDetailAction.Navigate(MapRoute.Map(sitePlannerNodeNum = node.num))) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.CellTower, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.site_planner_estimate),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable