mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-12 22:32:23 -04:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,6 @@ 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
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -113,7 +112,6 @@ import org.meshtastic.app.map.component.NodeClusterMarkers
|
||||
import org.meshtastic.app.map.component.NodeMapFilterDropdown
|
||||
import org.meshtastic.app.map.component.WaypointMarkers
|
||||
import org.meshtastic.app.map.model.NodeClusterItem
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
@@ -123,7 +121,6 @@ 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
|
||||
@@ -159,15 +156,12 @@ 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
|
||||
|
||||
@@ -1290,14 +1284,15 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
|
||||
val context = LocalContext.current
|
||||
var currentLayer by remember { mutableStateOf<Layer?>(null) }
|
||||
|
||||
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
|
||||
MapEffect(layerItem.id, layerItem.refreshToken) { map ->
|
||||
currentLayer?.safeRemoveLayerFromMap()
|
||||
currentLayer = null
|
||||
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
|
||||
val layer =
|
||||
try {
|
||||
when (layerItem.layerType) {
|
||||
LayerType.KML -> KmlLayer(map, inputStream, context)
|
||||
// KmlLayer parses the stream in its constructor but doesn't close it — close it ourselves.
|
||||
LayerType.KML -> inputStream.use { KmlLayer(map, it, context) }
|
||||
|
||||
LayerType.GEOJSON ->
|
||||
GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })).also {
|
||||
@@ -1430,67 +1425,9 @@ internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
|
||||
"\uD83D\uDCCD"
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun Uri.getFileName(context: android.content.Context): String {
|
||||
var name = this.lastPathSegment ?: "layer_$nowMillis"
|
||||
if (this.scheme == "content") {
|
||||
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
||||
if (displayNameIndex != -1) {
|
||||
name = cursor.getString(displayNameIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
|
||||
internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
|
||||
|
||||
/**
|
||||
* 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 ->
|
||||
|
||||
@@ -28,11 +28,6 @@ import com.google.android.gms.maps.model.TileProvider
|
||||
import com.google.android.gms.maps.model.UrlTileProvider
|
||||
import com.google.maps.android.compose.CameraPositionState
|
||||
import com.google.maps.android.compose.MapType
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.bodyAsChannel
|
||||
import io.ktor.http.isSuccess
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -41,12 +36,10 @@ 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
|
||||
@@ -86,7 +79,7 @@ data class MapCameraPosition(
|
||||
class MapViewModel(
|
||||
private val application: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val httpClient: HttpClient,
|
||||
private val mapLayersManager: MapLayersManager,
|
||||
mapPrefs: MapPrefs,
|
||||
private val googleMapsPrefs: GoogleMapsPrefs,
|
||||
nodeRepository: NodeRepository,
|
||||
@@ -366,24 +359,14 @@ class MapViewModel(
|
||||
urlTemplate.contains("{x}", ignoreCase = true) &&
|
||||
urlTemplate.contains("{y}", ignoreCase = true)
|
||||
|
||||
private val _mapLayers = MutableStateFlow<List<MapLayerItem>>(emptyList())
|
||||
val mapLayers: StateFlow<List<MapLayerItem>> = _mapLayers.asStateFlow()
|
||||
/** Imported overlay layers; owned by the flavor-neutral [MapLayersManager] and rendered by [MapLayerOverlay]. */
|
||||
val mapLayers: StateFlow<List<MapLayerItem>> = mapLayersManager.mapLayers
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
customTileProviderRepository.getCustomTileProviders().first()
|
||||
loadPersistedMapType()
|
||||
}
|
||||
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 {
|
||||
@@ -436,153 +419,11 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPersistedLayers() {
|
||||
viewModelScope.launch(dispatchers.io) {
|
||||
try {
|
||||
val layersDir = File(application.filesDir, "map_layers")
|
||||
if (layersDir.exists() && layersDir.isDirectory) {
|
||||
val persistedLayerFiles = layersDir.listFiles()
|
||||
|
||||
if (persistedLayerFiles != null) {
|
||||
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
|
||||
val loadedItems =
|
||||
persistedLayerFiles.mapNotNull { file ->
|
||||
if (file.isFile) {
|
||||
val layerType =
|
||||
when (file.extension.lowercase()) {
|
||||
"kml",
|
||||
"kmz",
|
||||
-> LayerType.KML
|
||||
|
||||
"geojson",
|
||||
"json",
|
||||
-> LayerType.GEOJSON
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
layerType?.let {
|
||||
val uri = Uri.fromFile(file)
|
||||
MapLayerItem(
|
||||
name = file.nameWithoutExtension,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = it,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val networkItems =
|
||||
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
|
||||
try {
|
||||
val parts = networkString.split("|:|")
|
||||
if (parts.size == 3) {
|
||||
val id = parts[0]
|
||||
val name = parts[1]
|
||||
val uri = Uri.parse(parts[2])
|
||||
MapLayerItem(
|
||||
id = id,
|
||||
name = name,
|
||||
uri = uri,
|
||||
isVisible = !hiddenLayerUrls.contains(uri.toString()),
|
||||
layerType = LayerType.KML,
|
||||
isNetwork = true,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
_mapLayers.value = loadedItems + networkItems
|
||||
if (_mapLayers.value.isNotEmpty()) {
|
||||
Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.withTag("MapViewModel").i("Map layers directory does not exist. No layers loaded.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error loading persisted map layers" }
|
||||
_mapLayers.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addMapLayer(uri: Uri, fileName: String?) {
|
||||
viewModelScope.launch {
|
||||
val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.value.size + 1}"
|
||||
|
||||
val extension =
|
||||
fileName?.substringAfterLast('.', "")?.lowercase()
|
||||
?: application.contentResolver.getType(uri)?.split('/')?.last()
|
||||
|
||||
val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz")
|
||||
val geoJsonExtensions = listOf("geojson", "json")
|
||||
|
||||
val layerType =
|
||||
when (extension) {
|
||||
in kmlExtensions -> LayerType.KML
|
||||
in geoJsonExtensions -> LayerType.GEOJSON
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (layerType == null) {
|
||||
Logger.withTag("MapViewModel").e("Unsupported map layer file type: $extension")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val finalFileName =
|
||||
if (fileName != null) {
|
||||
"$layerName.$extension"
|
||||
} else {
|
||||
"layer_${Uuid.random()}.$extension"
|
||||
}
|
||||
|
||||
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
|
||||
|
||||
if (localFileUri != null) {
|
||||
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
|
||||
_mapLayers.value = _mapLayers.value + newItem
|
||||
} else {
|
||||
Logger.withTag("MapViewModel").e("Failed to copy file to internal storage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
fun addMapLayer(uri: Uri, fileName: String?) = mapLayersManager.addMapLayer(uri, fileName)
|
||||
|
||||
fun addNetworkMapLayer(name: String, url: String) {
|
||||
viewModelScope.launch {
|
||||
if (name.isBlank() || url.isBlank()) {
|
||||
_errorFlow.emit("Invalid name or URL for network layer.")
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val uri = Uri.parse(url)
|
||||
if (uri.scheme != "http" && uri.scheme != "https") {
|
||||
_errorFlow.emit("URL must be http or https.")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val path = uri.path?.lowercase() ?: ""
|
||||
val layerType =
|
||||
when {
|
||||
path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
|
||||
else -> LayerType.KML // Default to KML
|
||||
}
|
||||
|
||||
val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
|
||||
_mapLayers.value = _mapLayers.value + newItem
|
||||
|
||||
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
|
||||
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
|
||||
} catch (e: Exception) {
|
||||
_errorFlow.emit("Invalid URL.")
|
||||
}
|
||||
mapLayersManager.addNetworkMapLayer(name, url)?.let { error ->
|
||||
viewModelScope.launch { _errorFlow.emit(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,88 +445,15 @@ 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
fun addGeoJsonLayer(name: String, geoJson: String) = mapLayersManager.addGeoJsonLayer(name, geoJson)
|
||||
|
||||
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) = mapLayersManager.toggleLayerVisibility(layerId)
|
||||
|
||||
fun toggleLayerVisibility(layerId: String) {
|
||||
var toggledLayer: MapLayerItem? = null
|
||||
val updatedLayers =
|
||||
_mapLayers.value.map {
|
||||
if (it.id == layerId) {
|
||||
toggledLayer = it.copy(isVisible = !it.isVisible)
|
||||
toggledLayer
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
_mapLayers.value = updatedLayers
|
||||
fun removeMapLayer(layerId: String) = mapLayersManager.removeMapLayer(layerId)
|
||||
|
||||
toggledLayer?.let {
|
||||
if (it.isVisible) {
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
|
||||
} else {
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
fun refreshMapLayer(layerId: String) = mapLayersManager.refreshMapLayer(layerId)
|
||||
|
||||
fun removeMapLayer(layerId: String) {
|
||||
viewModelScope.launch {
|
||||
val layerToRemove = _mapLayers.value.find { it.id == layerId }
|
||||
layerToRemove?.uri?.let { uri ->
|
||||
if (layerToRemove.isNetwork) {
|
||||
googleMapsPrefs.setNetworkMapLayers(
|
||||
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
|
||||
)
|
||||
} else {
|
||||
deleteFileToInternalStorage(uri)
|
||||
}
|
||||
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
|
||||
}
|
||||
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMapLayer(layerId: String) {
|
||||
viewModelScope.launch {
|
||||
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
|
||||
// By resetting the layer data in the UI (implied by just refreshing),
|
||||
// we trigger a reload in the Composable.
|
||||
_mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAllVisibleNetworkLayers() {
|
||||
_mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
|
||||
}
|
||||
fun refreshAllVisibleNetworkLayers() = mapLayersManager.refreshAllVisibleNetworkLayers()
|
||||
|
||||
private suspend fun deleteFileToInternalStorage(uri: Uri) {
|
||||
withContext(dispatchers.io) {
|
||||
@@ -700,27 +468,8 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("Recycle")
|
||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
|
||||
val uriToLoad = layerItem.uri ?: return null
|
||||
return withContext(dispatchers.io) {
|
||||
try {
|
||||
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
|
||||
val response = httpClient.get(uriToLoad.toString())
|
||||
if (!response.status.isSuccess()) {
|
||||
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
|
||||
return@withContext null
|
||||
}
|
||||
response.bodyAsChannel().toInputStream()
|
||||
} else {
|
||||
application.contentResolver.openInputStream(uriToLoad)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? =
|
||||
mapLayersManager.getInputStreamFromUri(layerItem)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
@@ -729,18 +478,3 @@ class MapViewModel(
|
||||
|
||||
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST)
|
||||
}
|
||||
|
||||
enum class LayerType {
|
||||
KML,
|
||||
GEOJSON,
|
||||
}
|
||||
|
||||
data class MapLayerItem(
|
||||
val id: String = Uuid.random().toString(),
|
||||
val name: String,
|
||||
val uri: Uri? = null,
|
||||
val isVisible: Boolean = true,
|
||||
val layerType: LayerType,
|
||||
val isNetwork: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map
|
||||
|
||||
import 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,
|
||||
)
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconToggleButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.MapLayerItem
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.add_layer
|
||||
import org.meshtastic.core.resources.add_network_layer
|
||||
import org.meshtastic.core.resources.cancel
|
||||
import org.meshtastic.core.resources.hide_layer
|
||||
import org.meshtastic.core.resources.manage_map_layers
|
||||
import org.meshtastic.core.resources.map_layer_formats
|
||||
import org.meshtastic.core.resources.name
|
||||
import org.meshtastic.core.resources.network_layer_url_hint
|
||||
import org.meshtastic.core.resources.no_map_layers_loaded
|
||||
import org.meshtastic.core.resources.refresh
|
||||
import org.meshtastic.core.resources.remove_layer
|
||||
import org.meshtastic.core.resources.save
|
||||
import org.meshtastic.core.resources.show_layer
|
||||
import org.meshtastic.core.resources.url
|
||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.Visibility
|
||||
import org.meshtastic.core.ui.icon.VisibilityOff
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun CustomMapLayersSheet(
|
||||
mapLayers: List<MapLayerItem>,
|
||||
onToggleVisibility: (String) -> Unit,
|
||||
onRemoveLayer: (String) -> Unit,
|
||||
onAddLayerClicked: () -> Unit,
|
||||
onRefreshLayer: (String) -> Unit,
|
||||
onAddNetworkLayer: (String, String) -> Unit,
|
||||
) {
|
||||
var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
|
||||
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = stringResource(Res.string.manage_map_layers),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp),
|
||||
text = stringResource(Res.string.map_layer_formats),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (mapLayers.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = stringResource(Res.string.no_map_layers_loaded),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(mapLayers, key = { it.id }) { layer ->
|
||||
ListItem(
|
||||
headlineContent = { Text(layer.name) },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (layer.isNetwork) {
|
||||
if (layer.isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp).padding(4.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = { onRefreshLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Refresh,
|
||||
contentDescription = stringResource(Res.string.refresh),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconToggleButton(
|
||||
checked = layer.isVisible,
|
||||
onCheckedChange = { onToggleVisibility(layer.id) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (layer.isVisible) {
|
||||
MeshtasticIcons.Visibility
|
||||
} else {
|
||||
MeshtasticIcons.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (layer.isVisible) {
|
||||
Res.string.hide_layer
|
||||
} else {
|
||||
Res.string.show_layer
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onRemoveLayer(layer.id) }) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
contentDescription = stringResource(Res.string.remove_layer),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
|
||||
Text(stringResource(Res.string.add_layer))
|
||||
}
|
||||
Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
|
||||
Text(stringResource(Res.string.add_network_layer))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddNetworkLayerDialog) {
|
||||
AddNetworkLayerDialog(
|
||||
onDismiss = { showAddNetworkLayerDialog = false },
|
||||
onConfirm = { name, url ->
|
||||
onAddNetworkLayer(name, url)
|
||||
showAddNetworkLayerDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf("") }
|
||||
|
||||
MeshtasticDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(Res.string.add_network_layer),
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(Res.string.name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text(stringResource(Res.string.url)) },
|
||||
placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
onConfirm = { onConfirm(name, url) },
|
||||
confirmTextRes = Res.string.save,
|
||||
dismissTextRes = Res.string.cancel,
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.google.maps.android.compose.MapType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -45,10 +44,6 @@ interface GoogleMapsPrefs {
|
||||
|
||||
fun setSelectedCustomTileUrl(value: String?)
|
||||
|
||||
val hiddenLayerUrls: StateFlow<Set<String>>
|
||||
|
||||
fun setHiddenLayerUrls(value: Set<String>)
|
||||
|
||||
val cameraTargetLat: StateFlow<Double>
|
||||
|
||||
fun setCameraTargetLat(value: Double)
|
||||
@@ -68,10 +63,6 @@ interface GoogleMapsPrefs {
|
||||
val cameraBearing: StateFlow<Float>
|
||||
|
||||
fun setCameraBearing(value: Float)
|
||||
|
||||
val networkMapLayers: StateFlow<Set<String>>
|
||||
|
||||
fun setNetworkMapLayers(value: Set<String>)
|
||||
}
|
||||
|
||||
@Single
|
||||
@@ -113,15 +104,6 @@ class GoogleMapsPrefsImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override val hiddenLayerUrls: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setHiddenLayerUrls(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
|
||||
}
|
||||
|
||||
override val cameraTargetLat: StateFlow<Double> =
|
||||
dataStore.data
|
||||
.map {
|
||||
@@ -173,24 +155,13 @@ class GoogleMapsPrefsImpl(
|
||||
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
|
||||
}
|
||||
|
||||
override val networkMapLayers: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setNetworkMapLayers(value: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
|
||||
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
|
||||
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
|
||||
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
|
||||
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
|
||||
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
|
||||
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
|
||||
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
|
||||
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user