feat(map): add support for GeoJSON map layers (#2827)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-24 07:14:41 -05:00 committed by GitHub
parent e6dfc8a595
commit 06c83313c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 140 additions and 44 deletions

View file

@ -191,7 +191,7 @@ fun MapView(
LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
val kmlFilePickerLauncher =
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
@ -290,10 +290,17 @@ fun MapView(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
val mimeTypes = arrayOf("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz")
val mimeTypes =
arrayOf(
"application/vnd.google-earth.kml+xml",
"application/vnd.google-earth.kmz",
"application/vnd.geo+json",
"application/geo+json",
"application/json",
)
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
kmlFilePickerLauncher.launch(intent)
filePickerLauncher.launch(intent)
}
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
@ -475,11 +482,27 @@ fun MapView(
MapEffect(mapLayers) { map ->
mapLayers.forEach { layerItem ->
mapViewModel.loadKmlLayerIfNeeded(map, layerItem)?.let { kmlLayer ->
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
kmlLayer.addLayerToMap()
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
kmlLayer.removeLayerFromMap()
coroutineScope.launch {
mapViewModel.loadMapLayerIfNeeded(map, layerItem)
when (layerItem.layerType) {
LayerType.KML -> {
layerItem.kmlLayerData?.let { kmlLayer ->
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
kmlLayer.addLayerToMap()
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
kmlLayer.removeLayerFromMap()
}
}
}
LayerType.GEOJSON -> {
layerItem.geoJsonLayerData?.let { geoJsonLayer ->
if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
geoJsonLayer.addLayerToMap()
} else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
geoJsonLayer.removeLayerFromMap()
}
}
}
}
}
}

View file

@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.map
import android.app.Application
import android.net.Uri
import androidx.core.net.toFile
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.android.BuildUtils.debug
@ -33,6 +34,7 @@ import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.MapType
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -49,6 +51,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
@ -287,11 +290,25 @@ constructor(
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
MapLayerItem(
name = file.nameWithoutExtension,
uri = Uri.fromFile(file),
isVisible = true,
)
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
MapLayerItem(
name = file.nameWithoutExtension,
uri = Uri.fromFile(file),
isVisible = true,
layerType = it,
)
}
} else {
null
}
@ -313,14 +330,41 @@ constructor(
fun addMapLayer(uri: Uri, fileName: String?) {
viewModelScope.launch {
val layerName = fileName ?: "Layer ${mapLayers.value.size + 1}"
val localFileUri = copyFileToInternalStorage(uri, fileName ?: "layer_${UUID.randomUUID()}")
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) {
Timber.tag("MapViewModel").e("Unsupported map layer file type: $extension")
return@launch
}
val finalFileName =
if (fileName != null) {
"$layerName.$extension"
} else {
"layer_${UUID.randomUUID()}.$extension"
}
val localFileUri = copyFileToInternalStorage(uri, finalFileName)
if (localFileUri != null) {
val newItem = MapLayerItem(name = layerName, uri = localFileUri)
val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType)
_mapLayers.value = _mapLayers.value + newItem
} else {
Timber.tag("MapViewModel").e("Failed to copy KML/KMZ file to internal storage.")
Timber.tag("MapViewModel").e("Failed to copy file to internal storage.")
}
}
}
@ -350,16 +394,20 @@ constructor(
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.kmlLayerData?.removeLayerFromMap()
layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) }
when (layerToRemove?.layerType) {
LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap()
LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap()
null -> {}
}
layerToRemove?.uri?.let { uri -> deleteFileToInternalStorage(uri) }
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
private suspend fun deleteFileFromInternalStorage(uri: Uri) {
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
val file = File(uri.path ?: return@withContext)
val file = uri.toFile()
if (file.exists()) {
file.delete()
}
@ -370,46 +418,71 @@ constructor(
}
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
private suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
val stream =
withContext(Dispatchers.IO) {
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
null
}
return withContext(Dispatchers.IO) {
try {
application.contentResolver.openInputStream(uriToLoad)
} catch (_: Exception) {
debug("MapViewModel: Error opening InputStream from URI: $uriToLoad")
null
}
return stream
}
}
suspend fun loadKmlLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem): KmlLayer? {
if (layerItem.kmlLayerData != null) {
return layerItem.kmlLayerData
}
return try {
getInputStreamFromUri(layerItem)?.use { inputStream ->
val kmlLayer = KmlLayer(map, inputStream, application.applicationContext)
_mapLayers.update { currentLayers ->
currentLayers.map { if (it.id == layerItem.id) it.copy(kmlLayerData = kmlLayer) else it }
suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) {
if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return
try {
when (layerItem.layerType) {
LayerType.KML -> {
val kmlLayer =
getInputStreamFromUri(layerItem)?.use { KmlLayer(map, it, application.applicationContext) }
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(kmlLayerData = kmlLayer)
} else {
it
}
}
}
}
LayerType.GEOJSON -> {
val geoJsonLayer =
getInputStreamFromUri(layerItem)?.use { inputStream ->
val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() })
GeoJsonLayer(map, jsonObject)
}
_mapLayers.update { currentLayers ->
currentLayers.map {
if (it.id == layerItem.id) {
it.copy(geoJsonLayerData = geoJsonLayer)
} else {
it
}
}
}
}
kmlLayer
}
} catch (e: Exception) {
Timber.tag("MapViewModel").e(e, "Error loading KML for ${layerItem.uri}")
null
Timber.tag("MapViewModel").e(e, "Error loading map layer for ${layerItem.uri}")
}
}
}
enum class LayerType {
KML,
GEOJSON,
}
data class MapLayerItem(
val id: String = UUID.randomUUID().toString(),
val name: String,
val uri: Uri? = null,
var isVisible: Boolean = true,
var kmlLayerData: KmlLayer? = null,
var geoJsonLayerData: GeoJsonLayer? = null,
val layerType: LayerType,
)
@Serializable