From e3e09452ddbfaba3f67b8df18394afb681569307 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:36:09 -0500 Subject: [PATCH] fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723) Co-authored-by: Claude Opus 4.8 (1M context) --- .agent_memory/session_context.archive.md | 7 + .agent_memory/session_context.md | 13 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 56 ++---- .../app/map/component/MarkerBitmapRenderer.kt | 139 --------------- .../app/map/component/NodeClusterMarkers.kt | 161 ++++++++++-------- .../app/map/component/WaypointMarkers.kt | 15 +- .../app/node/component/InlineMap.kt | 12 +- gradle/libs.versions.toml | 4 +- 8 files changed, 141 insertions(+), 266 deletions(-) delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt diff --git a/.agent_memory/session_context.archive.md b/.agent_memory/session_context.archive.md index 90cf98f4c1..bda6af55ef 100644 --- a/.agent_memory/session_context.archive.md +++ b/.agent_memory/session_context.archive.md @@ -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. diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index 912c57e4e6..b558448f0f 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -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. 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 31ff21ade6..6ca71eb6a5 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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?>(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, @@ -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) } } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt deleted file mode 100644 index 60ff875959..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt +++ /dev/null @@ -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 . - */ -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) -} 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 682984c1de..0e3f872be2 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 @@ -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, @@ -56,54 +69,58 @@ fun NodeClusterMarkers( onClusterClick: (Cluster) -> Boolean, ) { val context = LocalContext.current - val density = LocalDensity.current.density - val fontScale = LocalDensity.current.fontScale + val clusterManager = rememberClusterManager() - val clusterManager = rememberClusterManager() ?: 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 = 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() } + 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(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, - private val density: Float, - private val fontScale: Float, + private val iconProvider: (Int) -> BitmapDescriptor?, ) : DefaultClusterRenderer(context, map, clusterManager) { - val unclusteredItems: MutableState> = mutableStateOf(emptySet()) + val unclusteredItems = mutableStateOf>(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>) { 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() + } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index ed1044fc2e..227805ebdb 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt @@ -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, @@ -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, diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt index 6dbc0510ca..9930820676 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt @@ -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) } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d378d6af8..713b34fc29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }