From a0484e7707d933b17eb22aad98056efea24d2eb2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:51 -0500 Subject: [PATCH] refactor(map): adopt maps-compose 8.4.0 stock clustering, drop custom renderer workaround (#6301) Co-authored-by: Claude Opus 4.8 --- .../kotlin/org/meshtastic/app/map/MapView.kt | 17 ++- .../app/map/component/NodeClusterMarkers.kt | 140 ++++-------------- .../app/map/model/NodeClusterItem.kt | 33 +++-- gradle/libs.versions.toml | 2 +- 4 files changed, 63 insertions(+), 129 deletions(-) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index b1ae7a07a1..c5a8d751ed 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -1348,7 +1348,14 @@ private fun Layer.safeRemoveLayerFromMap() { private fun Layer.safeAddLayerToMap() { try { - if (!isLayerOnMap) addLayerToMap() + // maps-utils 5.0.0 dropped isLayerOnMap() from the Layer base class; both concrete layers still expose it. + val isOnMap = + when (this) { + is GeoJsonLayer -> isLayerOnMap() + is KmlLayer -> isLayerOnMap() + else -> false + } + if (!isOnMap) addLayerToMap() } catch (e: Exception) { Logger.withTag("MapView").e(e) { "Error adding map layer" } } @@ -1368,15 +1375,15 @@ private fun GeoJsonLayer.applySimpleStyleSpec() { val stroke = feature.cssColor("stroke") ?: feature.cssColor("color") val fillOpacity = feature.getProperty("fill-opacity")?.toFloatOrNull() val strokeWidth = feature.getProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH - when (feature.geometry?.geometryType) { + when (feature.getGeometry()?.getGeometryType()) { "Polygon", "MultiPolygon", -> feature.polygonStyle = GeoJsonPolygonStyle().apply { fill?.let { fillColor = it.resolveFillAlpha(fillOpacity) } - stroke?.let { strokeColor = it } - this.strokeWidth = strokeWidth + stroke?.let { setStrokeColor(it) } + setStrokeWidth(strokeWidth) } "LineString", @@ -1385,7 +1392,7 @@ private fun GeoJsonLayer.applySimpleStyleSpec() { feature.lineStringStyle = GeoJsonLineStringStyle().apply { stroke?.let { color = it } - width = strokeWidth + setWidth(strokeWidth) } else -> Unit // Points keep the default marker. diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt index 0e3f872be2..30fd33bb3c 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -16,51 +16,33 @@ */ package org.meshtastic.app.map.component -import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import com.google.android.gms.maps.GoogleMap -import com.google.android.gms.maps.model.BitmapDescriptor -import com.google.android.gms.maps.model.Marker -import com.google.android.gms.maps.model.MarkerOptions import com.google.maps.android.clustering.Cluster -import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.MapEffect import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.clustering.Clustering -import com.google.maps.android.compose.clustering.rememberClusterManager -import com.google.maps.android.compose.rememberComposeBitmapDescriptor +import com.google.maps.android.compose.clustering.ClusteringMarkerProperties import org.meshtastic.app.map.model.NodeClusterItem import org.meshtastic.feature.map.BaseMapViewModel private const val MIN_CLUSTER_SIZE = 10 /** - * Renders node markers with clustering. + * Renders node markers with clustering via the library's [Clustering] composable. * - * Marker bitmaps are generated **in the maps compose scope** via [rememberComposeBitmapDescriptor], which composes each - * chip in a `ComposeView` parented to the live host view (a real, attached view that has valid `ViewTreeLifecycleOwner` - * / `SavedStateRegistryOwner`) and renders it synchronously to a [BitmapDescriptor]. + * Each unclustered node is composed as a [PulsingNodeChip] (`clusterItemContent`), with its z-index forwarded through + * [ClusteringMarkerProperties]; cluster bubbles keep the library's default rendering. Native info windows + * (title/snippet from [NodeClusterItem]) and click interactions are handled by the library renderer, and precision + * circles are drawn for the currently-unclustered items via `clusterItemDecoration`. * - * This deliberately avoids the clustering library's `clusterItemContent` path: that renderer - * ([com.google.maps.android.compose.clustering.ComposeUiClusterRenderer]) composes each item in a *detached* - * `ComposeView` that only carries a fake lifecycle owner and no `SavedStateRegistryOwner`, so it crashes when the - * surrounding Navigation 3 / popup hierarchy lacks those owners (the top Crashlytics FATAL). A custom - * [DefaultClusterRenderer] subclass assigns the pre-baked bitmaps instead, keeping native info windows (title/snippet - * from [NodeClusterItem]) and click interactions intact. + * Requires maps-compose >= 8.4.0: earlier versions composed cluster items into a detached `ComposeView` that carried no + * lifecycle/saved-state owners, crashing under the Navigation 3 hierarchy (fixed upstream in + * googlemaps/android-maps-compose#930, which this file previously worked around with a custom [DefaultClusterRenderer] + * assigning pre-baked bitmaps). */ @OptIn(MapsComposeExperimentalApi::class) -@Suppress("NestedBlockDepth") @Composable fun NodeClusterMarkers( nodeClusterItems: List, @@ -68,48 +50,16 @@ fun NodeClusterMarkers( navigateToNodeDetails: (Int) -> Unit, onClusterClick: (Cluster) -> Boolean, ) { - val context = LocalContext.current - val clusterManager = rememberClusterManager() - - // Bake each node's marker icon in-scope. Keyed by node so a bitmap is only re-rendered when that node - // actually changes. The descriptors are stashed in a snapshot map the renderer reads at render time. - val iconDescriptors = remember { mutableStateMapOf() } - nodeClusterItems.forEach { item -> - key(item.node.num) { - val descriptor = rememberComposeBitmapDescriptor(item.node) { PulsingNodeChip(node = item.node) } - DisposableEffect(descriptor) { - iconDescriptors[item.node.num] = descriptor - onDispose { iconDescriptors.remove(item.node.num) } - } - } - } - - if (clusterManager != null) { - val rendererState = remember { mutableStateOf(null) } - - // The renderer needs the GoogleMap instance, only available inside the map scope. - MapEffect(clusterManager) { map -> - val renderer = NodeClusterRenderer(context, map, clusterManager) { iconDescriptors[it] } - clusterManager.renderer = renderer - rendererState.value = renderer - } - - // Keep listeners current — the lambdas can change across recompositions. - SideEffect { - clusterManager.setOnClusterClickListener { cluster -> onClusterClick(cluster) } - clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) } - } - - // Re-cluster once the renderer is attached and freshly-baked icons arrive, so markers pick up the bitmaps - // even when the cluster manager became available after the icons were rendered. - val renderer = rendererState.value - LaunchedEffect(renderer, iconDescriptors.size) { if (renderer != null) clusterManager.cluster() } - - Clustering(items = nodeClusterItems, clusterManager = clusterManager) - - // Precision circles for the currently-unclustered items (the renderer tracks them as the zoom changes). - if (mapFilterState.showPrecisionCircle) { - renderer?.unclusteredItems?.value?.forEach { item -> + Clustering( + items = nodeClusterItems, + onClusterClick = onClusterClick, + onClusterItemInfoWindowClick = { item -> navigateToNodeDetails(item.node.num) }, + clusterItemContent = { item -> + ClusteringMarkerProperties(zIndex = item.zIndex) + PulsingNodeChip(node = item.node) + }, + clusterItemDecoration = { item -> + if (mapFilterState.showPrecisionCircle) { item.getPrecisionMeters()?.let { precisionMeters -> if (precisionMeters > 0) { Circle( @@ -123,44 +73,14 @@ fun NodeClusterMarkers( } } } - } - } -} - -/** - * [DefaultClusterRenderer] that assigns the pre-baked [BitmapDescriptor]s (rendered in the maps compose scope) to - * non-clustered item markers, and exposes the set of currently-unclustered items so the caller can decorate them (e.g. - * precision circles). Cluster bubbles keep the library's default rendering. - */ -private class NodeClusterRenderer( - context: Context, - map: GoogleMap, - clusterManager: ClusterManager, - private val iconProvider: (Int) -> BitmapDescriptor?, -) : DefaultClusterRenderer(context, map, clusterManager) { - - val unclusteredItems = mutableStateOf>(emptySet()) - - init { - minClusterSize = MIN_CLUSTER_SIZE - } - - override fun onClustersChanged(clusters: Set>) { - super.onClustersChanged(clusters) - unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet() - } - - override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) { - // super sets title/snippet from the ClusterItem, which drives the native info window. - super.onBeforeClusterItemRendered(item, markerOptions) - iconProvider(item.node.num)?.let { markerOptions.icon(it) } - markerOptions.zIndex(item.getZIndex()) - } - - override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) { - // super keeps title/snippet (and the open info window) in sync. - super.onClusterItemUpdated(item, marker) - iconProvider(item.node.num)?.let { marker.setIcon(it) } - marker.zIndex = item.getZIndex() - } + }, + onClusterManager = { clusterManager -> + // The library renderer extends DefaultClusterRenderer; raise its clustering threshold once. + val renderer = clusterManager.renderer as? DefaultClusterRenderer<*> + if (renderer != null && renderer.minClusterSize != MIN_CLUSTER_SIZE) { + renderer.minClusterSize = MIN_CLUSTER_SIZE + clusterManager.cluster() + } + }, + ) } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt index c43dff841d..5b9a293202 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/model/NodeClusterItem.kt @@ -27,24 +27,32 @@ data class NodeClusterItem( val nodeSnippet: String, val myNodeNum: Int? = null, ) : ClusterItem { - override fun getPosition(): LatLng = nodePosition + override val position: LatLng + get() = nodePosition - override fun getTitle(): String = nodeTitle + override val title: String + get() = nodeTitle - override fun getSnippet(): String = nodeSnippet + override val snippet: String + get() = nodeSnippet - override fun getZIndex(): Float = when { - node.num == myNodeNum -> 5.0f + override val zIndex: Float + get() = + when { + node.num == myNodeNum -> 5.0f - // My node is always highest - node.isFavorite -> 5.0f + // My node is always highest + node.isFavorite -> 5.0f - // Favorites are equally high priority - else -> 4.0f - } + // Favorites are equally high priority + else -> 4.0f + } - fun getPrecisionMeters(): Double? { - val precisionMap = + fun getPrecisionMeters(): Double? = PRECISION_METERS_BY_BITS[node.position.precision_bits] + + companion object { + // Allocated once: this lookup runs for every unclustered item on each cluster pass. + private val PRECISION_METERS_BY_BITS = mapOf( 10 to 23345.484932, 11 to 11672.7369, @@ -57,6 +65,5 @@ data class NodeClusterItem( 18 to 91.182212, 19 to 45.58554, ) - return precisionMap[this.node.position.precision_bits] } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40864b26d8..7d20f44c6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,7 +62,7 @@ androidx-compose-bom-aligned = "1.11.4" jetbrains-adaptive = "1.3.0-beta02" # Google -maps-compose = "8.3.1" +maps-compose = "8.4.0" # ML Kit mlkit-barcode-scanning = "17.3.0"