From 3057c9be26ca1ca579a4cf43b9e41b70fee988c8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:42:17 +0000 Subject: [PATCH] chore(deps): migrate to maplibre-compose 0.18.0 (#7374) --- .../feature/map/maplibre/MapCamera.kt | 12 ++--- .../map/maplibre/MapLibreMapViewProvider.kt | 15 ++---- .../feature/map/maplibre/MeshMap.kt | 50 ++++++++++++++++--- .../map/maplibre/SecondaryMapScaffold.kt | 14 +++++- .../feature/map/maplibre/SecondaryMaps.kt | 6 +-- .../map/maplibre/component/MapOrnaments.kt | 14 +++--- .../component/SecondaryMapControls.kt | 8 +-- gradle/libs.versions.toml | 2 +- .../screenshot/marketing/MapSnapshot.kt | 12 ++++- 9 files changed, 86 insertions(+), 47 deletions(-) diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapCamera.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapCamera.kt index f06fefda9e..081ed09536 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapCamera.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapCamera.kt @@ -16,11 +16,11 @@ */ package org.meshtastic.feature.map.maplibre -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraUpdate import org.maplibre.compose.map.MapState +import org.maplibre.compose.util.DpPadding import org.maplibre.spatialk.geojson.BoundingBox /** @@ -35,7 +35,7 @@ import org.maplibre.spatialk.geojson.BoundingBox internal suspend fun MapState.zoomBy(delta: Double, range: ClosedFloatingPointRange) { val target = (cameraPosition.zoom + delta).coerceIn(range.start.toDouble(), range.endInclusive.toDouble()) if (target != cameraPosition.zoom) { - animateCameraPosition(cameraPosition.copy(zoom = target), animation = CameraAnimation.Ease()) + animateCamera(CameraUpdate(zoom = target), CameraAnimation.Ease()) } } @@ -46,9 +46,9 @@ internal const val ZOOM_STEP = 1.0 * Frames [bounds] without zooming past [FRAME_MAX_ZOOM]: the fit is computed first and capped before the camera moves, * so nodes standing metres apart open on their surroundings rather than on empty tiles. Eased, not the default flight. */ -internal suspend fun MapState.frameBounds(bounds: BoundingBox, padding: PaddingValues = PaddingValues(0.dp)) { - val fitted = cameraForBounds(bounds, padding = padding) - animateCameraPosition(fitted.cappedTo(FRAME_MAX_ZOOM), CameraAnimation.Ease()) +internal suspend fun MapState.frameBounds(bounds: BoundingBox, padding: DpPadding = DpPadding.Zero) { + val fitted = cameraForBounds(bounds, fitPadding = padding) + animateCamera(fitted.cappedTo(FRAME_MAX_ZOOM).toCameraUpdate(), CameraAnimation.Ease()) } /** This position, zoomed out to [maxZoom] if it is tighter than that. */ diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt index 86475ac01c..02b78cf7f3 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt @@ -38,6 +38,7 @@ import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraUpdate import org.maplibre.compose.location.BearingUpdate import org.maplibre.compose.location.LocationPermission import org.maplibre.compose.location.LocationState @@ -343,12 +344,7 @@ private fun SitePlannerSlot( nodeNum = nodeNum, mapCenter = { mapState.cameraPosition.target }, moveTo = { target -> - scope.launch { - mapState.animateCameraPosition( - mapState.cameraPosition.copy(target = target), - animation = CameraAnimation.Ease(), - ) - } + scope.launch { mapState.animateCamera(CameraUpdate(target = target), CameraAnimation.Ease()) } }, onDismiss = onDismiss, ), @@ -395,12 +391,7 @@ private fun BoxScope.MapToolbar( if (location.following) { location.onToggleBearingLock() } else { - scope.launch { - mapState.animateCameraPosition( - mapState.cameraPosition.copy(bearing = 0.0), - animation = CameraAnimation.Ease(), - ) - } + scope.launch { mapState.animateCamera(CameraUpdate(bearing = 0.0), CameraAnimation.Ease()) } } }, filterDropdownContent = { diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt index eb53910ce8..e5936ce96e 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MeshMap.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.maplibre +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -30,18 +31,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonObject import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraUpdate import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.interaction.ClickResult import org.maplibre.compose.interaction.MapInteractions import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.location.BearingUpdate -import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationState import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.updateCamera @@ -51,7 +53,8 @@ import org.maplibre.compose.map.LocalViewport import org.maplibre.compose.map.MapState import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.rememberMapState -import org.maplibre.compose.material3.LocationPuckDefaults +import org.maplibre.compose.material3.LocationIndicatorDefaults +import org.maplibre.compose.material3.LocationIndicatorLayer import org.maplibre.compose.overlay.include import org.maplibre.compose.util.MaplibreComposable import org.maplibre.spatialk.geojson.BoundingBox @@ -81,6 +84,8 @@ import org.meshtastic.feature.map.maplibre.style.MapOverlay import org.meshtastic.feature.map.maplibre.style.toBaseStyle import org.meshtastic.feature.map.maplibre.style.zoomRange import kotlin.math.floor +import kotlin.time.Duration.Companion.seconds +import org.maplibre.compose.layers.LocationIndicatorDefaults as CoreLocationIndicatorDefaults /** * Everything the mesh map draws, as one [MapState]. @@ -280,9 +285,9 @@ private fun MeshMapNodeLayers( val current = mapState.cameraPosition // A cluster that cannot report an expansion zoom answers with a sentinel (0 on // Android and desktop, -1 on iOS), so clamp — never zoom out on a tap. - mapState.animateCameraPosition( - current.copy(target = centre, zoom = maxOf(expansionZoom, current.zoom)), - animation = CameraAnimation.Ease(), + mapState.animateCamera( + CameraUpdate(target = centre, zoom = maxOf(expansionZoom, current.zoom)), + CameraAnimation.Ease(), ) } }, @@ -342,12 +347,41 @@ private fun FollowUserLocation( @Composable @MaplibreComposable private fun UserLocationPuck(locationState: LocationState?, visible: Boolean) { - if (locationState == null || !visible) return + if (locationState == null) return - // The state overload, which resolves the latest measurement and its most accurate bearing itself. - LocationPuck(idPrefix = "user-location", locationState = locationState, colors = LocationPuckDefaults.colors()) + // The indicator has no notion of age, so a fix that stops arriving is greyed and loses its accuracy ring here, + // or an old position reads as a current one. + val mark = locationState.lastLocationMeasurementMark + var stale by remember(mark) { mutableStateOf(mark != null && mark.elapsedNow() > STALE_LOCATION_AFTER) } + LaunchedEffect(mark) { + if (mark == null || stale) return@LaunchedEffect + delay(STALE_LOCATION_AFTER - mark.elapsedNow()) + stale = true + } + val colors = MaterialTheme.colorScheme + + // The state overload, which resolves the latest measurement and its most accurate bearing itself. Hidden rather + // than unmounted when tracking stops, since layer additions are queued and a quick toggle could lose the re-add. + LocationIndicatorLayer( + id = "user-location", + locationState = locationState, + visible = visible, + accuracyRadiusColor = if (stale) Color.Transparent else colors.primary.copy(alpha = ACCURACY_FILL_ALPHA), + accuracyRadiusBorderColor = if (stale) Color.Transparent else colors.primary, + topImage = + if (stale) { + CoreLocationIndicatorDefaults.topImage(colors.surfaceDim, colors.onPrimary) + } else { + LocationIndicatorDefaults.topImage() + }, + ) } +/** How long a location fix counts as current, the threshold the library's own puck used before 0.18. */ +private val STALE_LOCATION_AFTER = 30.seconds + +private const val ACCURACY_FILL_ALPHA = 0.15f + /** * The first corner tapped while authoring a geofence box. * diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt index 062c7c07b0..bac89fa934 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMapScaffold.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.interaction.MapInteractions @@ -34,6 +35,7 @@ import org.maplibre.compose.map.MapState import org.maplibre.compose.map.MapUiOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.rememberMapState +import org.maplibre.compose.util.DpPadding import org.maplibre.spatialk.geojson.BoundingBox import org.meshtastic.feature.map.component.MapEngineUnavailable import org.meshtastic.feature.map.maplibre.component.BasemapSelection @@ -153,8 +155,18 @@ internal fun FitBoundsOnceVisible( // is cancelled by user input as well as by [key]: a fit lost that way is not retried until [key] changes // again, which for these maps may be never. val hasViewport = mapState.viewport != null + // MapLibre takes physical edges; resolving start/end here keeps the wide trailing inset on the zoom pair's side + // in RTL too. + val layoutDirection = LocalLayoutDirection.current + val fitPadding = + DpPadding( + left = padding.calculateLeftPadding(layoutDirection), + top = padding.calculateTopPadding(), + right = padding.calculateRightPadding(layoutDirection), + bottom = padding.calculateBottomPadding(), + ) LaunchedEffect(key, hasViewport) { if (!hasViewport) return@LaunchedEffect - currentBounds()?.let { mapState.frameBounds(it, padding = padding) } + currentBounds()?.let { mapState.frameBounds(it, padding = fitPadding) } } } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt index 8c80fcffcd..4c8909c165 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/SecondaryMaps.kt @@ -42,6 +42,7 @@ import kotlinx.serialization.json.put import org.jetbrains.compose.resources.stringResource import org.maplibre.compose.camera.CameraAnimation import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraUpdate import org.maplibre.compose.expressions.dsl.asBoolean import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.feature @@ -117,10 +118,7 @@ fun MapLibreInlineMap( // first composition does not animate to where the camera already is. LaunchedEffect(target) { if (mapState.cameraPosition.target != target) { - mapState.animateCameraPosition( - mapState.cameraPosition.copy(target = target), - animation = CameraAnimation.Ease(), - ) + mapState.animateCamera(CameraUpdate(target = target), CameraAnimation.Ease()) } } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt index b1b2462830..305e85f0eb 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/MapOrnaments.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import org.maplibre.compose.map.LocalMapState import org.maplibre.compose.overlay.DisappearingScaleBar -import org.maplibre.compose.overlay.LocalCameraPadding +import org.maplibre.compose.overlay.LocalViewportInsets import org.maplibre.compose.overlay.include import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay @@ -46,21 +46,21 @@ import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay */ internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay { val mapState = checkNotNull(LocalMapState.current) - val cameraPadding = LocalCameraPadding.current + val viewportInsets = LocalViewportInsets.current // A custom overlay fills the map and the library keeps its own inset helper internal, so the scale bar has to - // carry the camera padding, safe-area insets and edge margin that the built-in controls apply for themselves. + // carry the viewport insets, safe-area insets and edge margin that the built-in controls apply for themselves. Box( modifier = Modifier.fillMaxSize() - .padding(cameraPadding) - .consumeWindowInsets(cameraPadding) + .padding(viewportInsets) + .consumeWindowInsets(viewportInsets) .windowInsetsPadding(WindowInsets.safeDrawing) .padding(MaplibreOverlay.Spacing), ) { DisappearingScaleBar( - metersPerDp = mapState.viewport?.metersPerDpAtTarget ?: 0.0, - zoom = mapState.cameraPosition.zoom, + metersPerDp = { mapState.viewport?.metersPerDpAtTarget ?: 0.0 }, + zoom = { mapState.cameraPosition.zoom }, modifier = Modifier.align(Alignment.TopStart), ) } diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/SecondaryMapControls.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/SecondaryMapControls.kt index c92ec965a5..e5b1dbf5f2 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/SecondaryMapControls.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/component/SecondaryMapControls.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraAnimation +import org.maplibre.compose.camera.CameraUpdate import org.maplibre.compose.map.MapState import org.meshtastic.feature.map.component.MapControlsOverlay @@ -54,12 +55,7 @@ internal fun SecondaryMapControls( filterDropdownContent = { filterMenu?.invoke(filterMenuExpanded) { filterMenuExpanded = false } }, bearing = mapState.cameraPosition.bearing.toFloat(), onCompassClick = { - scope.launch { - mapState.animateCameraPosition( - mapState.cameraPosition.copy(bearing = 0.0), - animation = CameraAnimation.Ease(), - ) - } + scope.launch { mapState.animateCamera(CameraUpdate(bearing = 0.0), CameraAnimation.Ease()) } }, mapTypeContent = { BasemapButton(selection = basemaps) }, onToggleLocationTracking = null, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fcd953e058..c3bc0dd5d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,7 +71,7 @@ jetbrains-adaptive = "1.3.0-rc01" # 2.8.0–2.8.1). KML is now read by the app's own xmlutil-based converter and that parser is unused, so # the pairing — and the canary test that guarded it — is gone. The floor stays as a plain minimum. android-maps-utils = "5.2.0" -maplibre-compose = "0.17.0" +maplibre-compose = "0.18.0" maps-compose = "8.6.0" # ML Kit diff --git a/marketing-screenshots/src/main/kotlin/org/meshtastic/screenshot/marketing/MapSnapshot.kt b/marketing-screenshots/src/main/kotlin/org/meshtastic/screenshot/marketing/MapSnapshot.kt index b6b03f2889..328b0a90a8 100644 --- a/marketing-screenshots/src/main/kotlin/org/meshtastic/screenshot/marketing/MapSnapshot.kt +++ b/marketing-screenshots/src/main/kotlin/org/meshtastic/screenshot/marketing/MapSnapshot.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.font.createFontFamilyResolver import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -39,6 +40,7 @@ import org.maplibre.spatialk.geojson.Position import org.meshtastic.feature.map.maplibre.layers.NodeLayers import org.meshtastic.feature.map.maplibre.style.Basemaps import java.nio.file.Files +import java.util.concurrent.Executors /** One capture: the map area in dp at the screen density, and the zoom the form factor asks for. */ internal data class MapArea(val widthDp: Int, val heightDp: Int, val density: Float, val zoom: Double) @@ -52,6 +54,12 @@ internal object MapSnapshot { private const val CAPTURE_TIMEOUT_MS = 180_000L private const val MAX_SETTLE_RUNTIMES = 4 + // The generator is headless and has no Dispatchers.Main, so the runtime owns this thread instead, and every map + // state call runs on it because map state rejects any other thread. Daemon, so it never holds the JVM open. + private val mapThread = + Executors.newSingleThreadExecutor { Thread(it, "maplibre-main").apply { isDaemon = true } } + .asCoroutineDispatcher() + fun capture(areas: List, mesh: SampleMesh): Map = areas.associateWith { area -> captureUntilStable(area, mesh) } @@ -62,7 +70,7 @@ internal object MapSnapshot { * shared tile cache: the first fills the cache from the network, the ones after it read the cache in a stable * order, and two consecutive runtimes agreeing means the run is reproducible. A warm runtime costs about a second. */ - private fun captureUntilStable(area: MapArea, mesh: SampleMesh): ImageBitmap = runBlocking { + private fun captureUntilStable(area: MapArea, mesh: SampleMesh): ImageBitmap = runBlocking(mapThread) { val cacheDir = Files.createTempDirectory("marketing-maplibre") val cacheFile = Path(cacheDir.resolve("cache.db").toString()) try { @@ -82,7 +90,7 @@ internal object MapSnapshot { } private suspend fun captureOnce(cacheFile: Path, area: MapArea, mesh: SampleMesh): ImageBitmap { - val runtime = createMapRuntime(MapRuntimeOptions(cacheFile = cacheFile)) + val runtime = createMapRuntime(MapRuntimeOptions(cacheFile = cacheFile, mainDispatcher = mapThread)) return try { withTimeout(CAPTURE_TIMEOUT_MS) { runtime.capture(area, mesh) } } finally {