mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 12:26:23 -04:00
feat(map): Site Planner coverage integration — import + estimate (#6136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
26
.skills/compose-ui/strings-index.txt
generated
26
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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~"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user