mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-05 20:45:22 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user