fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-03 06:36:09 -05:00
committed by GitHub
parent 94f6301ded
commit e3e09452dd
8 changed files with 141 additions and 266 deletions

View File

@@ -2,6 +2,13 @@
# Older handover entries rotated out of session_context.md. Not read by default.
# Consult only if you need historical detail on a specific past change.
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
## 2026-05-20 — Decoupled and Isolated Flatpak manifest generation logic to build-logic/flatpak
- Isolated the optimized `GenerateFlatpakSourcesTask` from monolithic `build-logic/convention` into its own specialized, lightweight `:flatpak` subproject under `build-logic`.
- Created `:flatpak` configuration and registered the formal plugin ID `"meshtastic.flatpak"` implemented by `FlatpakConventionPlugin` inside the default package namespace.

View File

@@ -6,6 +6,12 @@
# the oldest entries to `session_context.archive.md` (not read by default). The
# "Golden Context" block at the bottom is stable across sessions; keep it here.
## 2026-06-03 — Cluster-marker FATAL: revert shipped map series + in-scope rememberComposeBitmapDescriptor fix
- Reverted ALL google-flavor map changes to before #5684 (per user): restored MapView.kt, NodeClusterMarkers.kt, WaypointMarkers.kt, InlineMap.kt to parent commit bc9f1637; deleted MarkerBitmapRenderer.kt; re-pinned `play-services-maps = 20.0.0` in libs.versions.toml. The shipped #5702#5719 series (Canvas markers + ViewTree-owner band-aids) had lost the info-window popups + interactions.
- Root cause (verified against maps-compose 8.3.0 + android-maps-utils 4.1.1 SOURCE in gradle cache): ONLY `Clustering(clusterItemContent=…)` crashes — its `ComposeUiClusterRenderer` builds a *detached* `InvalidatingComposeView` with a fake lifecycle owner and NO SavedStateRegistryOwner. `MarkerComposable` already bakes its icon via the safe in-scope `rememberComposeBitmapDescriptor`; info windows render with the live marker compositionContext. So InlineMap/NodeTrack/Traceroute were left untouched.
- Fix (NodeClusterMarkers.kt ONLY): icons baked in-scope via `rememberComposeBitmapDescriptor(node){ PulsingNodeChip }` into a snapshot stateMap; custom `private class NodeClusterRenderer : DefaultClusterRenderer` assigns them in onBeforeClusterItemRendered/onClusterItemUpdated (bg thread, READ-only — never composes, so the crash class is gone). Native info windows (super sets title/snippet) + onClusterItemInfoWindowClick→navigateToNodeDetails; precision circles drawn from the renderer's own `unclusteredItems` MutableState (clusterItemDecoration can't fire — `ClusterRendererItemState` is lib-internal). Strictly better than the elegant-euler Canvas branch — keeps the REAL Compose chip.
- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
@@ -33,13 +39,6 @@
- Refactored `GeminiNanoDocAssistant.answer` to reuse `answerStream` flow under the hood, eliminating duplicate prompting code.
- Verified that all unit tests (`:feature:docs:allTests`) and static analysis checks (`spotlessApply spotlessCheck detekt`) pass 100% green.
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
## Golden Context (stable across sessions)
- Always check `.skills/compose-ui/strings-index.txt` before reading `strings.xml`.
- Run `python3 scripts/sort-strings.py` after adding strings to keep the index organized.

View File

@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
@@ -102,7 +102,6 @@ import org.meshtastic.app.map.component.MapTypeDropdown
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.component.rememberNodeChipDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -126,6 +125,7 @@ import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -186,7 +186,6 @@ fun MapView(
mode: GoogleMapMode = GoogleMapMode.Main,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
@@ -493,31 +492,7 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
// Resolve the selected custom tile provider once (cached). getTileProvider returns null when the
// configured source is unusable (bad {x}/{y}/{z} URL template, missing local MBTiles file, etc.).
val customTileConfigs by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
val customTileProvider =
remember(currentCustomTileProviderUrl, customTileConfigs) {
currentCustomTileProviderUrl?.let { url ->
val config = customTileConfigs.find { it.urlTemplate == url || it.localUri == url }
mapViewModel.getTileProvider(config)
}
}
// Only blank the Google base map (MapType.NONE) when we actually have a working custom basemap to draw
// over it. If the selected custom source failed to build, fall back to the user's base map instead of
// rendering MapType.NONE with no tiles — that is a solid black screen with no recourse.
val effectiveGoogleMapType = if (customTileProvider != null) MapType.NONE else selectedGoogleMapType
// Surface the fallback so a broken custom tile source is diagnosable instead of a silent black map.
LaunchedEffect(currentCustomTileProviderUrl, customTileProvider) {
if (currentCustomTileProviderUrl != null && customTileProvider == null) {
Logger.withTag("MapView").w {
"Custom tile provider '$currentCustomTileProviderUrl' could not be built; " +
"falling back to base map $selectedGoogleMapType"
}
}
}
val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
@@ -566,11 +541,16 @@ fun MapView(
}
},
) {
// Custom tile overlay (all modes) — uses the hoisted provider so the base-map decision above and
// this overlay stay consistent (no overlay ⇒ base map is shown, never a black MapType.NONE).
// Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
customTileProvider?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
currentCustomTileProviderUrl?.let { url ->
val config =
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
it.urlTemplate == url || it.localUri == url
}
mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
}
@@ -884,17 +864,17 @@ private fun NodeTrackOverlay(
}
if (index == sortedPositions.lastIndex) {
val chipIcon = rememberNodeChipDescriptor(focusedNode)
Marker(
MarkerComposable(
state = markerState,
icon = chipIcon,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
)
) {
NodeChip(node = focusedNode)
}
} else {
MarkerInfoWindowComposable(
state = markerState,
@@ -989,6 +969,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
// region --- Traceroute Map Content ---
@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun TracerouteMapContent(
forwardOffsetPoints: List<LatLng>,
@@ -1017,8 +998,7 @@ private fun TracerouteMapContent(
}
displayNodes.forEach { node ->
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
val chipIcon = rememberNodeChipDescriptor(node)
Marker(state = markerState, icon = chipIcon, zIndex = 4f)
MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
}
}

View File

@@ -1,139 +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 android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.core.graphics.createBitmap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import org.meshtastic.core.model.Node
private const val CHIP_CORNER_RADIUS_DP = 4f
private const val CHIP_PADDING_HORIZONTAL_DP = 8f
private const val CHIP_MIN_WIDTH_DP = 64f
private const val CHIP_MIN_HEIGHT_DP = 28f
private const val CHIP_TEXT_SIZE_SP = 14f
private const val EMOJI_TEXT_SIZE_SP = 32f
private const val EMOJI_PADDING_DP = 2f
/**
* Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
* maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
* measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
*/
@Composable
fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
buildNodeChipDescriptor(node, density, fontScale)
}
}
/**
* Non-`@Composable` variant of [rememberNodeChipDescriptor] for callers that have no composition to read [LocalDensity]
* from — specifically a [com.google.maps.android.clustering.view.DefaultClusterRenderer] building marker icons on its
* background render thread. Keeping the cluster icon on this Canvas path (instead of maps-compose's
* `clusterItemContent` Composable) avoids the off-screen ComposeView in `ComposeUiClusterRenderer`, which has no
* reachable `ViewTreeLifecycleOwner` from the async render Handler and was our top FATAL
* (googlemaps/android-maps-compose#325/#875).
*
* [BitmapDescriptorFactory] only works once the Maps SDK is initialized — which the SDK does when a GoogleMap creates
* its MapView. Every descriptor is built for markers of an on-screen map (this cluster path runs via `MapEffect` on a
* live map; the `@Composable` variants compose inside a `GoogleMap` content lambda), so the SDK is always ready by
* then.
*/
fun buildNodeChipDescriptor(node: Node, density: Float, fontScale: Float): BitmapDescriptor =
renderNodeChipBitmap(node, density, fontScale)
/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
@Composable
fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(codePoint) { renderEmojiBitmap(codePoint, density, fontScale) }
}
private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
val (textColorInt, nodeColorInt) = node.colors
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = CHIP_TEXT_SIZE_SP * scaledDensity
typeface = Typeface.DEFAULT_BOLD
color = textColorInt
textAlign = Paint.Align.CENTER
isStrikeThruText = node.isIgnored
}
val label = node.user.short_name.ifEmpty { "???" }
val textWidth = textPaint.measureText(label)
val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
val minWidth = CHIP_MIN_WIDTH_DP * density
val minHeight = CHIP_MIN_HEIGHT_DP * density
val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
val height = minHeight.toInt()
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
val cornerRadius = CHIP_CORNER_RADIUS_DP * density
canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
val textX = width / 2f
val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
canvas.drawText(label, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
textAlign = Paint.Align.CENTER
}
val emoji = String(Character.toChars(codePoint))
val padding = EMOJI_PADDING_DP * density
val textWidth = textPaint.measureText(emoji)
val metrics = textPaint.fontMetrics
val textHeight = metrics.descent - metrics.ascent
val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val textX = width / 2f
val textY = padding - metrics.ascent
canvas.drawText(emoji, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}

View File

@@ -18,15 +18,17 @@ package org.meshtastic.app.map.component
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
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 androidx.compose.ui.platform.LocalDensity
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
@@ -37,17 +39,28 @@ 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 org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
private const val MIN_CLUSTER_SIZE = 10
// Match the bottom-center anchor maps-compose used for the old `clusterItemContent` chip
// (clusterItemContentAnchor defaults to Offset(0.5f, 1.0f)), so chips keep sitting on the node coordinate.
private const val CHIP_ANCHOR_U = 0.5f
private const val CHIP_ANCHOR_V = 1.0f
/**
* Renders node markers with clustering.
*
* 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].
*
* 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.
*/
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
@@ -56,54 +69,58 @@ fun NodeClusterMarkers(
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val context = LocalContext.current
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
val clusterManager = rememberClusterManager<NodeClusterItem>()
val clusterManager = rememberClusterManager<NodeClusterItem>() ?: return
// Render each non-clustered node as a Canvas-built BitmapDescriptor through a custom
// DefaultClusterRenderer instead of passing a `clusterItemContent` Composable. The Composable path makes
// maps-compose rasterize the chip through an off-screen ComposeView (ComposeUiClusterRenderer); that view
// walks the view tree for a ViewTreeLifecycleOwner/SavedStateRegistryOwner and, because the cluster
// renderer drives marker creation from an async Handler after the screen may have stopped, finds none and
// crashes with "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!"
// (googlemaps/android-maps-compose#325 / #875) — historically our #1 FATAL. A renderer that paints the icon
// in onBeforeClusterItemRendered never creates a View, so the crash class is eliminated rather than raced
// against (see the owner-propagation workarounds in #5704/#5708 that could not win that race).
val rendererState: MutableState<NodeChipClusterRenderer?> = remember { mutableStateOf(null) }
MapEffect(clusterManager, density, fontScale) { map ->
val renderer = NodeChipClusterRenderer(context, map, clusterManager, density, fontScale)
renderer.minClusterSize = MIN_CLUSTER_SIZE
clusterManager.renderer = renderer
rendererState.value = renderer
// 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<Int, BitmapDescriptor>() }
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) }
}
}
}
SideEffect {
clusterManager.setOnClusterClickListener(onClusterClick)
clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
}
if (clusterManager != null) {
val rendererState = remember { mutableStateOf<NodeClusterRenderer?>(null) }
Clustering(items = nodeClusterItems, clusterManager = clusterManager)
// 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
}
// The library's `clusterItemDecoration` only fires for its internal ComposeUiClusterRenderer (the gating
// ClusterRendererItemState type is library-internal), so it never runs for our custom renderer. Draw the
// precision circles ourselves for exactly the unclustered items the renderer exposes, preserving the prior
// "circle only on non-clustered nodes" behavior.
val renderer = rendererState.value
if (renderer != null && mapFilterState.showPrecisionCircle) {
val unclusteredItems by renderer.unclusteredItems
unclusteredItems.forEach { item ->
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = item.position,
radius = precisionMeters,
fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(item.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
// 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 ->
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
center = item.position,
radius = precisionMeters,
fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
strokeColor = Color(item.node.colors.second),
strokeWidth = 2f,
zIndex = 0f,
)
}
}
}
}
@@ -111,37 +128,39 @@ fun NodeClusterMarkers(
}
/**
* A [DefaultClusterRenderer] that draws each non-clustered node's chip as a Canvas [BitmapDescriptor]
* ([buildNodeChipDescriptor]) instead of a Composable, avoiding maps-compose's crash-prone off-screen ComposeView
* rasterization. It also exposes the current set of unclustered items so the caller can draw precision circles (the
* library's `clusterItemDecoration` hook is unavailable to non-library renderers).
* [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 NodeChipClusterRenderer(
private class NodeClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<NodeClusterItem>,
private val density: Float,
private val fontScale: Float,
private val iconProvider: (Int) -> BitmapDescriptor?,
) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
val unclusteredItems: MutableState<Set<NodeClusterItem>> = mutableStateOf(emptySet())
val unclusteredItems = mutableStateOf<Set<NodeClusterItem>>(emptySet())
// Called on a background render thread — building the descriptor here keeps marker rasterization off the
// main thread, and BitmapDescriptorFactory is safe once the SDK is initialized (the live map guarantees it).
override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
markerOptions
.icon(buildNodeChipDescriptor(item.node, density, fontScale))
.anchor(CHIP_ANCHOR_U, CHIP_ANCHOR_V)
.zIndex(item.getZIndex())
}
override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
marker.setIcon(buildNodeChipDescriptor(item.node, density, fontScale))
marker.zIndex = item.getZIndex()
init {
minClusterSize = MIN_CLUSTER_SIZE
}
override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
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()
}
}

View File

@@ -16,14 +16,22 @@
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
@@ -31,6 +39,7 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
@@ -56,7 +65,11 @@ fun WaypointMarkers(
}
val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
Marker(
state = markerState,

View File

@@ -27,15 +27,17 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
@@ -77,11 +79,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeWidth = 2f,
)
}
// Build the marker icon inside the map content: by the time this composes, the GoogleMap has created its
// MapView and initialized the Maps SDK, so BitmapDescriptorFactory is ready. Building it before the map
// (outside this lambda) is what crashed the inline map in #5702 / #5709.
val markerIcon = rememberNodeChipDescriptor(node)
Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}

View File

@@ -178,9 +178,7 @@ maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", ve
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
# No version pin: maps-compose (via maps-ktx) already pulls the play-services-maps version it was built
# against. Pinning here risks forcing a version maps-compose was not tested with — let the library drive it.
play-services-maps = { module = "com.google.android.gms:play-services-maps" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" }