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 6ca71eb6a5..40f2756d9d 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.MarkerComposable +import com.google.maps.android.compose.Marker import com.google.maps.android.compose.MarkerInfoWindowComposable import com.google.maps.android.compose.Polyline import com.google.maps.android.compose.TileOverlay @@ -102,6 +102,7 @@ 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 @@ -125,7 +126,6 @@ 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 @@ -864,17 +864,17 @@ private fun NodeTrackOverlay( } if (index == sortedPositions.lastIndex) { - MarkerComposable( + val chipIcon = rememberNodeChipDescriptor(focusedNode) + Marker( 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, @@ -969,7 +969,6 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S // region --- Traceroute Map Content --- -@OptIn(MapsComposeExperimentalApi::class) @Composable private fun TracerouteMapContent( forwardOffsetPoints: List, @@ -998,7 +997,8 @@ private fun TracerouteMapContent( } displayNodes.forEach { node -> val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng()) - MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) } + val chipIcon = rememberNodeChipDescriptor(node) + Marker(state = markerState, icon = chipIcon, zIndex = 4f) } } 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 new file mode 100644 index 0000000000..6e07e49664 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt @@ -0,0 +1,123 @@ +/* + * 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) { + 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/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt index 227805ebdb..ed1044fc2e 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,22 +16,14 @@ */ 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 @@ -39,7 +31,6 @@ 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, @@ -65,11 +56,7 @@ fun WaypointMarkers( } val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon - val emojiText = convertIntToEmoji(iconCodePoint) - val icon = - rememberComposeBitmapDescriptor(iconCodePoint) { - Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp)) - } + val icon = rememberEmojiMarkerDescriptor(iconCodePoint) 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 3ac4f605f1..53af32bb01 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 @@ -17,14 +17,12 @@ package org.meshtastic.app.node.component import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner @@ -37,17 +35,15 @@ 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.MapsComposeExperimentalApi -import com.google.maps.android.compose.MarkerComposable +import com.google.maps.android.compose.Marker 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() @@ -57,14 +53,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { else -> ComposeMapColorScheme.LIGHT } - // Workaround for a maps-compose issue where MarkerComposable's internal ComposeView cannot find a - // ViewTreeLifecycleOwner, causing a crash on bitmap rendering. We attach the current owners to the - // window root view for the lifetime of the map. + // Defensive workaround: propagate ViewTreeLifecycleOwner to the root view so that + // any internal maps-compose ComposeView (e.g., info windows) can find the lifecycle + // when walking up the view tree. // // IMPORTANT: capture and restore the previous owners on dispose. This InlineMap is hosted inside the // node-detail NavEntry, whose LocalLifecycleOwner is a transient, entry-scoped lifecycle. Leaving it // attached to the activity root view after the entry is destroyed (e.g. navigating back to the node - // list) would make every subsequently opened Popup/DropdownMenu inherit a DESTROYED lifecycle and + // list) would make subsequently opened Popups/DropdownMenus inherit a DESTROYED lifecycle and // render at 0x0 (invisible). See the node-list popup regression. val view = LocalView.current val lifecycleOwner = LocalLifecycleOwner.current @@ -86,6 +82,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { val cameraState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM) } + val markerIcon = rememberNodeChipDescriptor(node) GoogleMap( mapColorScheme = mapColorScheme, @@ -114,9 +111,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeWidth = 2f, ) } - MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { - NodeChip(node = node, modifier = Modifier.defaultMinSize(minWidth = 64.dp, minHeight = 28.dp)) - } + Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon) } } }