chore(map): bump MapLibre Compose to 0.13.0, light up desktop base map, drop Google Maps leftovers

Rebased onto main and dusted off the MapLibre Compose Multiplatform branch.

- Bump maplibre-compose 0.12.1 -> 0.13.0 and migrate the redesigned location API
  (BearingUpdate.TRACK_LOCATION -> TRACK_AUTOMATIC; LocationPuck locationState -> location).
- desktopApp: add a host-detecting maplibre-native-bindings-jni runtime backend so the
  desktop base map renders (Metal on macOS arm64, OpenGL on Linux/Windows amd64). Without it
  Gradle links no native renderer and the map canvas is black.
- Gate Compose map overlays off on the JVM target via a mapOverlaysSupported expect/actual flag
  (true Android/iOS, false desktop). maplibre-compose 0.13.0 stubs the desktop layers/sources
  API with TODO(), so composing markers/waypoints/tracks/traceroute threw NotImplementedError
  and tore down the window. Desktop now renders base-map-only; overlays auto-enable when upstream
  implements desktop layers.
- Remove orphaned Google Maps leftovers the rebase carried over from main's #5702/#5709 marker
  work (MarkerBitmapRenderer.kt + play-services-maps); this branch replaces Google Maps entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-02 13:30:52 -05:00
parent b7cdc26a6e
commit aed1c42286
12 changed files with 167 additions and 162 deletions
@@ -1,144 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import android.content.Context
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.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.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 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) {
ensureMapsInitialized(context)
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". Initialize explicitly first; [MapsInitializer.initialize] is
* synchronous and idempotent, so it is a no-op once the SDK is already up.
*/
@Suppress("DEPRECATION")
private fun ensureMapsInitialized(context: Context) {
MapsInitializer.initialize(context)
}
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)
}
+35
View File
@@ -233,7 +233,42 @@ compose.desktop {
}
}
/**
* Selects the MapLibre Native desktop backend capability matching the build host.
*
* MapLibre Compose ships the native renderer as mutually-exclusive per-OS/arch capability variants of
* `maplibre-native-bindings-jni`. Gradle can't auto-pick among them, so without an explicit selection no native library
* is linked and the map canvas renders black. See https://maplibre.org/maplibre-compose/getting-started/.
*
* macOS is Apple-Silicon only (Metal); Linux/Windows use OpenGL here for broadest driver/Flatpak/CI compatibility
* (Vulkan variants — `*-amd64-vulkan` — are also published if preferred).
*/
fun maplibreNativeTarget(): String {
val os = System.getProperty("os.name").lowercase()
val arch = System.getProperty("os.arch").lowercase()
return when {
os.contains("mac") || os.contains("darwin") -> {
require(arch == "aarch64" || arch == "arm64") {
"MapLibre Native desktop ships only a macOS arm64 (Apple Silicon) backend; host arch '$arch' is unsupported."
}
"macos-aarch64-metal"
}
os.contains("win") -> "windows-amd64-opengl"
else -> "linux-amd64-opengl"
}
}
dependencies {
// MapLibre Native renderer for the current desktop host (runtime-only). Selects exactly one OS/arch
// capability — required, or the map renders black. See maplibreNativeTarget() above.
runtimeOnly("org.maplibre.compose:maplibre-native-bindings-jni:${libs.versions.maplibre.compose.get()}") {
capabilities {
requireCapability("org.maplibre.compose:maplibre-native-bindings-jni-${maplibreNativeTarget()}")
}
}
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries.compose.m3)
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
/** Android implements the full MapLibre Compose sources/layers API. */
actual val mapOverlaysSupported: Boolean = true
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
/**
* Whether the current platform's MapLibre Compose target implements programmatic map sources and layers
* (`rememberGeoJsonSource`, `CircleLayer`, `SymbolLayer`, `LineLayer`, `HillshadeLayer`, ).
*
* As of maplibre-compose 0.13.0 the desktop (JVM) target stubs the **entire** sources/layers API with `TODO()`, so
* composing any overlay throws `NotImplementedError` ("An operation is not implemented") and tears down the window. The
* base map style still renders natively from its style URI. Every source/layer composition must therefore be guarded
* behind this flag; when `false`, only the base map is shown.
*
* Re-evaluate on each maplibre-compose upgrade once the desktop target implements layers/sources, set this `true` for
* JVM and the guards become no-ops.
*/
expect val mapOverlaysSupported: Boolean
@@ -133,7 +133,7 @@ fun MapScreen(
// Location tracking state: 3-mode cycling (Off → Track → TrackBearing → Off)
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_LOCATION) }
var bearingUpdate by remember { mutableStateOf(BearingUpdate.TRACK_AUTOMATIC) }
val locationProvider = rememberLocationProviderOrNull()
val locationState = rememberUserLocationState(locationProvider ?: rememberNullLocationProvider())
val locationAvailable = locationProvider != null
@@ -144,8 +144,13 @@ fun MapScreen(
if (isLocationTrackingEnabled) {
when (bearingUpdate) {
BearingUpdate.IGNORE -> GestureOptions.PositionLocked
BearingUpdate.ALWAYS_NORTH -> GestureOptions.ZoomOnly
BearingUpdate.TRACK_LOCATION -> GestureOptions.ZoomOnly
BearingUpdate.TRACK_AUTOMATIC,
BearingUpdate.TRACK_COURSE,
BearingUpdate.TRACK_ORIENTATION,
-> GestureOptions.ZoomOnly
}
} else {
GestureOptions.Standard
@@ -220,7 +225,7 @@ fun MapScreen(
LocationTrackingEffect(
locationState = locationState,
enabled = isLocationTrackingEnabled,
trackBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
trackBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
) {
cameraState.updateFromLocation(updateBearing = bearingUpdate)
}
@@ -239,7 +244,7 @@ fun MapScreen(
modifier = Modifier.align(Alignment.TopEnd).padding(paddingValues),
bearing = cameraState.position.bearing.toFloat(),
onCompassClick = { scope.launch { cameraState.animateTo(cameraState.position.copy(bearing = 0.0)) } },
followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_LOCATION,
followPhoneBearing = isLocationTrackingEnabled && bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
activeFilterCount = activeFilterCount,
filterDropdownContent = {
MapFilterDropdown(
@@ -267,27 +272,23 @@ fun MapScreen(
},
layersContent = { OfflineMapContent(styleUri = selectedMapStyle.styleUri, cameraState = cameraState) },
isLocationTrackingEnabled = isLocationTrackingEnabled,
isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_LOCATION,
isTrackingBearing = bearingUpdate == BearingUpdate.TRACK_AUTOMATIC,
onToggleLocationTracking = {
if (!locationAvailable) {
scope.launch { snackbarHostState.showSnackbar(locationUnavailableMsg) }
} else if (!isLocationTrackingEnabled) {
// Off → Track with bearing
bearingUpdate = BearingUpdate.TRACK_LOCATION
bearingUpdate = BearingUpdate.TRACK_AUTOMATIC
isLocationTrackingEnabled = true
} else {
when (bearingUpdate) {
BearingUpdate.TRACK_LOCATION -> {
BearingUpdate.TRACK_AUTOMATIC -> {
// TrackBearing → TrackNorth
bearingUpdate = BearingUpdate.ALWAYS_NORTH
}
BearingUpdate.ALWAYS_NORTH -> {
// TrackNorth → Off
isLocationTrackingEnabled = false
}
BearingUpdate.IGNORE -> {
else -> {
// TrackNorth (or any other) → Off
isLocationTrackingEnabled = false
}
}
@@ -41,6 +41,7 @@ import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.mapOverlaysSupported
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
@@ -84,6 +85,10 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
options =
MapOptions(gestureOptions = GestureOptions.AllDisabled, ornamentOptions = OrnamentOptions.AllDisabled),
) {
// Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See
// [mapOverlaysSupported].
if (!mapOverlaysSupported) return@MaplibreMap
val source = rememberGeoJsonSource(data = GeoJsonData.Features(nodeFeature))
// Node marker dot
@@ -79,6 +79,7 @@ import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Point
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.feature.map.mapOverlaysSupported
import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA
@@ -157,6 +158,10 @@ fun MaplibreMapContent(
onMapLoadFinished = onMapLoad,
onMapLoadFailed = onMapLoadFail,
) {
// MapLibre Compose layers/sources are stubbed on desktop (maplibre-compose 0.13.0); gate overlays off
// there so the base map still renders without throwing NotImplementedError. See [mapOverlaysSupported].
if (!mapOverlaysSupported) return@MaplibreMap
// --- Terrain hillshade overlay ---
if (showHillshade) {
val demSource = rememberRasterDemSource(tiles = TERRAIN_TILES, encoding = RasterDemEncoding.Terrarium)
@@ -181,7 +186,7 @@ fun MaplibreMapContent(
if (locationState != null) {
LocationPuck(
idPrefix = "user-location",
locationState = locationState,
location = locationState.location,
cameraState = cameraState,
colors = LocationPuckDefaults.colors(),
)
@@ -28,6 +28,7 @@ import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.meshtastic.feature.map.mapOverlaysSupported
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.computeBoundingBox
import org.meshtastic.feature.map.util.toGeoPositionOrNull
@@ -79,6 +80,9 @@ fun NodeTrackMap(
options =
MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled),
) {
// Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported].
if (!mapOverlaysSupported) return@MaplibreMap
NodeTrackLayers(
positions = positions,
selectedPositionTime = selectedPositionTime,
@@ -30,6 +30,7 @@ import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.feature.map.mapOverlaysSupported
import org.meshtastic.feature.map.model.MapStyle
import org.meshtastic.feature.map.util.computeBoundingBox
import org.meshtastic.feature.map.util.toGeoPositionOrNull
@@ -87,6 +88,9 @@ fun TracerouteMap(
options =
MapOptions(gestureOptions = GestureOptions.RotationLocked, ornamentOptions = OrnamentOptions.AllEnabled),
) {
// Desktop (maplibre-compose 0.13.0) stubs all layers/sources; render base map only. See [mapOverlaysSupported].
if (!mapOverlaysSupported) return@MaplibreMap
TracerouteLayers(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
/** iOS implements the full MapLibre Compose sources/layers API. */
actual val mapOverlaysSupported: Boolean = true
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
/**
* Desktop (JVM) does NOT implement the MapLibre Compose sources/layers API in maplibre-compose 0.13.0 every
* `Layer`/`Source` is stubbed with `TODO()`. Overlays are gated off so the base map renders without crashing.
*/
actual val mapOverlaysSupported: Boolean = false
+5 -4
View File
@@ -47,14 +47,15 @@ compose-multiplatform-material3 = "1.11.0-alpha07"
# AndroidCompose.kt's resolutionStrategy force-aligns these groups to *this* version
# at resolution time, so it is the source of truth for the Android target.
androidx-compose-bom-aligned = "1.11.2"
# `androidx-compose-material` (M2) is independent of CMP. Pinned because
# maps-compose-widgets requests `androidx.compose.material:material` without
# a version (relying on a BOM that we exclude). M2 is frozen at 1.7.8.
# `androidx-compose-material` (M2) is independent of CMP. Pinned because some
# transitive consumers request `androidx.compose.material:material` without a
# version (relying on a BOM that we exclude). M2 is frozen at 1.7.8.
# Consumed by build-logic AndroidCompose.kt to force-align the M2 material group.
androidx-compose-material = "1.7.8"
jetbrains-adaptive = "1.3.0-beta01"
# MapLibre
maplibre-compose = "0.12.1"
maplibre-compose = "0.13.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"