fix(map-maplibre): clear warnings and collect state with lifecycle (#7460)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 21:07:16 +00:00
1 parent 47c254402b
commit fafea7bb04
15 files changed
+47 -198

No files matched your search

@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.convertIntToEmoji
@@ -67,10 +66,6 @@ fun WaypointMarkers(
val iconCodePoint = waypoint.icon.waypointIconOrDefault()
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
// Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's
// accessible snippet for screen-reader and color-challenged users.
@@ -88,12 +83,9 @@ fun WaypointMarkers(
val cleanName = waypoint.name.replace('\n', ' ').replace('\b', ' ')
val title = if (waypoint.isLocked) "${convertIntToEmoji(LOCK)} $cleanName" else cleanName
// rememberComposeBitmapDescriptor shares its applier with its content lambda, so the Text drawn into the
// icon makes this map-scope function infer as UI.
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
Marker(
MarkerComposable(
iconCodePoint,
state = markerState,
icon = icon,
title = title,
snippet = snippet,
visible = true,
@@ -109,7 +101,9 @@ fun WaypointMarkers(
else -> onDeleteWaypointRequest(waypoint)
}
},
)
) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
}
}
}
@@ -50,7 +50,7 @@ internal fun rememberRestoredCamera(): RestoredCamera? {
val mapPrefs: MapPrefs = koinInject()
var restored by remember { mutableStateOf<RestoredCamera?>(null) }
LaunchedEffect(Unit) {
LaunchedEffect(mapPrefs) {
val saved = mapPrefs.awaitCameraPosition()
restored =
RestoredCamera(
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -232,7 +231,7 @@ private fun rememberMapScreenMapState(
basemap = basemap,
initialCameraPosition = restored.position ?: CameraPosition(),
overlays = screen.overlays,
layerOpacity = koinInject<LayerOpacityStore>().opacity.collectAsState().value,
layerOpacity = koinInject<LayerOpacityStore>().opacity.collectAsStateWithLifecycle().value,
customLayers = customLayers,
onClusterMembers = { screen.clusterMembers = it },
onWaypointClick = { screen.infoWaypointId = it },
@@ -17,11 +17,8 @@
package org.meshtastic.feature.map.maplibre
import org.maplibre.spatialk.geojson.BoundingBox
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.tan
import org.meshtastic.feature.map.maplibre.terrain.toGeoBounds
import org.meshtastic.feature.map.terrain.TerrainTileMath
/**
* How many tiles cover [this] region between [minZoom] and [maxZoom] inclusive.
@@ -33,33 +30,6 @@ import kotlin.math.tan
* Standard slippy-map arithmetic, so it matches what the renderer will actually request.
*/
internal fun BoundingBox.tileCount(minZoom: Int, maxZoom: Int): Long {
if (maxZoom < minZoom) return 0L
return (minZoom..maxZoom).sumOf { zoom ->
val span = 1 shl zoom
val left = longitudeToTileX(west, span)
val right = longitudeToTileX(east, span)
// Tile rows run north to south, so the northern edge gives the lower index.
val top = latitudeToTileY(north, span)
val bottom = latitudeToTileY(south, span)
// A box straddling the antimeridian arrives with west > east: its columns wrap around the tile grid,
// and the direct difference would go negative.
val columns = if (right >= left) right - left + 1 else span - left + right + 1
columns.toLong() * (bottom - top + 1).toLong()
}
val bounds = toGeoBounds()
return (minZoom..maxZoom).sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) }
}
private fun longitudeToTileX(longitude: Double, span: Int): Int =
floor((longitude + HALF_TURN) / FULL_TURN * span).toInt().coerceIn(0, span - 1)
private fun latitudeToTileY(latitude: Double, span: Int): Int {
// Clamped to the Mercator limit: the projection runs to infinity at the poles.
val radians = latitude.coerceIn(-MERCATOR_LIMIT, MERCATOR_LIMIT) * PI / HALF_TURN
val projected = ln(tan(radians) + 1.0 / cos(radians)) / PI
return floor((1.0 - projected) / 2.0 * span).toInt().coerceIn(0, span - 1)
}
private const val HALF_TURN = 180.0
private const val FULL_TURN = 360.0
private const val MERCATOR_LIMIT = 85.05112878
@@ -19,9 +19,9 @@ package org.meshtastic.feature.map.maplibre.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
@@ -58,7 +58,7 @@ internal fun rememberCacheUsage(packs: Set<OfflinePack>): CacheUsage {
}
}
}
.collectAsState(CacheUsage())
.collectAsStateWithLifecycle(CacheUsage())
return usage
}
@@ -17,8 +17,8 @@
package org.meshtastic.feature.map.maplibre.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.koinInject
import org.meshtastic.feature.map.component.CustomMapLayersSheet
import org.meshtastic.feature.map.layers.LayerOpacityStore
@@ -35,8 +35,8 @@ import org.meshtastic.feature.map.layers.rememberMapLayerPicker
fun ImportedLayersSlot() {
val manager: MapLayersManager = koinInject()
val opacityStore: LayerOpacityStore = koinInject()
val layers by manager.mapLayers.collectAsState()
val opacity by opacityStore.opacity.collectAsState()
val layers by manager.mapLayers.collectAsStateWithLifecycle()
val opacity by opacityStore.opacity.collectAsStateWithLifecycle()
val picker = rememberMapLayerPicker(onPick = manager::addMapLayer)
CustomMapLayersSheet(
@@ -25,13 +25,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.meshtastic.core.resources.Res
@@ -68,7 +68,7 @@ internal fun MapLayersButton(
) {
var sheetVisible by remember { mutableStateOf(false) }
val opacityStore: LayerOpacityStore = koinInject()
val opacity by opacityStore.opacity.collectAsState()
val opacity by opacityStore.opacity.collectAsStateWithLifecycle()
MapButton(
icon = MeshtasticIcons.Layers,
@@ -29,7 +29,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -37,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -91,7 +91,7 @@ internal fun OfflineMapsSection(target: OfflineMapTarget, onShowRegion: (Boundin
// the one every map here uses, so its packs are the ones the user sees on the map.
val manager = DefaultMapRuntime.instance.offlineManager
val scope = rememberCoroutineScope()
val packs by manager.packs.collectAsState()
val packs by manager.packs.collectAsStateWithLifecycle()
// A pack definition now carries the pixel ratio it was downloaded at, so the tiles match this display.
val pixelRatio = LocalDensity.current.density
@@ -165,7 +165,7 @@ private fun OfflinePackRow(
onToggle: () -> Unit,
onDelete: () -> Unit,
) {
val progress = pack.downloadProgress.collectAsState().value
val progress = pack.downloadProgress.collectAsStateWithLifecycle().value
val bounds = (pack.definition as? OfflinePackDefinition.TilePyramid)?.bounds
Row(
@@ -30,13 +30,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.maplibre.spatialk.geojson.BoundingBox
@@ -59,13 +59,13 @@ import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRegion
import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository
import org.meshtastic.feature.map.maplibre.terrain.estimateTerrainTiles
import org.meshtastic.feature.map.maplibre.terrain.toBoundingBox
import org.meshtastic.feature.map.maplibre.terrain.toGeoBounds
import org.meshtastic.feature.map.terrain.GeoBounds
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.terrain.TerrainDownloadState
import org.meshtastic.feature.map.terrain.TerrainRegionExtractor
import org.meshtastic.feature.map.terrain.terrainTileCount
/**
* Offline terrain — hillshade and elevation contours for the viewport, downloaded as its own section of the layers
@@ -80,17 +80,17 @@ import org.meshtastic.feature.map.terrain.TerrainRegionExtractor
internal fun OfflineTerrainSection(target: OfflineMapTarget, onShowRegion: (BoundingBox) -> Unit) {
val repository = remember { OfflineTerrainRepository.default }
val scope = rememberCoroutineScope()
val region by repository.region.collectAsState()
val region by repository.region.collectAsStateWithLifecycle()
// Not a local composable state: startDownload runs on the repository's own scope (see its doc comment for
// why), so the state it reports has to be read from there too, or progress would appear to vanish the moment
// this composable leaves composition and reappear wrong on the next one.
val downloadState by repository.downloadState.collectAsState()
val downloadState by repository.downloadState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { repository.refresh() }
LaunchedEffect(repository) { repository.refresh() }
val bounds = target.bounds()
val maxZoom = target.terrainMaxZoom()
val estimate = bounds?.let { estimateTerrainTiles(it.toGeoBounds(), maxZoom) } ?: 0L
val estimate = bounds?.let { terrainTileCount(it.toGeoBounds(), maxZoom) } ?: 0L
val overLimit = estimate > TerrainRegionExtractor.MAX_TILES
val isDownloading = downloadState is TerrainDownloadState.InProgress
@@ -224,8 +224,8 @@ private fun DownloadedTerrainRow(region: OfflineTerrainRegion, onShow: () -> Uni
* The zoom levels a terrain download covers: the current level plus a couple deeper, mirroring [OfflineMapTarget]'s own
* private `zoomRange` convention for the base map's offline packs.
*
* Bounded by [MapterhornEndpoints.REGIONAL_MAX_ZOOM] rather than MapLibre's own 0..20, since [estimateTerrainTiles] and
* [TerrainRegionExtractor] never fetch anything deeper than that regardless of what is asked for.
* Bounded by [MapterhornEndpoints.REGIONAL_MAX_ZOOM] rather than MapLibre's own 0..20, since [terrainTileCount] and
* [TerrainRegionExtractor] never count or fetch anything deeper than that regardless of what is asked for.
*/
private fun OfflineMapTarget.terrainMaxZoom(): Int {
val current = zoom().toInt().coerceIn(0, MapterhornEndpoints.REGIONAL_MAX_ZOOM)
@@ -17,7 +17,6 @@
package org.meshtastic.feature.map.maplibre.layers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
@@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
@@ -185,7 +185,7 @@ private fun rememberLayerIcons(urls: Set<String>): Map<String, Painter> {
key(url) {
val painter =
rememberAsyncImagePainter(ImageRequest.Builder(LocalPlatformContext.current).data(url).build())
val state by painter.state.collectAsState()
val state by painter.state.collectAsStateWithLifecycle()
// The loaded painter, not the async wrapper around it: MapLibre rasterizes a painter outside the
// composition driving it, where an AsyncImagePainter draws nothing.
(state as? AsyncImagePainter.State.Success)?.let { loaded[url] = it.painter }
@@ -18,11 +18,11 @@ package org.meshtastic.feature.map.maplibre.layers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import org.maplibre.compose.expressions.dsl.asNumber
@@ -74,9 +74,9 @@ import org.meshtastic.feature.map.terrain.TerrainTileMath
@Composable
internal fun TerrainLayers(viewportBounds: BoundingBox?, zoom: Double, displayUnits: MeasurementSystem) {
val repository = remember { OfflineTerrainRepository.default }
val region by repository.region.collectAsState()
val region by repository.region.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { repository.refresh() }
LaunchedEffect(repository) { repository.refresh() }
// Two early returns (detekt's ReturnCount limit), kept inline rather than in a helper function: a helper
// returning a resolved nullable can't hand the compiler back a smart-cast on *this* function's own
@@ -1,53 +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.feature.map.maplibre.terrain
import org.meshtastic.feature.map.terrain.GeoBounds
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.terrain.TerrainTileMath
/**
* How many tiles [TerrainRegionExtractor][org.meshtastic.feature.map.terrain.TerrainRegionExtractor] would fetch for
* [bounds] up to [maxZoom] — the global tier always, the regional tier only where [MapterhornEndpoints.regionalUrlFor]
* finds one.
*
* The counting itself is [TerrainTileMath.tileCountAt] — the same O(1)-per-zoom corner arithmetic the extractor uses to
* bound memory before it enumerates tiles, so this and the extractor can never silently disagree on *how many* a zoom
* level covers. What's re-derived here, network-free, is only the *which zoom levels / which tier* decision from the
* extractor's own suspending `download` [kotlinx.coroutines.flow.Flow] — matching
* [org.meshtastic.feature.map.maplibre.tileCount]'s own "shown before a download starts" role for the base offline
* layer. If the extractor's own tier/zoom-range logic ever changes, this must change with it — that coupling is
* intentional, not an oversight; a unit test pins it against [MapterhornEndpoints]'s real constants rather than a copy
* of them.
*/
internal fun estimateTerrainTiles(bounds: GeoBounds, maxZoom: Int): Long {
val globalZoomRange = 0..minOf(maxZoom, MapterhornEndpoints.GLOBAL_MAX_ZOOM)
val globalCount = globalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) }
val hasRegionalTier =
maxZoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM && MapterhornEndpoints.regionalUrlFor(bounds) != null
val regionalCount =
if (hasRegionalTier) {
val regionalMaxZoom = minOf(maxZoom, MapterhornEndpoints.REGIONAL_MAX_ZOOM)
val regionalZoomRange = MapterhornEndpoints.REGIONAL_MIN_ZOOM..regionalMaxZoom
regionalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) }
} else {
0L
}
return globalCount + regionalCount
}
@@ -18,7 +18,6 @@ package org.meshtastic.feature.map.maplibre.geojson
import kotlinx.serialization.json.float
import kotlinx.serialization.json.jsonPrimitive
import org.maplibre.spatialk.geojson.LineString
import org.meshtastic.feature.map.terrain.ContourLine
import org.meshtastic.feature.map.terrain.ContourPoint
import org.meshtastic.feature.map.terrain.TerrainTileMath
@@ -38,7 +37,7 @@ class ContourFeaturesTest {
val features = contourLinesToFeatures(tile, listOf(line), zoom = 12, metric = true)
assertEquals(1, features.size)
assertEquals(2, (features.single().geometry as LineString).coordinates.size)
assertEquals(2, features.single().geometry.coordinates.size)
}
@Test
@@ -51,7 +50,7 @@ class ContourFeaturesTest {
fun `tile-local points convert to the same lat lon TerrainTileMath's own inverse would produce`() {
val line = ContourLine(elevationMeters = 100f, points = listOf(ContourPoint(0f, 0f), ContourPoint(1f, 1f)))
val feature = contourLinesToFeatures(tile, listOf(line), zoom = 12, metric = true).single()
val positions = (feature.geometry as LineString).coordinates
val positions = feature.geometry.coordinates
val expectedFirst = TerrainTileMath.lonLatAt(tile, 0f, 0f)
assertEquals(expectedFirst.longitude, positions.first().longitude)
@@ -1,70 +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.feature.map.maplibre.terrain
import org.meshtastic.feature.map.terrain.GeoBounds
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
import org.meshtastic.feature.map.terrain.TerrainTileMath
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TerrainDownloadEstimateTest {
private val tinyBounds = GeoBounds(south = 47.6, west = -122.4, north = 47.61, east = -122.39)
@Test
fun `matches a manual sum of tilesAt across the global zoom range only`() {
val maxZoom = MapterhornEndpoints.GLOBAL_MAX_ZOOM
val expected = (0..maxZoom).sumOf { zoom -> TerrainTileMath.tilesAt(zoom, tinyBounds).size.toLong() }
assertEquals(expected, estimateTerrainTiles(tinyBounds, maxZoom))
}
@Test
fun `a maxZoom at or below the global ceiling never counts a regional tier`() {
val globalOnly = estimateTerrainTiles(tinyBounds, MapterhornEndpoints.GLOBAL_MAX_ZOOM)
val deeper = estimateTerrainTiles(tinyBounds, MapterhornEndpoints.GLOBAL_MAX_ZOOM + 1)
// The regional tier only exists past the global ceiling — one more zoom level must add regional tiles too.
assertTrue(deeper > globalOnly)
}
@Test
fun `a huge box that never fits a single regional archive gets no regional tier`() {
// Spans more than one z6 tile, so MapterhornEndpoints.regionalUrlFor returns null.
val huge = GeoBounds(south = -60.0, west = -170.0, north = 60.0, east = 170.0)
val atGlobalCeiling = estimateTerrainTiles(huge, MapterhornEndpoints.GLOBAL_MAX_ZOOM)
val past = estimateTerrainTiles(huge, MapterhornEndpoints.GLOBAL_MAX_ZOOM + 1)
// Past the global ceiling with no regional archive, nothing further is fetched — the count doesn't grow.
assertEquals(atGlobalCeiling, past)
}
@Test
fun `negative maxZoom counts nothing`() {
assertEquals(0L, estimateTerrainTiles(tinyBounds, -1))
}
@Test
fun `maxZoom 0 counts the single world-covering z0 tile`() {
// Unlike a negative maxZoom, 0 is a real, valid zoom range (0..0) -- it legitimately counts the one z0 tile
// that covers the whole world, not nothing.
assertEquals(1L, estimateTerrainTiles(tinyBounds, 0))
}
}
@@ -42,4 +42,14 @@ class TerrainTileCountTest {
assertEquals(8_869_485L, globalOnly)
assertEquals(globalOnly, terrainTileCount(continentalBox, maxZoom = MapterhornEndpoints.REGIONAL_MAX_ZOOM))
}
@Test
fun `a negative maxZoom counts nothing`() {
assertEquals(0L, terrainTileCount(tinySeattleBox, maxZoom = -1))
}
@Test
fun `maxZoom 0 counts the one tile that covers the world`() {
assertEquals(1L, terrainTileCount(tinySeattleBox, maxZoom = 0))
}
}