Skip to content

Commit e3e0945

Browse files
jamesarichclaude
andauthored
fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 94f6301 commit e3e0945

8 files changed

Lines changed: 143 additions & 268 deletions

File tree

.agent_memory/session_context.archive.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
# Older handover entries rotated out of session_context.md. Not read by default.
33
# Consult only if you need historical detail on a specific past change.
44

5+
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
6+
- 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"`.
7+
- 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 = ...)`.
8+
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
9+
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
10+
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
11+
512
## 2026-05-20 — Decoupled and Isolated Flatpak manifest generation logic to build-logic/flatpak
613
- Isolated the optimized `GenerateFlatpakSourcesTask` from monolithic `build-logic/convention` into its own specialized, lightweight `:flatpak` subproject under `build-logic`.
714
- Created `:flatpak` configuration and registered the formal plugin ID `"meshtastic.flatpak"` implemented by `FlatpakConventionPlugin` inside the default package namespace.

.agent_memory/session_context.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
# the oldest entries to `session_context.archive.md` (not read by default). The
77
# "Golden Context" block at the bottom is stable across sessions; keep it here.
88

9+
## 2026-06-03 — Cluster-marker FATAL: revert shipped map series + in-scope rememberComposeBitmapDescriptor fix
10+
- 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.
11+
- 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.
12+
- 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.
13+
- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
14+
915
## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
1016
- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
1117
- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
@@ -33,13 +39,6 @@
3339
- Refactored `GeminiNanoDocAssistant.answer` to reuse `answerStream` flow under the hood, eliminating duplicate prompting code.
3440
- Verified that all unit tests (`:feature:docs:allTests`) and static analysis checks (`spotlessApply spotlessCheck detekt`) pass 100% green.
3541

36-
## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
37-
- 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"`.
38-
- 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 = ...)`.
39-
- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
40-
- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
41-
- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
42-
4342
## Golden Context (stable across sessions)
4443
- Always check `.skills/compose-ui/strings-index.txt` before reading `strings.xml`.
4544
- Run `python3 scripts/sort-strings.py` after adding strings to keep the index organized.

androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt

Lines changed: 18 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
7878
import com.google.maps.android.compose.MapType
7979
import com.google.maps.android.compose.MapUiSettings
8080
import com.google.maps.android.compose.MapsComposeExperimentalApi
81-
import com.google.maps.android.compose.Marker
81+
import com.google.maps.android.compose.MarkerComposable
8282
import com.google.maps.android.compose.MarkerInfoWindowComposable
8383
import com.google.maps.android.compose.Polyline
8484
import com.google.maps.android.compose.TileOverlay
@@ -102,7 +102,6 @@ import org.meshtastic.app.map.component.MapTypeDropdown
102102
import org.meshtastic.app.map.component.NodeClusterMarkers
103103
import org.meshtastic.app.map.component.NodeMapFilterDropdown
104104
import org.meshtastic.app.map.component.WaypointMarkers
105-
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
106105
import org.meshtastic.app.map.model.NodeClusterItem
107106
import org.meshtastic.core.common.util.nowMillis
108107
import org.meshtastic.core.common.util.nowSeconds
@@ -126,6 +125,7 @@ import org.meshtastic.core.resources.sats
126125
import org.meshtastic.core.resources.speed
127126
import org.meshtastic.core.resources.timestamp
128127
import org.meshtastic.core.resources.track_point
128+
import org.meshtastic.core.ui.component.NodeChip
129129
import org.meshtastic.core.ui.icon.Layers
130130
import org.meshtastic.core.ui.icon.Map
131131
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -186,7 +186,6 @@ fun MapView(
186186
mode: GoogleMapMode = GoogleMapMode.Main,
187187
) {
188188
val context = LocalContext.current
189-
190189
val coroutineScope = rememberCoroutineScope()
191190
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
192191

@@ -493,31 +492,7 @@ fun MapView(
493492
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
494493
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
495494

496-
// Resolve the selected custom tile provider once (cached). getTileProvider returns null when the
497-
// configured source is unusable (bad {x}/{y}/{z} URL template, missing local MBTiles file, etc.).
498-
val customTileConfigs by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
499-
val customTileProvider =
500-
remember(currentCustomTileProviderUrl, customTileConfigs) {
501-
currentCustomTileProviderUrl?.let { url ->
502-
val config = customTileConfigs.find { it.urlTemplate == url || it.localUri == url }
503-
mapViewModel.getTileProvider(config)
504-
}
505-
}
506-
507-
// Only blank the Google base map (MapType.NONE) when we actually have a working custom basemap to draw
508-
// over it. If the selected custom source failed to build, fall back to the user's base map instead of
509-
// rendering MapType.NONE with no tiles — that is a solid black screen with no recourse.
510-
val effectiveGoogleMapType = if (customTileProvider != null) MapType.NONE else selectedGoogleMapType
511-
512-
// Surface the fallback so a broken custom tile source is diagnosable instead of a silent black map.
513-
LaunchedEffect(currentCustomTileProviderUrl, customTileProvider) {
514-
if (currentCustomTileProviderUrl != null && customTileProvider == null) {
515-
Logger.withTag("MapView").w {
516-
"Custom tile provider '$currentCustomTileProviderUrl' could not be built; " +
517-
"falling back to base map $selectedGoogleMapType"
518-
}
519-
}
520-
}
495+
val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
521496

522497
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
523498

@@ -566,11 +541,16 @@ fun MapView(
566541
}
567542
},
568543
) {
569-
// Custom tile overlay (all modes) — uses the hoisted provider so the base-map decision above and
570-
// this overlay stay consistent (no overlay ⇒ base map is shown, never a black MapType.NONE).
544+
// Custom tile overlay (all modes)
571545
key(currentCustomTileProviderUrl) {
572-
customTileProvider?.let { tileProvider ->
573-
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
546+
currentCustomTileProviderUrl?.let { url ->
547+
val config =
548+
mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
549+
it.urlTemplate == url || it.localUri == url
550+
}
551+
mapViewModel.getTileProvider(config)?.let { tileProvider ->
552+
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
553+
}
574554
}
575555
}
576556

@@ -884,17 +864,17 @@ private fun NodeTrackOverlay(
884864
}
885865

886866
if (index == sortedPositions.lastIndex) {
887-
val chipIcon = rememberNodeChipDescriptor(focusedNode)
888-
Marker(
867+
MarkerComposable(
889868
state = markerState,
890-
icon = chipIcon,
891869
zIndex = activeNodeZIndex,
892870
alpha = if (isHighPriority) 1.0f else 0.9f,
893871
onClick = {
894872
onPositionSelected?.invoke(position.time)
895873
false // Allow default info window behavior
896874
},
897-
)
875+
) {
876+
NodeChip(node = focusedNode)
877+
}
898878
} else {
899879
MarkerInfoWindowComposable(
900880
state = markerState,
@@ -989,6 +969,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
989969

990970
// region --- Traceroute Map Content ---
991971

972+
@OptIn(MapsComposeExperimentalApi::class)
992973
@Composable
993974
private fun TracerouteMapContent(
994975
forwardOffsetPoints: List<LatLng>,
@@ -1017,8 +998,7 @@ private fun TracerouteMapContent(
1017998
}
1018999
displayNodes.forEach { node ->
10191000
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
1020-
val chipIcon = rememberNodeChipDescriptor(node)
1021-
Marker(state = markerState, icon = chipIcon, zIndex = 4f)
1001+
MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
10221002
}
10231003
}
10241004

androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt

Lines changed: 0 additions & 139 deletions
This file was deleted.

0 commit comments

Comments
 (0)