From ef4ef8e2f59f5e1468673174729d75992504d4c7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:11:17 -0500 Subject: [PATCH 01/15] chore(deps): update core/proto/src/main/proto digest to 6b1ded4 (#5712) --- core/proto/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto index c4540bb762..6b1ded4396 160000 --- a/core/proto/src/main/proto +++ b/core/proto/src/main/proto @@ -1 +1 @@ -Subproject commit c4540bb762e5c3bdced003c5357b9739a114fc21 +Subproject commit 6b1ded439633cd03d4af85b44231b91d1d106278 From c45466a633d6e34df3a0a27c83e169c7eb91e481 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:11:36 -0500 Subject: [PATCH 02/15] chore(deps): update compose-multiplatform to v1.11.1 (#5713) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 16324a9850..b8d7d5050d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ turbine = "1.2.1" compose-screenshot = "0.0.1-alpha15" # Compose Multiplatform -compose-multiplatform = "1.11.0" +compose-multiplatform = "1.11.1" compose-multiplatform-material3 = "1.11.0-alpha07" # `androidx-compose-bom-aligned` tracks androidx.compose.{runtime,ui,foundation,animation} # artifacts that ship in lockstep with CMP. Kept as a separate version ref so Renovate From 0f123adb72d3d13a208959a17f7d9dd40809804b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:12:35 -0500 Subject: [PATCH 03/15] fix(map): eliminate cluster-renderer FATAL and harden black-map paths (#5715) Co-authored-by: Claude Opus 4.8 (1M context) --- .../kotlin/org/meshtastic/app/map/MapView.kt | 44 ++++- .../meshtastic/app/map/MapsSdkInitializer.kt | 61 ++++++ .../app/map/component/MarkerBitmapRenderer.kt | 25 ++- .../app/map/component/NodeClusterMarkers.kt | 173 +++++++++++------- 4 files changed, 216 insertions(+), 87 deletions(-) create mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt 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 40f2756d9d..3c76f7b00b 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -186,6 +186,11 @@ fun MapView( mode: GoogleMapMode = GoogleMapMode.Main, ) { val context = LocalContext.current + + // Initialize the Maps SDK up front (idempotent) so the loaded renderer is logged even when the mesh has + // no nodes/waypoints to build marker descriptors from. See MapsSdkInitializer. + LaunchedEffect(Unit) { MapsSdkInitializer.ensureInitialized(context) } + val coroutineScope = rememberCoroutineScope() val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() @@ -492,7 +497,31 @@ fun MapView( val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) } val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) } - val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType + // 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" + } + } + } var showClusterItemsDialog by remember { mutableStateOf?>(null) } @@ -541,16 +570,11 @@ fun MapView( } }, ) { - // Custom tile overlay (all modes) + // 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). key(currentCustomTileProviderUrl) { - 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) - } + customTileProvider?.let { tileProvider -> + TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f) } } diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt new file mode 100644 index 0000000000..c8fad692ac --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt @@ -0,0 +1,61 @@ +/* + * 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 + +import android.content.Context +import co.touchlab.kermit.Logger +import com.google.android.gms.maps.MapsInitializer +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Centralized, run-once Google Maps SDK initialization for the google flavor. + * + * Two things happen here, deliberately decoupled: + * 1. **Synchronous init** via the single-arg [MapsInitializer.initialize] overload. This is the only overload + * documented as synchronous, and it guarantees `BitmapDescriptorFactory` is ready before any eager Canvas descriptor + * is built off a live `GoogleMap` (the node-detail inline map builds its icon before its map loads — see #5709 and + * `MarkerBitmapRenderer`). It is idempotent, so repeated calls are no-ops. + * 2. **Renderer reporting** via the callback overload, registered exactly once. We can no longer *force* a renderer + * (the LEGACY renderer was decommissioned in March 2025, so a preference is honored only as a hint), but the + * documented "Latest renderer" tile-rendering failures can still leave the base map blank on some devices. Logging + * which renderer actually loaded lets us correlate "black map" field reports in Crashlytics/Datadog. Kermit's + * [Logger] is the sink because `GooglePlatformAnalytics` wires its Crashlytics/Datadog log writers at startup while + * delaying SDK init until consent — so logging through Kermit is the privacy-correct, already-sanctioned path (vs. + * touching `Firebase.crashlytics` directly). + */ +object MapsSdkInitializer { + + private val callbackRegistered = AtomicBoolean(false) + + fun ensureInitialized(context: Context) { + val app = context.applicationContext + + // (1) Synchronous readiness guarantee — see kdoc. Deprecated overload retained intentionally. + @Suppress("DEPRECATION") + MapsInitializer.initialize(app) + + // (2) Register the renderer-reporting callback once. The SDK is already initialized above, so the + // callback fires promptly with the renderer that actually loaded. + if (callbackRegistered.compareAndSet(false, true)) { + MapsInitializer.initialize(app, MapsInitializer.Renderer.LATEST) { renderer -> + Logger.withTag(TAG).i { "Google Maps renderer loaded: $renderer" } + } + } + } + + private const val TAG = "MapsSdkInitializer" +} 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 index 9b0c161eb8..baf8070705 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt @@ -27,9 +27,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.core.graphics.createBitmap -import com.google.android.gms.maps.MapsInitializer import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory +import org.meshtastic.app.map.MapsSdkInitializer import org.meshtastic.core.model.Node private const val CHIP_CORNER_RADIUS_DP = 4f @@ -51,11 +51,23 @@ 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) { - ensureMapsInitialized(context) - renderNodeChipBitmap(node, density, fontScale) + buildNodeChipDescriptor(context, node, density, fontScale) } } +/** + * Non-`@Composable` variant of [rememberNodeChipDescriptor] for callers that have no composition to read + * [LocalContext]/[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). + */ +fun buildNodeChipDescriptor(context: Context, node: Node, density: Float, fontScale: Float): BitmapDescriptor { + ensureMapsInitialized(context) + return renderNodeChipBitmap(node, density, fontScale) +} + /** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */ @Composable fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor { @@ -72,12 +84,11 @@ fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor { * [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a * GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the * icon is computed before that screen's GoogleMap has loaded the SDK — so [BitmapDescriptorFactory.fromBitmap] crashes - * with "IBitmapDescriptorFactory is not initialized". Initialize explicitly first; [MapsInitializer.initialize] is - * synchronous and idempotent, so it is a no-op once the SDK is already up. + * with "IBitmapDescriptorFactory is not initialized". Delegate to [MapsSdkInitializer], which initializes the SDK + * synchronously and idempotently (and reports the loaded renderer), so this is a no-op once the SDK is already up. */ -@Suppress("DEPRECATION") private fun ensureMapsInitialized(context: Context) { - MapsInitializer.initialize(context) + MapsSdkInitializer.ensureInitialized(context) } private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor { 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 6d38e176af..e160fb6f55 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -16,30 +16,38 @@ */ package org.meshtastic.app.map.component +import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.currentStateAsState -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner +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.Marker +import com.google.android.gms.maps.model.MarkerOptions import com.google.maps.android.clustering.Cluster +import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.Circle +import com.google.maps.android.compose.MapEffect import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.clustering.Clustering -import com.google.maps.android.compose.clustering.ClusteringMarkerProperties +import com.google.maps.android.compose.clustering.rememberClusterManager 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 + @OptIn(MapsComposeExperimentalApi::class) -@Suppress("NestedBlockDepth") @Composable fun NodeClusterMarkers( nodeClusterItems: List, @@ -47,68 +55,93 @@ fun NodeClusterMarkers( navigateToNodeDetails: (Int) -> Unit, onClusterClick: (Cluster) -> Boolean, ) { - val view = LocalView.current - val lifecycleOwner = LocalLifecycleOwner.current - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() + val context = LocalContext.current + val density = LocalDensity.current.density + val fontScale = LocalDensity.current.fontScale - // maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that - // it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in - // MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and, - // when it finds none, crashes with "Composed into the View which doesn't propagate - // ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) — a FATAL on the map screen. - // - // Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor - // of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately - // do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is - // what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader - // workaround entirely. Scoping to the map host view and restoring the previous owners on dispose - // keeps the fix local to the map and leaves Popups/DropdownMenus untouched. - DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) { - val prevLifecycleOwner = view.findViewTreeLifecycleOwner() - val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner() - view.setViewTreeLifecycleOwner(lifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - onDispose { - view.setViewTreeLifecycleOwner(prevLifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner) - } + 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 } - // The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's - // MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is - // detached — at which point no owner is reachable regardless of the above. Skip rendering once the - // lifecycle is no longer at least STARTED to close most of that race. - if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return + SideEffect { + clusterManager.setOnClusterClickListener(onClusterClick) + clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) } + } - Clustering( - items = nodeClusterItems, - onClusterClick = onClusterClick, - onClusterItemInfoWindowClick = { item -> - navigateToNodeDetails(item.node.num) - false - }, - clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, - onClusterManager = { clusterManager -> - (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 - }, - clusterItemDecoration = { clusterItem -> - if (mapFilterState.showPrecisionCircle) { - clusterItem.getPrecisionMeters()?.let { precisionMeters -> - if (precisionMeters > 0) { - Circle( - center = clusterItem.position, - radius = precisionMeters, - fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f), - strokeColor = Color(clusterItem.node.colors.second), - strokeWidth = 2f, - zIndex = 0f, - ) - } + Clustering(items = nodeClusterItems, clusterManager = clusterManager) + + // 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, + ) } } - // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others) - ClusteringMarkerProperties(zIndex = clusterItem.getZIndex()) - }, - ) + } + } +} + +/** + * 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). + */ +private class NodeChipClusterRenderer( + private val context: Context, + map: GoogleMap, + clusterManager: ClusterManager, + private val density: Float, + private val fontScale: Float, +) : DefaultClusterRenderer(context, map, clusterManager) { + + val unclusteredItems: MutableState> = 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(context, item.node, density, fontScale)) + .anchor(CHIP_ANCHOR_U, CHIP_ANCHOR_V) + .zIndex(item.getZIndex()) + } + + override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) { + marker.setIcon(buildNodeChipDescriptor(context, item.node, density, fontScale)) + marker.zIndex = item.getZIndex() + } + + override fun onClustersChanged(clusters: Set>) { + super.onClustersChanged(clusters) + unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet() + } } From aeed07364c4465bede0c9d83037ac4c9518e8a56 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:46:45 -0500 Subject: [PATCH 04/15] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5711) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- docs/ru-rRU/user/settings-radio-user.md | 66 ++++++++++----------- docs/ru-rRU/user/signal-meter.md | 76 ++++++++++++------------- 2 files changed, 71 insertions(+), 71 deletions(-) diff --git a/docs/ru-rRU/user/settings-radio-user.md b/docs/ru-rRU/user/settings-radio-user.md index 6f6be20de4..2171e2597f 100644 --- a/docs/ru-rRU/user/settings-radio-user.md +++ b/docs/ru-rRU/user/settings-radio-user.md @@ -113,13 +113,13 @@ aliases: ### Настройка питания -| Настройка | Описание | -| --------------------------------------- | ------------------------------------------------- | -| Энергосбережение | Включить спящий режим с низким энергопотреблением | -| Завершение работы через | Таймер автоотключения при простое | -| Множитель ADC | Коэффициент калибровки напряжения батареи | -| Wait Bluetooth (s) | Time to wait for BLE connection at boot | -| Mesh SDS Timeout (s) | Super-deep-sleep timeout | +| Настройка | Описание | +| ----------------------- | ------------------------------------------------- | +| Энергосбережение | Включить спящий режим с низким энергопотреблением | +| Завершение работы через | Таймер автоотключения при простое | +| Множитель ADC | Коэффициент калибровки напряжения батареи | +| Ожидание Bluetooth | Время ожидания подключения BLE при загрузке | +| Тайм-аут Mesh SDS | Тайм-аут сверхглубокого сна | ### Настройка сети @@ -131,44 +131,44 @@ aliases: | NTP-сервер | Сервер синхронизации времени | | Syslog-сервер | Удалённый сервер логирования | -![IP address field](../../assets/screenshots/settings_ipv4_field.png) +![Поле IP-адреса](../../assets/screenshots/settings_ipv4_field.png) ### Настройка Bluetooth -| Настройка | Описание | -| --------------------- | ------------------------------------------------------------------------- | -| Bluetooth Enabled | Enable/disable BLE radio | -| Pairing Mode | Fixed PIN, Random PIN, or No PIN | -| Фиксированный PIN-код | PIN code for pairing (default: 123456) | +| Настройка | Описание | +| --------------------- | -------------------------------------------------------------------------------- | +| Bluetooth включен | Включение/отключение BLE радиостанции | +| Режим сопряжения | Фиксированный PIN, случайный PIN или без PIN | +| Фиксированный PIN-код | PIN-код для сопряжения (по умолчанию: 123456) | ### Настройки безопасности -| Настройка | Описание | -| ------------------------- | -------------------------------------------------------------------------- | -| Публичный ключ | Your node's public key (read-only) | -| Ключ администратора | Key for remote administration | -| Приватный ключ | Your node's private key (handle securely) | -| ~~Admin Channel Enabled~~ | ⚠️ Removed — now configured automatically when an admin key is set | -| Журнал отладки | Output live debug logging over serial/bluetooth | -| Serial Enabled | Enable serial console access (moved from Device Config) | -| Управляемый режим | Restrict non-admin channel changes | +| Настройка | Описание | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | +| Публичный ключ | Публичный ключ твоей ноды (только для чтения) | +| Ключ администратора | Ключ для удалённого администрирования | +| Приватный ключ | Приватный ключ твоей ноды (обращайся с ним осторожно) | +| ~~Канал администратора включен~~ | ⚠️ Удалено — теперь настраивается автоматически при установке ключа администратора | +| Журнал отладки | Выводить живой отладочный лог через последовательный порт/Bluetooth | +| Последовательная включена | Включить доступ к последовательной консоли (перемещено из настроек устройства) | +| Управляемый режим | Ограничить изменения канала для неадминистраторов | -![Password field](../../assets/screenshots/settings_password_field.png) +![Поле пароля](../../assets/screenshots/settings_password_field.png) -Settings use standard preference controls — dropdowns, toggles, and sliders: +Настройки используют стандартные элементы управления предпочтениями — выпадающие списки, переключатели и ползунки: -| Control | Screenshot | -| -------- | ----------------------------------------------------------- | -| Dropdown | ![Dropdown](../../assets/screenshots/settings_dropdown.png) | -| Toggle | ![Toggle](../../assets/screenshots/settings_switch.png) | -| Slider | ![Slider](../../assets/screenshots/settings_slider.png) | +| Управление | Снимок экрана | +| ----------------- | -------------------------------------------------------------------- | +| Выпадающий список | ![Выпадающий список](../../assets/screenshots/settings_dropdown.png) | +| Переключатель | ![Переключатель](../../assets/screenshots/settings_switch.png) | +| Ползунок | ![Ползунок](../../assets/screenshots/settings_slider.png) | ## Связанные темы -- [Settings — Modules & Admin](settings-module-admin) — optional feature modules and device administration -- [Signal Meter](signal-meter) — how modem presets affect signal quality thresholds -- [LoRa configuration](https://meshtastic.org/docs/configuration/radio/lora) — detailed LoRa settings reference on meshtastic.org -- [Initial configuration](https://meshtastic.org/docs/getting-started/initial-config) — region setup guide on meshtastic.org +- [Настройки — Модули и администрирование](settings-module-admin) — дополнительные модули и управление устройством +- [Измеритель сигнала](signal-meter) — как предустановки модема влияют на пороги качества сигнала +- [Конфигурация LoRa](https://meshtastic.org/docs/configuration/radio/lora) — подробная справка по настройкам LoRa на meshtastic.org +- [Начальная конфигурация](https://meshtastic.org/docs/getting-started/initial-config) — руководство по настройке региона на meshtastic.org --- diff --git a/docs/ru-rRU/user/signal-meter.md b/docs/ru-rRU/user/signal-meter.md index ece65f93ca..26c7852afd 100644 --- a/docs/ru-rRU/user/signal-meter.md +++ b/docs/ru-rRU/user/signal-meter.md @@ -1,9 +1,9 @@ --- -title: How the Meshtastic Signal Meter Works +title: Как работает измеритель сигнала Meshtastic parent: Руководство пользователя nav_order: 15 last_updated: 2026-05-13 -description: How the signal meter calculates quality from RSSI and SNR — LoRa spread spectrum, presets, and what the bars really mean. +description: Как измеритель сигнала вычисляет качество по RSSI и SNR — спектр с расширением LoRa, предустановки и что на самом деле означают эти полосы. aliases: - signal - signal-meter @@ -11,72 +11,72 @@ aliases: - rssi --- -# How the Meshtastic Signal Meter Works +# Как работает измеритель сигнала Meshtastic -The Meshtastic signal meter — the familiar bars or status color in the app — is calculated very differently than the "bars" on a traditional cell phone or Wi-Fi router. +Измеритель сигнала Meshtastic — знакомые полосы или цвет состояния в приложении — рассчитывается совсем иначе, чем "полоски" на традиционном мобильном телефоне или Wi-Fi роутере. -Most consumer devices simply measure how "loud" a signal is. However, because Meshtastic uses **LoRa (Long Range)** technology, its signal meter measures how **clear** the signal is, relative to the specific settings your mesh is using. +Большинство потребительских устройств просто измеряют, насколько "громкий" сигнал. Однако, поскольку Meshtastic использует технологию **LoRa (Long Range)**, его измеритель сигнала оценивает, насколько **чистый** сигнал, относительно конкретных настроек вашей mesh-сети. --- -## 1. The Two Metrics: "Loudness" vs. "Clarity" +## 1. Две метрики: "Громкость" против "Чёткости" -Every time the LoRa radio chip receives a message, it reports two measurements: +Каждый раз, когда радиочип LoRa получает сообщение, он сообщает два измерения: -- **RSSI (Received Signal Strength Indicator):** The **loudness** of the raw power hitting your antenna. -- **SNR (Signal-to-Noise Ratio):** The **clarity** of the signal compared to the background static. +- **RSSI (Индикатор уровня принятого сигнала):** **громкость** необработанного сигнала, поступающего на твою антенну. +- **SNR (Соотношение сигнал/шум):** **Чёткость** сигнала по сравнению с фоновым шумом. -> **Tip — The Analogy:** Imagine you are trying to hear a friend talking to you. +> **Совет — аналогия:** Представь, что ты пытаешься услышать, как друг разговаривает с тобой. > -> - **RSSI** is how loud their voice is. -> - **The Noise Floor** is the background noise in the room (air conditioning, other people talking, traffic). -> - **SNR** is how easily you can distinguish your friend's voice from the background noise. +> - **RSSI** — это громкость его голоса. +> - **Минимальный уровень шума** — это фоновый шум в комнате (кондиционер, разговоры других людей, движение транспорта). +> - **SNR** — это то, насколько легко ты можешь различить голос твоего друга на фоне шума. -If your friend shouts at you at a deafening rock concert, the signal is incredibly loud (High RSSI), but you still can't understand them because the background noise is louder (Bad SNR). Conversely, if your friend whispers to you in a dead-silent library, the signal is very weak (Low RSSI), but you can understand them perfectly (Great SNR). +Если твой друг кричит тебе на оглушительном рок-концерте, сигнал невероятно громкий (высокий RSSI), но ты всё равно не можешь его понять, потому что фоновый шум громче (плохое SNR). И наоборот, если твой друг шепчет тебе в абсолютно тихой библиотеке, сигнал очень слабый (низкий RSSI), но ты можешь его прекрасно понять (отличный SNR). --- -## 2. The Magic of LoRa: Hearing "Below the Noise Floor" +## 2. Магия LoRa: слышит "ниже уровня шума" -For standard radios (like FM or Wi-Fi), if the background noise is louder than the signal (a negative SNR), the receiver just hears static. +Для стандартных радиостанций (таких как FM или Wi-Fi), если фоновый шум громче сигнала (отрицательное отношение сигнал/шум), приемник просто слышит помехи. -LoRa is special. It uses **"Spread Spectrum"** modulation, which allows the radio to mathematically pull a signal out of the air even when it is buried deep _underneath_ the background noise. This is why you will frequently see **negative SNR numbers** in Meshtastic (e.g., -10 dB, which means the signal is 10 decibels weaker than the background static). +LoRa особенная. Она использует модуляцию **"широкополосного спектра"**, которая позволяет радио математически извлекать сигнал из воздуха даже когда он глубоко _под_ фоновым шумом. Вот почему ты часто будете видеть **отрицательные значения SNR** в Meshtastic (например, -10 дБ, что означает, что сигнал на 10 децибел слабее фонового шума). -Depending on which Meshtastic preset you are using (e.g., `LongFast` vs. `ShortFast`), the radio has a specific **SNR Limit** — the absolute maximum amount of noise it can tolerate before the message is completely lost to the static. +В зависимости от того, какой пресет Meshtastic ты используешь (например, `LongFast` или `ShortFast`), у радиоприемника есть определённый **предел SNR** — абсолютное максимальное количество шума, которое он может выдержать, прежде чем сообщение полностью потеряется в помехах. --- -## 3. How the Signal Meter Calculates Quality +## 3. Как измеритель сигнала рассчитывает качество -The Meshtastic apps take both RSSI and SNR and run them through a specific formula to assign your signal a quality rating (None, Bad, Fair, or Good). It specifically scales these values based on the physical limits of the radio preset you are using. +Приложения Meshtastic используют как RSSI, так и SNR и пропускают их через определённую формулу для присвоения сигналу оценки качества (Нет, Плохой, Удовлетворительный или Хороший). Оно специально масштабирует эти значения на основе физических ограничений используемого тобой пресета радиоприемника. -Here is exactly how the app decides how many bars (or what color) to show you: +Вот точно как приложение решает, сколько полосок (или какого цвета) показывать тебе: -| Level | Bars | Criteria | Meaning | -| ----------- | ---- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| Хороший | 3 | RSSI better than `-115 dBm` **AND** SNR better than `-7 dB` | Signal is both loud and clear — healthy connection. | -| Средний | 2 | RSSI better than `-126 dBm` with good SNR, **OR** SNR better than `-15 dB` with good RSSI | Signal getting quieter or noisier, but still decodable. | -| Плохой | 1 | Falls between Fair and None thresholds | At the edge of range or experiencing interference. | -| Отсутствует | 0 | RSSI worse than `-126 dBm` **AND** SNR worse than `-15 dB` | Transmission completely buried in noise. | +| Уровень | Деления | Критерии | Значение | +| ----------- | ------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| Хороший | 3 | RSSI лучше `-115 dBm` **И** SNR лучше `-7 dB` | Сигнал как громкий, так и четкий — здоровое соединение. | +| Средний | 2 | RSSI лучше `-126 dBm` с хорошим SNR, **ИЛИ** SNR лучше `-15 dB` с хорошим RSSI | Сигнал становится тише или громче, но всё ещё читается. | +| Плохой | 1 | Попадает между порогами Fair и None | На границе диапазона или при наличии помех. | +| Отсутствует | 0 | RSSI хуже `-126 dBm` **И** SNR хуже `-15 dB` | Передача полностью погребена в шуме. | --- -## 4. What This Means for You +## 4. Что это значит для тебя -Because Meshtastic's meter acts as a **"Clarity Meter"**, it behaves differently than what most people expect: +Поскольку измеритель Meshtastic действует как **"Измеритель чёткости"**, он ведёт себя иначе, чем ожидает большинство людей: -> **Tip — Don't panic over low RSSI:** You might see a seemingly terrible RSSI value like `-118 dBm`. On a cell phone, you would have zero bars. But if you have an SNR of `+2 dB`, Meshtastic will still show a strong signal! _The library is quiet, so the whisper is heard perfectly._ +> **Совет — не паникуй из-за низкого RSSI:** ты можешь увидеть кажущееся ужасным значение RSSI, например `-118 dBm`. На сотовом телефоне у тебя было бы ноль делений. Но если отношение сигнал/шум (SNR) `+2 дБ`, Meshtastic все равно покажет сильный сигнал! _Библиотека тихая, поэтому шепот слышен идеально._ -> **Warning — Watch out for local noise:** If you hook up a massive antenna and see a great RSSI (e.g., `-90 dBm`) but your signal meter is only showing **1 Bar (Bad)**, you have a problem. It means you have local interference — perhaps a cheap power supply, a noisy computer, or a nearby radio tower — creating so much static that it is drowning out your mesh. +> **Внимание — остерегайся местного шума:** если ты подключишь массивную антенну и увидишь отличный RSSI (например, `-90 dBm`), но индикатор сигнала показывает только **1 полоску (плохо)**, у тебя есть проблема. Это означает, что у тебя есть местные помехи — возможно, дешевый источник питания, шумный компьютер или расположенная рядом радиовышка — создающие столько статического шума, что он заглушает сеть. -## Where Signal Information Appears +## Где появляется информация о сигнале -In the app, signal data is shown in several places: +В приложении данные сигнала отображаются в нескольких местах: -- **Node list** — signal bars icon next to each node -- **Node detail** — SNR, RSSI, and signal quality in the device metrics section -- **Traceroute** — per-hop signal quality for each relay node -- **Signal metrics** — historical SNR and RSSI data in the metrics charts +- **Список нод** — значок полос сигнала рядом с каждой нодой +- **Детали ноды** — SNR, RSSI и качество сигнала в разделе метрик устройства +- **Трассировка** — качество сигнала на каждой промежуточной ноде +- **Метрики сигнала** — история данных SNR и RSSI на графиках метрик -![Node entry showing SNR, RSSI values and colored signal bars](../../assets/screenshots/nodes_signal_info.png) +![Запись ноды, показывающая значения SNR, RSSI и цветные индикаторы сигнала](../../assets/screenshots/nodes_signal_info.png) From dd9d53528164b1f98a974735e9304c50b7e18da6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:47:53 -0500 Subject: [PATCH 05/15] docs: update CHANGELOG.md (#5710) --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c4f0924b..a90d858b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) ### Unreleased (not yet in any build) -* chore(agents): prune governance cruft and add Claude token guards (#5706) by James Rich (@jamesarich) in [`813acee71`](https://github.com/meshtastic/Meshtastic-Android/commit/813acee71604686dc4c98b43cf692fe4586cb5c5) -* fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view (#5708) by James Rich (@jamesarich) in [`1b661739e`](https://github.com/meshtastic/Meshtastic-Android/commit/1b661739e346f6d64ff06731ee2ac0bc80501d19) +* chore(deps): update core/proto/src/main/proto digest to 6b1ded4 (#5712) by renovate[bot] (@renovate[bot]) in [`ef4ef8e2f`](https://github.com/meshtastic/Meshtastic-Android/commit/ef4ef8e2f59f5e1468673174729d75992504d4c7) +* chore(deps): update compose-multiplatform to v1.11.1 (#5713) by renovate[bot] (@renovate[bot]) in [`c45466a63`](https://github.com/meshtastic/Meshtastic-Android/commit/c45466a633d6e34df3a0a27c83e169c7eb91e481) +* fix(map): eliminate cluster-renderer FATAL and harden black-map paths (#5715) by James Rich (@jamesarich) in [`0f123adb7`](https://github.com/meshtastic/Meshtastic-Android/commit/0f123adb72d3d13a208959a17f7d9dd40809804b) +* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5711) by James Rich (@jamesarich) in [`aeed07364`](https://github.com/meshtastic/Meshtastic-Android/commit/aeed07364c4465bede0c9d83037ac4c9518e8a56) -### Open Beta (v2.7.14-open.18) +### Open Beta (v2.7.14-open.19) Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13): #### 🏗️ Features @@ -239,6 +241,8 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea * fix(firmware): surface error state when BLE OTA connection attempts are exhausted by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5700 * fix(map): replace MarkerComposable with Canvas-rendered bitmaps by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5702 * fix(map): remove manual ViewTree lifecycle owner workarounds by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5704 +* fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5708 +* fix(map): initialize Maps SDK before building marker bitmap descriptors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5709 #### 📝 Other Changes * refactor(ui): compose resources, domain layer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4628 * Add per-message transport method icons for new message format by @Kealper in https://github.com/meshtastic/Meshtastic-Android/pull/4643 From f6d972d2127cad3aab5c372b44483774f6049e77 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:20:33 -0500 Subject: [PATCH 06/15] chore(deps): update net.java.dev.jna:jna to v5.19.0 (#5716) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8d7d5050d..713b34fc29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -280,7 +280,7 @@ test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", vers aboutlibraries-gradlePlugin = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlibraries" } jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" } -jna = { module = "net.java.dev.jna:jna", version = "5.18.1" } +jna = { module = "net.java.dev.jna:jna", version = "5.19.0" } # TAK takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "takpacket-sdk" } From 580b3ed128486aa7732f236a974f60d60c7c7271 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:36:25 -0500 Subject: [PATCH 07/15] docs: update CHANGELOG.md (#5717) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a90d858b09..8bf03a3eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) * chore(deps): update compose-multiplatform to v1.11.1 (#5713) by renovate[bot] (@renovate[bot]) in [`c45466a63`](https://github.com/meshtastic/Meshtastic-Android/commit/c45466a633d6e34df3a0a27c83e169c7eb91e481) * fix(map): eliminate cluster-renderer FATAL and harden black-map paths (#5715) by James Rich (@jamesarich) in [`0f123adb7`](https://github.com/meshtastic/Meshtastic-Android/commit/0f123adb72d3d13a208959a17f7d9dd40809804b) * chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5711) by James Rich (@jamesarich) in [`aeed07364`](https://github.com/meshtastic/Meshtastic-Android/commit/aeed07364c4465bede0c9d83037ac4c9518e8a56) +* chore(deps): update net.java.dev.jna:jna to v5.19.0 (#5716) by renovate[bot] (@renovate[bot]) in [`f6d972d21`](https://github.com/meshtastic/Meshtastic-Android/commit/f6d972d2127cad3aab5c372b44483774f6049e77) ### Open Beta (v2.7.14-open.19) Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13): From e50ab28d122535db71060f7972e6b9efcb8f26a4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:04:05 -0500 Subject: [PATCH 08/15] chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5718) Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com> --- androidApp/src/main/assets/firmware_releases.json | 6 ++++++ .../src/commonMain/composeResources/values-de/strings.xml | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/androidApp/src/main/assets/firmware_releases.json b/androidApp/src/main/assets/firmware_releases.json index 543d391238..fa418c0b9c 100644 --- a/androidApp/src/main/assets/firmware_releases.json +++ b/androidApp/src/main/assets/firmware_releases.json @@ -188,6 +188,12 @@ ] }, "pullRequests": [ + { + "id": "10604", + "title": "Add XIAO ESP32C6 + Wio SX1262 variant with hardware SPI support", + "page_url": "https://github.com/meshtastic/firmware/pull/10604", + "zip_url": "https://discord.com/invite/meshtastic" + }, { "id": "10587", "title": "Add SHT2x CRC check to fix i2c scan hang", diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml index f71a790e73..0ab4b8db8d 100644 --- a/core/resources/src/commonMain/composeResources/values-de/strings.xml +++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml @@ -559,7 +559,7 @@ Verlauf Rückgabewert maximal Zeitraum Rückgabewert Anzahl der Weiterleitungen - Zwischenschritte entfernt + Sprünge entfernt Host Host Kennzahlen Feuchtigkeit @@ -843,7 +843,7 @@ Das Signal ist stark, das Signal-Rauschverhältnis ist über −7 dB und die Signalstärke ist über −115 dBm. Kombiniert SNR und RSSI zu einem Qualitätsniveau, das als farbiges Symbol mit einer Beschreibung angezeigt wird. Kein nutzbares Signal erkannt. Unterhalb aller Qualitätsgrenzen. - Hops Entfernt + Sprünge entfernt Zuletzt gesehen Umweltdaten Keine Knoten für die Vorschau vorhanden. @@ -862,7 +862,7 @@ Sortieroptionen Kanal Entfernung - Zwischenschritte entfernt + Sprünge entfernt Zuletzt gehört Sortieren nach über Favorit From 7efbc699da7889b59ca34d6e2a58a8ae6b3c490c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:21:49 -0500 Subject: [PATCH 09/15] chore(deps): update com.github.luben:zstd-jni to v1.5.7-10 (#5721) --- core/takserver/build.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/takserver/build.gradle.kts b/core/takserver/build.gradle.kts index a89201ec3e..bb77c08232 100644 --- a/core/takserver/build.gradle.kts +++ b/core/takserver/build.gradle.kts @@ -56,7 +56,7 @@ kotlin { jvmMain.dependencies { // Desktop JVM: standard JAR bundles native libs for desktop archs. - implementation("com.github.luben:zstd-jni:1.5.7-9") + implementation("com.github.luben:zstd-jni:1.5.7-10") // xpp3 is excluded from jvmAndroidMain (Android ships it as a // platform class), but Desktop JVM still needs it for XmlPullParser. implementation("org.ogce:xpp3:1.1.6") @@ -66,7 +66,7 @@ kotlin { // Android: @aar variant ships .so files for arm/arm64/x86/x86_64. // Without this, zstd-jni's ZstdDictCompress. throws // UnsatisfiedLinkError and poisons TakV2Compressor permanently. - implementation("com.github.luben:zstd-jni:1.5.7-9@aar") + implementation("com.github.luben:zstd-jni:1.5.7-10@aar") } commonTest.dependencies { @@ -81,7 +81,7 @@ kotlin { dependencies { // Host-JVM tests need the platform JAR (not AAR) for zstd native // libs — the @aar from androidMain only ships ARM/x86 .so files. - implementation("com.github.luben:zstd-jni:1.5.7-9") + implementation("com.github.luben:zstd-jni:1.5.7-10") } } } From 9629daa5136496f721a86b5520b35de1c4b4dfc2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:39:41 -0500 Subject: [PATCH 10/15] fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash (#5719) Co-authored-by: Claude Opus 4.8 (1M context) --- .../kotlin/org/meshtastic/app/map/MapView.kt | 4 -- .../meshtastic/app/map/MapsSdkInitializer.kt | 61 ------------------- .../app/map/component/MarkerBitmapRenderer.kt | 44 +++++-------- .../app/map/component/NodeClusterMarkers.kt | 6 +- .../app/node/component/InlineMap.kt | 5 +- gradle/libs.versions.toml | 4 +- 6 files changed, 24 insertions(+), 100 deletions(-) delete mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt 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 3c76f7b00b..31ff21ade6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -187,10 +187,6 @@ fun MapView( ) { val context = LocalContext.current - // Initialize the Maps SDK up front (idempotent) so the loaded renderer is logged even when the mesh has - // no nodes/waypoints to build marker descriptors from. See MapsSdkInitializer. - LaunchedEffect(Unit) { MapsSdkInitializer.ensureInitialized(context) } - val coroutineScope = rememberCoroutineScope() val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt deleted file mode 100644 index c8fad692ac..0000000000 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapsSdkInitializer.kt +++ /dev/null @@ -1,61 +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 - -import android.content.Context -import co.touchlab.kermit.Logger -import com.google.android.gms.maps.MapsInitializer -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Centralized, run-once Google Maps SDK initialization for the google flavor. - * - * Two things happen here, deliberately decoupled: - * 1. **Synchronous init** via the single-arg [MapsInitializer.initialize] overload. This is the only overload - * documented as synchronous, and it guarantees `BitmapDescriptorFactory` is ready before any eager Canvas descriptor - * is built off a live `GoogleMap` (the node-detail inline map builds its icon before its map loads — see #5709 and - * `MarkerBitmapRenderer`). It is idempotent, so repeated calls are no-ops. - * 2. **Renderer reporting** via the callback overload, registered exactly once. We can no longer *force* a renderer - * (the LEGACY renderer was decommissioned in March 2025, so a preference is honored only as a hint), but the - * documented "Latest renderer" tile-rendering failures can still leave the base map blank on some devices. Logging - * which renderer actually loaded lets us correlate "black map" field reports in Crashlytics/Datadog. Kermit's - * [Logger] is the sink because `GooglePlatformAnalytics` wires its Crashlytics/Datadog log writers at startup while - * delaying SDK init until consent — so logging through Kermit is the privacy-correct, already-sanctioned path (vs. - * touching `Firebase.crashlytics` directly). - */ -object MapsSdkInitializer { - - private val callbackRegistered = AtomicBoolean(false) - - fun ensureInitialized(context: Context) { - val app = context.applicationContext - - // (1) Synchronous readiness guarantee — see kdoc. Deprecated overload retained intentionally. - @Suppress("DEPRECATION") - MapsInitializer.initialize(app) - - // (2) Register the renderer-reporting callback once. The SDK is already initialized above, so the - // callback fires promptly with the renderer that actually loaded. - if (callbackRegistered.compareAndSet(false, true)) { - MapsInitializer.initialize(app, MapsInitializer.Renderer.LATEST) { renderer -> - Logger.withTag(TAG).i { "Google Maps renderer loaded: $renderer" } - } - } - } - - private const val TAG = "MapsSdkInitializer" -} 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 index baf8070705..60ff875959 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.app.map.component -import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF @@ -24,12 +23,10 @@ import android.graphics.Typeface import android.text.TextPaint import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext 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.app.map.MapsSdkInitializer import org.meshtastic.core.model.Node private const val CHIP_CORNER_RADIUS_DP = 4f @@ -47,48 +44,35 @@ private const val EMOJI_PADDING_DP = 2f */ @Composable fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor { - val context = LocalContext.current val density = LocalDensity.current.density val fontScale = LocalDensity.current.fontScale return remember(node.num, node.user.short_name, node.colors, node.isIgnored) { - buildNodeChipDescriptor(context, node, density, fontScale) + buildNodeChipDescriptor(node, density, fontScale) } } /** - * Non-`@Composable` variant of [rememberNodeChipDescriptor] for callers that have no composition to read - * [LocalContext]/[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 + * 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(context: Context, node: Node, density: Float, fontScale: Float): BitmapDescriptor { - ensureMapsInitialized(context) - return renderNodeChipBitmap(node, density, fontScale) -} +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 context = LocalContext.current val density = LocalDensity.current.density val fontScale = LocalDensity.current.fontScale - return remember(codePoint) { - ensureMapsInitialized(context) - renderEmojiBitmap(codePoint, density, fontScale) - } -} - -/** - * [BitmapDescriptorFactory] only works after the Maps SDK has been initialized, which normally happens when a - * GoogleMap/MapView is created. These descriptors are built during composition, and on the node-detail inline map the - * icon is computed before that screen's GoogleMap has loaded the SDK — so [BitmapDescriptorFactory.fromBitmap] crashes - * with "IBitmapDescriptorFactory is not initialized". Delegate to [MapsSdkInitializer], which initializes the SDK - * synchronously and idempotently (and reports the loaded renderer), so this is a no-op once the SDK is already up. - */ -private fun ensureMapsInitialized(context: Context) { - MapsSdkInitializer.ensureInitialized(context) + return remember(codePoint) { renderEmojiBitmap(codePoint, density, fontScale) } } private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor { 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 e160fb6f55..682984c1de 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 @@ -117,7 +117,7 @@ fun NodeClusterMarkers( * library's `clusterItemDecoration` hook is unavailable to non-library renderers). */ private class NodeChipClusterRenderer( - private val context: Context, + context: Context, map: GoogleMap, clusterManager: ClusterManager, private val density: Float, @@ -130,13 +130,13 @@ private class NodeChipClusterRenderer( // 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(context, item.node, density, fontScale)) + .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(context, item.node, density, fontScale)) + marker.setIcon(buildNodeChipDescriptor(item.node, density, fontScale)) marker.zIndex = item.getZIndex() } 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 f77f8aec18..6dbc0510ca 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 @@ -49,7 +49,6 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { val cameraState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM) } - val markerIcon = rememberNodeChipDescriptor(node) GoogleMap( mapColorScheme = mapColorScheme, @@ -78,6 +77,10 @@ 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) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 713b34fc29..6d378d6af8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -178,7 +178,9 @@ 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" } -play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } +# 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" } 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" } From 94f6301ded69619102809393724272ff13b8eade Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:47:07 -0500 Subject: [PATCH 11/15] docs: update CHANGELOG.md (#5720) Co-authored-by: github-actions[bot] --- CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf03a3eb0..673bc94283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,11 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) ### Unreleased (not yet in any build) -* chore(deps): update core/proto/src/main/proto digest to 6b1ded4 (#5712) by renovate[bot] (@renovate[bot]) in [`ef4ef8e2f`](https://github.com/meshtastic/Meshtastic-Android/commit/ef4ef8e2f59f5e1468673174729d75992504d4c7) -* chore(deps): update compose-multiplatform to v1.11.1 (#5713) by renovate[bot] (@renovate[bot]) in [`c45466a63`](https://github.com/meshtastic/Meshtastic-Android/commit/c45466a633d6e34df3a0a27c83e169c7eb91e481) -* fix(map): eliminate cluster-renderer FATAL and harden black-map paths (#5715) by James Rich (@jamesarich) in [`0f123adb7`](https://github.com/meshtastic/Meshtastic-Android/commit/0f123adb72d3d13a208959a17f7d9dd40809804b) -* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5711) by James Rich (@jamesarich) in [`aeed07364`](https://github.com/meshtastic/Meshtastic-Android/commit/aeed07364c4465bede0c9d83037ac4c9518e8a56) -* chore(deps): update net.java.dev.jna:jna to v5.19.0 (#5716) by renovate[bot] (@renovate[bot]) in [`f6d972d21`](https://github.com/meshtastic/Meshtastic-Android/commit/f6d972d2127cad3aab5c372b44483774f6049e77) +* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5718) by James Rich (@jamesarich) in [`e50ab28d1`](https://github.com/meshtastic/Meshtastic-Android/commit/e50ab28d122535db71060f7972e6b9efcb8f26a4) +* chore(deps): update com.github.luben:zstd-jni to v1.5.7-10 (#5721) by renovate[bot] (@renovate[bot]) in [`7efbc699d`](https://github.com/meshtastic/Meshtastic-Android/commit/7efbc699da7889b59ca34d6e2a58a8ae6b3c490c) +* fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash (#5719) by James Rich (@jamesarich) in [`9629daa51`](https://github.com/meshtastic/Meshtastic-Android/commit/9629daa5136496f721a86b5520b35de1c4b4dfc2) -### Open Beta (v2.7.14-open.19) +### Open Beta (v2.7.14-open.20) Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13): #### 🏗️ Features @@ -244,6 +242,7 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea * fix(map): remove manual ViewTree lifecycle owner workarounds by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5704 * fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5708 * fix(map): initialize Maps SDK before building marker bitmap descriptors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5709 +* fix(map): eliminate cluster-renderer FATAL and harden black-map paths by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5715 #### 📝 Other Changes * refactor(ui): compose resources, domain layer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4628 * Add per-message transport method icons for new message format by @Kealper in https://github.com/meshtastic/Meshtastic-Android/pull/4643 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 12/15] 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" } From 01c5b9fbfd8f56fcbf6ba8fd61678841014f41a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:38:27 -0500 Subject: [PATCH 13/15] docs: update CHANGELOG.md (#5724) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 673bc94283..5aecbd1cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) * chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5718) by James Rich (@jamesarich) in [`e50ab28d1`](https://github.com/meshtastic/Meshtastic-Android/commit/e50ab28d122535db71060f7972e6b9efcb8f26a4) * chore(deps): update com.github.luben:zstd-jni to v1.5.7-10 (#5721) by renovate[bot] (@renovate[bot]) in [`7efbc699d`](https://github.com/meshtastic/Meshtastic-Android/commit/7efbc699da7889b59ca34d6e2a58a8ae6b3c490c) * fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash (#5719) by James Rich (@jamesarich) in [`9629daa51`](https://github.com/meshtastic/Meshtastic-Android/commit/9629daa5136496f721a86b5520b35de1c4b4dfc2) +* fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723) by James Rich (@jamesarich) in [`e3e09452d`](https://github.com/meshtastic/Meshtastic-Android/commit/e3e09452ddbfaba3f67b8df18394afb681569307) ### Open Beta (v2.7.14-open.20) Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13): From 0e4f12ec0dbf2d303d09574698e3ef3c73341cf6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:10:54 -0500 Subject: [PATCH 14/15] fix(map): apply kotlinx-serialization compiler plugin to androidApp (#5726) Co-authored-by: Claude Opus 4.8 (1M context) --- androidApp/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 480989d8a1..bc3697bf8e 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -26,6 +26,7 @@ plugins { alias(libs.plugins.meshtastic.android.application) alias(libs.plugins.meshtastic.android.application.flavors) alias(libs.plugins.meshtastic.android.application.compose) + alias(libs.plugins.meshtastic.kotlinx.serialization) id("meshtastic.koin") alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.secrets) From ffb07534fc45c4e4ec74c834810816a69628159a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:15:23 -0500 Subject: [PATCH 15/15] docs: update CHANGELOG.md (#5727) --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aecbd1cde..ae536f65dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,9 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) ### Unreleased (not yet in any build) -* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5718) by James Rich (@jamesarich) in [`e50ab28d1`](https://github.com/meshtastic/Meshtastic-Android/commit/e50ab28d122535db71060f7972e6b9efcb8f26a4) -* chore(deps): update com.github.luben:zstd-jni to v1.5.7-10 (#5721) by renovate[bot] (@renovate[bot]) in [`7efbc699d`](https://github.com/meshtastic/Meshtastic-Android/commit/7efbc699da7889b59ca34d6e2a58a8ae6b3c490c) -* fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash (#5719) by James Rich (@jamesarich) in [`9629daa51`](https://github.com/meshtastic/Meshtastic-Android/commit/9629daa5136496f721a86b5520b35de1c4b4dfc2) -* fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723) by James Rich (@jamesarich) in [`e3e09452d`](https://github.com/meshtastic/Meshtastic-Android/commit/e3e09452ddbfaba3f67b8df18394afb681569307) +* fix(map): apply kotlinx-serialization compiler plugin to androidApp (#5726) by James Rich (@jamesarich) in [`0e4f12ec0`](https://github.com/meshtastic/Meshtastic-Android/commit/0e4f12ec0dbf2d303d09574698e3ef3c73341cf6) -### Open Beta (v2.7.14-open.20) +### Open Beta (v2.7.14-open.21) Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13): #### 🏗️ Features @@ -244,6 +241,8 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea * fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5708 * fix(map): initialize Maps SDK before building marker bitmap descriptors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5709 * fix(map): eliminate cluster-renderer FATAL and harden black-map paths by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5715 +* fix(map): revert app-side Maps SDK init to library-idiomatic, fix inline-map crash by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5719 +* fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5723 #### 📝 Other Changes * refactor(ui): compose resources, domain layer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4628 * Add per-message transport method icons for new message format by @Kealper in https://github.com/meshtastic/Meshtastic-Android/pull/4643