From d4ab60009c2033ff4f9cd2ed8fae4ffaec64e8e3 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 14 Nov 2025 08:49:09 -0800 Subject: [PATCH 01/62] initial commit : maplibre loaded --- app/build.gradle.kts | 3 + docs/design/maplibre-native-integration.md | 278 ++++++++++++++++++ feature/map/build.gradle.kts | 3 + .../org/meshtastic/feature/map/MapView.kt | 14 + .../feature/map/maplibre/MapLibrePOC.kt | 187 ++++++++++++ 5 files changed, 485 insertions(+) create mode 100644 docs/design/maplibre-native-integration.md create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 370f4b8192..915a111701 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -250,6 +250,9 @@ dependencies { googleImplementation(libs.location.services) googleImplementation(libs.play.services.maps) + // MapLibre for F-Droid flavor (POC - additive, does not remove osmdroid yet) + fdroidImplementation("org.maplibre.gl:android-sdk:12.1.0") + fdroidImplementation(libs.osmdroid.android) fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") } diff --git a/docs/design/maplibre-native-integration.md b/docs/design/maplibre-native-integration.md new file mode 100644 index 0000000000..69dd79d9ce --- /dev/null +++ b/docs/design/maplibre-native-integration.md @@ -0,0 +1,278 @@ +# MapLibre Native integration for F-Droid flavor (replace osmdroid) + +## Overview + +The F-Droid variant currently uses osmdroid, which is archived and no longer actively maintained. This document proposes migrating the F-Droid flavor to MapLibre Native for a modern, actively maintained, fully open-source mapping stack compatible with F-Droid constraints. + +Reference: MapLibre Native (BSD-2-Clause) — `org.maplibre.gl:android-sdk` [GitHub repository and README](https://github.com/maplibre/maplibre-native). The README shows Android setup and basic usage, e.g.: + +```gradle +implementation 'org.maplibre.gl:android-sdk:12.1.0' // use latest stable from releases +``` + +Releases page (latest): see Android release notes (e.g., android-v12.1.0) in the repository releases feed. + + +## Goals + +- Replace osmdroid in the `fdroid` flavor with MapLibre Native. +- Maintain core map features: live node markers, waypoints, tracks (polylines), bounding-box selection, map layers, and user location. +- Stay F-Droid compatible: no proprietary SDKs, no analytics, and tile sources/styles that don’t require proprietary keys. +- Provide a path for offline usage (caching and/or offline regions) comparable to current expectations. +- Keep the Google flavor unchanged (continues using Google Maps). + + +## Non-goals + +- Changing the Google flavor map implementation. +- Shipping proprietary tile styles or API-key–gated providers by default. +- Reworking unrelated UI/UX; only adapt what’s necessary for MapLibre. + + +## Current state (F-Droid flavor) + +The `fdroid` flavor is implemented with osmdroid components and includes custom overlays, caching, clustering, and tile source utilities. Key entry points and features include (non-exhaustive): + +- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt`: Composable map screen hosting an `AndroidView` of `org.osmdroid.views.MapView`. +- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewWithLifecycle.kt`: Lifecycle-aware creation and configuration of osmdroid `MapView`, including zoom bounds, DPI scaling, scroll limits, and user-agent config. +- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt`: Node-focused map screen using fdroid map utilities. +- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt`: Helpers to add copyright, overlays (markers, polylines, scale bar, gridlines). +- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/*`: Tile source abstractions and WMS helpers (e.g., NOAA WMS), custom tile source with auth, and marker classes. +- `feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/*`: Custom clustering (RadiusMarkerClusterer, MarkerClusterer, etc.). +- Caching/downloading logic via osmdroid `CacheManager`, `SqliteArchiveTileWriter`, and related helpers. + +Build dependencies: + +- `fdroidImplementation(libs.osmdroid.android)` and `fdroidImplementation(libs.osmdroid.geopackage)` in `app/build.gradle.kts`. + + +## Proposed architecture (F-Droid flavor on MapLibre Native) + +At a high level, we replace the fdroid/osmdroid source set with a MapLibre-based implementation using `org.maplibre.android.maps.MapView` hosted in `AndroidView` for Compose interop. Feature parity is achieved by translating current overlays/utilities to MapLibre’s source/layer model. + +### Parity with Google Maps flavor (feature-by-feature) + +This section captures the Google flavor’s current capabilities and how we’ll provide equivalent behavior with MapLibre Native for the F-Droid flavor. + +- Map types (Normal/Satellite/Terrain/Hybrid) + - Google: `MapTypeDropdown` switches `MapType` (Normal, Satellite, Terrain, Hybrid). + - MapLibre: Provide a “Style chooser” mapped to a set of styles (e.g., vector “basic/streets”, “terrain” style when available, “satellite”, and “hybrid” if a provider is configured). Implementation: swap style URLs at runtime. Note: Satellite/Hybrid require non-proprietary providers; we will ship only F-Droid-compliant defaults and allow users to add custom styles. + +- Custom raster tile overlays (user-defined URL template) + - Google: `TileOverlay` with user-managed providers and a manager sheet to add/edit/remove templates. + - MapLibre: Add `RasterSource` + `RasterLayer` using `{z}/{x}/{y}` URL templates. Keep the same management UI: add/edit/remove templates, persist selection, z-order above/below base style as applicable. + +- Custom map layers: KML and GeoJSON import + - Google: Imports KML (`KmlLayer`) and GeoJSON (`GeoJsonLayer`) with visibility toggles and persistence. + - MapLibre: Native `GeoJsonSource` for GeoJSON; for KML, implement conversion to GeoJSON at import time (preferred) or a KML renderer. Keep the same UI: file picker, layer list with visibility toggles, and persistence. Large layers should be loaded off the main thread. + +- Clustering of node markers + - Google: Clustering via utility logic and a dialog to display items within a cluster. + - MapLibre: Enable clustering on a `GeoJsonSource` (cluster=true). Use styled `SymbolLayer` for clusters and single points. On cluster tap, either zoom into the cluster or surface a dialog with the items (obtain children via query on `cluster_id` using runtime API or by maintaining an index in the view model). Match the existing UX where feasible. + +- Marker info and selection UI + - Google: `MarkerInfoWindowComposable` for node/waypoint info; cluster dialog for multiple items. + - MapLibre: Use map click callbacks to detect feature selection (via queryRenderedFeatures) and show Compose-based bottom sheets or dialogs for details. Highlight the selected feature via data-driven styling (e.g., different icon or halo) to mimic info-window emphasis. + +- Tracks and polylines + - Google: `Polyline` for node tracks. + - MapLibre: `GeoJsonSource` + `LineLayer` for tracks. Style width/color dynamically (e.g., by selection or theme). Maintain performance by updating sources incrementally. + +- Location indicator and follow/bearing modes + - Google: Map UI shows device location, with toggles for follow and bearing. + - MapLibre: Use the built-in location component to display position and bearing. Implement follow mode (camera tracking current location) and toggle bearing-follow (bearing locked to phone heading) via camera updates. Respect permissions as in the current flow. + +- Scale bar + - Google: `ScaleBar` widget (compose). + - MapLibre: Implement a Compose overlay scale bar using map camera state and projection (compute meters-per-pixel at latitude; snap to nice distances). Show/hide per the current controls. + +- Camera, gestures, and UI controls + - Google: `MapProperties`/`MapUiSettings` for gestures, compass, traffic, etc. + - MapLibre: Use MapLibre’s `UiSettings` and camera APIs to align gesture enablement. Provide a compass toggle if needed (Compose overlay or style-embedded widget). + +- Map filter menu and other HUD controls + - Google: Compose-driven “filter” and “map type” menus; custom layer manager; custom tile provider manager. + - MapLibre: Reuse the same Compose controls; wire actions to MapLibre implementations (style swap, raster source add/remove, layer visibility toggles). + +- Persistence and state + - Keep the same persistence strategy used by the Google flavor for selected map type/style, custom tile providers, and imported layers (URIs, visibility). Ensure parity in initial load behavior and error handling. + +Gaps and proposed handling: +- Satellite/Hybrid availability depends on F-Droid-compliant providers; we will ship only compliant defaults and rely on user-provided styles for others. +- KML requires conversion or a dedicated renderer; we will implement KML→GeoJSON conversion at import time for parity with visibility toggles and persistence. + +### Core components + +- Lifecycle-aware `MapView` wrapper (Compose): Use `AndroidView` to create and manage `org.maplibre.android.maps.MapView` with lifecycle forwarding (onStart/onStop/etc.), mirroring the current `MapViewWithLifecycle.kt` responsibilities. +- Style and sources: + - Default dev style: `https://demotiles.maplibre.org/style.json` for initial bring-up (public demo). This avoids proprietary keys and is fine for development. Later, we can switch to a more appropriate default for production. + - Raster tile support: Add `RasterSource` for user-provided raster tile URL templates (equivalent to existing “custom tile provider URL” functionality). + - Vector data for app overlays: Use `GeoJsonSource` for nodes, waypoints, and tracks, with appropriate `SymbolLayer` and `LineLayer` styling. Polygons (e.g., bounding box) via `FillLayer` or line+fill combo. +- Clustering: + - Use `GeoJsonSource` with clustering enabled for node markers (MapLibre supports clustering at the source level). Configure cluster radius and properties to emulate current behavior. +- Location indicator: + - Use the built-in location component in MapLibre Native to show the device location and bearing (when permitted). +- Gestures and camera: + - MapLibre’s `UiSettings` and camera APIs mirror Mapbox GL Native; expose zoom, bearing, tilt as needed to match osmdroid behavior. +- Permissions: + - Retain existing Compose permission handling; wire to enable/disable location component. + + +## Offline and caching + +MapLibre Native offers mechanisms similar to Mapbox GL Native for tile caching and offline regions. We will: + +1. Start with default HTTP caching (on-device tile cache) to improve repeated region performance. +2. Evaluate and, if feasible, implement offline regions for vector tiles (download defined bounding boxes and zoom ranges). This would replace osmdroid’s `CacheManager` flow and UI. If vector offline proves complex, a phase may introduce raster MBTiles as an interim solution (note: MapLibre Native does not directly read MBTiles; an import/conversion path or custom source is needed if we choose that route). +3. Preserve current UX affordances: bounding-box selection for offline region definition, progress UI, cache size/budget, purge actions. + +Open question: confirm current MapLibre Native offline APIs and recommended approach on Android at the chosen SDK version. If upstream guidance prefers vector style packs or a particular cache API, we’ll align to that. + + +## Tile sources, styles, and F-Droid compliance + +- Default dev style: MapLibre demo style (`demotiles.maplibre.org`) for development/testing only. +- Production defaults must: + - Avoid proprietary SDKs and keys. + - Respect provider TOS (e.g., OSM or self-hosted tiles). Consider self-hosted vector tiles (OpenMapTiles) or community-friendly providers with clear terms for mobile clients. +- Custom tile provider URL: + - Support raster custom URLs via `RasterSource` in the style at runtime. + - For vector custom sources, we’ll likely require a user-supplied style JSON URL (vector styles are described by style JSONs that reference vector sources); we can enable “Custom style URL” input for advanced users. + + +## Build and dependencies + +- Add MapLibre Native to the `fdroid` configuration in `app/build.gradle.kts`: + - `fdroidImplementation("org.maplibre.gl:android-sdk:")` +- Remove: + - `fdroidImplementation(libs.osmdroid.android)` + - `fdroidImplementation(libs.osmdroid.geopackage)` (and related exclusions) +- Native ABIs: + - App already configures `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`; MapLibre Native provides native libs accordingly, so the existing NDK filters should be compatible. +- Min/target SDK: + - MapLibre Native minSdk is compatible (App targets API 26+; verify exact minSdk for selected MapLibre version). + + +## Migration plan (phased) + +Phase 1: Bring-up and parity core +- Create `fdroid`-only MapLibre `MapView` composable using `AndroidView` and lifecycle wiring. +- Initialize `MapLibre.getInstance(context)` and load a simple style. +- Render nodes and waypoints using `GeoJsonSource` + `SymbolLayer`. +- Render tracks using `GeoJsonSource` + `LineLayer`. +- Replace location overlay with MapLibre location component. +- Replace map gestures and scale bar equivalents (either via style or simple Compose overlay). + +Phase 2: Clustering and UI polish +- Implement clustering with `GeoJsonSource` clustering features. +- Style cluster circles/labels; match existing look/feel as feasible. +- Restore “map filter” UX and marker selection/infowindows (Compose side panels/dialogs). + +Phase 3: Offline and cache UX +- Implement region selection overlay as style layers (polygon/line) and coordinate it with cache/offline manager. +- Add offline region creation, progress, and management (estimates, purge, etc.). + +Phase 4: Cleanup +- Remove osmdroid-specific code: tile source models, WMS helpers (unless replaced with MapLibre raster sources), custom cluster Java classes, cache manager extensions. + + +## Risks and mitigations + +- Native size increase: MapLibre includes native libs; monitor APK size impact per ABI and leverage existing ABI filters. +- GPU driver quirks: MapLibre uses OpenGL/Metal (platform dependent); test across representative devices/ABIs. +- Offline complexity: Vector offline requires careful style/source handling; mitigate via phased rollout and clear user-facing expectations. +- Tile provider TOS: Ensure defaults are compliant; prefer self-hosted or community-safe options. +- Performance: Reassess clustering and update strategies (debounce updates, differential GeoJSON updates) to keep frame times smooth. + + +## Testing strategy + +- Device matrix across `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`. +- Regression tests for: + - Marker rendering and selection. + - Tracks and waypoints visibility and styles. + - Location component and permissions. + - Clustering behavior at varying zooms. + - Offline region create/purge flows (Phase 3). +- Manual checks for F-Droid build and install flow (“no Google” hygiene already present in the build). + + +## Timeline (estimate) + +- Phase 1: 1–2 weeks (core rendering, location, parity for main screen) +- Phase 2: 1 week (clustering + polish) +- Phase 3: 2–3 weeks (offline regions + UX) +- Phase 4: 0.5–1 week (cleanup, remove osmdroid code) + + +## Open questions + +- Preferred default production style: community vector tiles vs. raster OSM tiles? +- Confirm MapLibre Native offline APIs and best-practice for Android in the selected version. +- Retain any WMS layers? If needed, evaluate WMS via raster tile intermediary or custom source pipeline. + + +## References + +- MapLibre Native repository and README (Android usage and examples): https://github.com/maplibre/maplibre-native + - Shows `MapView` usage, style loading, and dependency coordinates in the README. + - Recent Android releases (e.g., android-v12.1.0) are available in the releases section. + +## Proof of Concept (POC) scope and steps + +Objective: Stand up MapLibre Native in the F-Droid flavor alongside the existing osmdroid implementation without removing osmdroid yet. Demonstrate base map rendering, device location, and rendering of core app entities (nodes, waypoints, tracks) using MapLibre sources/layers. Keep the change additive and easy to revert. + +Scope (must-have): +- Add MapLibre dependency to `fdroid` configuration. +- Introduce a new, isolated Composable (e.g., `MapLibreMapView`) using `AndroidView` to host `org.maplibre.android.maps.MapView`. +- Initialize MapLibre and load a dev-safe style (e.g., `https://demotiles.maplibre.org/style.json`). +- Render nodes and waypoints using `GeoJsonSource` + `SymbolLayer` with a simple, built-in marker image. +- Render tracks as polylines using `GeoJsonSource` + `LineLayer`. +- Enable the MapLibre location component (when permissions are granted). +- Provide a temporary developer entry point to reach the POC screen (e.g., a debug-only navigation route or an in-app dev menu item for `fdroidDebug` builds). + +Scope (nice-to-have, if time allows): +- Simple style switcher between 2–3 known-good styles (dev/demo only). +- Add a “Custom raster URL” input to attach a `RasterSource` + `RasterLayer` using `{z}/{x}/{y}` templates. +- Basic cluster styling using a clustered `GeoJsonSource` (no cluster detail dialog yet). + +Out of scope for POC: +- Offline region downloads and cache management UI. +- KML import and full custom layer manager (GeoJSON-only import is acceptable if trivial). +- Complete parity polish and advanced gestures/controls. + +Implementation steps: +1) Build configuration + - Add `fdroidImplementation("org.maplibre.gl:android-sdk:")`. + - Keep osmdroid dependencies during POC; we will not remove them yet. + +2) New POC Composable and lifecycle + - Create `MapLibreMapView` in the `fdroid` source set. + - Use `AndroidView` to host `MapView`; forward lifecycle events (onStart/onStop/etc.). + - Call `MapLibre.getInstance(context)` and set the style URI once the map is ready. + +3) Data plumbing + - From the existing `MapViewModel`, derive FeatureCollections for nodes, waypoints, and tracks. + - Create `GeoJsonSource` entries for each category; update them on state changes. + - Add a default marker image to the style and wire a `SymbolLayer` for nodes/waypoints; add a `LineLayer` for tracks. + +4) Location component + - Enable MapLibre’s location component when location permission is granted. + - Add a simple “my location” action (centers the camera on the device location). + +5) Temporary navigation + - Add a debug-only nav destination or dev menu entry to open the POC screen without impacting existing map flows. + +6) Developer QA checklist + - Build `assembleFdroidDebug` and open the POC map. + - Verify: base map loads; device location shows when permitted; nodes/waypoints/track render. + - Verify: panning/zooming works smoothly on at least one ARM64 device and one emulator. + +Acceptance criteria: +- On `fdroidDebug`, a developer can open the POC screen and see: + - A MapLibre-based map with a working style. + - Device location indicator (when permission is granted). + - Visible nodes, waypoints, and at least one track drawn via MapLibre layers. +- No regressions to existing osmdroid-based map screens. + +Estimated effort: 1–2 days \ No newline at end of file diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 3372a7c947..ce458eae3d 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -53,6 +53,9 @@ dependencies { implementation(libs.osmdroid.android) implementation(libs.timber) + // MapLibre for fdroid POC + fdroidImplementation("org.maplibre.gl:android-sdk:12.1.0") + googleImplementation(libs.location.services) googleImplementation(libs.maps.compose) googleImplementation(libs.maps.compose.utils) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 3c1464d061..d11cd5d8a1 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -238,6 +238,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: var showCurrentCacheInfo by remember { mutableStateOf(false) } var showPurgeTileSourceDialog by remember { mutableStateOf(false) } var showMapStyleDialog by remember { mutableStateOf(false) } + var showMapLibrePOC by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -610,6 +611,11 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection, ) + MapButton( + onClick = { showMapLibrePOC = true }, + icon = Icons.Outlined.Layers, + contentDescription = Res.string.map_style_selection, + ) Box(modifier = Modifier) { MapButton( onClick = { mapFilterExpanded = true }, @@ -759,6 +765,14 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) } + if (showMapLibrePOC) { + androidx.compose.ui.window.Dialog(onDismissRequest = { showMapLibrePOC = false }) { + Box(modifier = Modifier.fillMaxSize()) { + org.meshtastic.feature.map.maplibre.MapLibrePOC() + } + } + } + if (showEditWaypointDialog != null) { EditWaypointDialog( waypoint = showEditWaypointDialog ?: return, // Safe call diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt new file mode 100644 index 0000000000..67a447bfcf --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.maplibre.android.MapLibre +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.proto.MeshProtos.Waypoint + +private const val DEG_D = 1e-7 + +private const val STYLE_URL = "https://demotiles.maplibre.org/style.json" + +private const val NODES_SOURCE_ID = "meshtastic-nodes-source" +private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" +private const val TRACKS_SOURCE_ID = "meshtastic-tracks-source" + +private const val NODES_LAYER_ID = "meshtastic-nodes-layer" +private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" +private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" + +@SuppressLint("MissingPermission") +@Composable +fun MapLibrePOC( + mapViewModel: MapViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + MapLibre.getInstance(context) + MapView(context).apply { + getMapAsync { map -> + map.setStyle(STYLE_URL) { style -> + ensureSourcesAndLayers(style) + // Enable location component (if permissions granted) + try { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } catch (_: Throwable) { + // Location component may fail if permissions are not granted; ignore for POC + } + } + } + } + }, + update = { mapView: MapView -> + mapView.getMapAsync { map -> + val style = map.style ?: return@getMapAsync + // Update nodes + (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(nodesToFeatureCollectionJson(nodes)) + // Update waypoints + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) + // Tracks are optional in POC: keep empty for now + (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(emptyFeatureCollectionJson()) + } + }, + ) + + // Forward lifecycle events to MapView + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding + when (event) { + Lifecycle.Event.ON_START -> {} + Lifecycle.Event.ON_RESUME -> {} + Lifecycle.Event.ON_PAUSE -> {} + Lifecycle.Event.ON_STOP -> {} + Lifecycle.Event.ON_DESTROY -> {} + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + +} + +private fun ensureSourcesAndLayers(style: Style) { + if (style.getSource(NODES_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + } + if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + } + if (style.getSource(TRACKS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(TRACKS_SOURCE_ID, emptyFeatureCollectionJson())) + } + + if (style.getLayer(NODES_LAYER_ID) == null) { + // Use circles for POC to avoid image dependencies + val layer = SymbolLayer(NODES_LAYER_ID, NODES_SOURCE_ID) + // A minimal circle-like via icon is more complex; switch to CircleLayer later if desired + // Using SymbolLayer with default icon is okay for POC; alternatively, CircleLayer: + // val layer = CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID).withProperties(circleColor("#1E88E5"), circleRadius(6f)) + style.addLayer(layer) + } + if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { + val layer = SymbolLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + style.addLayer(layer) + } + if (style.getLayer(TRACKS_LAYER_ID) == null) { + val layer = LineLayer(TRACKS_LAYER_ID, TRACKS_SOURCE_ID).withProperties(lineColor("#FF6D00"), lineWidth(3f)) + style.addLayer(layer) + } +} + +private fun nodesToFeatureCollectionJson(nodes: List): String { + val features = + nodes.mapNotNull { node -> + val pos = node.position ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{}}""" + } + return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" +} + +private fun waypointsToFeatureCollectionJson( + waypoints: Collection, +): String { + val features = + waypoints.mapNotNull { pkt -> + val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null + val lat = w.latitudeI * DEG_D + val lon = w.longitudeI * DEG_D + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{}}""" + } + return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" +} + +private fun emptyFeatureCollectionJson(): String { + return """{"type":"FeatureCollection","features":[]}""" +} + + From 3b6883bada49c8d3a6f9b46d759c7f9f1c0ee515 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 14 Nov 2025 10:59:46 -0800 Subject: [PATCH 02/62] clustering, node info on click, basic layering --- .../org/meshtastic/feature/map/MapView.kt | 18 +- .../feature/map/maplibre/MapLibrePOC.kt | 539 ++++++++++++++++-- 2 files changed, 511 insertions(+), 46 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index d11cd5d8a1..1e44f85403 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -37,6 +37,7 @@ import androidx.compose.material.icons.filled.Lens import androidx.compose.material.icons.filled.LocationDisabled import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Tune @@ -54,6 +55,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -72,6 +74,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist @@ -766,9 +769,20 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } if (showMapLibrePOC) { - androidx.compose.ui.window.Dialog(onDismissRequest = { showMapLibrePOC = false }) { + androidx.compose.ui.window.Dialog( + onDismissRequest = { showMapLibrePOC = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { Box(modifier = Modifier.fillMaxSize()) { - org.meshtastic.feature.map.maplibre.MapLibrePOC() + org.meshtastic.feature.map.maplibre.MapLibrePOC( + onNavigateToNodeDetails = { num -> + navigateToNodeDetails(num) + showMapLibrePOC = false + }, + ) + IconButton(modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), onClick = { showMapLibrePOC = false }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(Res.string.close)) + } } } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index 67a447bfcf..233c1f5e04 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -18,11 +18,21 @@ package org.meshtastic.feature.map.maplibre import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -30,19 +40,53 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.Button +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.ExperimentalMaterial3Api import org.maplibre.android.MapLibre import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.CircleLayer import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleRadius import org.maplibre.android.style.layers.PropertyFactory.lineColor import org.maplibre.android.style.layers.PropertyFactory.lineWidth +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur +import org.maplibre.android.style.layers.PropertyFactory.textHaloColor +import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth +import org.maplibre.android.style.layers.PropertyFactory.textSize +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textOffset +import org.maplibre.android.style.layers.PropertyFactory.textAnchor +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.PropertyFactory.rasterOpacity import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.RasterSource +import org.maplibre.android.style.layers.RasterLayer +import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.expressions.Expression.* +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng + import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.MapViewModel import org.meshtastic.proto.MeshProtos.Waypoint +import timber.log.Timber +import org.meshtastic.core.ui.component.NodeChip +import org.maplibre.android.style.layers.BackgroundLayer private const val DEG_D = 1e-7 @@ -51,61 +95,288 @@ private const val STYLE_URL = "https://demotiles.maplibre.org/style.json" private const val NODES_SOURCE_ID = "meshtastic-nodes-source" private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" private const val TRACKS_SOURCE_ID = "meshtastic-tracks-source" +private const val OSM_SOURCE_ID = "osm-tiles" private const val NODES_LAYER_ID = "meshtastic-nodes-layer" +private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" +private const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" +private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" +private const val OSM_LAYER_ID = "osm-layer" @SuppressLint("MissingPermission") @Composable +@OptIn(ExperimentalMaterial3Api::class) fun MapLibrePOC( mapViewModel: MapViewModel = hiltViewModel(), + onNavigateToNodeDetails: (Int) -> Unit = {}, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + var selectedInfo by remember { mutableStateOf(null) } + var selectedNodeNum by remember { mutableStateOf(null) } + val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { - MapLibre.getInstance(context) - MapView(context).apply { - getMapAsync { map -> - map.setStyle(STYLE_URL) { style -> - ensureSourcesAndLayers(style) - // Enable location component (if permissions granted) - try { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - } catch (_: Throwable) { - // Location component may fail if permissions are not granted; ignore for POC + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + MapLibre.getInstance(context) + Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") + MapView(context).apply { + getMapAsync { map -> + Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") + map.setStyle(STYLE_URL) { style -> + Timber.tag("MapLibrePOC").d("Style loaded: %s", STYLE_URL) + ensureSourcesAndLayers(style) + // Push current data immediately after style load + try { + val density = context.resources.displayMetrics.density + val bounds = map.projection.visibleRegion.latLngBounds + val labelSet = + run { + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = + map.projection.toScreenLocation( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + ) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) + (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(oneTrackFromNodesJson(nodes)) + Timber.tag("MapLibrePOC").d( + "Initial data set after style load. nodes=%d waypoints=%d", + nodes.size, + waypoints.size, + ) + } catch (t: Throwable) { + Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") + } + // Keep base vector layers; OSM raster will sit below node layers for labels/roads + // Enable location component (if permissions granted) + try { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + Timber.tag("MapLibrePOC").d("Location component enabled") + } catch (_: Throwable) { + // ignore + Timber.tag("MapLibrePOC").w("Location component not enabled (likely missing permissions)") + } + map.addOnMapClickListener { latLng -> + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (24 * context.resources.displayMetrics.density) + val rect = android.graphics.RectF( + (screenPoint.x - r).toFloat(), + (screenPoint.y - r).toFloat(), + (screenPoint.x + r).toFloat(), + (screenPoint.y + r).toFloat(), + ) + val features = map.queryRenderedFeatures(rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, WAYPOINTS_LAYER_ID) + Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) + val f = features.firstOrNull() + // If cluster tapped, zoom in a bit + if (f != null && f.hasProperty("point_count")) { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return@addOnMapClickListener true + } + selectedInfo = + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + val n = nodes.firstOrNull { node -> node.num == num } + selectedNodeNum = num + n?.let { node -> "Node ${node.user.longName.ifBlank { node.num.toString() }} (${node.gpsString()})" } + ?: "Node $num" + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + "Waypoint $id" + } + else -> null + } + } + true + } + // Update clustering visibility on camera idle (zoom changes) + map.addOnCameraIdleListener { + val zoomNow = map.cameraPosition.zoom + val bounds = map.projection.visibleRegion.latLngBounds + val visibleCount = + nodes.count { n -> + val p = n.validPosition ?: return@count false + val lat = p.latitudeI * DEG_D + val lon = p.longitudeI * DEG_D + bounds.contains(LatLng(lat, lon)) + } + // Cluster only when zoom <= 10 and viewport density is high + val showClustersNow = zoomNow <= 10.0 && visibleCount > 50 + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + Timber.tag("MapLibrePOC").d( + "Camera idle; cluster visibility=%s (visible=%d, zoom=%.2f)", + showClustersNow, + visibleCount, + zoomNow, + ) + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, nodes, density) + (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) + } } } } + }, + update = { mapView: MapView -> + mapView.getMapAsync { map -> + val style = map.style + if (style == null) { + Timber.tag("MapLibrePOC").w("Style not yet available in update()") + return@getMapAsync + } + Timber.tag("MapLibrePOC").d( + "Updating sources. nodes=%d, waypoints=%d", + nodes.size, + waypoints.size, + ) + val density = context.resources.displayMetrics.density + val bounds2 = map.projection.visibleRegion.latLngBounds + val labelSet = + run { + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = + map.projection.toScreenLocation( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + ) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) + (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(oneTrackFromNodesJson(nodes)) + // Toggle clustering visibility based on zoom and VISIBLE node count (viewport density) + val zoom = map.cameraPosition.zoom + val bounds = map.projection.visibleRegion.latLngBounds + val visibleCount = + nodes.count { n -> + val p = n.validPosition ?: return@count false + val lat = p.latitudeI * DEG_D + val lon = p.longitudeI * DEG_D + bounds.contains(LatLng(lat, lon)) + } + val showClusters = zoom <= 10.0 && visibleCount > 50 + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + Timber.tag("MapLibrePOC").d( + "Sources updated; cluster visibility=%s (visible=%d, zoom=%.2f)", + showClusters, + visibleCount, + zoom, + ) + } + }, + ) + + selectedInfo?.let { info -> + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(12.dp), + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { Text(text = info, style = MaterialTheme.typography.bodyMedium) } } - }, - update = { mapView: MapView -> - mapView.getMapAsync { map -> - val style = map.style ?: return@getMapAsync - // Update nodes - (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(nodesToFeatureCollectionJson(nodes)) - // Update waypoints - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) - // Tracks are optional in POC: keep empty for now - (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(emptyFeatureCollectionJson()) + } + + // Bottom sheet with node details and actions + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + if (selectedNode != null) { + ModalBottomSheet( + onDismissRequest = { selectedNodeNum = null }, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + NodeChip(node = selectedNode) + val lastHeard = selectedNode.lastHeard + val coords = selectedNode.gpsString() + Text(text = "Last heard: $lastHeard s ago") + Text(text = "Coordinates: $coords") + // Quick actions (placeholder) + Button( + onClick = { + onNavigateToNodeDetails(selectedNode.num) + selectedNodeNum = null + }, + modifier = Modifier.padding(top = 12.dp), + ) { + Text("View full node") + } + } } - }, - ) + } + } // Forward lifecycle events to MapView DisposableEffect(lifecycleOwner) { @@ -128,41 +399,153 @@ fun MapLibrePOC( } private fun ensureSourcesAndLayers(style: Style) { + Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() begin. Existing layers=%d, sources=%d", style.layers.size, style.sources.size) + if (style.getSource(OSM_SOURCE_ID) == null) { + // Try standard OpenStreetMap raster tiles (with subdomain 'a') for streets/cities + style.addSource(RasterSource(OSM_SOURCE_ID, "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", 256)) + Timber.tag("MapLibrePOC").d("Added OSM Standard RasterSource") + } if (style.getSource(NODES_SOURCE_ID) == null) { - style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + // Enable clustering only for lower zooms; stop clustering once zoom > 10 + val options = GeoJsonOptions() + .withCluster(true) + .withClusterRadius(36) + .withClusterMaxZoom(10) + style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson(), options)) + Timber.tag("MapLibrePOC").d("Added nodes GeoJsonSource") } if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added waypoints GeoJsonSource") } if (style.getSource(TRACKS_SOURCE_ID) == null) { style.addSource(GeoJsonSource(TRACKS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added tracks GeoJsonSource") } + if (style.getLayer(OSM_LAYER_ID) == null) { + // Put OSM tiles on TOP so labels/roads are visible during POC + val rl = RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f)) + // Add early, then ensure it's below node layers if present + style.addLayer(rl) + Timber.tag("MapLibrePOC").d("Added OSM RasterLayer") + } + if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { + val clusterLayer = + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_SOURCE_ID) + .withProperties(circleColor("#6D4C41"), circleRadius(14f)) + .withFilter(has("point_count")) + style.addLayer(clusterLayer) + Timber.tag("MapLibrePOC").d("Added cluster CircleLayer") + } + if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { + val countLayer = + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")) + style.addLayer(countLayer) + Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") + } if (style.getLayer(NODES_LAYER_ID) == null) { - // Use circles for POC to avoid image dependencies - val layer = SymbolLayer(NODES_LAYER_ID, NODES_SOURCE_ID) - // A minimal circle-like via icon is more complex; switch to CircleLayer later if desired - // Using SymbolLayer with default icon is okay for POC; alternatively, CircleLayer: - // val layer = CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID).withProperties(circleColor("#1E88E5"), circleRadius(6f)) + val layer = + CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID) + .withProperties(circleColor("#1565C0"), circleRadius(6f)) + .withFilter(not(has("point_count"))) style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") + } + if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { + val textLayer = + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + // Scale label size with zoom to reduce clutter + textSize( + interpolate( + linear(), + zoom(), + stop(8, 10f), + stop(12, 12f), + stop(15, 14f), + stop(18, 16f), + ), + ), + // At close zooms, prefer showing all labels even if they overlap + textAllowOverlap( + step( + zoom(), + literal(false), // default for low zooms + stop(12, literal(true)), // enable overlap >= 12 + ), + ), + textIgnorePlacement( + step( + zoom(), + literal(false), + stop(12, literal(true)), + ), + ), + // place label above the circle + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter( + all( + not(has("point_count")), + eq(get("showLabel"), literal(1)), + ), + ) + style.addLayer(textLayer) + Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") } if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { - val layer = SymbolLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f)) style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") } if (style.getLayer(TRACKS_LAYER_ID) == null) { val layer = LineLayer(TRACKS_LAYER_ID, TRACKS_SOURCE_ID).withProperties(lineColor("#FF6D00"), lineWidth(3f)) style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added tracks LineLayer") } + Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) } private fun nodesToFeatureCollectionJson(nodes: List): String { val features = nodes.mapNotNull { node -> - val pos = node.position ?: return@mapNotNull null + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val short = protoShortName(node) ?: shortNameFallback(node) + // Default showLabel=0; it will be turned on for selected nodes by viewport selection + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","showLabel":0}}""" + } + return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" +} + +private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set): String { + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null val lat = pos.latitudeI * DEG_D val lon = pos.longitudeI * DEG_D - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{}}""" + val short = protoShortName(node) ?: shortNameFallback(node) + val show = if (labelNums.contains(node.num)) 1 else 0 + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","showLabel":$show}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -173,9 +556,12 @@ private fun waypointsToFeatureCollectionJson( val features = waypoints.mapNotNull { pkt -> val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null + // Filter invalid/placeholder coordinates (avoid 0,0 near Africa) val lat = w.latitudeI * DEG_D val lon = w.longitudeI * DEG_D - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{}}""" + if (lat == 0.0 && lon == 0.0) return@mapNotNull null + if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"waypoint","id":${w.id}}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -184,4 +570,69 @@ private fun emptyFeatureCollectionJson(): String { return """{"type":"FeatureCollection","features":[]}""" } +private fun oneTrackFromNodesJson(nodes: List): String { + val valid = nodes.mapNotNull { it.validPosition } + if (valid.size < 2) return emptyFeatureCollectionJson() + val a = valid[0] + val b = valid[1] + val lat1 = a.latitudeI * DEG_D + val lon1 = a.longitudeI * DEG_D + val lat2 = b.latitudeI * DEG_D + val lon2 = b.longitudeI * DEG_D + val line = + """{"type":"Feature","geometry":{"type":"LineString","coordinates":[[$lon1,$lat1],[$lon2,$lat2]]},"properties":{"kind":"track"}}""" + return """{"type":"FeatureCollection","features":[$line]}""" +} + +private fun shortName(node: Node): String { + // Deprecated; kept for compatibility + return shortNameFallback(node) +} + +private fun protoShortName(node: Node): String? { + // Prefer the protocol-defined short name if present + val s = node.user.shortName + return if (s.isNullOrBlank()) null else s +} + +private fun shortNameFallback(node: Node): String { + val long = node.user.longName + if (!long.isNullOrBlank()) return long.take(4) + val hex = node.num.toString(16).uppercase() + return if (hex.length >= 4) hex.takeLast(4) else hex +} + +// Select one label per grid cell in the current viewport, prioritizing favorites and recent nodes. +private fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density: Float): Set { + val bounds = map.projection.visibleRegion.latLngBounds + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + val lat = p.latitudeI * DEG_D + val lon = p.longitudeI * DEG_D + bounds.contains(LatLng(lat, lon)) + } + if (visible.isEmpty()) return emptySet() + // Priority: favorites first, then more recently heard + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cellSizePx = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cellSizePx).toInt() + val cy = (pt.y / cellSizePx).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) { + chosen.add(n.num) + } + } + return chosen +} + From 11ef38f3829ccec89ca77b211d2850d264c68481 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 14 Nov 2025 12:43:17 -0800 Subject: [PATCH 03/62] cluster fan-out, role-based colors, legend toggle, and UX polish --- .../feature/map/maplibre/MapLibrePOC.kt | 402 ++++++++++++++++-- 1 file changed, 377 insertions(+), 25 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index 233c1f5e04..408850e635 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -20,6 +20,16 @@ package org.meshtastic.feature.map.maplibre import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -47,6 +57,15 @@ import androidx.compose.material3.Button import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FloatingActionButton import org.maplibre.android.MapLibre import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapView @@ -79,14 +98,21 @@ import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.expressions.Expression.* import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng +import kotlin.math.cos +import kotlin.math.sin import org.meshtastic.core.database.model.Node +import org.meshtastic.proto.ConfigProtos import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.MapViewModel import org.meshtastic.proto.MeshProtos.Waypoint import timber.log.Timber import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.component.MapButton + import org.maplibre.android.style.layers.BackgroundLayer +import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth +import org.maplibre.geojson.Point private const val DEG_D = 1e-7 @@ -104,6 +130,8 @@ private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" private const val OSM_LAYER_ID = "osm-layer" +private const val CLUSTER_RADIAL_MAX = 8 +private const val CLUSTER_LIST_FETCH_MAX = 200L @SuppressLint("MissingPermission") @Composable @@ -117,6 +145,15 @@ fun MapLibrePOC( var selectedInfo by remember { mutableStateOf(null) } var selectedNodeNum by remember { mutableStateOf(null) } val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + var recenterRequest by remember { mutableStateOf(false) } + var followBearing by remember { mutableStateOf(false) } + data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) + var expandedCluster by remember { mutableStateOf(null) } + var clusterListMembers by remember { mutableStateOf?>(null) } + var mapRef by remember { mutableStateOf(null) } + var didInitialCenter by remember { mutableStateOf(false) } + var showLegend by remember { mutableStateOf(false) } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() @@ -129,6 +166,7 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") MapView(context).apply { getMapAsync { map -> + mapRef = map Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") map.setStyle(STYLE_URL) { style -> Timber.tag("MapLibrePOC").d("Style loaded: %s", STYLE_URL) @@ -140,7 +178,7 @@ fun MapLibrePOC( val labelSet = run { val visible = - nodes.filter { n -> + applyNodeFilters(nodes, mapFilterState).filter { n -> val p = n.validPosition ?: return@filter false bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) } @@ -166,11 +204,11 @@ fun MapLibrePOC( chosen } (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyNodeFilters(nodes, mapFilterState), labelSet)) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(oneTrackFromNodesJson(nodes)) + ?.setGeoJson(oneTrackFromNodesJson(applyNodeFilters(nodes, mapFilterState))) Timber.tag("MapLibrePOC").d( "Initial data set after style load. nodes=%d waypoints=%d", nodes.size, @@ -196,7 +234,36 @@ fun MapLibrePOC( // ignore Timber.tag("MapLibrePOC").w("Location component not enabled (likely missing permissions)") } + // Initial center on user's device location if available, else our node + if (!didInitialCenter) { + try { + val loc = map.locationComponent.lastKnownLocation + if (loc != null) { + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(loc.latitude, loc.longitude), + 12.0, + ), + ) + didInitialCenter = true + } else { + ourNode?.validPosition?.let { p -> + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 12.0, + ), + ) + didInitialCenter = true + } + } + } catch (_: Throwable) { + } + } map.addOnMapClickListener { latLng -> + // Any tap on the map clears overlays unless replaced below + expandedCluster = null + clusterListMembers = null val screenPoint = map.projection.toScreenLocation(latLng) // Use a small hitbox to improve taps on small circles val r = (24 * context.resources.displayMetrics.density) @@ -209,10 +276,41 @@ fun MapLibrePOC( val features = map.queryRenderedFeatures(rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, WAYPOINTS_LAYER_ID) Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) val f = features.firstOrNull() - // If cluster tapped, zoom in a bit + // If cluster tapped, expand using true cluster leaves from the source if (f != null && f.hasProperty("point_count")) { - map.animateCamera(CameraUpdateFactory.zoomIn()) - return@addOnMapClickListener true + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) + val src = (map.style?.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = + fc.features()?.mapNotNull { feat -> + try { + feat.getNumberProperty("num")?.toInt() + } catch (_: Throwable) { + null + } + } ?: emptyList() + val members = nodes.filter { nums.contains(it.num) } + if (members.isNotEmpty()) { + // Center the radial overlay on the actual cluster point (not the raw click) + val clusterCenter = + (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + clusterListMembers = members + } else { + // Show radial overlay for small clusters + expandedCluster = ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + } + } + return@addOnMapClickListener true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return@addOnMapClickListener true + } } selectedInfo = f?.let { @@ -257,9 +355,16 @@ fun MapLibrePOC( ) // Compute which nodes get labels in viewport and update source val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, nodes, density) + val labelSet = selectLabelsForViewport(map, applyNodeFilters(nodes, mapFilterState), density) (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyNodeFilters(nodes, mapFilterState), labelSet)) + } + // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions + map.addOnCameraMoveListener { + if (expandedCluster != null || clusterListMembers != null) { + expandedCluster = null + clusterListMembers = null + } } } } @@ -272,6 +377,18 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").w("Style not yet available in update()") return@getMapAsync } + // Apply bearing render mode toggle + try { + map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + } catch (_: Throwable) { /* ignore */ } + // Handle recenter requests + if (recenterRequest) { + recenterRequest = false + ourNode?.validPosition?.let { p -> + val ll = LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(ll, 14.5)) + } + } Timber.tag("MapLibrePOC").d( "Updating sources. nodes=%d, waypoints=%d", nodes.size, @@ -349,9 +466,172 @@ fun MapLibrePOC( } } + // Role legend (based on roles present in current nodes) + val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } + if (showLegend && rolesPresent.isNotEmpty()) { + Surface( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp), + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + rolesPresent.take(6).forEach { role -> + val fakeNode = Node(num = 0, user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build()) + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(shape = CircleShape, color = roleColor(fakeNode), modifier = Modifier.size(12.dp)) {} + Spacer(modifier = Modifier.width(8.dp)) + Text(text = role.name.lowercase().replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.labelMedium) + } + } + } + } + } + + // Map controls: recenter/follow and filter menu + var mapFilterExpanded by remember { mutableStateOf(false) } + Column( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp), + ) { + MapButton( + onClick = { recenterRequest = true }, + icon = Icons.Outlined.MyLocation, + contentDescription = null, + ) + MapButton( + onClick = { followBearing = !followBearing }, + icon = Icons.Outlined.Explore, + contentDescription = null, + ) + Box { + MapButton( + onClick = { mapFilterExpanded = true }, + icon = Icons.Outlined.Tune, + contentDescription = null, + ) + DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { mapViewModel.toggleOnlyFavorites(); mapFilterExpanded = false }, + trailingIcon = { Checkbox(checked = mapFilterState.onlyFavorites, onCheckedChange = { mapViewModel.toggleOnlyFavorites() }) }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { mapViewModel.toggleShowPrecisionCircleOnMap(); mapFilterExpanded = false }, + trailingIcon = { Checkbox(checked = mapFilterState.showPrecisionCircle, onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }) }, + ) + } + } + MapButton( + onClick = { showLegend = !showLegend }, + icon = Icons.Outlined.Info, + contentDescription = null, + ) + } + + // Expanded cluster radial overlay + expandedCluster?.let { ec -> + val d = context.resources.displayMetrics.density + val centerX = (ec.centerPx.x / d).dp + val centerY = (ec.centerPx.y / d).dp + val radiusPx = 72f * d + val itemSize = 40.dp + val n = ec.members.size.coerceAtLeast(1) + ec.members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / d).dp + val yDp = (y / d).dp + Surface( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = xDp - itemSize / 2, y = yDp - itemSize / 2) + .size(itemSize) + .clickable { + selectedNodeNum = node.num + expandedCluster = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = (protoShortName(node) ?: shortNameFallback(node)).take(4), color = Color.White, maxLines = 1) + } + } + } + } + // Bottom sheet with node details and actions val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + // Cluster list bottom sheet (for large clusters) + clusterListMembers?.let { members -> + ModalBottomSheet( + onDismissRequest = { clusterListMembers = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip(node = node, onClick = { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } + } if (selectedNode != null) { ModalBottomSheet( onDismissRequest = { selectedNodeNum = null }, @@ -359,19 +639,27 @@ fun MapLibrePOC( ) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { NodeChip(node = selectedNode) - val lastHeard = selectedNode.lastHeard + val longName = selectedNode.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) val coords = selectedNode.gpsString() - Text(text = "Last heard: $lastHeard s ago") + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) Text(text = "Coordinates: $coords") - // Quick actions (placeholder) - Button( - onClick = { + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button(onClick = { onNavigateToNodeDetails(selectedNode.num) selectedNodeNum = null - }, - modifier = Modifier.padding(top = 12.dp), - ) { - Text("View full node") + }) { + Text("View full node") + } } } } @@ -458,7 +746,7 @@ private fun ensureSourcesAndLayers(style: Style) { if (style.getLayer(NODES_LAYER_ID) == null) { val layer = CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID) - .withProperties(circleColor("#1565C0"), circleRadius(6f)) + .withProperties(circleColor(get("color")), circleRadius(6f)) .withFilter(not(has("point_count"))) style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") @@ -477,12 +765,13 @@ private fun ensureSourcesAndLayers(style: Style) { interpolate( linear(), zoom(), - stop(8, 10f), - stop(12, 12f), - stop(15, 14f), + stop(8, 9f), + stop(12, 11f), + stop(15, 13f), stop(18, 16f), ), ), + textMaxWidth(4f), // At close zooms, prefer showing all labels even if they overlap textAllowOverlap( step( @@ -524,15 +813,21 @@ private fun ensureSourcesAndLayers(style: Style) { Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) } +private fun applyNodeFilters(all: List, filter: BaseMapViewModel.MapFilterState): List { + return if (filter.onlyFavorites) all.filter { it.isFavorite } else all +} + private fun nodesToFeatureCollectionJson(nodes: List): String { val features = nodes.mapNotNull { node -> val pos = node.validPosition ?: return@mapNotNull null val lat = pos.latitudeI * DEG_D val lon = pos.longitudeI * DEG_D - val short = protoShortName(node) ?: shortNameFallback(node) + val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val role = node.user.role.name + val color = roleColorHex(node) // Default showLabel=0; it will be turned on for selected nodes by viewport selection - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","showLabel":0}}""" + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","role":"$role","color":"$color","showLabel":0}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -543,9 +838,11 @@ private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNu val pos = node.validPosition ?: return@mapNotNull null val lat = pos.latitudeI * DEG_D val lon = pos.longitudeI * DEG_D - val short = protoShortName(node) ?: shortNameFallback(node) + val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) val show = if (labelNums.contains(node.num)) 1 else 0 - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","showLabel":$show}}""" + val role = node.user.role.name + val color = roleColorHex(node) + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","role":"$role","color":"$color","showLabel":$show}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -635,4 +932,59 @@ private fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density return chosen } +// Human friendly "x min ago" from epoch seconds +private fun formatSecondsAgo(lastHeardEpochSeconds: Int): String { + val now = System.currentTimeMillis() / 1000 + val delta = (now - lastHeardEpochSeconds).coerceAtLeast(0) + val minutes = delta / 60 + val hours = minutes / 60 + val days = hours / 24 + return when { + delta < 60 -> "$delta s ago" + minutes < 60 -> "$minutes min ago" + hours < 24 -> "$hours h ago" + else -> "$days d ago" + } +} + +// Simple haversine distance between two nodes in kilometers +private fun distanceKmBetween(a: Node, b: Node): Double? { + val pa = a.validPosition ?: return null + val pb = b.validPosition ?: return null + val lat1 = pa.latitudeI * DEG_D + val lon1 = pa.longitudeI * DEG_D + val lat2 = pb.latitudeI * DEG_D + val lon2 = pb.longitudeI * DEG_D + val R = 6371.0 // km + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val s1 = kotlin.math.sin(dLat / 2) + val s2 = kotlin.math.sin(dLon / 2) + val aTerm = s1 * s1 + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * s2 * s2 + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(aTerm), kotlin.math.sqrt(1 - aTerm)) + return R * c +} + +// Role -> hex color used for map dots and radial overlay +private fun roleColorHex(node: Node): String { + return when (node.user.role) { + ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#616161" // gray + ConfigProtos.Config.DeviceConfig.Role.ROUTER_CLIENT -> "#00897B" // teal + ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#EF6C00" // orange + ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple + ConfigProtos.Config.DeviceConfig.Role.SENSOR -> "#1E88E5" // blue + ConfigProtos.Config.DeviceConfig.Role.TAK, ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER -> "#C62828" // red + ConfigProtos.Config.DeviceConfig.Role.CLIENT -> "#2E7D32" // green + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#43A047" // green (lighter) + ConfigProtos.Config.DeviceConfig.Role.CLIENT_MUTE -> "#9E9D24" // olive + ConfigProtos.Config.DeviceConfig.Role.CLIENT_HIDDEN -> "#546E7A" // blue-grey + ConfigProtos.Config.DeviceConfig.Role.LOST_AND_FOUND -> "#AD1457" // magenta + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#757575" // mid-grey + null, + ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED -> "#2E7D32" // default green + } +} + +private fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) + From e6b91d0811427c63a42c9c71bffb6c9316aba8bc Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 14 Nov 2025 13:49:57 -0800 Subject: [PATCH 04/62] better clustering, add filtering etc --- .../feature/map/maplibre/MapLibrePOC.kt | 282 +++++++++++++----- 1 file changed, 202 insertions(+), 80 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index 408850e635..bba0ac68f9 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -113,18 +113,26 @@ import org.meshtastic.feature.map.component.MapButton import org.maplibre.android.style.layers.BackgroundLayer import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth import org.maplibre.geojson.Point +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF private const val DEG_D = 1e-7 private const val STYLE_URL = "https://demotiles.maplibre.org/style.json" private const val NODES_SOURCE_ID = "meshtastic-nodes-source" +private const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" private const val TRACKS_SOURCE_ID = "meshtastic-tracks-source" private const val OSM_SOURCE_ID = "osm-tiles" private const val NODES_LAYER_ID = "meshtastic-nodes-layer" private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" +private const val NODE_TEXT_BG_LAYER_ID = "meshtastic-node-text-bg-layer" +private const val NODES_LAYER_CLUSTERED_ID = "meshtastic-nodes-layer-clustered" +private const val NODE_TEXT_LAYER_CLUSTERED_ID = "meshtastic-node-text-layer-clustered" private const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" @@ -154,6 +162,8 @@ fun MapLibrePOC( var mapRef by remember { mutableStateOf(null) } var didInitialCenter by remember { mutableStateOf(false) } var showLegend by remember { mutableStateOf(false) } + var enabledRoles by remember { mutableStateOf>(emptySet()) } + var clusteringEnabled by remember { mutableStateOf(true) } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() @@ -178,7 +188,7 @@ fun MapLibrePOC( val labelSet = run { val visible = - applyNodeFilters(nodes, mapFilterState).filter { n -> + applyFilters(nodes, mapFilterState, enabledRoles).filter { n -> val p = n.validPosition ?: return@filter false bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) } @@ -204,11 +214,13 @@ fun MapLibrePOC( chosen } (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyNodeFilters(nodes, mapFilterState), labelSet)) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) - (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(oneTrackFromNodesJson(applyNodeFilters(nodes, mapFilterState))) + // Removed test track line rendering + // Update clustered source too + (style.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) Timber.tag("MapLibrePOC").d( "Initial data set after style load. nodes=%d waypoints=%d", nodes.size, @@ -273,14 +285,21 @@ fun MapLibrePOC( (screenPoint.x + r).toFloat(), (screenPoint.y + r).toFloat(), ) - val features = map.queryRenderedFeatures(rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, WAYPOINTS_LAYER_ID) + val features = + map.queryRenderedFeatures( + rect, + CLUSTER_CIRCLE_LAYER_ID, + NODES_LAYER_ID, + NODES_LAYER_CLUSTERED_ID, + WAYPOINTS_LAYER_ID, + ) Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) val f = features.firstOrNull() // If cluster tapped, expand using true cluster leaves from the source if (f != null && f.hasProperty("point_count")) { val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) - val src = (map.style?.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) if (src != null) { val fc = src.getClusterLeaves(f, limit, 0L) val nums = @@ -334,30 +353,12 @@ fun MapLibrePOC( } // Update clustering visibility on camera idle (zoom changes) map.addOnCameraIdleListener { - val zoomNow = map.cameraPosition.zoom - val bounds = map.projection.visibleRegion.latLngBounds - val visibleCount = - nodes.count { n -> - val p = n.validPosition ?: return@count false - val lat = p.latitudeI * DEG_D - val lon = p.longitudeI * DEG_D - bounds.contains(LatLng(lat, lon)) - } - // Cluster only when zoom <= 10 and viewport density is high - val showClustersNow = zoomNow <= 10.0 && visibleCount > 50 - style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - Timber.tag("MapLibrePOC").d( - "Camera idle; cluster visibility=%s (visible=%d, zoom=%.2f)", - showClustersNow, - visibleCount, - zoomNow, - ) + setClusterVisibility(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) // Compute which nodes get labels in viewport and update source val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, applyNodeFilters(nodes, mapFilterState), density) + val labelSet = selectLabelsForViewport(map, applyFilters(nodes, mapFilterState, enabledRoles), density) (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyNodeFilters(nodes, mapFilterState), labelSet)) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) } // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { @@ -428,27 +429,11 @@ fun MapLibrePOC( ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) - (style.getSource(TRACKS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(oneTrackFromNodesJson(nodes)) - // Toggle clustering visibility based on zoom and VISIBLE node count (viewport density) - val zoom = map.cameraPosition.zoom - val bounds = map.projection.visibleRegion.latLngBounds - val visibleCount = - nodes.count { n -> - val p = n.validPosition ?: return@count false - val lat = p.latitudeI * DEG_D - val lon = p.longitudeI * DEG_D - bounds.contains(LatLng(lat, lon)) - } - val showClusters = zoom <= 10.0 && visibleCount > 50 - style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - Timber.tag("MapLibrePOC").d( - "Sources updated; cluster visibility=%s (visible=%d, zoom=%.2f)", - showClusters, - visibleCount, - zoom, - ) + // Removed test track line rendering + (style.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) + // Toggle clustering visibility now (no need to wait for camera move) + setClusterVisibility(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } }, ) @@ -526,6 +511,49 @@ fun MapLibrePOC( onClick = { mapViewModel.toggleShowPrecisionCircleOnMap(); mapFilterExpanded = false }, trailingIcon = { Checkbox(checked = mapFilterState.showPrecisionCircle, onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }) }, ) + androidx.compose.material3.Divider() + Text(text = "Roles", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + enabledRoles = + if (enabledRoles.isEmpty()) setOf(role) + else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role + mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { + enabledRoles = + if (enabledRoles.isEmpty()) setOf(role) + else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role + mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + }, + ) + }, + ) + } + androidx.compose.material3.Divider() + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + clusteringEnabled = !clusteringEnabled + mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + }, + trailingIcon = { + Checkbox( + checked = clusteringEnabled, + onCheckedChange = { + clusteringEnabled = it + mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + }, + ) + }, + ) } } MapButton( @@ -549,11 +577,14 @@ fun MapLibrePOC( val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() val xDp = (x / d).dp val yDp = (y / d).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp Surface( modifier = Modifier .align(Alignment.TopStart) - .offset(x = xDp - itemSize / 2, y = yDp - itemSize / 2) - .size(itemSize) + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) .clickable { selectedNodeNum = node.num expandedCluster = null @@ -571,7 +602,7 @@ fun MapLibrePOC( shadowElevation = 6.dp, ) { Box(contentAlignment = Alignment.Center) { - Text(text = (protoShortName(node) ?: shortNameFallback(node)).take(4), color = Color.White, maxLines = 1) + Text(text = label, color = Color.White, maxLines = 1) } } } @@ -694,22 +725,21 @@ private fun ensureSourcesAndLayers(style: Style) { Timber.tag("MapLibrePOC").d("Added OSM Standard RasterSource") } if (style.getSource(NODES_SOURCE_ID) == null) { - // Enable clustering only for lower zooms; stop clustering once zoom > 10 - val options = GeoJsonOptions() - .withCluster(true) - .withClusterRadius(36) - .withClusterMaxZoom(10) - style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson(), options)) - Timber.tag("MapLibrePOC").d("Added nodes GeoJsonSource") + // Plain (non-clustered) source for nodes and labels + style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added nodes plain GeoJsonSource") + } + if (style.getSource(NODES_CLUSTER_SOURCE_ID) == null) { + // Clustered source for cluster layers + val options = GeoJsonOptions().withCluster(true).withClusterRadius(36).withClusterMaxZoom(10) + style.addSource(GeoJsonSource(NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), options)) + Timber.tag("MapLibrePOC").d("Added nodes clustered GeoJsonSource") } if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) Timber.tag("MapLibrePOC").d("Added waypoints GeoJsonSource") } - if (style.getSource(TRACKS_SOURCE_ID) == null) { - style.addSource(GeoJsonSource(TRACKS_SOURCE_ID, emptyFeatureCollectionJson())) - Timber.tag("MapLibrePOC").d("Added tracks GeoJsonSource") - } + // Removed test track GeoJsonSource if (style.getLayer(OSM_LAYER_ID) == null) { // Put OSM tiles on TOP so labels/roads are visible during POC @@ -720,7 +750,7 @@ private fun ensureSourcesAndLayers(style: Style) { } if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { val clusterLayer = - CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_SOURCE_ID) + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) .withProperties(circleColor("#6D4C41"), circleRadius(14f)) .withFilter(has("point_count")) style.addLayer(clusterLayer) @@ -728,7 +758,7 @@ private fun ensureSourcesAndLayers(style: Style) { } if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { val countLayer = - SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_SOURCE_ID) + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) .withProperties( textField(toString(get("point_count"))), textColor("#FFFFFF"), @@ -746,20 +776,28 @@ private fun ensureSourcesAndLayers(style: Style) { if (style.getLayer(NODES_LAYER_ID) == null) { val layer = CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID) - .withProperties(circleColor(get("color")), circleRadius(6f)) + .withProperties(circleColor(get("color")), circleRadius(7f)) .withFilter(not(has("point_count"))) style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") } + if (style.getLayer(NODES_LAYER_CLUSTERED_ID) == null) { + val layer = + CircleLayer(NODES_LAYER_CLUSTERED_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor(get("color")), circleRadius(7f), visibility("none")) + .withFilter(not(has("point_count"))) + style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added clustered nodes CircleLayer") + } if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { val textLayer = SymbolLayer(NODE_TEXT_LAYER_ID, NODES_SOURCE_ID) .withProperties( textField(get("short")), - textColor("#FFFFFF"), - textHaloColor("#000000"), - textHaloWidth(1.5f), - textHaloBlur(0.5f), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), // Scale label size with zoom to reduce clutter textSize( interpolate( @@ -772,19 +810,19 @@ private fun ensureSourcesAndLayers(style: Style) { ), ), textMaxWidth(4f), - // At close zooms, prefer showing all labels even if they overlap + // At close zooms, prefer showing all labels even if they overlap a bit textAllowOverlap( step( zoom(), literal(false), // default for low zooms - stop(12, literal(true)), // enable overlap >= 12 + stop(11, literal(true)), // enable overlap >= 11 ), ), textIgnorePlacement( step( zoom(), literal(false), - stop(12, literal(true)), + stop(11, literal(true)), ), ), // place label above the circle @@ -800,21 +838,71 @@ private fun ensureSourcesAndLayers(style: Style) { style.addLayer(textLayer) Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") } + if (style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID) == null) { + val textLayer = + SymbolLayer(NODE_TEXT_LAYER_CLUSTERED_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate( + linear(), + zoom(), + stop(8, 9f), + stop(12, 11f), + stop(15, 13f), + stop(18, 16f), + ), + ), + textMaxWidth(4f), + textAllowOverlap( + step( + zoom(), + literal(false), + stop(11, literal(true)), + ), + ), + textIgnorePlacement( + step( + zoom(), + literal(false), + stop(11, literal(true)), + ), + ), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + visibility("none"), + ) + .withFilter( + all( + not(has("point_count")), + eq(get("showLabel"), literal(1)), + ), + ) + style.addLayer(textLayer) + Timber.tag("MapLibrePOC").d("Added clustered node text SymbolLayer") + } if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f)) style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") } - if (style.getLayer(TRACKS_LAYER_ID) == null) { - val layer = LineLayer(TRACKS_LAYER_ID, TRACKS_SOURCE_ID).withProperties(lineColor("#FF6D00"), lineWidth(3f)) - style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added tracks LineLayer") - } + // Removed test track LineLayer Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) } -private fun applyNodeFilters(all: List, filter: BaseMapViewModel.MapFilterState): List { - return if (filter.onlyFavorites) all.filter { it.isFavorite } else all +private fun applyFilters( + all: List, + filter: BaseMapViewModel.MapFilterState, + enabledRoles: Set, +): List { + var out = all + if (filter.onlyFavorites) out = out.filter { it.isFavorite } + if (enabledRoles.isNotEmpty()) out = out.filter { enabledRoles.contains(it.user.role) } + return out } private fun nodesToFeatureCollectionJson(nodes: List): String { @@ -916,7 +1004,20 @@ private fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density compareByDescending { it.isFavorite } .thenByDescending { it.lastHeard }, ) - val cellSizePx = (80f * density).toInt().coerceAtLeast(48) + // Dynamic cell size by zoom so more labels appear as you zoom in + val zoom = map.cameraPosition.zoom + val baseCellDp = + when { + zoom < 10 -> 96f + zoom < 11 -> 88f + zoom < 12 -> 80f + zoom < 13 -> 72f + zoom < 14 -> 64f + zoom < 15 -> 56f + zoom < 16 -> 48f + else -> 36f + } + val cellSizePx = (baseCellDp * density).toInt().coerceAtLeast(32) val occupied = HashSet() val chosen = LinkedHashSet() for (n in sorted) { @@ -987,4 +1088,25 @@ private fun roleColorHex(node: Node): String { private fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) +// Show/hide cluster layers vs plain nodes based on zoom, density, and toggle +private fun setClusterVisibility(map: MapLibreMap, style: Style, filteredNodes: List, enableClusters: Boolean) { + val zoom = map.cameraPosition.zoom + val bounds = map.projection.visibleRegion.latLngBounds + val visibleCount = + filteredNodes.count { n -> + val p = n.validPosition ?: return@count false + bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val showClustersNow = enableClusters && (zoom <= 10.0 && visibleCount > 50) + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + // Plain nodes visible when clusters hidden + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "none" else "visible")) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "none" else "visible")) + // Clustered unclustered-points layers visible when clusters shown + style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) + Timber.tag("MapLibrePOC").d("Cluster visibility=%s (visible=%d, zoom=%.2f)", showClustersNow, visibleCount, zoom) +} + From dad09d88193b9df960f0ddb9cb9699ec65763ecb Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Mon, 17 Nov 2025 23:42:41 -0800 Subject: [PATCH 05/62] styles working --- .../feature/map/maplibre/MapLibrePOC.kt | 744 ++++++++++++++---- 1 file changed, 603 insertions(+), 141 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index bba0ac68f9..e42750dbc0 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -18,6 +18,10 @@ package org.meshtastic.feature.map.maplibre import android.annotation.SuppressLint +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.SystemClock import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -52,6 +56,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.core.content.ContextCompat import androidx.compose.material3.Text import androidx.compose.material3.Button import androidx.compose.material3.ModalBottomSheet @@ -62,6 +67,7 @@ import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Checkbox @@ -94,6 +100,7 @@ import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.android.style.sources.RasterSource import org.maplibre.android.style.layers.RasterLayer import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.sources.TileSet import org.maplibre.android.style.expressions.Expression import org.maplibre.android.style.expressions.Expression.* import org.maplibre.android.camera.CameraUpdateFactory @@ -113,6 +120,8 @@ import org.meshtastic.feature.map.component.MapButton import org.maplibre.android.style.layers.BackgroundLayer import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth import org.maplibre.geojson.Point +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint @@ -135,12 +144,190 @@ private const val NODES_LAYER_CLUSTERED_ID = "meshtastic-nodes-layer-clustered" private const val NODE_TEXT_LAYER_CLUSTERED_ID = "meshtastic-node-text-layer-clustered" private const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" +private const val CLUSTER_TIER0_LAYER_ID = "meshtastic-cluster-0" +private const val CLUSTER_TIER1_LAYER_ID = "meshtastic-cluster-1" +private const val CLUSTER_TIER2_LAYER_ID = "meshtastic-cluster-2" +private const val CLUSTER_TEXT_TIER_LAYER_ID = "meshtastic-cluster-text" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" private const val OSM_LAYER_ID = "osm-layer" private const val CLUSTER_RADIAL_MAX = 8 private const val CLUSTER_LIST_FETCH_MAX = 200L +private const val TEST_POINT_SOURCE_ID = "meshtastic-test-point-source" +private const val TEST_POINT_LAYER_ID = "meshtastic-test-point-layer" +private const val DEBUG_ALL_LAYER_ID = "meshtastic-debug-all" +private const val DEBUG_SYMBOL_LAYER_ID = "meshtastic-debug-symbols" +private const val DEBUG_RAW_SOURCE_ID = "meshtastic-debug-raw-source" +private const val DEBUG_RAW_LAYER_ID = "meshtastic-debug-raw-layer" +private const val DEBUG_RAW_SYMBOL_LAYER_ID = "meshtastic-debug-raw-symbols" +// Base map style options (raster tiles; key-free) +private enum class BaseMapStyle(val label: String, val urlTemplate: String) { + OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), + CARTO_LIGHT("Light", "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"), + CARTO_DARK("Dark", "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"), + ESRI_SATELLITE("Satellite", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"), +} + +private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { + // Load a complete vector style first (has fonts, glyphs, sprites MapLibre needs) + val builder = Style.Builder().fromUri("https://demotiles.maplibre.org/style.json") + // Add our raster overlay on top + .withSource( + RasterSource( + OSM_SOURCE_ID, + TileSet("osm", base.urlTemplate).apply { + minZoom = 0f + maxZoom = 22f + }, + 128, + ), + ) + .withLayer(RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f))) + // Sources + .withSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + .withSource( + GeoJsonSource( + NODES_CLUSTER_SOURCE_ID, + emptyFeatureCollectionJson(), + GeoJsonOptions().withCluster(true).withClusterRadius(50).withClusterMaxZoom(14).withClusterMinPoints(2), + ), + ) + // Non-clustered debug copy of nodes to bypass clustering entirely + .withSource(GeoJsonSource(DEBUG_RAW_SOURCE_ID, emptyFeatureCollectionJson())) + .withSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + // Test point source to validate rendering independent of data plumbing + .withSource(GeoJsonSource(TEST_POINT_SOURCE_ID, emptyFeatureCollectionJson())) + // Layers - order ensures they are above raster + .withLayer( + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID).withProperties(circleColor("#6D4C41"), circleRadius(14f)).withFilter(has("point_count")), + ) + .withLayer( + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")), + ) + .withLayer( + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate( + linear(), + zoom(), + stop(8, 4f), + stop(12, 6f), + stop(16, 8f), + stop(18, 9.5f), + ), + ), + ) + .withFilter(not(has("point_count"))), // Only show unclustered points + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate( + linear(), + zoom(), + stop(8, 9f), + stop(12, 11f), + stop(15, 13f), + stop(18, 16f), + ), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))), + ) + .withLayer(CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f))) + // Debug layers (hidden by default, can be toggled for diagnosis) + .withLayer( + CircleLayer(DEBUG_ALL_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#FF00FF"), circleRadius(5.0f), visibility("none")), + ) + .withLayer( + SymbolLayer(DEBUG_SYMBOL_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(literal("•")), + textColor("#FF00FF"), + textSize(14f), + textAllowOverlap(true), + textIgnorePlacement(true), + visibility("none"), + ), + ) + .withLayer( + CircleLayer(TEST_POINT_LAYER_ID, TEST_POINT_SOURCE_ID) + .withProperties(circleColor("#00FF00"), circleRadius(12.0f), visibility("none")), + ) + .withLayer( + CircleLayer(DEBUG_RAW_LAYER_ID, DEBUG_RAW_SOURCE_ID) + .withProperties(circleColor("#00FFFF"), circleRadius(9.0f), visibility("none")), + ) + .withLayer( + SymbolLayer(DEBUG_RAW_SYMBOL_LAYER_ID, DEBUG_RAW_SOURCE_ID) + .withProperties( + textField(literal("•")), + textColor("#00FFFF"), + textSize(16f), + textAllowOverlap(true), + textIgnorePlacement(true), + visibility("none"), + ), + ) + // Tiered cluster layers and text (initially hidden; we toggle later) + .withLayer( + CircleLayer(CLUSTER_TIER0_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#6D4C41"), circleRadius(18f), visibility("none")) + .withFilter(all(has("point_count"), gte(toNumber(get("point_count")), literal(150)))), + ) + .withLayer( + CircleLayer(CLUSTER_TIER1_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#795548"), circleRadius(18f), visibility("none")) + .withFilter(all(has("point_count"), gt(toNumber(get("point_count")), literal(20)), lt(toNumber(get("point_count")), literal(150)))), + ) + .withLayer( + CircleLayer(CLUSTER_TIER2_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#8D6E63"), circleRadius(18f), visibility("none")) + .withFilter(all(has("point_count"), gte(toNumber(get("point_count")), literal(0)), lt(toNumber(get("point_count")), literal(20)))), + ) + .withLayer( + SymbolLayer(CLUSTER_TEXT_TIER_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + visibility("none"), + ) + .withFilter(has("point_count")), + ) + return builder +} @SuppressLint("MissingPermission") @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -160,10 +347,18 @@ fun MapLibrePOC( var expandedCluster by remember { mutableStateOf(null) } var clusterListMembers by remember { mutableStateOf?>(null) } var mapRef by remember { mutableStateOf(null) } + var mapViewRef by remember { mutableStateOf(null) } var didInitialCenter by remember { mutableStateOf(false) } var showLegend by remember { mutableStateOf(false) } var enabledRoles by remember { mutableStateOf>(emptySet()) } var clusteringEnabled by remember { mutableStateOf(true) } + // Base map style rotation + val baseStyles = remember { enumValues().toList() } + var baseStyleIndex by remember { mutableStateOf(0) } + val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] + // Remember last applied cluster visibility to reduce flashing + var clustersShown by remember { mutableStateOf(false) } + var lastClusterEvalMs by remember { mutableStateOf(0L) } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() @@ -175,11 +370,14 @@ fun MapLibrePOC( MapLibre.getInstance(context) Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") MapView(context).apply { + mapViewRef = this getMapAsync { map -> mapRef = map Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") - map.setStyle(STYLE_URL) { style -> - Timber.tag("MapLibrePOC").d("Style loaded: %s", STYLE_URL) + // Set initial base raster style using MapLibre test-app pattern + map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> + Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) + logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) // Push current data immediately after style load try { @@ -187,13 +385,11 @@ fun MapLibrePOC( val bounds = map.projection.visibleRegion.latLngBounds val labelSet = run { - val visible = - applyFilters(nodes, mapFilterState, enabledRoles).filter { n -> + val visible = applyFilters(nodes, mapFilterState, enabledRoles).filter { n -> val p = n.validPosition ?: return@filter false bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) } - val sorted = - visible.sortedWith( + val sorted = visible.sortedWith( compareByDescending { it.isFavorite } .thenByDescending { it.lastHeard }, ) @@ -213,35 +409,43 @@ fun MapLibrePOC( } chosen } - (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) - // Removed test track line rendering - // Update clustered source too - (style.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + // Set clustered source only (like MapLibre example) + val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + Timber.tag("MapLibrePOC").d("Setting nodes clustered source: %d nodes, jsonBytes=%d", nodes.size, json.length) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) + // Place a known visible test point (San Jose area) to verify rendering + val testFC = singlePointFC(-121.8893, 37.3349) + (style.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(testFC) Timber.tag("MapLibrePOC").d( "Initial data set after style load. nodes=%d waypoints=%d", nodes.size, waypoints.size, ) + logStyleState("after-style-load(post-sources)", style) } catch (t: Throwable) { Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") } // Keep base vector layers; OSM raster will sit below node layers for labels/roads // Enable location component (if permissions granted) try { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - Timber.tag("MapLibrePOC").d("Location component enabled") + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + Timber.tag("MapLibrePOC").d("Location component enabled") + } else { + Timber.tag("MapLibrePOC").w("Location permission missing; skipping location component enablement") + } } catch (_: Throwable) { // ignore Timber.tag("MapLibrePOC").w("Location component not enabled (likely missing permissions)") @@ -267,6 +471,24 @@ fun MapLibrePOC( ), ) didInitialCenter = true + } ?: run { + // Fallback: center to bounds of current nodes if available + val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() + var any = false + filtered.forEach { n -> + n.validPosition?.let { vp -> + boundsBuilder.include(LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D)) + any = true + } + } + if (any) { + val b = boundsBuilder.build() + map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) + } else { + map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5)) + } + didInitialCenter = true } } } catch (_: Throwable) { @@ -290,7 +512,6 @@ fun MapLibrePOC( rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, - NODES_LAYER_CLUSTERED_ID, WAYPOINTS_LAYER_ID, ) Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) @@ -353,12 +574,37 @@ fun MapLibrePOC( } // Update clustering visibility on camera idle (zoom changes) map.addOnCameraIdleListener { - setClusterVisibility(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) + val st = map.style ?: return@addOnCameraIdleListener + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener + lastClusterEvalMs = now + val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + Timber.tag("MapLibrePOC").d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) + clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown) // Compute which nodes get labels in viewport and update source val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, applyFilters(nodes, mapFilterState, enabledRoles), density) - (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + Timber.tag("MapLibrePOC").d("onCameraIdle: updating clustered source. labelSet=%d jsonBytes=%d", labelSet.size, jsonIdle.length) + // Update only the clustered source (unclustered points are filtered by not has(\"point_count\")) + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = map.queryRenderedFeatures( + bbox, + DEBUG_ALL_LAYER_ID, + DEBUG_SYMBOL_LAYER_ID, + DEBUG_RAW_LAYER_ID, + DEBUG_RAW_SYMBOL_LAYER_ID, + NODES_LAYER_ID, + CLUSTER_CIRCLE_LAYER_ID, + ) + Timber.tag("MapLibrePOC").d("onCameraIdle: rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) { } } // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { @@ -399,13 +645,11 @@ fun MapLibrePOC( val bounds2 = map.projection.visibleRegion.latLngBounds val labelSet = run { - val visible = - nodes.filter { n -> + val visible = nodes.filter { n -> val p = n.validPosition ?: return@filter false bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) } - val sorted = - visible.sortedWith( + val sorted = visible.sortedWith( compareByDescending { it.isFavorite } .thenByDescending { it.lastHeard }, ) @@ -425,15 +669,15 @@ fun MapLibrePOC( } chosen } - (style.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(nodes, labelSet)) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionJson(waypoints.values)) - // Removed test track line rendering - (style.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(applyFilters(nodes, mapFilterState, enabledRoles), labelSet)) - // Toggle clustering visibility now (no need to wait for camera move) - setClusterVisibility(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + val filteredNow = applyFilters(nodes, mapFilterState, enabledRoles) + val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, jsonNow) + // Apply visibility now + clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + logStyleState("update(block)", style) } }, ) @@ -484,6 +728,52 @@ fun MapLibrePOC( .align(Alignment.TopEnd) .padding(top = 16.dp, end = 16.dp), ) { + MapButton( + onClick = { + // Cycle base styles via setStyle(new Style.Builder(...)) + baseStyleIndex = (baseStyleIndex + 1) % baseStyles.size + val next = baseStyles[baseStyleIndex] + mapRef?.let { map -> + map.setStyle(buildMeshtasticStyle(next)) { style -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + ensureSourcesAndLayers(style) + try { + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, applyFilters(nodes, mapFilterState, enabledRoles), density) + val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + // Re-arm test point after style switch + (style.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) + // Re-enable location component for new style + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } else { + Timber.tag("MapLibrePOC").w("Location permission missing on style switch; skipping location component") + } + } catch (_: Throwable) { + } + } catch (_: Throwable) { + } + } + } + }, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) MapButton( onClick = { recenterRequest = true }, icon = Icons.Outlined.MyLocation, @@ -504,7 +794,17 @@ fun MapLibrePOC( DropdownMenuItem( text = { Text("Only favorites") }, onClick = { mapViewModel.toggleOnlyFavorites(); mapFilterExpanded = false }, - trailingIcon = { Checkbox(checked = mapFilterState.onlyFavorites, onCheckedChange = { mapViewModel.toggleOnlyFavorites() }) }, + trailingIcon = { Checkbox(checked = mapFilterState.onlyFavorites, onCheckedChange = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = applyFilters(nodes, mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), enabledRoles) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(filtered, emptySet())) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(filtered, emptySet())) + } + }) }, ) DropdownMenuItem( text = { Text("Show precision circle") }, @@ -522,7 +822,9 @@ fun MapLibrePOC( enabledRoles = if (enabledRoles.isEmpty()) setOf(role) else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role - mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + mapRef?.style?.let { st -> mapRef?.let { map -> + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + } } }, trailingIcon = { Checkbox( @@ -531,7 +833,9 @@ fun MapLibrePOC( enabledRoles = if (enabledRoles.isEmpty()) setOf(role) else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role - mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + mapRef?.style?.let { st -> mapRef?.let { map -> + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + } } }, ) }, @@ -542,14 +846,18 @@ fun MapLibrePOC( text = { Text("Enable clustering") }, onClick = { clusteringEnabled = !clusteringEnabled - mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + mapRef?.style?.let { st -> mapRef?.let { map -> + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + } } }, trailingIcon = { Checkbox( checked = clusteringEnabled, onCheckedChange = { clusteringEnabled = it - mapRef?.style?.let { st -> mapRef?.let { setClusterVisibility(it, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled) } } + mapRef?.style?.let { st -> mapRef?.let { map -> + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + } } }, ) }, @@ -719,11 +1027,6 @@ fun MapLibrePOC( private fun ensureSourcesAndLayers(style: Style) { Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() begin. Existing layers=%d, sources=%d", style.layers.size, style.sources.size) - if (style.getSource(OSM_SOURCE_ID) == null) { - // Try standard OpenStreetMap raster tiles (with subdomain 'a') for streets/cities - style.addSource(RasterSource(OSM_SOURCE_ID, "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", 256)) - Timber.tag("MapLibrePOC").d("Added OSM Standard RasterSource") - } if (style.getSource(NODES_SOURCE_ID) == null) { // Plain (non-clustered) source for nodes and labels style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) @@ -731,7 +1034,12 @@ private fun ensureSourcesAndLayers(style: Style) { } if (style.getSource(NODES_CLUSTER_SOURCE_ID) == null) { // Clustered source for cluster layers - val options = GeoJsonOptions().withCluster(true).withClusterRadius(36).withClusterMaxZoom(10) + val options = + GeoJsonOptions() + .withCluster(true) + .withClusterRadius(50) // match TestApp defaults + .withClusterMaxZoom(14) // allow clusters up to z14 like examples + .withClusterMinPoints(2) style.addSource(GeoJsonSource(NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), options)) Timber.tag("MapLibrePOC").d("Added nodes clustered GeoJsonSource") } @@ -741,19 +1049,12 @@ private fun ensureSourcesAndLayers(style: Style) { } // Removed test track GeoJsonSource - if (style.getLayer(OSM_LAYER_ID) == null) { - // Put OSM tiles on TOP so labels/roads are visible during POC - val rl = RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f)) - // Add early, then ensure it's below node layers if present - style.addLayer(rl) - Timber.tag("MapLibrePOC").d("Added OSM RasterLayer") - } if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { val clusterLayer = CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) .withProperties(circleColor("#6D4C41"), circleRadius(14f)) .withFilter(has("point_count")) - style.addLayer(clusterLayer) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(clusterLayer, OSM_LAYER_ID) else style.addLayer(clusterLayer) Timber.tag("MapLibrePOC").d("Added cluster CircleLayer") } if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { @@ -770,28 +1071,79 @@ private fun ensureSourcesAndLayers(style: Style) { textIgnorePlacement(true), ) .withFilter(has("point_count")) - style.addLayer(countLayer) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(countLayer, OSM_LAYER_ID) else style.addLayer(countLayer) Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") } - if (style.getLayer(NODES_LAYER_ID) == null) { + // Tiered cluster layers similar to GeoJsonClusteringActivity + if (style.getLayer(CLUSTER_TIER0_LAYER_ID) == null) { + val pointCount = toNumber(get("point_count")) val layer = - CircleLayer(NODES_LAYER_ID, NODES_SOURCE_ID) - .withProperties(circleColor(get("color")), circleRadius(7f)) - .withFilter(not(has("point_count"))) - style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") + CircleLayer(CLUSTER_TIER0_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#6D4C41"), circleRadius(18f), visibility("none")) + .withFilter( + all( + has("point_count"), + gte(pointCount, literal(150)), + ), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + } + if (style.getLayer(CLUSTER_TIER1_LAYER_ID) == null) { + val pointCount = toNumber(get("point_count")) + val layer = + CircleLayer(CLUSTER_TIER1_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#795548"), circleRadius(18f), visibility("none")) + .withFilter( + all( + has("point_count"), + gt(pointCount, literal(20)), + lt(pointCount, literal(150)), + ), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + } + if (style.getLayer(CLUSTER_TIER2_LAYER_ID) == null) { + val pointCount = toNumber(get("point_count")) + val layer = + CircleLayer(CLUSTER_TIER2_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#8D6E63"), circleRadius(18f), visibility("none")) + .withFilter( + all( + has("point_count"), + gte(pointCount, literal(0)), + lt(pointCount, literal(20)), + ), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + } + if (style.getLayer(CLUSTER_TEXT_TIER_LAYER_ID) == null) { + val textLayer = + SymbolLayer(CLUSTER_TEXT_TIER_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + visibility("none"), + ) + .withFilter(has("point_count")) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(textLayer, OSM_LAYER_ID) else style.addLayer(textLayer) } - if (style.getLayer(NODES_LAYER_CLUSTERED_ID) == null) { + if (style.getLayer(NODES_LAYER_ID) == null) { val layer = - CircleLayer(NODES_LAYER_CLUSTERED_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor(get("color")), circleRadius(7f), visibility("none")) - .withFilter(not(has("point_count"))) - style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added clustered nodes CircleLayer") + CircleLayer(NODES_LAYER_ID, DEBUG_RAW_SOURCE_ID) + .withProperties(circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), circleRadius(7f)) + // No filter: render raw points for diagnosis + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") } if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { val textLayer = - SymbolLayer(NODE_TEXT_LAYER_ID, NODES_SOURCE_ID) + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) .withProperties( textField(get("short")), textColor("#1B1B1B"), @@ -835,63 +1187,19 @@ private fun ensureSourcesAndLayers(style: Style) { eq(get("showLabel"), literal(1)), ), ) - style.addLayer(textLayer) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(textLayer, OSM_LAYER_ID) else style.addLayer(textLayer) Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") } - if (style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID) == null) { - val textLayer = - SymbolLayer(NODE_TEXT_LAYER_CLUSTERED_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(get("short")), - textColor("#1B1B1B"), - textHaloColor("#FFFFFF"), - textHaloWidth(3.0f), - textHaloBlur(0.7f), - textSize( - interpolate( - linear(), - zoom(), - stop(8, 9f), - stop(12, 11f), - stop(15, 13f), - stop(18, 16f), - ), - ), - textMaxWidth(4f), - textAllowOverlap( - step( - zoom(), - literal(false), - stop(11, literal(true)), - ), - ), - textIgnorePlacement( - step( - zoom(), - literal(false), - stop(11, literal(true)), - ), - ), - textOffset(arrayOf(0f, -1.4f)), - textAnchor("bottom"), - visibility("none"), - ) - .withFilter( - all( - not(has("point_count")), - eq(get("showLabel"), literal(1)), - ), - ) - style.addLayer(textLayer) - Timber.tag("MapLibrePOC").d("Added clustered node text SymbolLayer") - } if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f)) - style.addLayer(layer) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") } // Removed test track LineLayer Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) + val order = try { style.layers.joinToString(" > ") { it.id } } catch (_: Throwable) { "" } + Timber.tag("MapLibrePOC").d("Layer order: %s", order) + logStyleState("ensureSourcesAndLayers(end)", style) } private fun applyFilters( @@ -914,8 +1222,9 @@ private fun nodesToFeatureCollectionJson(nodes: List): String { val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) val role = node.user.role.name val color = roleColorHex(node) + val longEsc = escapeJson(node.user.longName ?: "") // Default showLabel=0; it will be turned on for selected nodes by viewport selection - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","role":"$role","color":"$color","showLabel":0}}""" + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$short","role":"$role","color":"$color","showLabel":0}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -930,11 +1239,46 @@ private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNu val show = if (labelNums.contains(node.num)) 1 else 0 val role = node.user.role.name val color = roleColorHex(node) - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"${node.user.longName}","short":"$short","role":"$role","color":"$color","showLabel":$show}}""" + val longEsc = escapeJson(node.user.longName ?: "") + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$short","role":"$role","color":"$color","showLabel":$show}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } +// FeatureCollection builders using MapLibre GeoJSON types (safer than manual JSON strings) +private fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums: Set): FeatureCollection { + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLon = Double.POSITIVE_INFINITY + var maxLon = Double.NEGATIVE_INFINITY + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lon < minLon) minLon = lon + if (lon > maxLon) maxLon = lon + val point = Point.fromLngLat(lon, lat) + val f = Feature.fromGeometry(point) + f.addStringProperty("kind", "node") + f.addNumberProperty("num", node.num) + f.addStringProperty("name", node.user.longName ?: "") + val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) + f.addStringProperty("short", short) + f.addStringProperty("role", node.user.role.name) + f.addStringProperty("color", roleColorHex(node)) + f.addNumberProperty("showLabel", if (labelNums.contains(node.num)) 1 else 0) + f + } + Timber.tag("MapLibrePOC").d( + "FC bounds: lat=[%.5f, %.5f] lon=[%.5f, %.5f] count=%d", + minLat, maxLat, minLon, maxLon, features.size, + ) + return FeatureCollection.fromFeatures(features) +} + private fun waypointsToFeatureCollectionJson( waypoints: Collection, ): String { @@ -951,10 +1295,110 @@ private fun waypointsToFeatureCollectionJson( return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } +private fun waypointsToFeatureCollectionFC( + waypoints: Collection, +): FeatureCollection { + val features = + waypoints.mapNotNull { pkt -> + val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null + val lat = w.latitudeI * DEG_D + val lon = w.longitudeI * DEG_D + if (lat == 0.0 && lon == 0.0) return@mapNotNull null + if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null + Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { it.addNumberProperty("id", w.id) } + } + return FeatureCollection.fromFeatures(features) +} + private fun emptyFeatureCollectionJson(): String { return """{"type":"FeatureCollection","features":[]}""" } +private fun singlePointFC(lon: Double, lat: Double): FeatureCollection { + val f = Feature.fromGeometry(Point.fromLngLat(lon, lat)) + return FeatureCollection.fromFeatures(listOf(f)) +} + +private fun safeSetGeoJson(style: Style, sourceId: String, json: String) { + try { + val fc = FeatureCollection.fromJson(json) + val count = fc.features()?.size ?: -1 + Timber.tag("MapLibrePOC").d("safeSetGeoJson(%s): features=%d", sourceId, count) + (style.getSource(sourceId) as? GeoJsonSource)?.setGeoJson(fc) + } catch (t: Throwable) { + Timber.tag("MapLibrePOC").e(t, "safeSetGeoJson(%s) failed to parse", sourceId) + } +} + +// Minimal JSON string escaper for embedding user-provided names in properties +private fun escapeJson(input: String): String { + if (input.isEmpty()) return "" + val sb = StringBuilder(input.length + 8) + input.forEach { ch -> + when (ch) { + '\\' -> sb.append("\\\\") + '"' -> sb.append("\\\"") + '\b' -> sb.append("\\b") + '\u000C' -> sb.append("\\f") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> { + if (ch < ' ') { + val hex = ch.code.toString(16).padStart(4, '0') + sb.append("\\u").append(hex) + } else { + sb.append(ch) + } + } + } + } + return sb.toString() +} + +// Log current style state: presence and visibility of key layers/sources +private fun logStyleState(whenTag: String, style: Style) { + try { + val layersToCheck = + listOf( + OSM_LAYER_ID, + CLUSTER_CIRCLE_LAYER_ID, + CLUSTER_COUNT_LAYER_ID, + CLUSTER_TIER0_LAYER_ID, + CLUSTER_TIER1_LAYER_ID, + CLUSTER_TIER2_LAYER_ID, + CLUSTER_TEXT_TIER_LAYER_ID, + NODES_LAYER_ID, + NODE_TEXT_LAYER_ID, + NODES_LAYER_CLUSTERED_ID, + NODE_TEXT_LAYER_CLUSTERED_ID, + WAYPOINTS_LAYER_ID, + DEBUG_ALL_LAYER_ID, + ) + val sourcesToCheck = listOf(NODES_SOURCE_ID, NODES_CLUSTER_SOURCE_ID, WAYPOINTS_SOURCE_ID, OSM_SOURCE_ID) + val layerStates = + layersToCheck.joinToString(", ") { id -> + val layer = style.getLayer(id) + if (layer == null) "$id=∅" else "$id=${layer.visibility?.value}" + } + val sourceStates = + sourcesToCheck.joinToString(", ") { id -> + if (style.getSource(id) == null) "$id=∅" else "$id=ok" + } + Timber.tag("MapLibrePOC").d("[%s] Layers: %s", whenTag, layerStates) + Timber.tag("MapLibrePOC").d("[%s] Sources: %s", whenTag, sourceStates) + } catch (_: Throwable) { + } +} + +private fun hasAnyLocationPermission(context: Context): Boolean { + val fine = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val coarse = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + return fine || coarse +} + private fun oneTrackFromNodesJson(nodes: List): String { val valid = nodes.mapNotNull { it.validPosition } if (valid.size < 2) return emptyFeatureCollectionJson() @@ -1089,24 +1533,42 @@ private fun roleColorHex(node: Node): String { private fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) // Show/hide cluster layers vs plain nodes based on zoom, density, and toggle -private fun setClusterVisibility(map: MapLibreMap, style: Style, filteredNodes: List, enableClusters: Boolean) { - val zoom = map.cameraPosition.zoom - val bounds = map.projection.visibleRegion.latLngBounds - val visibleCount = - filteredNodes.count { n -> - val p = n.validPosition ?: return@count false - bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) +private fun setClusterVisibilityHysteresis( + map: MapLibreMap, + style: Style, + filteredNodes: List, + enableClusters: Boolean, + currentlyShown: Boolean, +): Boolean { + try { + val zoom = map.cameraPosition.zoom + // Render like the MapLibre example: + // - Always show unclustered nodes (filtered by not has("point_count")) + // - Show cluster circle/count layers only when clustering is enabled + val showClusters = enableClusters + + // Enforce intended visibility + // Cluster circle/count + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + // Hide tiered cluster layers for now (ignored) + style.getLayer(CLUSTER_TIER0_LAYER_ID)?.setProperties(visibility("none")) + style.getLayer(CLUSTER_TIER1_LAYER_ID)?.setProperties(visibility("none")) + style.getLayer(CLUSTER_TIER2_LAYER_ID)?.setProperties(visibility("none")) + style.getLayer(CLUSTER_TEXT_TIER_LAYER_ID)?.setProperties(visibility("none")) + // Always show unclustered nodes and labels; label density still controlled by showLabel property + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility("visible")) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility("visible")) + // Legacy clustered node layers are not used + style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) + style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) + if (showClusters != currentlyShown) { + Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) } - val showClustersNow = enableClusters && (zoom <= 10.0 && visibleCount > 50) - style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - // Plain nodes visible when clusters hidden - style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "none" else "visible")) - style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClustersNow) "none" else "visible")) - // Clustered unclustered-points layers visible when clusters shown - style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility(if (showClustersNow) "visible" else "none")) - Timber.tag("MapLibrePOC").d("Cluster visibility=%s (visible=%d, zoom=%.2f)", showClustersNow, visibleCount, zoom) + return showClusters + } catch (_: Throwable) { + return currentlyShown + } } From b8f9884e301db726e4740c6ba3dff88eaaa9fa5a Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 08:30:36 -0800 Subject: [PATCH 06/62] waypoint mgmt, waypoint rendering, dropdown for style selector --- .../feature/map/maplibre/MapLibrePOC.kt | 368 ++++++++++++++++-- 1 file changed, 346 insertions(+), 22 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index e42750dbc0..2eefe5e495 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -68,6 +68,8 @@ import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.Icon import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Checkbox @@ -79,8 +81,11 @@ import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.Style import org.maplibre.android.style.layers.CircleLayer import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.layers.HeatmapLayer import org.maplibre.android.style.layers.PropertyFactory.circleColor import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth import org.maplibre.android.style.layers.PropertyFactory.lineColor import org.maplibre.android.style.layers.PropertyFactory.lineWidth import org.maplibre.android.style.layers.PropertyFactory.textColor @@ -95,6 +100,8 @@ import org.maplibre.android.style.layers.PropertyFactory.textOffset import org.maplibre.android.style.layers.PropertyFactory.textAnchor import org.maplibre.android.style.layers.PropertyFactory.visibility import org.maplibre.android.style.layers.PropertyFactory.rasterOpacity +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.TransitionOptions import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.android.style.sources.RasterSource @@ -113,9 +120,13 @@ import org.meshtastic.proto.ConfigProtos import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.MapViewModel import org.meshtastic.proto.MeshProtos.Waypoint +import org.meshtastic.proto.waypoint +import org.meshtastic.proto.copy import timber.log.Timber import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.component.EditWaypointDialog +import androidx.core.graphics.createBitmap import org.maplibre.android.style.layers.BackgroundLayer import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth @@ -137,8 +148,10 @@ private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" private const val TRACKS_SOURCE_ID = "meshtastic-tracks-source" private const val OSM_SOURCE_ID = "osm-tiles" -private const val NODES_LAYER_ID = "meshtastic-nodes-layer" -private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" +private const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered +private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" // From clustered source, filtered +private const val NODES_LAYER_NOCLUSTER_ID = "meshtastic-nodes-layer-nocluster" // From non-clustered source +private const val NODE_TEXT_LAYER_NOCLUSTER_ID = "meshtastic-node-text-layer-nocluster" // From non-clustered source private const val NODE_TEXT_BG_LAYER_ID = "meshtastic-node-text-bg-layer" private const val NODES_LAYER_CLUSTERED_ID = "meshtastic-nodes-layer-clustered" private const val NODE_TEXT_LAYER_CLUSTERED_ID = "meshtastic-node-text-layer-clustered" @@ -160,7 +173,6 @@ private const val DEBUG_SYMBOL_LAYER_ID = "meshtastic-debug-symbols" private const val DEBUG_RAW_SOURCE_ID = "meshtastic-debug-raw-source" private const val DEBUG_RAW_LAYER_ID = "meshtastic-debug-raw-layer" private const val DEBUG_RAW_SYMBOL_LAYER_ID = "meshtastic-debug-raw-symbols" - // Base map style options (raster tiles; key-free) private enum class BaseMapStyle(val label: String, val urlTemplate: String) { OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), @@ -190,7 +202,13 @@ private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { GeoJsonSource( NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), - GeoJsonOptions().withCluster(true).withClusterRadius(50).withClusterMaxZoom(14).withClusterMinPoints(2), + GeoJsonOptions() + .withCluster(true) + .withClusterRadius(50) + .withClusterMaxZoom(14) + .withClusterMinPoints(2) + .withLineMetrics(false) + .withTolerance(0.375f), // Smooth clustering transitions ), ) // Non-clustered debug copy of nodes to bypass clustering entirely @@ -200,7 +218,13 @@ private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { .withSource(GeoJsonSource(TEST_POINT_SOURCE_ID, emptyFeatureCollectionJson())) // Layers - order ensures they are above raster .withLayer( - CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID).withProperties(circleColor("#6D4C41"), circleRadius(14f)).withFilter(has("point_count")), + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor("#6D4C41"), + circleRadius(14f), + circleOpacity(1.0f), // Needed for transitions + ) + .withFilter(has("point_count")), ) .withLayer( SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) @@ -230,8 +254,20 @@ private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { stop(18, 9.5f), ), ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate( + linear(), + zoom(), + stop(8, 1.5f), + stop(12, 2f), + stop(16, 2.5f), + stop(18, 3f), + ), + ), + circleOpacity(1.0f), // Needed for transitions ) - .withFilter(not(has("point_count"))), // Only show unclustered points + .withFilter(not(has("point_count"))), ) .withLayer( SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) @@ -259,6 +295,63 @@ private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { ) .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))), ) + // Non-clustered node layers (shown when clustering is disabled) + .withLayer( + CircleLayer(NODES_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate( + linear(), + zoom(), + stop(8, 4f), + stop(12, 6f), + stop(16, 8f), + stop(18, 9.5f), + ), + ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate( + linear(), + zoom(), + stop(8, 1.5f), + stop(12, 2f), + stop(16, 2.5f), + stop(18, 3f), + ), + ), + circleOpacity(1.0f), // Needed for transitions + visibility("none") // Hidden by default, shown when clustering disabled + ), + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate( + linear(), + zoom(), + stop(8, 9f), + stop(12, 11f), + stop(15, 13f), + stop(18, 16f), + ), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + visibility("none") // Hidden by default, shown when clustering disabled + ) + .withFilter(eq(get("showLabel"), literal(1))), + ) .withLayer(CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f))) // Debug layers (hidden by default, can be toggled for diagnosis) .withLayer( @@ -352,6 +445,8 @@ fun MapLibrePOC( var showLegend by remember { mutableStateOf(false) } var enabledRoles by remember { mutableStateOf>(emptySet()) } var clusteringEnabled by remember { mutableStateOf(true) } + var editingWaypoint by remember { mutableStateOf(null) } + var mapTypeMenuExpanded by remember { mutableStateOf(false) } // Base map style rotation val baseStyles = remember { enumValues().toList() } var baseStyleIndex by remember { mutableStateOf(0) } @@ -362,6 +457,8 @@ fun MapLibrePOC( val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() + val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() + val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } Box(modifier = Modifier.fillMaxSize()) { AndroidView( @@ -377,6 +474,7 @@ fun MapLibrePOC( // Set initial base raster style using MapLibre test-app pattern map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) + style.setTransition(TransitionOptions(0, 0)) logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) // Push current data immediately after style load @@ -414,8 +512,9 @@ fun MapLibrePOC( // Set clustered source only (like MapLibre example) val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles) val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) - Timber.tag("MapLibrePOC").d("Setting nodes clustered source: %d nodes, jsonBytes=%d", nodes.size, json.length) + Timber.tag("MapLibrePOC").d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) // Place a known visible test point (San Jose area) to verify rendering val testFC = singlePointFC(-121.8893, 37.3349) @@ -512,6 +611,7 @@ fun MapLibrePOC( rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, WAYPOINTS_LAYER_ID, ) Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) @@ -565,13 +665,29 @@ fun MapLibrePOC( } "waypoint" -> { val id = it.getNumberProperty("id")?.toInt() ?: -1 - "Waypoint $id" + // Open edit dialog for waypoint + waypoints.values.find { pkt -> pkt.data.waypoint?.id == id }?.let { pkt -> + editingWaypoint = pkt.data.waypoint + } + "Waypoint: ${it.getStringProperty("name") ?: id}" } else -> null } } true } + // Long-press to create waypoint + map.addOnMapLongClickListener { latLng -> + if (isConnected) { + val newWaypoint = waypoint { + latitudeI = (latLng.latitude / DEG_D).toInt() + longitudeI = (latLng.longitude / DEG_D).toInt() + } + editingWaypoint = newWaypoint + Timber.tag("MapLibrePOC").d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") + } + true + } // Update clustering visibility on camera idle (zoom changes) map.addOnCameraIdleListener { val st = map.style ?: return@addOnCameraIdleListener @@ -586,9 +702,10 @@ fun MapLibrePOC( val density = context.resources.displayMetrics.density val labelSet = selectLabelsForViewport(map, filtered, density) val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - Timber.tag("MapLibrePOC").d("onCameraIdle: updating clustered source. labelSet=%d jsonBytes=%d", labelSet.size, jsonIdle.length) - // Update only the clustered source (unclustered points are filtered by not has(\"point_count\")) + Timber.tag("MapLibrePOC").d("onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", labelSet.size, labelSet.take(5).joinToString(","), jsonIdle.length) + // Update both clustered and non-clustered sources safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) logStyleState("onCameraIdle(post-update)", st) try { val w = mapViewRef?.width ?: 0 @@ -674,6 +791,7 @@ fun MapLibrePOC( val filteredNow = applyFilters(nodes, mapFilterState, enabledRoles) val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, jsonNow) // Apply visibility now clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) @@ -736,6 +854,7 @@ fun MapLibrePOC( mapRef?.let { map -> map.setStyle(buildMeshtasticStyle(next)) { style -> Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + style.setTransition(TransitionOptions(0, 0)) ensureSourcesAndLayers(style) try { val density = context.resources.displayMetrics.density @@ -743,6 +862,7 @@ fun MapLibrePOC( val filtered = applyFilters(nodes, mapFilterState, enabledRoles) val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) @@ -869,6 +989,79 @@ fun MapLibrePOC( icon = Icons.Outlined.Info, contentDescription = null, ) + // Map style selector + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + baseStyleIndex = index + mapTypeMenuExpanded = false + val next = baseStyles[baseStyleIndex % baseStyles.size] + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.setStyle(buildMeshtasticStyle(next)) { st -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + // Enable smooth transitions for clustering animations (1000ms = 1 second) + st.setTransition(TransitionOptions(1000, 0)) + ensureSourcesAndLayers(st) + // Repopulate all sources after style switch + (st.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + // Re-arm test point after style switch + (st.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) + // Re-enable location component for new style + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + st, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } + } catch (_: SecurityException) { + Timber.tag("MapLibrePOC").w("Location permissions not granted") + } + // Trigger data update + val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(st, NODES_SOURCE_ID, json) + safeSetGeoJson(st, DEBUG_RAW_SOURCE_ID, json) + clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown) + } + } + }, + trailingIcon = { + if (index == baseStyleIndex) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Selected", + ) + } + }, + ) + } + } + } } // Expanded cluster radial overlay @@ -1003,6 +1196,32 @@ fun MapLibrePOC( } } } + // Waypoint editing dialog + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } + } + if (updatedWp.icon == 0) { + finalWp = finalWp.copy { icon = 0x1F4CD } + } + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null + }, + onDeleteClicked = { wpToDelete -> + if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { + val deleteMarkerWp = wpToDelete.copy { expire = 1 } + mapViewModel.sendWaypoint(deleteMarkerWp) + } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null + }, + onDismissRequest = { editingWaypoint = null }, + ) + } } // Forward lifecycle events to MapView @@ -1191,7 +1410,12 @@ private fun ensureSourcesAndLayers(style: Style) { Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") } if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { - val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f)) + val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties( + circleColor("#FF5722"), // Orange-red color for visibility + circleRadius(8f), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(2f), + ) if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") } @@ -1219,12 +1443,18 @@ private fun nodesToFeatureCollectionJson(nodes: List): String { val pos = node.validPosition ?: return@mapNotNull null val lat = pos.latitudeI * DEG_D val lon = pos.longitudeI * DEG_D - val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = stripEmojisForMapLabel(short) ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + val shortEsc = escapeJson(shortForMap) val role = node.user.role.name val color = roleColorHex(node) val longEsc = escapeJson(node.user.longName ?: "") // Default showLabel=0; it will be turned on for selected nodes by viewport selection - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$short","role":"$role","color":"$color","showLabel":0}}""" + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":0}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -1235,12 +1465,18 @@ private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNu val pos = node.validPosition ?: return@mapNotNull null val lat = pos.latitudeI * DEG_D val lon = pos.longitudeI * DEG_D - val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = stripEmojisForMapLabel(short) ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + val shortEsc = escapeJson(shortForMap) val show = if (labelNums.contains(node.num)) 1 else 0 val role = node.user.role.name val color = roleColorHex(node) val longEsc = escapeJson(node.user.longName ?: "") - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$short","role":"$role","color":"$color","showLabel":$show}}""" + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -1265,8 +1501,13 @@ private fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums f.addStringProperty("kind", "node") f.addNumberProperty("num", node.num) f.addStringProperty("name", node.user.longName ?: "") - val short = (protoShortName(node) ?: shortNameFallback(node)).take(4) - f.addStringProperty("short", short) + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = stripEmojisForMapLabel(short) ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + f.addStringProperty("short", shortForMap) f.addStringProperty("role", node.user.role.name) f.addStringProperty("color", roleColorHex(node)) f.addNumberProperty("showLabel", if (labelNums.contains(node.num)) 1 else 0) @@ -1305,7 +1546,12 @@ private fun waypointsToFeatureCollectionFC( val lon = w.longitudeI * DEG_D if (lat == 0.0 && lon == 0.0) return@mapNotNull null if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null - Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { it.addNumberProperty("id", w.id) } + Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { f -> + f.addStringProperty("kind", "waypoint") + f.addNumberProperty("id", w.id) + f.addStringProperty("name", w.name ?: "Waypoint ${w.id}") + f.addNumberProperty("icon", w.icon) + } } return FeatureCollection.fromFeatures(features) } @@ -1426,11 +1672,52 @@ private fun protoShortName(node: Node): String? { private fun shortNameFallback(node: Node): String { val long = node.user.longName - if (!long.isNullOrBlank()) return long.take(4) + if (!long.isNullOrBlank()) return safeSubstring(long, 4) val hex = node.num.toString(16).uppercase() return if (hex.length >= 4) hex.takeLast(4) else hex } +// Safely take up to maxLength characters, respecting emoji boundaries +// Emojis can be composed of multiple code points, so we need to be careful not to split them +private fun safeSubstring(text: String, maxLength: Int): String { + if (text.length <= maxLength) return text + + // Use grapheme cluster breaking to respect emoji boundaries + var count = 0 + var lastSafeIndex = 0 + + val breakIterator = java.text.BreakIterator.getCharacterInstance() + breakIterator.setText(text) + + var start = breakIterator.first() + var end = breakIterator.next() + + while (end != java.text.BreakIterator.DONE && count < maxLength) { + lastSafeIndex = end + count++ + end = breakIterator.next() + } + + return if (lastSafeIndex > 0) text.substring(0, lastSafeIndex) else text.take(maxLength) +} + +// Remove emojis from text for MapLibre rendering (MapLibre text rendering doesn't support emojis well) +// Keep only ASCII alphanumeric and common punctuation +// Returns null if the text is emoji-only (so caller can use fallback like hex ID) +private fun stripEmojisForMapLabel(text: String): String? { + if (text.isEmpty()) return null + + // Filter to keep only characters that MapLibre can reliably render + // This includes ASCII letters, numbers, spaces, and basic punctuation + val filtered = text.filter { ch -> + ch.code in 0x20..0x7E || // Basic ASCII printable characters + ch.code in 0xA0..0xFF // Latin-1 supplement (accented characters) + }.trim() + + // If filtering removed everything, return null (caller should use fallback) + return if (filtered.isEmpty()) null else filtered +} + // Select one label per grid cell in the current viewport, prioritizing favorites and recent nodes. private fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density: Float): Set { val bounds = map.projection.visibleRegion.latLngBounds @@ -1556,12 +1843,19 @@ private fun setClusterVisibilityHysteresis( style.getLayer(CLUSTER_TIER1_LAYER_ID)?.setProperties(visibility("none")) style.getLayer(CLUSTER_TIER2_LAYER_ID)?.setProperties(visibility("none")) style.getLayer(CLUSTER_TEXT_TIER_LAYER_ID)?.setProperties(visibility("none")) - // Always show unclustered nodes and labels; label density still controlled by showLabel property - style.getLayer(NODES_LAYER_ID)?.setProperties(visibility("visible")) - style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility("visible")) + + // When clustering is enabled: show clustered source layers (which filter out clusters) + // When clustering is disabled: show non-clustered source layers (which show ALL nodes) + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + // Legacy clustered node layers are not used style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) + + Timber.tag("MapLibrePOC").d("Node layer visibility: clustered=%s, nocluster=%s", showClusters, !showClusters) if (showClusters != currentlyShown) { Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) } @@ -1571,4 +1865,34 @@ private fun setClusterVisibilityHysteresis( } } +/** + * Convert a Unicode code point (int) to an emoji string + */ +internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { + String(Character.toChars(unicodeCodePoint)) +} catch (e: IllegalArgumentException) { + Timber.tag("MapLibrePOC").w(e, "Invalid unicode code point: $unicodeCodePoint") + "\uD83D\uDCCD" // 📍 default pin emoji +} + +/** + * Convert emoji to Bitmap for use as a MapLibre marker icon + */ +internal fun unicodeEmojiToBitmap(icon: Int): Bitmap { + val unicodeEmoji = convertIntToEmoji(icon) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 64f + color = android.graphics.Color.BLACK + textAlign = Paint.Align.CENTER + } + + val baseline = -paint.ascent() + val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() + val height = (baseline + paint.descent() + 0.5f).toInt() + val image = createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(image) + canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) + return image +} \ No newline at end of file From 62e7aaf7b4be034a46ba1162b5ed33d7de2052b9 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 09:23:45 -0800 Subject: [PATCH 07/62] change some colors, hide user loc on gps toggle --- .../feature/map/maplibre/MapLibrePOC.kt | 448 ++++++++++++++---- 1 file changed, 362 insertions(+), 86 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index 2eefe5e495..5c322cf04d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -44,6 +44,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp @@ -62,6 +64,11 @@ import androidx.compose.material3.Button import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.TextButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Explore @@ -142,6 +149,34 @@ private const val DEG_D = 1e-7 private const val STYLE_URL = "https://demotiles.maplibre.org/style.json" +// Convert precision bits to meters (radius of accuracy circle) +private fun getPrecisionMeters(precisionBits: Int): Double? = when (precisionBits) { + 10 -> 23345.484932 + 11 -> 11672.7369 + 12 -> 5836.36288 + 13 -> 2918.175876 + 14 -> 1459.0823719999053 + 15 -> 729.5370149076749 + 16 -> 364.76796802673495 + 17 -> 182.38363847854606 + 18 -> 91.19178201473192 + 19 -> 45.59587874512555 + 20 -> 22.797938919871483 + 21 -> 11.398969292955733 + 22 -> 5.699484588175269 + 23 -> 2.8497422889870207 + 24 -> 1.424871149078816 + 25 -> 0.7124355732781771 + 26 -> 0.3562177850463231 + 27 -> 0.17810889188369584 + 28 -> 0.08905444562935878 + 29 -> 0.04452722265708971 + 30 -> 0.022263611293647812 + 31 -> 0.011131805632411625 + 32 -> 0.005565902808395108 + else -> null +} + private const val NODES_SOURCE_ID = "meshtastic-nodes-source" private const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" @@ -163,6 +198,7 @@ private const val CLUSTER_TIER2_LAYER_ID = "meshtastic-cluster-2" private const val CLUSTER_TEXT_TIER_LAYER_ID = "meshtastic-cluster-text" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" +private const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" private const val OSM_LAYER_ID = "osm-layer" private const val CLUSTER_RADIAL_MAX = 8 private const val CLUSTER_LIST_FETCH_MAX = 200L @@ -181,14 +217,15 @@ private enum class BaseMapStyle(val label: String, val urlTemplate: String) { ESRI_SATELLITE("Satellite", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"), } -private fun buildMeshtasticStyle(base: BaseMapStyle): Style.Builder { +private fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Style.Builder { // Load a complete vector style first (has fonts, glyphs, sprites MapLibre needs) + val tileUrl = customTileUrl ?: base.urlTemplate val builder = Style.Builder().fromUri("https://demotiles.maplibre.org/style.json") // Add our raster overlay on top .withSource( RasterSource( OSM_SOURCE_ID, - TileSet("osm", base.urlTemplate).apply { + TileSet("osm", tileUrl).apply { minZoom = 0f maxZoom = 22f }, @@ -434,8 +471,9 @@ fun MapLibrePOC( var selectedNodeNum by remember { mutableStateOf(null) } val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - var recenterRequest by remember { mutableStateOf(false) } + var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followBearing by remember { mutableStateOf(false) } + var hasLocationPermission by remember { mutableStateOf(false) } data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) var expandedCluster by remember { mutableStateOf(null) } var clusterListMembers by remember { mutableStateOf?>(null) } @@ -447,6 +485,10 @@ fun MapLibrePOC( var clusteringEnabled by remember { mutableStateOf(true) } var editingWaypoint by remember { mutableStateOf(null) } var mapTypeMenuExpanded by remember { mutableStateOf(false) } + var showCustomTileDialog by remember { mutableStateOf(false) } + var customTileUrl by remember { mutableStateOf("") } + var customTileUrlInput by remember { mutableStateOf("") } + var usingCustomTiles by remember { mutableStateOf(false) } // Base map style rotation val baseStyles = remember { enumValues().toList() } var baseStyleIndex by remember { mutableStateOf(0) } @@ -460,6 +502,63 @@ fun MapLibrePOC( val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + // Check location permission + hasLocationPermission = hasAnyLocationPermission(context) + + // Apply location tracking settings when state changes + LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { + mapRef?.let { map -> + map.style?.let { style -> + try { + if (hasLocationPermission) { + val locationComponent = map.locationComponent + + // Enable/disable location component based on tracking state + if (isLocationTrackingEnabled) { + // Enable and show location component + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + } + + // Set render mode + locationComponent.renderMode = if (followBearing) { + RenderMode.COMPASS + } else { + RenderMode.NORMAL + } + + // Set camera mode + locationComponent.cameraMode = if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + // Disable location component to hide the blue dot + if (locationComponent.isLocationComponentEnabled) { + locationComponent.isLocationComponentEnabled = false + } + } + + Timber.tag("MapLibrePOC").d( + "Location component updated: enabled=%s, follow=%s", + isLocationTrackingEnabled, + followBearing + ) + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + } + } + Box(modifier = Modifier.fillMaxSize()) { AndroidView( modifier = Modifier.fillMaxSize(), @@ -483,7 +582,7 @@ fun MapLibrePOC( val bounds = map.projection.visibleRegion.latLngBounds val labelSet = run { - val visible = applyFilters(nodes, mapFilterState, enabledRoles).filter { n -> + val visible = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled).filter { n -> val p = n.validPosition ?: return@filter false bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) } @@ -510,7 +609,7 @@ fun MapLibrePOC( (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) // Set clustered source only (like MapLibre example) - val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles) + val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) Timber.tag("MapLibrePOC").d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) @@ -572,7 +671,7 @@ fun MapLibrePOC( didInitialCenter = true } ?: run { // Fallback: center to bounds of current nodes if available - val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() var any = false filtered.forEach { n -> @@ -695,9 +794,9 @@ fun MapLibrePOC( val now = SystemClock.uptimeMillis() if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener lastClusterEvalMs = now - val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) Timber.tag("MapLibrePOC").d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) - clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) // Compute which nodes get labels in viewport and update source val density = context.resources.displayMetrics.density val labelSet = selectLabelsForViewport(map, filtered, density) @@ -745,12 +844,30 @@ fun MapLibrePOC( try { map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL } catch (_: Throwable) { /* ignore */ } - // Handle recenter requests - if (recenterRequest) { - recenterRequest = false - ourNode?.validPosition?.let { p -> - val ll = LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D) - map.animateCamera(CameraUpdateFactory.newLatLngZoom(ll, 14.5)) + + // Handle location tracking state changes + if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { + try { + val locationComponent = map.locationComponent + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + map.style!!, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + } + locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + locationComponent.cameraMode = if (isLocationTrackingEnabled) { + if (followBearing) org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + else org.maplibre.android.location.modes.CameraMode.TRACKING + } else { + org.maplibre.android.location.modes.CameraMode.NONE + } + Timber.tag("MapLibrePOC").d("Location tracking: enabled=%s, follow=%s, mode=%s", isLocationTrackingEnabled, followBearing, locationComponent.cameraMode) + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") } } Timber.tag("MapLibrePOC").d( @@ -788,13 +905,13 @@ fun MapLibrePOC( } (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - val filteredNow = applyFilters(nodes, mapFilterState, enabledRoles) + val filteredNow = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, jsonNow) // Apply visibility now - clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) logStyleState("update(block)", style) } }, @@ -844,66 +961,74 @@ fun MapLibrePOC( Column( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp), + .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), ) { - MapButton( + // My Location button with visual feedback + FloatingActionButton( onClick = { - // Cycle base styles via setStyle(new Style.Builder(...)) - baseStyleIndex = (baseStyleIndex + 1) % baseStyles.size - val next = baseStyles[baseStyleIndex] - mapRef?.let { map -> - map.setStyle(buildMeshtasticStyle(next)) { style -> - Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) - style.setTransition(TransitionOptions(0, 0)) - ensureSourcesAndLayers(style) - try { - val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, applyFilters(nodes, mapFilterState, enabledRoles), density) - val filtered = applyFilters(nodes, mapFilterState, enabledRoles) - val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source - safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - // Re-arm test point after style switch - (style.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) - // Re-enable location component for new style - try { - if (hasAnyLocationPermission(context)) { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - } else { - Timber.tag("MapLibrePOC").w("Location permission missing on style switch; skipping location component") - } - } catch (_: Throwable) { - } - } catch (_: Throwable) { - } + if (hasLocationPermission) { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + followBearing = false } + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") } }, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) - MapButton( - onClick = { recenterRequest = true }, - icon = Icons.Outlined.MyLocation, - contentDescription = null, - ) - MapButton( - onClick = { followBearing = !followBearing }, - icon = Icons.Outlined.Explore, - contentDescription = null, - ) + containerColor = if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.MyLocation, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } + + // Compass button with visual feedback + FloatingActionButton( + onClick = { + if (isLocationTrackingEnabled) { + followBearing = !followBearing + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) + } else { + // Enable tracking when compass is clicked + if (hasLocationPermission) { + isLocationTrackingEnabled = true + followBearing = true + Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + } + }, + containerColor = if (followBearing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.Explore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (followBearing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + ) + } Box { MapButton( onClick = { mapFilterExpanded = true }, @@ -943,7 +1068,7 @@ fun MapLibrePOC( if (enabledRoles.isEmpty()) setOf(role) else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) } } }, trailingIcon = { @@ -954,7 +1079,7 @@ fun MapLibrePOC( if (enabledRoles.isEmpty()) setOf(role) else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) } } }, ) @@ -967,7 +1092,7 @@ fun MapLibrePOC( onClick = { clusteringEnabled = !clusteringEnabled mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) } } }, trailingIcon = { @@ -976,7 +1101,7 @@ fun MapLibrePOC( onCheckedChange = { clusteringEnabled = it mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles), clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) } } }, ) @@ -1007,6 +1132,7 @@ fun MapLibrePOC( text = { Text(style.label) }, onClick = { baseStyleIndex = index + usingCustomTiles = false mapTypeMenuExpanded = false val next = baseStyles[baseStyleIndex % baseStyles.size] mapRef?.let { map -> @@ -1039,19 +1165,19 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").w("Location permissions not granted") } // Trigger data update - val filtered = applyFilters(nodes, mapFilterState, enabledRoles) + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val density = context.resources.displayMetrics.density val labelSet = selectLabelsForViewport(map, filtered, density) val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, json) safeSetGeoJson(st, NODES_SOURCE_ID, json) safeSetGeoJson(st, DEBUG_RAW_SOURCE_ID, json) - clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown) + clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) } } }, trailingIcon = { - if (index == baseStyleIndex) { + if (index == baseStyleIndex && !usingCustomTiles) { Icon( imageVector = Icons.Outlined.Check, contentDescription = "Selected", @@ -1060,9 +1186,113 @@ fun MapLibrePOC( }, ) } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { Text(if (customTileUrl.isEmpty()) "Custom Tile URL..." else "Custom: ${customTileUrl.take(30)}...") }, + onClick = { + mapTypeMenuExpanded = false + customTileUrlInput = customTileUrl + showCustomTileDialog = true + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Selected", + ) + } + }, + ) } } } + + // Custom tile URL dialog + if (showCustomTileDialog) { + AlertDialog( + onDismissRequest = { showCustomTileDialog = false }, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp) + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = { customTileUrlInput = it }, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + customTileUrl = customTileUrlInput.trim() + if (customTileUrl.isNotEmpty()) { + usingCustomTiles = true + // Apply custom tiles (use first base style as template but we'll override the raster source) + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) + map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> + Timber.tag("MapLibrePOC").d("Custom tiles applied") + st.setTransition(TransitionOptions(1000, 0)) + ensureSourcesAndLayers(st) + // Repopulate all sources after style switch + (st.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + (st.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) + // Re-enable location component + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + st, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } + } catch (_: SecurityException) { + Timber.tag("MapLibrePOC").w("Location permissions not granted") + } + // Trigger data update + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(st, NODES_SOURCE_ID, json) + safeSetGeoJson(st, DEBUG_RAW_SOURCE_ID, json) + clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) + } + } + } + showCustomTileDialog = false + } + ) { + Text("Apply") + } + }, + dismissButton = { + TextButton(onClick = { showCustomTileDialog = false }) { + Text("Cancel") + } + } + ) + } // Expanded cluster radial overlay expandedCluster?.let { ec -> @@ -1352,6 +1582,43 @@ private fun ensureSourcesAndLayers(style: Style) { .withFilter(has("point_count")) if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(textLayer, OSM_LAYER_ID) else style.addLayer(textLayer) } + // Precision circle layer (accuracy circles around nodes) + if (style.getLayer(PRECISION_CIRCLE_LAYER_ID) == null) { + val layer = + CircleLayer(PRECISION_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleRadius( + interpolate( + exponential(2.0), + zoom(), + // Convert meters to pixels at different zoom levels + // At equator: metersPerPixel = 156543.03392 * cos(latitude) / 2^zoom + // Approximation: pixels = meters * 2^zoom / 156543 (at equator) + // For better visibility, we use empirical values + stop(0, product(get("precisionMeters"), literal(0.0000025))), + stop(5, product(get("precisionMeters"), literal(0.00008))), + stop(10, product(get("precisionMeters"), literal(0.0025))), + stop(15, product(get("precisionMeters"), literal(0.08))), + stop(18, product(get("precisionMeters"), literal(0.64))), + stop(20, product(get("precisionMeters"), literal(2.56))), + ) + ), + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleOpacity(0.15f), + circleStrokeColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleStrokeWidth(1.5f), + visibility("none"), // Hidden by default + ) + .withFilter( + all( + not(has("point_count")), // Only individual nodes, not clusters + gt(get("precisionMeters"), literal(0)) // Only show if precision > 0 + ) + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added precision circle layer") + } + if (style.getLayer(NODES_LAYER_ID) == null) { val layer = CircleLayer(NODES_LAYER_ID, DEBUG_RAW_SOURCE_ID) @@ -1430,10 +1697,14 @@ private fun applyFilters( all: List, filter: BaseMapViewModel.MapFilterState, enabledRoles: Set, + ourNodeNum: Int? = null, + isLocationTrackingEnabled: Boolean = false, ): List { var out = all if (filter.onlyFavorites) out = out.filter { it.isFavorite } if (enabledRoles.isNotEmpty()) out = out.filter { enabledRoles.contains(it.user.role) } + // Note: We don't filter out the user's node - that should always be visible + // The location component (blue dot) is controlled separately return out } @@ -1476,7 +1747,8 @@ private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNu val role = node.user.role.name val color = roleColorHex(node) val longEsc = escapeJson(node.user.longName ?: "") - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show}}""" + val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show,"precisionMeters":$precisionMeters}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -1800,18 +2072,18 @@ private fun distanceKmBetween(a: Node, b: Node): Double? { // Role -> hex color used for map dots and radial overlay private fun roleColorHex(node: Node): String { return when (node.user.role) { - ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#616161" // gray + ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#D32F2F" // red (infrastructure) ConfigProtos.Config.DeviceConfig.Role.ROUTER_CLIENT -> "#00897B" // teal - ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#EF6C00" // orange - ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple + ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#7B1FA2" // purple + ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple (lighter) ConfigProtos.Config.DeviceConfig.Role.SENSOR -> "#1E88E5" // blue - ConfigProtos.Config.DeviceConfig.Role.TAK, ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER -> "#C62828" // red + ConfigProtos.Config.DeviceConfig.Role.TAK, ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER -> "#F57C00" // orange (TAK) ConfigProtos.Config.DeviceConfig.Role.CLIENT -> "#2E7D32" // green - ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#43A047" // green (lighter) + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#1976D2" // blue (client base) ConfigProtos.Config.DeviceConfig.Role.CLIENT_MUTE -> "#9E9D24" // olive ConfigProtos.Config.DeviceConfig.Role.CLIENT_HIDDEN -> "#546E7A" // blue-grey ConfigProtos.Config.DeviceConfig.Role.LOST_AND_FOUND -> "#AD1457" // magenta - ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#757575" // mid-grey + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#E57373" // light red (late router) null, ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED -> "#2E7D32" // default green } @@ -1826,6 +2098,7 @@ private fun setClusterVisibilityHysteresis( filteredNodes: List, enableClusters: Boolean, currentlyShown: Boolean, + showPrecisionCircle: Boolean = false, ): Boolean { try { val zoom = map.cameraPosition.zoom @@ -1855,7 +2128,10 @@ private fun setClusterVisibilityHysteresis( style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) - Timber.tag("MapLibrePOC").d("Node layer visibility: clustered=%s, nocluster=%s", showClusters, !showClusters) + // Precision circle visibility (always controlled by toggle, independent of clustering) + style.getLayer(PRECISION_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showPrecisionCircle) "visible" else "none")) + + Timber.tag("MapLibrePOC").d("Node layer visibility: clustered=%s, nocluster=%s, precision=%s", showClusters, !showClusters, showPrecisionCircle) if (showClusters != currentlyShown) { Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) } From 097c17761b032b815250ee0fc06187e275ed39e6 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 09:42:03 -0800 Subject: [PATCH 08/62] janitorial work: code dedup, rm unused vars, rm debug work --- .../feature/map/maplibre/MapLibrePOC.kt | 435 +++++------------- 1 file changed, 119 insertions(+), 316 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt index 5c322cf04d..92235c25b4 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -43,9 +43,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.unit.dp @@ -80,7 +78,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Checkbox -import androidx.compose.material3.FloatingActionButton import org.maplibre.android.MapLibre import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapView @@ -180,35 +177,19 @@ private fun getPrecisionMeters(precisionBits: Int): Double? = when (precisionBit private const val NODES_SOURCE_ID = "meshtastic-nodes-source" private const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" -private const val TRACKS_SOURCE_ID = "meshtastic-tracks-source" private const val OSM_SOURCE_ID = "osm-tiles" private const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" // From clustered source, filtered private const val NODES_LAYER_NOCLUSTER_ID = "meshtastic-nodes-layer-nocluster" // From non-clustered source private const val NODE_TEXT_LAYER_NOCLUSTER_ID = "meshtastic-node-text-layer-nocluster" // From non-clustered source -private const val NODE_TEXT_BG_LAYER_ID = "meshtastic-node-text-bg-layer" -private const val NODES_LAYER_CLUSTERED_ID = "meshtastic-nodes-layer-clustered" -private const val NODE_TEXT_LAYER_CLUSTERED_ID = "meshtastic-node-text-layer-clustered" private const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" -private const val CLUSTER_TIER0_LAYER_ID = "meshtastic-cluster-0" -private const val CLUSTER_TIER1_LAYER_ID = "meshtastic-cluster-1" -private const val CLUSTER_TIER2_LAYER_ID = "meshtastic-cluster-2" -private const val CLUSTER_TEXT_TIER_LAYER_ID = "meshtastic-cluster-text" private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" -private const val TRACKS_LAYER_ID = "meshtastic-tracks-layer" private const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" private const val OSM_LAYER_ID = "osm-layer" private const val CLUSTER_RADIAL_MAX = 8 private const val CLUSTER_LIST_FETCH_MAX = 200L -private const val TEST_POINT_SOURCE_ID = "meshtastic-test-point-source" -private const val TEST_POINT_LAYER_ID = "meshtastic-test-point-layer" -private const val DEBUG_ALL_LAYER_ID = "meshtastic-debug-all" -private const val DEBUG_SYMBOL_LAYER_ID = "meshtastic-debug-symbols" -private const val DEBUG_RAW_SOURCE_ID = "meshtastic-debug-raw-source" -private const val DEBUG_RAW_LAYER_ID = "meshtastic-debug-raw-layer" -private const val DEBUG_RAW_SYMBOL_LAYER_ID = "meshtastic-debug-raw-symbols" // Base map style options (raster tiles; key-free) private enum class BaseMapStyle(val label: String, val urlTemplate: String) { OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), @@ -248,11 +229,7 @@ private fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = nu .withTolerance(0.375f), // Smooth clustering transitions ), ) - // Non-clustered debug copy of nodes to bypass clustering entirely - .withSource(GeoJsonSource(DEBUG_RAW_SOURCE_ID, emptyFeatureCollectionJson())) .withSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) - // Test point source to validate rendering independent of data plumbing - .withSource(GeoJsonSource(TEST_POINT_SOURCE_ID, emptyFeatureCollectionJson())) // Layers - order ensures they are above raster .withLayer( CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) @@ -390,72 +367,6 @@ private fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = nu .withFilter(eq(get("showLabel"), literal(1))), ) .withLayer(CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f))) - // Debug layers (hidden by default, can be toggled for diagnosis) - .withLayer( - CircleLayer(DEBUG_ALL_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#FF00FF"), circleRadius(5.0f), visibility("none")), - ) - .withLayer( - SymbolLayer(DEBUG_SYMBOL_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(literal("•")), - textColor("#FF00FF"), - textSize(14f), - textAllowOverlap(true), - textIgnorePlacement(true), - visibility("none"), - ), - ) - .withLayer( - CircleLayer(TEST_POINT_LAYER_ID, TEST_POINT_SOURCE_ID) - .withProperties(circleColor("#00FF00"), circleRadius(12.0f), visibility("none")), - ) - .withLayer( - CircleLayer(DEBUG_RAW_LAYER_ID, DEBUG_RAW_SOURCE_ID) - .withProperties(circleColor("#00FFFF"), circleRadius(9.0f), visibility("none")), - ) - .withLayer( - SymbolLayer(DEBUG_RAW_SYMBOL_LAYER_ID, DEBUG_RAW_SOURCE_ID) - .withProperties( - textField(literal("•")), - textColor("#00FFFF"), - textSize(16f), - textAllowOverlap(true), - textIgnorePlacement(true), - visibility("none"), - ), - ) - // Tiered cluster layers and text (initially hidden; we toggle later) - .withLayer( - CircleLayer(CLUSTER_TIER0_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#6D4C41"), circleRadius(18f), visibility("none")) - .withFilter(all(has("point_count"), gte(toNumber(get("point_count")), literal(150)))), - ) - .withLayer( - CircleLayer(CLUSTER_TIER1_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#795548"), circleRadius(18f), visibility("none")) - .withFilter(all(has("point_count"), gt(toNumber(get("point_count")), literal(20)), lt(toNumber(get("point_count")), literal(150)))), - ) - .withLayer( - CircleLayer(CLUSTER_TIER2_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#8D6E63"), circleRadius(18f), visibility("none")) - .withFilter(all(has("point_count"), gte(toNumber(get("point_count")), literal(0)), lt(toNumber(get("point_count")), literal(20)))), - ) - .withLayer( - SymbolLayer(CLUSTER_TEXT_TIER_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(toString(get("point_count"))), - textColor("#FFFFFF"), - textHaloColor("#000000"), - textHaloWidth(1.5f), - textHaloBlur(0.5f), - textSize(12f), - textAllowOverlap(true), - textIgnorePlacement(true), - visibility("none"), - ) - .withFilter(has("point_count")), - ) return builder } @SuppressLint("MissingPermission") @@ -614,10 +525,6 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source - safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, json) - // Place a known visible test point (San Jose area) to verify rendering - val testFC = singlePointFC(-121.8893, 37.3349) - (style.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(testFC) Timber.tag("MapLibrePOC").d( "Initial data set after style load. nodes=%d waypoints=%d", nodes.size, @@ -629,25 +536,8 @@ fun MapLibrePOC( } // Keep base vector layers; OSM raster will sit below node layers for labels/roads // Enable location component (if permissions granted) - try { - if (hasAnyLocationPermission(context)) { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - Timber.tag("MapLibrePOC").d("Location component enabled") - } else { - Timber.tag("MapLibrePOC").w("Location permission missing; skipping location component enablement") - } - } catch (_: Throwable) { - // ignore - Timber.tag("MapLibrePOC").w("Location component not enabled (likely missing permissions)") - } + activateLocationComponentForStyle(context, map, style) + Timber.tag("MapLibrePOC").d("Location component initialization attempted") // Initial center on user's device location if available, else our node if (!didInitialCenter) { try { @@ -812,10 +702,6 @@ fun MapLibrePOC( val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) val rendered = map.queryRenderedFeatures( bbox, - DEBUG_ALL_LAYER_ID, - DEBUG_SYMBOL_LAYER_ID, - DEBUG_RAW_LAYER_ID, - DEBUG_RAW_SYMBOL_LAYER_ID, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID, ) @@ -909,7 +795,6 @@ fun MapLibrePOC( val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source - safeSetGeoJson(style, DEBUG_RAW_SOURCE_ID, jsonNow) // Apply visibility now clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) logStyleState("update(block)", style) @@ -1139,40 +1024,12 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) map.setStyle(buildMeshtasticStyle(next)) { st -> Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) - // Enable smooth transitions for clustering animations (1000ms = 1 second) - st.setTransition(TransitionOptions(1000, 0)) - ensureSourcesAndLayers(st) - // Repopulate all sources after style switch - (st.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - // Re-arm test point after style switch - (st.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) - // Re-enable location component for new style - try { - if (hasAnyLocationPermission(context)) { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - st, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - } - } catch (_: SecurityException) { - Timber.tag("MapLibrePOC").w("Location permissions not granted") - } - // Trigger data update - val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(st, NODES_SOURCE_ID, json) - safeSetGeoJson(st, DEBUG_RAW_SOURCE_ID, json) - clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) + clustersShown = reinitializeStyleAfterSwitch( + context, map, st, waypoints, nodes, mapFilterState, + enabledRoles, ourNode?.num, isLocationTrackingEnabled, + clusteringEnabled, clustersShown, density + ) } } }, @@ -1245,38 +1102,12 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> Timber.tag("MapLibrePOC").d("Custom tiles applied") - st.setTransition(TransitionOptions(1000, 0)) - ensureSourcesAndLayers(st) - // Repopulate all sources after style switch - (st.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - (st.getSource(TEST_POINT_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(singlePointFC(-121.8893, 37.3349)) - // Re-enable location component - try { - if (hasAnyLocationPermission(context)) { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - st, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - } - } catch (_: SecurityException) { - Timber.tag("MapLibrePOC").w("Location permissions not granted") - } - // Trigger data update - val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(st, NODES_SOURCE_ID, json) - safeSetGeoJson(st, DEBUG_RAW_SOURCE_ID, json) - clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) + clustersShown = reinitializeStyleAfterSwitch( + context, map, st, waypoints, nodes, mapFilterState, + enabledRoles, ourNode?.num, isLocationTrackingEnabled, + clusteringEnabled, clustersShown, density + ) } } } @@ -1523,65 +1354,6 @@ private fun ensureSourcesAndLayers(style: Style) { if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(countLayer, OSM_LAYER_ID) else style.addLayer(countLayer) Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") } - // Tiered cluster layers similar to GeoJsonClusteringActivity - if (style.getLayer(CLUSTER_TIER0_LAYER_ID) == null) { - val pointCount = toNumber(get("point_count")) - val layer = - CircleLayer(CLUSTER_TIER0_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#6D4C41"), circleRadius(18f), visibility("none")) - .withFilter( - all( - has("point_count"), - gte(pointCount, literal(150)), - ), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - } - if (style.getLayer(CLUSTER_TIER1_LAYER_ID) == null) { - val pointCount = toNumber(get("point_count")) - val layer = - CircleLayer(CLUSTER_TIER1_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#795548"), circleRadius(18f), visibility("none")) - .withFilter( - all( - has("point_count"), - gt(pointCount, literal(20)), - lt(pointCount, literal(150)), - ), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - } - if (style.getLayer(CLUSTER_TIER2_LAYER_ID) == null) { - val pointCount = toNumber(get("point_count")) - val layer = - CircleLayer(CLUSTER_TIER2_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#8D6E63"), circleRadius(18f), visibility("none")) - .withFilter( - all( - has("point_count"), - gte(pointCount, literal(0)), - lt(pointCount, literal(20)), - ), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - } - if (style.getLayer(CLUSTER_TEXT_TIER_LAYER_ID) == null) { - val textLayer = - SymbolLayer(CLUSTER_TEXT_TIER_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(toString(get("point_count"))), - textColor("#FFFFFF"), - textHaloColor("#000000"), - textHaloWidth(1.5f), - textHaloBlur(0.5f), - textSize(12f), - textAllowOverlap(true), - textIgnorePlacement(true), - visibility("none"), - ) - .withFilter(has("point_count")) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(textLayer, OSM_LAYER_ID) else style.addLayer(textLayer) - } // Precision circle layer (accuracy circles around nodes) if (style.getLayer(PRECISION_CIRCLE_LAYER_ID) == null) { val layer = @@ -1621,9 +1393,31 @@ private fun ensureSourcesAndLayers(style: Style) { if (style.getLayer(NODES_LAYER_ID) == null) { val layer = - CircleLayer(NODES_LAYER_ID, DEBUG_RAW_SOURCE_ID) - .withProperties(circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), circleRadius(7f)) - // No filter: render raw points for diagnosis + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate( + linear(), + zoom(), + stop(8, 4f), + stop(12, 6f), + stop(16, 8f), + stop(18, 9.5f), + ), + ), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth( + interpolate( + linear(), + zoom(), + stop(8, 1.5f), + stop(12, 2f), + stop(16, 2.5f), + ), + ), + circleOpacity(1.0f), + ) if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") } @@ -1708,28 +1502,6 @@ private fun applyFilters( return out } -private fun nodesToFeatureCollectionJson(nodes: List): String { - val features = - nodes.mapNotNull { node -> - val pos = node.validPosition ?: return@mapNotNull null - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) - // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID - val shortForMap = stripEmojisForMapLabel(short) ?: run { - val hex = node.num.toString(16).uppercase() - if (hex.length >= 4) hex.takeLast(4) else hex - } - val shortEsc = escapeJson(shortForMap) - val role = node.user.role.name - val color = roleColorHex(node) - val longEsc = escapeJson(node.user.longName ?: "") - // Default showLabel=0; it will be turned on for selected nodes by viewport selection - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":0}}""" - } - return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" -} - private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set): String { val features = nodes.mapNotNull { node -> @@ -1792,22 +1564,6 @@ private fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums return FeatureCollection.fromFeatures(features) } -private fun waypointsToFeatureCollectionJson( - waypoints: Collection, -): String { - val features = - waypoints.mapNotNull { pkt -> - val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null - // Filter invalid/placeholder coordinates (avoid 0,0 near Africa) - val lat = w.latitudeI * DEG_D - val lon = w.longitudeI * DEG_D - if (lat == 0.0 && lon == 0.0) return@mapNotNull null - if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"waypoint","id":${w.id}}}""" - } - return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" -} - private fun waypointsToFeatureCollectionFC( waypoints: Collection, ): FeatureCollection { @@ -1832,11 +1588,6 @@ private fun emptyFeatureCollectionJson(): String { return """{"type":"FeatureCollection","features":[]}""" } -private fun singlePointFC(lon: Double, lat: Double): FeatureCollection { - val f = Feature.fromGeometry(Point.fromLngLat(lon, lat)) - return FeatureCollection.fromFeatures(listOf(f)) -} - private fun safeSetGeoJson(style: Style, sourceId: String, json: String) { try { val fc = FeatureCollection.fromJson(json) @@ -1882,16 +1633,12 @@ private fun logStyleState(whenTag: String, style: Style) { OSM_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID, CLUSTER_COUNT_LAYER_ID, - CLUSTER_TIER0_LAYER_ID, - CLUSTER_TIER1_LAYER_ID, - CLUSTER_TIER2_LAYER_ID, - CLUSTER_TEXT_TIER_LAYER_ID, NODES_LAYER_ID, NODE_TEXT_LAYER_ID, - NODES_LAYER_CLUSTERED_ID, - NODE_TEXT_LAYER_CLUSTERED_ID, + NODES_LAYER_NOCLUSTER_ID, + NODE_TEXT_LAYER_NOCLUSTER_ID, + PRECISION_CIRCLE_LAYER_ID, WAYPOINTS_LAYER_ID, - DEBUG_ALL_LAYER_ID, ) val sourcesToCheck = listOf(NODES_SOURCE_ID, NODES_CLUSTER_SOURCE_ID, WAYPOINTS_SOURCE_ID, OSM_SOURCE_ID) val layerStates = @@ -1917,20 +1664,6 @@ private fun hasAnyLocationPermission(context: Context): Boolean { return fine || coarse } -private fun oneTrackFromNodesJson(nodes: List): String { - val valid = nodes.mapNotNull { it.validPosition } - if (valid.size < 2) return emptyFeatureCollectionJson() - val a = valid[0] - val b = valid[1] - val lat1 = a.latitudeI * DEG_D - val lon1 = a.longitudeI * DEG_D - val lat2 = b.latitudeI * DEG_D - val lon2 = b.longitudeI * DEG_D - val line = - """{"type":"Feature","geometry":{"type":"LineString","coordinates":[[$lon1,$lat1],[$lon2,$lat2]]},"properties":{"kind":"track"}}""" - return """{"type":"FeatureCollection","features":[$line]}""" -} - private fun shortName(node: Node): String { // Deprecated; kept for compatibility return shortNameFallback(node) @@ -2091,6 +1824,85 @@ private fun roleColorHex(node: Node): String { private fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) +/** + * Helper to activate location component after style changes + */ +private fun activateLocationComponentForStyle( + context: Context, + map: MapLibreMap, + style: Style, +) { + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ).useDefaultLocationEngine(true).build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } + } catch (_: SecurityException) { + Timber.tag("MapLibrePOC").w("Location permissions not granted") + } +} + +/** + * Helper to update node data sources after filtering or style changes + */ +private fun updateNodeDataSources( + map: MapLibreMap, + style: Style, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNodeNum, isLocationTrackingEnabled) + val labelSet = selectLabelsForViewport(map, filtered, density) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) + return setClusterVisibilityHysteresis(map, style, filtered, clusteringEnabled, currentClustersShown, mapFilterState.showPrecisionCircle) +} + +/** + * Helper to reinitialize style after a style switch (base map change or custom tiles) + */ +private fun reinitializeStyleAfterSwitch( + context: Context, + map: MapLibreMap, + style: Style, + waypoints: Map, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + style.setTransition(TransitionOptions(1000, 0)) + ensureSourcesAndLayers(style) + // Repopulate waypoints + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) + // Re-enable location component + activateLocationComponentForStyle(context, map, style) + // Update node data + return updateNodeDataSources( + map, style, nodes, mapFilterState, enabledRoles, ourNodeNum, + isLocationTrackingEnabled, clusteringEnabled, currentClustersShown, density + ) +} + // Show/hide cluster layers vs plain nodes based on zoom, density, and toggle private fun setClusterVisibilityHysteresis( map: MapLibreMap, @@ -2111,11 +1923,6 @@ private fun setClusterVisibilityHysteresis( // Cluster circle/count style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - // Hide tiered cluster layers for now (ignored) - style.getLayer(CLUSTER_TIER0_LAYER_ID)?.setProperties(visibility("none")) - style.getLayer(CLUSTER_TIER1_LAYER_ID)?.setProperties(visibility("none")) - style.getLayer(CLUSTER_TIER2_LAYER_ID)?.setProperties(visibility("none")) - style.getLayer(CLUSTER_TEXT_TIER_LAYER_ID)?.setProperties(visibility("none")) // When clustering is enabled: show clustered source layers (which filter out clusters) // When clustering is disabled: show non-clustered source layers (which show ALL nodes) @@ -2124,10 +1931,6 @@ private fun setClusterVisibilityHysteresis( style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) - // Legacy clustered node layers are not used - style.getLayer(NODES_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) - style.getLayer(NODE_TEXT_LAYER_CLUSTERED_ID)?.setProperties(visibility("none")) - // Precision circle visibility (always controlled by toggle, independent of clustering) style.getLayer(PRECISION_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showPrecisionCircle) "visible" else "none")) From 6e0cc4edcd7abbbb68bd837fe5b8b79e335fb135 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 11:00:33 -0800 Subject: [PATCH 09/62] refactor --- .../org/meshtastic/feature/map/MapView.kt | 11 +- .../feature/map/maplibre/MapLibreConstants.kt | 87 + .../feature/map/maplibre/MapLibrePOC.kt | 1977 ----------------- .../maplibre/core/MapLibreDataTransformers.kt | 157 ++ .../map/maplibre/core/MapLibreLayerManager.kt | 299 +++ .../maplibre/core/MapLibreLocationManager.kt | 119 + .../map/maplibre/core/MapLibreStyleBuilder.kt | 212 ++ .../map/maplibre/ui/MapLibreControlButtons.kt | 280 +++ .../map/maplibre/ui/MapLibreNodeDetails.kt | 77 + .../map/maplibre/ui/MapLibreOverlays.kt | 213 ++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 1259 +++++++++++ .../map/maplibre/utils/MapLibreHelpers.kt | 216 ++ .../maplibre/utils/MapLibreWaypointUtils.kt | 52 + 13 files changed, 2978 insertions(+), 1981 deletions(-) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt delete mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 1e44f85403..281fe10d00 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -33,11 +33,11 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lens import androidx.compose.material.icons.filled.LocationDisabled import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Tune @@ -50,12 +50,12 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -774,13 +774,16 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: properties = DialogProperties(usePlatformDefaultWidth = false), ) { Box(modifier = Modifier.fillMaxSize()) { - org.meshtastic.feature.map.maplibre.MapLibrePOC( + org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( onNavigateToNodeDetails = { num -> navigateToNodeDetails(num) showMapLibrePOC = false }, ) - IconButton(modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), onClick = { showMapLibrePOC = false }) { + IconButton( + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + onClick = { showMapLibrePOC = false }, + ) { Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(Res.string.close)) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt new file mode 100644 index 0000000000..9d1e999113 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre + +/** Constants used throughout the MapLibre implementation */ +object MapLibreConstants { + // Coordinate conversion + const val DEG_D = 1e-7 + + // Style URL + const val STYLE_URL = "https://demotiles.maplibre.org/style.json" + + // Source IDs + const val NODES_SOURCE_ID = "meshtastic-nodes-source" + const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" + const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" + const val OSM_SOURCE_ID = "osm-tiles" + + // Layer IDs + const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered + const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" // From clustered source, filtered + const val NODES_LAYER_NOCLUSTER_ID = "meshtastic-nodes-layer-nocluster" // From non-clustered source + const val NODE_TEXT_LAYER_NOCLUSTER_ID = "meshtastic-node-text-layer-nocluster" // From non-clustered source + const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" + const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" + const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" + const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" + const val OSM_LAYER_ID = "osm-layer" + + // Cluster configuration + const val CLUSTER_RADIAL_MAX = 8 + const val CLUSTER_LIST_FETCH_MAX = 200L +} + +/** Base map style options (raster tiles; key-free) */ +enum class BaseMapStyle(val label: String, val urlTemplate: String) { + OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), + CARTO_LIGHT("Light", "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"), + CARTO_DARK("Dark", "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"), + ESRI_SATELLITE( + "Satellite", + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + ), +} + +/** Converts precision bits to meters for accuracy circles */ +fun getPrecisionMeters(precisionBits: Int): Double? = when (precisionBits) { + 10 -> 23345.484932 + 11 -> 11672.7369 + 12 -> 5836.36288 + 13 -> 2918.175876 + 14 -> 1459.0823719999053 + 15 -> 729.5370149076749 + 16 -> 364.76796802673495 + 17 -> 182.38363847854606 + 18 -> 91.19178201473192 + 19 -> 45.59587874512555 + 20 -> 22.797938919871483 + 21 -> 11.398969292955733 + 22 -> 5.699484588175269 + 23 -> 2.8497422889870207 + 24 -> 1.424871149078816 + 25 -> 0.7124355732781771 + 26 -> 0.3562177850463231 + 27 -> 0.17810889188369584 + 28 -> 0.08905444562935878 + 29 -> 0.04452722265708971 + 30 -> 0.022263611293647812 + 31 -> 0.011131805632411625 + 32 -> 0.005565902808395108 + else -> null +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt deleted file mode 100644 index 92235c25b4..0000000000 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ /dev/null @@ -1,1977 +0,0 @@ -/* - * Copyright (c) 2025 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.feature.map.maplibre - -import android.annotation.SuppressLint -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.SystemClock -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.clickable -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.Alignment -import androidx.compose.ui.unit.dp -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.core.content.ContextCompat -import androidx.compose.material3.Text -import androidx.compose.material3.Button -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.TextButton -import androidx.compose.material3.FloatingActionButton -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Checkbox -import org.maplibre.android.MapLibre -import org.maplibre.android.location.modes.RenderMode -import org.maplibre.android.maps.MapView -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import org.maplibre.android.style.layers.CircleLayer -import org.maplibre.android.style.layers.LineLayer -import org.maplibre.android.style.layers.HeatmapLayer -import org.maplibre.android.style.layers.PropertyFactory.circleColor -import org.maplibre.android.style.layers.PropertyFactory.circleRadius -import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor -import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth -import org.maplibre.android.style.layers.PropertyFactory.lineColor -import org.maplibre.android.style.layers.PropertyFactory.lineWidth -import org.maplibre.android.style.layers.PropertyFactory.textColor -import org.maplibre.android.style.layers.PropertyFactory.textField -import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur -import org.maplibre.android.style.layers.PropertyFactory.textHaloColor -import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth -import org.maplibre.android.style.layers.PropertyFactory.textSize -import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap -import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement -import org.maplibre.android.style.layers.PropertyFactory.textOffset -import org.maplibre.android.style.layers.PropertyFactory.textAnchor -import org.maplibre.android.style.layers.PropertyFactory.visibility -import org.maplibre.android.style.layers.PropertyFactory.rasterOpacity -import org.maplibre.android.style.layers.PropertyFactory.circleOpacity -import org.maplibre.android.style.layers.TransitionOptions -import org.maplibre.android.style.layers.SymbolLayer -import org.maplibre.android.style.sources.GeoJsonSource -import org.maplibre.android.style.sources.RasterSource -import org.maplibre.android.style.layers.RasterLayer -import org.maplibre.android.style.sources.GeoJsonOptions -import org.maplibre.android.style.sources.TileSet -import org.maplibre.android.style.expressions.Expression -import org.maplibre.android.style.expressions.Expression.* -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.geometry.LatLng -import kotlin.math.cos -import kotlin.math.sin - -import org.meshtastic.core.database.model.Node -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.feature.map.BaseMapViewModel -import org.meshtastic.feature.map.MapViewModel -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.waypoint -import org.meshtastic.proto.copy -import timber.log.Timber -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.component.EditWaypointDialog -import androidx.core.graphics.createBitmap - -import org.maplibre.android.style.layers.BackgroundLayer -import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth -import org.maplibre.geojson.Point -import org.maplibre.geojson.Feature -import org.maplibre.geojson.FeatureCollection -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF - -private const val DEG_D = 1e-7 - -private const val STYLE_URL = "https://demotiles.maplibre.org/style.json" - -// Convert precision bits to meters (radius of accuracy circle) -private fun getPrecisionMeters(precisionBits: Int): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.5370149076749 - 16 -> 364.76796802673495 - 17 -> 182.38363847854606 - 18 -> 91.19178201473192 - 19 -> 45.59587874512555 - 20 -> 22.797938919871483 - 21 -> 11.398969292955733 - 22 -> 5.699484588175269 - 23 -> 2.8497422889870207 - 24 -> 1.424871149078816 - 25 -> 0.7124355732781771 - 26 -> 0.3562177850463231 - 27 -> 0.17810889188369584 - 28 -> 0.08905444562935878 - 29 -> 0.04452722265708971 - 30 -> 0.022263611293647812 - 31 -> 0.011131805632411625 - 32 -> 0.005565902808395108 - else -> null -} - -private const val NODES_SOURCE_ID = "meshtastic-nodes-source" -private const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" -private const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" -private const val OSM_SOURCE_ID = "osm-tiles" - -private const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered -private const val NODE_TEXT_LAYER_ID = "meshtastic-node-text-layer" // From clustered source, filtered -private const val NODES_LAYER_NOCLUSTER_ID = "meshtastic-nodes-layer-nocluster" // From non-clustered source -private const val NODE_TEXT_LAYER_NOCLUSTER_ID = "meshtastic-node-text-layer-nocluster" // From non-clustered source -private const val CLUSTER_CIRCLE_LAYER_ID = "meshtastic-cluster-circle-layer" -private const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" -private const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" -private const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" -private const val OSM_LAYER_ID = "osm-layer" -private const val CLUSTER_RADIAL_MAX = 8 -private const val CLUSTER_LIST_FETCH_MAX = 200L -// Base map style options (raster tiles; key-free) -private enum class BaseMapStyle(val label: String, val urlTemplate: String) { - OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), - CARTO_LIGHT("Light", "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"), - CARTO_DARK("Dark", "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"), - ESRI_SATELLITE("Satellite", "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"), -} - -private fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Style.Builder { - // Load a complete vector style first (has fonts, glyphs, sprites MapLibre needs) - val tileUrl = customTileUrl ?: base.urlTemplate - val builder = Style.Builder().fromUri("https://demotiles.maplibre.org/style.json") - // Add our raster overlay on top - .withSource( - RasterSource( - OSM_SOURCE_ID, - TileSet("osm", tileUrl).apply { - minZoom = 0f - maxZoom = 22f - }, - 128, - ), - ) - .withLayer(RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f))) - // Sources - .withSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) - .withSource( - GeoJsonSource( - NODES_CLUSTER_SOURCE_ID, - emptyFeatureCollectionJson(), - GeoJsonOptions() - .withCluster(true) - .withClusterRadius(50) - .withClusterMaxZoom(14) - .withClusterMinPoints(2) - .withLineMetrics(false) - .withTolerance(0.375f), // Smooth clustering transitions - ), - ) - .withSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) - // Layers - order ensures they are above raster - .withLayer( - CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - circleColor("#6D4C41"), - circleRadius(14f), - circleOpacity(1.0f), // Needed for transitions - ) - .withFilter(has("point_count")), - ) - .withLayer( - SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(toString(get("point_count"))), - textColor("#FFFFFF"), - textHaloColor("#000000"), - textHaloWidth(1.5f), - textHaloBlur(0.5f), - textSize(12f), - textAllowOverlap(true), - textIgnorePlacement(true), - ) - .withFilter(has("point_count")), - ) - .withLayer( - CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), - circleRadius( - interpolate( - linear(), - zoom(), - stop(8, 4f), - stop(12, 6f), - stop(16, 8f), - stop(18, 9.5f), - ), - ), - circleStrokeColor("#FFFFFF"), // White border - circleStrokeWidth( - interpolate( - linear(), - zoom(), - stop(8, 1.5f), - stop(12, 2f), - stop(16, 2.5f), - stop(18, 3f), - ), - ), - circleOpacity(1.0f), // Needed for transitions - ) - .withFilter(not(has("point_count"))), - ) - .withLayer( - SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(get("short")), - textColor("#1B1B1B"), - textHaloColor("#FFFFFF"), - textHaloWidth(3.0f), - textHaloBlur(0.7f), - textSize( - interpolate( - linear(), - zoom(), - stop(8, 9f), - stop(12, 11f), - stop(15, 13f), - stop(18, 16f), - ), - ), - textMaxWidth(4f), - textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), - textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), - textOffset(arrayOf(0f, -1.4f)), - textAnchor("bottom"), - ) - .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))), - ) - // Non-clustered node layers (shown when clustering is disabled) - .withLayer( - CircleLayer(NODES_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) - .withProperties( - circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), - circleRadius( - interpolate( - linear(), - zoom(), - stop(8, 4f), - stop(12, 6f), - stop(16, 8f), - stop(18, 9.5f), - ), - ), - circleStrokeColor("#FFFFFF"), // White border - circleStrokeWidth( - interpolate( - linear(), - zoom(), - stop(8, 1.5f), - stop(12, 2f), - stop(16, 2.5f), - stop(18, 3f), - ), - ), - circleOpacity(1.0f), // Needed for transitions - visibility("none") // Hidden by default, shown when clustering disabled - ), - ) - .withLayer( - SymbolLayer(NODE_TEXT_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) - .withProperties( - textField(get("short")), - textColor("#1B1B1B"), - textHaloColor("#FFFFFF"), - textHaloWidth(3.0f), - textHaloBlur(0.7f), - textSize( - interpolate( - linear(), - zoom(), - stop(8, 9f), - stop(12, 11f), - stop(15, 13f), - stop(18, 16f), - ), - ), - textMaxWidth(4f), - textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), - textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), - textOffset(arrayOf(0f, -1.4f)), - textAnchor("bottom"), - visibility("none") // Hidden by default, shown when clustering disabled - ) - .withFilter(eq(get("showLabel"), literal(1))), - ) - .withLayer(CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties(circleColor("#2E7D32"), circleRadius(5f))) - return builder -} -@SuppressLint("MissingPermission") -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun MapLibrePOC( - mapViewModel: MapViewModel = hiltViewModel(), - onNavigateToNodeDetails: (Int) -> Unit = {}, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var selectedInfo by remember { mutableStateOf(null) } - var selectedNodeNum by remember { mutableStateOf(null) } - val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var followBearing by remember { mutableStateOf(false) } - var hasLocationPermission by remember { mutableStateOf(false) } - data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) - var expandedCluster by remember { mutableStateOf(null) } - var clusterListMembers by remember { mutableStateOf?>(null) } - var mapRef by remember { mutableStateOf(null) } - var mapViewRef by remember { mutableStateOf(null) } - var didInitialCenter by remember { mutableStateOf(false) } - var showLegend by remember { mutableStateOf(false) } - var enabledRoles by remember { mutableStateOf>(emptySet()) } - var clusteringEnabled by remember { mutableStateOf(true) } - var editingWaypoint by remember { mutableStateOf(null) } - var mapTypeMenuExpanded by remember { mutableStateOf(false) } - var showCustomTileDialog by remember { mutableStateOf(false) } - var customTileUrl by remember { mutableStateOf("") } - var customTileUrlInput by remember { mutableStateOf("") } - var usingCustomTiles by remember { mutableStateOf(false) } - // Base map style rotation - val baseStyles = remember { enumValues().toList() } - var baseStyleIndex by remember { mutableStateOf(0) } - val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] - // Remember last applied cluster visibility to reduce flashing - var clustersShown by remember { mutableStateOf(false) } - var lastClusterEvalMs by remember { mutableStateOf(0L) } - - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } - - // Check location permission - hasLocationPermission = hasAnyLocationPermission(context) - - // Apply location tracking settings when state changes - LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { - mapRef?.let { map -> - map.style?.let { style -> - try { - if (hasLocationPermission) { - val locationComponent = map.locationComponent - - // Enable/disable location component based on tracking state - if (isLocationTrackingEnabled) { - // Enable and show location component - if (!locationComponent.isLocationComponentEnabled) { - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - } - - // Set render mode - locationComponent.renderMode = if (followBearing) { - RenderMode.COMPASS - } else { - RenderMode.NORMAL - } - - // Set camera mode - locationComponent.cameraMode = if (followBearing) { - org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS - } else { - org.maplibre.android.location.modes.CameraMode.TRACKING - } - } else { - // Disable location component to hide the blue dot - if (locationComponent.isLocationComponentEnabled) { - locationComponent.isLocationComponentEnabled = false - } - } - - Timber.tag("MapLibrePOC").d( - "Location component updated: enabled=%s, follow=%s", - isLocationTrackingEnabled, - followBearing - ) - } - } catch (e: Exception) { - Timber.tag("MapLibrePOC").w(e, "Failed to update location component") - } - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { - MapLibre.getInstance(context) - Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") - MapView(context).apply { - mapViewRef = this - getMapAsync { map -> - mapRef = map - Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") - // Set initial base raster style using MapLibre test-app pattern - map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> - Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) - style.setTransition(TransitionOptions(0, 0)) - logStyleState("after-style-load(pre-ensure)", style) - ensureSourcesAndLayers(style) - // Push current data immediately after style load - try { - val density = context.resources.displayMetrics.density - val bounds = map.projection.visibleRegion.latLngBounds - val labelSet = - run { - val visible = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled).filter { n -> - val p = n.validPosition ?: return@filter false - bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - ) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - // Set clustered source only (like MapLibre example) - val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) - Timber.tag("MapLibrePOC").d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source - Timber.tag("MapLibrePOC").d( - "Initial data set after style load. nodes=%d waypoints=%d", - nodes.size, - waypoints.size, - ) - logStyleState("after-style-load(post-sources)", style) - } catch (t: Throwable) { - Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") - } - // Keep base vector layers; OSM raster will sit below node layers for labels/roads - // Enable location component (if permissions granted) - activateLocationComponentForStyle(context, map, style) - Timber.tag("MapLibrePOC").d("Location component initialization attempted") - // Initial center on user's device location if available, else our node - if (!didInitialCenter) { - try { - val loc = map.locationComponent.lastKnownLocation - if (loc != null) { - map.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(loc.latitude, loc.longitude), - 12.0, - ), - ) - didInitialCenter = true - } else { - ourNode?.validPosition?.let { p -> - map.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 12.0, - ), - ) - didInitialCenter = true - } ?: run { - // Fallback: center to bounds of current nodes if available - val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() - var any = false - filtered.forEach { n -> - n.validPosition?.let { vp -> - boundsBuilder.include(LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D)) - any = true - } - } - if (any) { - val b = boundsBuilder.build() - map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) - } else { - map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5)) - } - didInitialCenter = true - } - } - } catch (_: Throwable) { - } - } - map.addOnMapClickListener { latLng -> - // Any tap on the map clears overlays unless replaced below - expandedCluster = null - clusterListMembers = null - val screenPoint = map.projection.toScreenLocation(latLng) - // Use a small hitbox to improve taps on small circles - val r = (24 * context.resources.displayMetrics.density) - val rect = android.graphics.RectF( - (screenPoint.x - r).toFloat(), - (screenPoint.y - r).toFloat(), - (screenPoint.x + r).toFloat(), - (screenPoint.y + r).toFloat(), - ) - val features = - map.queryRenderedFeatures( - rect, - CLUSTER_CIRCLE_LAYER_ID, - NODES_LAYER_ID, - NODES_LAYER_NOCLUSTER_ID, - WAYPOINTS_LAYER_ID, - ) - Timber.tag("MapLibrePOC").d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) - val f = features.firstOrNull() - // If cluster tapped, expand using true cluster leaves from the source - if (f != null && f.hasProperty("point_count")) { - val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 - val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) - val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - if (src != null) { - val fc = src.getClusterLeaves(f, limit, 0L) - val nums = - fc.features()?.mapNotNull { feat -> - try { - feat.getNumberProperty("num")?.toInt() - } catch (_: Throwable) { - null - } - } ?: emptyList() - val members = nodes.filter { nums.contains(it.num) } - if (members.isNotEmpty()) { - // Center the radial overlay on the actual cluster point (not the raw click) - val clusterCenter = - (f.geometry() as? Point)?.let { p -> - map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) - } ?: screenPoint - if (pointCount > CLUSTER_RADIAL_MAX) { - // Show list for large clusters - clusterListMembers = members - } else { - // Show radial overlay for small clusters - expandedCluster = ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) - } - } - return@addOnMapClickListener true - } else { - map.animateCamera(CameraUpdateFactory.zoomIn()) - return@addOnMapClickListener true - } - } - selectedInfo = - f?.let { - val kind = it.getStringProperty("kind") - when (kind) { - "node" -> { - val num = it.getNumberProperty("num")?.toInt() ?: -1 - val n = nodes.firstOrNull { node -> node.num == num } - selectedNodeNum = num - n?.let { node -> "Node ${node.user.longName.ifBlank { node.num.toString() }} (${node.gpsString()})" } - ?: "Node $num" - } - "waypoint" -> { - val id = it.getNumberProperty("id")?.toInt() ?: -1 - // Open edit dialog for waypoint - waypoints.values.find { pkt -> pkt.data.waypoint?.id == id }?.let { pkt -> - editingWaypoint = pkt.data.waypoint - } - "Waypoint: ${it.getStringProperty("name") ?: id}" - } - else -> null - } - } - true - } - // Long-press to create waypoint - map.addOnMapLongClickListener { latLng -> - if (isConnected) { - val newWaypoint = waypoint { - latitudeI = (latLng.latitude / DEG_D).toInt() - longitudeI = (latLng.longitude / DEG_D).toInt() - } - editingWaypoint = newWaypoint - Timber.tag("MapLibrePOC").d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") - } - true - } - // Update clustering visibility on camera idle (zoom changes) - map.addOnCameraIdleListener { - val st = map.style ?: return@addOnCameraIdleListener - // Debounce to avoid rapid toggling during kinetic flings/tiles loading - val now = SystemClock.uptimeMillis() - if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener - lastClusterEvalMs = now - val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - Timber.tag("MapLibrePOC").d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) - clustersShown = setClusterVisibilityHysteresis(map, st, filtered, clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - // Compute which nodes get labels in viewport and update source - val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - Timber.tag("MapLibrePOC").d("onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", labelSet.size, labelSet.take(5).joinToString(","), jsonIdle.length) - // Update both clustered and non-clustered sources - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) - safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) - logStyleState("onCameraIdle(post-update)", st) - try { - val w = mapViewRef?.width ?: 0 - val h = mapViewRef?.height ?: 0 - val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) - val rendered = map.queryRenderedFeatures( - bbox, - NODES_LAYER_ID, - CLUSTER_CIRCLE_LAYER_ID, - ) - Timber.tag("MapLibrePOC").d("onCameraIdle: rendered features in viewport=%d", rendered.size) - } catch (_: Throwable) { } - } - // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions - map.addOnCameraMoveListener { - if (expandedCluster != null || clusterListMembers != null) { - expandedCluster = null - clusterListMembers = null - } - } - } - } - } - }, - update = { mapView: MapView -> - mapView.getMapAsync { map -> - val style = map.style - if (style == null) { - Timber.tag("MapLibrePOC").w("Style not yet available in update()") - return@getMapAsync - } - // Apply bearing render mode toggle - try { - map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL - } catch (_: Throwable) { /* ignore */ } - - // Handle location tracking state changes - if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { - try { - val locationComponent = map.locationComponent - if (!locationComponent.isLocationComponentEnabled) { - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - map.style!!, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - } - locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL - locationComponent.cameraMode = if (isLocationTrackingEnabled) { - if (followBearing) org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS - else org.maplibre.android.location.modes.CameraMode.TRACKING - } else { - org.maplibre.android.location.modes.CameraMode.NONE - } - Timber.tag("MapLibrePOC").d("Location tracking: enabled=%s, follow=%s, mode=%s", isLocationTrackingEnabled, followBearing, locationComponent.cameraMode) - } catch (e: Exception) { - Timber.tag("MapLibrePOC").w(e, "Failed to update location component") - } - } - Timber.tag("MapLibrePOC").d( - "Updating sources. nodes=%d, waypoints=%d", - nodes.size, - waypoints.size, - ) - val density = context.resources.displayMetrics.density - val bounds2 = map.projection.visibleRegion.latLngBounds - val labelSet = - run { - val visible = nodes.filter { n -> - val p = n.validPosition ?: return@filter false - bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - ) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - val filteredNow = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) - safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source - // Apply visibility now - clustersShown = setClusterVisibilityHysteresis(map, style, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - logStyleState("update(block)", style) - } - }, - ) - - selectedInfo?.let { info -> - Surface( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .padding(12.dp), - tonalElevation = 6.dp, - shadowElevation = 6.dp, - ) { - Column(modifier = Modifier.padding(12.dp)) { Text(text = info, style = MaterialTheme.typography.bodyMedium) } - } - } - - // Role legend (based on roles present in current nodes) - val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } - if (showLegend && rolesPresent.isNotEmpty()) { - Surface( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp), - tonalElevation = 4.dp, - shadowElevation = 4.dp, - ) { - Column(modifier = Modifier.padding(8.dp)) { - rolesPresent.take(6).forEach { role -> - val fakeNode = Node(num = 0, user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build()) - Row( - modifier = Modifier.padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface(shape = CircleShape, color = roleColor(fakeNode), modifier = Modifier.size(12.dp)) {} - Spacer(modifier = Modifier.width(8.dp)) - Text(text = role.name.lowercase().replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.labelMedium) - } - } - } - } - } - - // Map controls: recenter/follow and filter menu - var mapFilterExpanded by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button - verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), - ) { - // My Location button with visual feedback - FloatingActionButton( - onClick = { - if (hasLocationPermission) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followBearing = false - } - Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - }, - containerColor = if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.MyLocation, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - ) - } - - // Compass button with visual feedback - FloatingActionButton( - onClick = { - if (isLocationTrackingEnabled) { - followBearing = !followBearing - Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) - } else { - // Enable tracking when compass is clicked - if (hasLocationPermission) { - isLocationTrackingEnabled = true - followBearing = true - Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - } - }, - containerColor = if (followBearing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.Explore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = if (followBearing) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - } - ) - } - Box { - MapButton( - onClick = { mapFilterExpanded = true }, - icon = Icons.Outlined.Tune, - contentDescription = null, - ) - DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { - DropdownMenuItem( - text = { Text("Only favorites") }, - onClick = { mapViewModel.toggleOnlyFavorites(); mapFilterExpanded = false }, - trailingIcon = { Checkbox(checked = mapFilterState.onlyFavorites, onCheckedChange = { - mapViewModel.toggleOnlyFavorites() - // Refresh both sources when filters change - mapRef?.style?.let { st -> - val filtered = applyFilters(nodes, mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), enabledRoles) - (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(filtered, emptySet())) - (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(nodesToFeatureCollectionJsonWithSelection(filtered, emptySet())) - } - }) }, - ) - DropdownMenuItem( - text = { Text("Show precision circle") }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap(); mapFilterExpanded = false }, - trailingIcon = { Checkbox(checked = mapFilterState.showPrecisionCircle, onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }) }, - ) - androidx.compose.material3.Divider() - Text(text = "Roles", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) - val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } - roles.forEach { role -> - val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) - DropdownMenuItem( - text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, - onClick = { - enabledRoles = - if (enabledRoles.isEmpty()) setOf(role) - else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role - mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - } } - }, - trailingIcon = { - Checkbox( - checked = checked, - onCheckedChange = { - enabledRoles = - if (enabledRoles.isEmpty()) setOf(role) - else if (enabledRoles.contains(role)) enabledRoles - role else enabledRoles + role - mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - } } - }, - ) - }, - ) - } - androidx.compose.material3.Divider() - DropdownMenuItem( - text = { Text("Enable clustering") }, - onClick = { - clusteringEnabled = !clusteringEnabled - mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - } } - }, - trailingIcon = { - Checkbox( - checked = clusteringEnabled, - onCheckedChange = { - clusteringEnabled = it - mapRef?.style?.let { st -> mapRef?.let { map -> - clustersShown = setClusterVisibilityHysteresis(map, st, applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle) - } } - }, - ) - }, - ) - } - } - MapButton( - onClick = { showLegend = !showLegend }, - icon = Icons.Outlined.Info, - contentDescription = null, - ) - // Map style selector - Box { - MapButton( - onClick = { mapTypeMenuExpanded = true }, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) - DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { - Text( - text = "Map Style", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - baseStyles.forEachIndexed { index, style -> - DropdownMenuItem( - text = { Text(style.label) }, - onClick = { - baseStyleIndex = index - usingCustomTiles = false - mapTypeMenuExpanded = false - val next = baseStyles[baseStyleIndex % baseStyles.size] - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) - map.setStyle(buildMeshtasticStyle(next)) { st -> - Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) - val density = context.resources.displayMetrics.density - clustersShown = reinitializeStyleAfterSwitch( - context, map, st, waypoints, nodes, mapFilterState, - enabledRoles, ourNode?.num, isLocationTrackingEnabled, - clusteringEnabled, clustersShown, density - ) - } - } - }, - trailingIcon = { - if (index == baseStyleIndex && !usingCustomTiles) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = "Selected", - ) - } - }, - ) - } - androidx.compose.material3.HorizontalDivider() - DropdownMenuItem( - text = { Text(if (customTileUrl.isEmpty()) "Custom Tile URL..." else "Custom: ${customTileUrl.take(30)}...") }, - onClick = { - mapTypeMenuExpanded = false - customTileUrlInput = customTileUrl - showCustomTileDialog = true - }, - trailingIcon = { - if (usingCustomTiles && customTileUrl.isNotEmpty()) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = "Selected", - ) - } - }, - ) - } - } - } - - // Custom tile URL dialog - if (showCustomTileDialog) { - AlertDialog( - onDismissRequest = { showCustomTileDialog = false }, - title = { Text("Custom Tile URL") }, - text = { - Column { - Text( - text = "Enter tile URL with {z}/{x}/{y} placeholders:", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - Text( - text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp) - ) - OutlinedTextField( - value = customTileUrlInput, - onValueChange = { customTileUrlInput = it }, - label = { Text("Tile URL") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton( - onClick = { - customTileUrl = customTileUrlInput.trim() - if (customTileUrl.isNotEmpty()) { - usingCustomTiles = true - // Apply custom tiles (use first base style as template but we'll override the raster source) - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) - map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> - Timber.tag("MapLibrePOC").d("Custom tiles applied") - val density = context.resources.displayMetrics.density - clustersShown = reinitializeStyleAfterSwitch( - context, map, st, waypoints, nodes, mapFilterState, - enabledRoles, ourNode?.num, isLocationTrackingEnabled, - clusteringEnabled, clustersShown, density - ) - } - } - } - showCustomTileDialog = false - } - ) { - Text("Apply") - } - }, - dismissButton = { - TextButton(onClick = { showCustomTileDialog = false }) { - Text("Cancel") - } - } - ) - } - - // Expanded cluster radial overlay - expandedCluster?.let { ec -> - val d = context.resources.displayMetrics.density - val centerX = (ec.centerPx.x / d).dp - val centerY = (ec.centerPx.y / d).dp - val radiusPx = 72f * d - val itemSize = 40.dp - val n = ec.members.size.coerceAtLeast(1) - ec.members.forEachIndexed { idx, node -> - val theta = (2.0 * Math.PI * idx / n) - val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() - val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() - val xDp = (x / d).dp - val yDp = (y / d).dp - val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) - val itemHeight = 36.dp - val itemWidth = (40 + label.length * 10).dp - Surface( - modifier = Modifier - .align(Alignment.TopStart) - .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) - .size(width = itemWidth, height = itemHeight) - .clickable { - selectedNodeNum = node.num - expandedCluster = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - shape = CircleShape, - color = roleColor(node), - shadowElevation = 6.dp, - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = label, color = Color.White, maxLines = 1) - } - } - } - } - - // Bottom sheet with node details and actions - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } - // Cluster list bottom sheet (for large clusters) - clusterListMembers?.let { members -> - ModalBottomSheet( - onDismissRequest = { clusterListMembers = null }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) - LazyColumn { - items(members) { node -> - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) - .clickable { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - verticalAlignment = Alignment.CenterVertically, - ) { - NodeChip(node = node, onClick = { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }) - Spacer(modifier = Modifier.width(12.dp)) - val longName = node.user.longName - if (!longName.isNullOrBlank()) { - Text(text = longName, style = MaterialTheme.typography.bodyLarge) - } - } - } - } - } - } - } - if (selectedNode != null) { - ModalBottomSheet( - onDismissRequest = { selectedNodeNum = null }, - sheetState = sheetState, - ) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - NodeChip(node = selectedNode) - val longName = selectedNode.user.longName - if (!longName.isNullOrBlank()) { - Text( - text = longName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } - val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) - val coords = selectedNode.gpsString() - Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) - Text(text = "Coordinates: $coords") - val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } - if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") - Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { - Button(onClick = { - onNavigateToNodeDetails(selectedNode.num) - selectedNodeNum = null - }) { - Text("View full node") - } - } - } - } - } - // Waypoint editing dialog - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } - } - if (updatedWp.icon == 0) { - finalWp = finalWp.copy { icon = 0x1F4CD } - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy { expire = 1 } - mapViewModel.sendWaypoint(deleteMarkerWp) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) - } - } - - // Forward lifecycle events to MapView - DisposableEffect(lifecycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding - when (event) { - Lifecycle.Event.ON_START -> {} - Lifecycle.Event.ON_RESUME -> {} - Lifecycle.Event.ON_PAUSE -> {} - Lifecycle.Event.ON_STOP -> {} - Lifecycle.Event.ON_DESTROY -> {} - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } - -} - -private fun ensureSourcesAndLayers(style: Style) { - Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() begin. Existing layers=%d, sources=%d", style.layers.size, style.sources.size) - if (style.getSource(NODES_SOURCE_ID) == null) { - // Plain (non-clustered) source for nodes and labels - style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) - Timber.tag("MapLibrePOC").d("Added nodes plain GeoJsonSource") - } - if (style.getSource(NODES_CLUSTER_SOURCE_ID) == null) { - // Clustered source for cluster layers - val options = - GeoJsonOptions() - .withCluster(true) - .withClusterRadius(50) // match TestApp defaults - .withClusterMaxZoom(14) // allow clusters up to z14 like examples - .withClusterMinPoints(2) - style.addSource(GeoJsonSource(NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), options)) - Timber.tag("MapLibrePOC").d("Added nodes clustered GeoJsonSource") - } - if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { - style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) - Timber.tag("MapLibrePOC").d("Added waypoints GeoJsonSource") - } - // Removed test track GeoJsonSource - - if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { - val clusterLayer = - CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties(circleColor("#6D4C41"), circleRadius(14f)) - .withFilter(has("point_count")) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(clusterLayer, OSM_LAYER_ID) else style.addLayer(clusterLayer) - Timber.tag("MapLibrePOC").d("Added cluster CircleLayer") - } - if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { - val countLayer = - SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(toString(get("point_count"))), - textColor("#FFFFFF"), - textHaloColor("#000000"), - textHaloWidth(1.5f), - textHaloBlur(0.5f), - textSize(12f), - textAllowOverlap(true), - textIgnorePlacement(true), - ) - .withFilter(has("point_count")) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(countLayer, OSM_LAYER_ID) else style.addLayer(countLayer) - Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") - } - // Precision circle layer (accuracy circles around nodes) - if (style.getLayer(PRECISION_CIRCLE_LAYER_ID) == null) { - val layer = - CircleLayer(PRECISION_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - circleRadius( - interpolate( - exponential(2.0), - zoom(), - // Convert meters to pixels at different zoom levels - // At equator: metersPerPixel = 156543.03392 * cos(latitude) / 2^zoom - // Approximation: pixels = meters * 2^zoom / 156543 (at equator) - // For better visibility, we use empirical values - stop(0, product(get("precisionMeters"), literal(0.0000025))), - stop(5, product(get("precisionMeters"), literal(0.00008))), - stop(10, product(get("precisionMeters"), literal(0.0025))), - stop(15, product(get("precisionMeters"), literal(0.08))), - stop(18, product(get("precisionMeters"), literal(0.64))), - stop(20, product(get("precisionMeters"), literal(2.56))), - ) - ), - circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), - circleOpacity(0.15f), - circleStrokeColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), - circleStrokeWidth(1.5f), - visibility("none"), // Hidden by default - ) - .withFilter( - all( - not(has("point_count")), // Only individual nodes, not clusters - gt(get("precisionMeters"), literal(0)) // Only show if precision > 0 - ) - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added precision circle layer") - } - - if (style.getLayer(NODES_LAYER_ID) == null) { - val layer = - CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), - circleRadius( - interpolate( - linear(), - zoom(), - stop(8, 4f), - stop(12, 6f), - stop(16, 8f), - stop(18, 9.5f), - ), - ), - circleStrokeColor("#FFFFFF"), - circleStrokeWidth( - interpolate( - linear(), - zoom(), - stop(8, 1.5f), - stop(12, 2f), - stop(16, 2.5f), - ), - ), - circleOpacity(1.0f), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") - } - if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { - val textLayer = - SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) - .withProperties( - textField(get("short")), - textColor("#1B1B1B"), - textHaloColor("#FFFFFF"), - textHaloWidth(3.0f), - textHaloBlur(0.7f), - // Scale label size with zoom to reduce clutter - textSize( - interpolate( - linear(), - zoom(), - stop(8, 9f), - stop(12, 11f), - stop(15, 13f), - stop(18, 16f), - ), - ), - textMaxWidth(4f), - // At close zooms, prefer showing all labels even if they overlap a bit - textAllowOverlap( - step( - zoom(), - literal(false), // default for low zooms - stop(11, literal(true)), // enable overlap >= 11 - ), - ), - textIgnorePlacement( - step( - zoom(), - literal(false), - stop(11, literal(true)), - ), - ), - // place label above the circle - textOffset(arrayOf(0f, -1.4f)), - textAnchor("bottom"), - ) - .withFilter( - all( - not(has("point_count")), - eq(get("showLabel"), literal(1)), - ), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(textLayer, OSM_LAYER_ID) else style.addLayer(textLayer) - Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") - } - if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { - val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID).withProperties( - circleColor("#FF5722"), // Orange-red color for visibility - circleRadius(8f), - circleStrokeColor("#FFFFFF"), - circleStrokeWidth(2f), - ) - if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) - Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") - } - // Removed test track LineLayer - Timber.tag("MapLibrePOC").d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) - val order = try { style.layers.joinToString(" > ") { it.id } } catch (_: Throwable) { "" } - Timber.tag("MapLibrePOC").d("Layer order: %s", order) - logStyleState("ensureSourcesAndLayers(end)", style) -} - -private fun applyFilters( - all: List, - filter: BaseMapViewModel.MapFilterState, - enabledRoles: Set, - ourNodeNum: Int? = null, - isLocationTrackingEnabled: Boolean = false, -): List { - var out = all - if (filter.onlyFavorites) out = out.filter { it.isFavorite } - if (enabledRoles.isNotEmpty()) out = out.filter { enabledRoles.contains(it.user.role) } - // Note: We don't filter out the user's node - that should always be visible - // The location component (blue dot) is controlled separately - return out -} - -private fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set): String { - val features = - nodes.mapNotNull { node -> - val pos = node.validPosition ?: return@mapNotNull null - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) - // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID - val shortForMap = stripEmojisForMapLabel(short) ?: run { - val hex = node.num.toString(16).uppercase() - if (hex.length >= 4) hex.takeLast(4) else hex - } - val shortEsc = escapeJson(shortForMap) - val show = if (labelNums.contains(node.num)) 1 else 0 - val role = node.user.role.name - val color = roleColorHex(node) - val longEsc = escapeJson(node.user.longName ?: "") - val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show,"precisionMeters":$precisionMeters}}""" - } - return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" -} - -// FeatureCollection builders using MapLibre GeoJSON types (safer than manual JSON strings) -private fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums: Set): FeatureCollection { - var minLat = Double.POSITIVE_INFINITY - var maxLat = Double.NEGATIVE_INFINITY - var minLon = Double.POSITIVE_INFINITY - var maxLon = Double.NEGATIVE_INFINITY - val features = - nodes.mapNotNull { node -> - val pos = node.validPosition ?: return@mapNotNull null - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - if (lat < minLat) minLat = lat - if (lat > maxLat) maxLat = lat - if (lon < minLon) minLon = lon - if (lon > maxLon) maxLon = lon - val point = Point.fromLngLat(lon, lat) - val f = Feature.fromGeometry(point) - f.addStringProperty("kind", "node") - f.addNumberProperty("num", node.num) - f.addStringProperty("name", node.user.longName ?: "") - val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) - // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID - val shortForMap = stripEmojisForMapLabel(short) ?: run { - val hex = node.num.toString(16).uppercase() - if (hex.length >= 4) hex.takeLast(4) else hex - } - f.addStringProperty("short", shortForMap) - f.addStringProperty("role", node.user.role.name) - f.addStringProperty("color", roleColorHex(node)) - f.addNumberProperty("showLabel", if (labelNums.contains(node.num)) 1 else 0) - f - } - Timber.tag("MapLibrePOC").d( - "FC bounds: lat=[%.5f, %.5f] lon=[%.5f, %.5f] count=%d", - minLat, maxLat, minLon, maxLon, features.size, - ) - return FeatureCollection.fromFeatures(features) -} - -private fun waypointsToFeatureCollectionFC( - waypoints: Collection, -): FeatureCollection { - val features = - waypoints.mapNotNull { pkt -> - val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null - val lat = w.latitudeI * DEG_D - val lon = w.longitudeI * DEG_D - if (lat == 0.0 && lon == 0.0) return@mapNotNull null - if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null - Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { f -> - f.addStringProperty("kind", "waypoint") - f.addNumberProperty("id", w.id) - f.addStringProperty("name", w.name ?: "Waypoint ${w.id}") - f.addNumberProperty("icon", w.icon) - } - } - return FeatureCollection.fromFeatures(features) -} - -private fun emptyFeatureCollectionJson(): String { - return """{"type":"FeatureCollection","features":[]}""" -} - -private fun safeSetGeoJson(style: Style, sourceId: String, json: String) { - try { - val fc = FeatureCollection.fromJson(json) - val count = fc.features()?.size ?: -1 - Timber.tag("MapLibrePOC").d("safeSetGeoJson(%s): features=%d", sourceId, count) - (style.getSource(sourceId) as? GeoJsonSource)?.setGeoJson(fc) - } catch (t: Throwable) { - Timber.tag("MapLibrePOC").e(t, "safeSetGeoJson(%s) failed to parse", sourceId) - } -} - -// Minimal JSON string escaper for embedding user-provided names in properties -private fun escapeJson(input: String): String { - if (input.isEmpty()) return "" - val sb = StringBuilder(input.length + 8) - input.forEach { ch -> - when (ch) { - '\\' -> sb.append("\\\\") - '"' -> sb.append("\\\"") - '\b' -> sb.append("\\b") - '\u000C' -> sb.append("\\f") - '\n' -> sb.append("\\n") - '\r' -> sb.append("\\r") - '\t' -> sb.append("\\t") - else -> { - if (ch < ' ') { - val hex = ch.code.toString(16).padStart(4, '0') - sb.append("\\u").append(hex) - } else { - sb.append(ch) - } - } - } - } - return sb.toString() -} - -// Log current style state: presence and visibility of key layers/sources -private fun logStyleState(whenTag: String, style: Style) { - try { - val layersToCheck = - listOf( - OSM_LAYER_ID, - CLUSTER_CIRCLE_LAYER_ID, - CLUSTER_COUNT_LAYER_ID, - NODES_LAYER_ID, - NODE_TEXT_LAYER_ID, - NODES_LAYER_NOCLUSTER_ID, - NODE_TEXT_LAYER_NOCLUSTER_ID, - PRECISION_CIRCLE_LAYER_ID, - WAYPOINTS_LAYER_ID, - ) - val sourcesToCheck = listOf(NODES_SOURCE_ID, NODES_CLUSTER_SOURCE_ID, WAYPOINTS_SOURCE_ID, OSM_SOURCE_ID) - val layerStates = - layersToCheck.joinToString(", ") { id -> - val layer = style.getLayer(id) - if (layer == null) "$id=∅" else "$id=${layer.visibility?.value}" - } - val sourceStates = - sourcesToCheck.joinToString(", ") { id -> - if (style.getSource(id) == null) "$id=∅" else "$id=ok" - } - Timber.tag("MapLibrePOC").d("[%s] Layers: %s", whenTag, layerStates) - Timber.tag("MapLibrePOC").d("[%s] Sources: %s", whenTag, sourceStates) - } catch (_: Throwable) { - } -} - -private fun hasAnyLocationPermission(context: Context): Boolean { - val fine = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - val coarse = - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - return fine || coarse -} - -private fun shortName(node: Node): String { - // Deprecated; kept for compatibility - return shortNameFallback(node) -} - -private fun protoShortName(node: Node): String? { - // Prefer the protocol-defined short name if present - val s = node.user.shortName - return if (s.isNullOrBlank()) null else s -} - -private fun shortNameFallback(node: Node): String { - val long = node.user.longName - if (!long.isNullOrBlank()) return safeSubstring(long, 4) - val hex = node.num.toString(16).uppercase() - return if (hex.length >= 4) hex.takeLast(4) else hex -} - -// Safely take up to maxLength characters, respecting emoji boundaries -// Emojis can be composed of multiple code points, so we need to be careful not to split them -private fun safeSubstring(text: String, maxLength: Int): String { - if (text.length <= maxLength) return text - - // Use grapheme cluster breaking to respect emoji boundaries - var count = 0 - var lastSafeIndex = 0 - - val breakIterator = java.text.BreakIterator.getCharacterInstance() - breakIterator.setText(text) - - var start = breakIterator.first() - var end = breakIterator.next() - - while (end != java.text.BreakIterator.DONE && count < maxLength) { - lastSafeIndex = end - count++ - end = breakIterator.next() - } - - return if (lastSafeIndex > 0) text.substring(0, lastSafeIndex) else text.take(maxLength) -} - -// Remove emojis from text for MapLibre rendering (MapLibre text rendering doesn't support emojis well) -// Keep only ASCII alphanumeric and common punctuation -// Returns null if the text is emoji-only (so caller can use fallback like hex ID) -private fun stripEmojisForMapLabel(text: String): String? { - if (text.isEmpty()) return null - - // Filter to keep only characters that MapLibre can reliably render - // This includes ASCII letters, numbers, spaces, and basic punctuation - val filtered = text.filter { ch -> - ch.code in 0x20..0x7E || // Basic ASCII printable characters - ch.code in 0xA0..0xFF // Latin-1 supplement (accented characters) - }.trim() - - // If filtering removed everything, return null (caller should use fallback) - return if (filtered.isEmpty()) null else filtered -} - -// Select one label per grid cell in the current viewport, prioritizing favorites and recent nodes. -private fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density: Float): Set { - val bounds = map.projection.visibleRegion.latLngBounds - val visible = - nodes.filter { n -> - val p = n.validPosition ?: return@filter false - val lat = p.latitudeI * DEG_D - val lon = p.longitudeI * DEG_D - bounds.contains(LatLng(lat, lon)) - } - if (visible.isEmpty()) return emptySet() - // Priority: favorites first, then more recently heard - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, - ) - // Dynamic cell size by zoom so more labels appear as you zoom in - val zoom = map.cameraPosition.zoom - val baseCellDp = - when { - zoom < 10 -> 96f - zoom < 11 -> 88f - zoom < 12 -> 80f - zoom < 13 -> 72f - zoom < 14 -> 64f - zoom < 15 -> 56f - zoom < 16 -> 48f - else -> 36f - } - val cellSizePx = (baseCellDp * density).toInt().coerceAtLeast(32) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - val cx = (pt.x / cellSizePx).toInt() - val cy = (pt.y / cellSizePx).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) { - chosen.add(n.num) - } - } - return chosen -} - -// Human friendly "x min ago" from epoch seconds -private fun formatSecondsAgo(lastHeardEpochSeconds: Int): String { - val now = System.currentTimeMillis() / 1000 - val delta = (now - lastHeardEpochSeconds).coerceAtLeast(0) - val minutes = delta / 60 - val hours = minutes / 60 - val days = hours / 24 - return when { - delta < 60 -> "$delta s ago" - minutes < 60 -> "$minutes min ago" - hours < 24 -> "$hours h ago" - else -> "$days d ago" - } -} - -// Simple haversine distance between two nodes in kilometers -private fun distanceKmBetween(a: Node, b: Node): Double? { - val pa = a.validPosition ?: return null - val pb = b.validPosition ?: return null - val lat1 = pa.latitudeI * DEG_D - val lon1 = pa.longitudeI * DEG_D - val lat2 = pb.latitudeI * DEG_D - val lon2 = pb.longitudeI * DEG_D - val R = 6371.0 // km - val dLat = Math.toRadians(lat2 - lat1) - val dLon = Math.toRadians(lon2 - lon1) - val s1 = kotlin.math.sin(dLat / 2) - val s2 = kotlin.math.sin(dLon / 2) - val aTerm = s1 * s1 + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * s2 * s2 - val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(aTerm), kotlin.math.sqrt(1 - aTerm)) - return R * c -} - -// Role -> hex color used for map dots and radial overlay -private fun roleColorHex(node: Node): String { - return when (node.user.role) { - ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#D32F2F" // red (infrastructure) - ConfigProtos.Config.DeviceConfig.Role.ROUTER_CLIENT -> "#00897B" // teal - ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#7B1FA2" // purple - ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple (lighter) - ConfigProtos.Config.DeviceConfig.Role.SENSOR -> "#1E88E5" // blue - ConfigProtos.Config.DeviceConfig.Role.TAK, ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER -> "#F57C00" // orange (TAK) - ConfigProtos.Config.DeviceConfig.Role.CLIENT -> "#2E7D32" // green - ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#1976D2" // blue (client base) - ConfigProtos.Config.DeviceConfig.Role.CLIENT_MUTE -> "#9E9D24" // olive - ConfigProtos.Config.DeviceConfig.Role.CLIENT_HIDDEN -> "#546E7A" // blue-grey - ConfigProtos.Config.DeviceConfig.Role.LOST_AND_FOUND -> "#AD1457" // magenta - ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#E57373" // light red (late router) - null, - ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED -> "#2E7D32" // default green - } -} - -private fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) - -/** - * Helper to activate location component after style changes - */ -private fun activateLocationComponentForStyle( - context: Context, - map: MapLibreMap, - style: Style, -) { - try { - if (hasAnyLocationPermission(context)) { - val locationComponent = map.locationComponent - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ).useDefaultLocationEngine(true).build(), - ) - locationComponent.isLocationComponentEnabled = true - locationComponent.renderMode = RenderMode.COMPASS - } - } catch (_: SecurityException) { - Timber.tag("MapLibrePOC").w("Location permissions not granted") - } -} - -/** - * Helper to update node data sources after filtering or style changes - */ -private fun updateNodeDataSources( - map: MapLibreMap, - style: Style, - nodes: List, - mapFilterState: BaseMapViewModel.MapFilterState, - enabledRoles: Set, - ourNodeNum: Int?, - isLocationTrackingEnabled: Boolean, - clusteringEnabled: Boolean, - currentClustersShown: Boolean, - density: Float, -): Boolean { - val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNodeNum, isLocationTrackingEnabled) - val labelSet = selectLabelsForViewport(map, filtered, density) - val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(style, NODES_SOURCE_ID, json) - return setClusterVisibilityHysteresis(map, style, filtered, clusteringEnabled, currentClustersShown, mapFilterState.showPrecisionCircle) -} - -/** - * Helper to reinitialize style after a style switch (base map change or custom tiles) - */ -private fun reinitializeStyleAfterSwitch( - context: Context, - map: MapLibreMap, - style: Style, - waypoints: Map, - nodes: List, - mapFilterState: BaseMapViewModel.MapFilterState, - enabledRoles: Set, - ourNodeNum: Int?, - isLocationTrackingEnabled: Boolean, - clusteringEnabled: Boolean, - currentClustersShown: Boolean, - density: Float, -): Boolean { - style.setTransition(TransitionOptions(1000, 0)) - ensureSourcesAndLayers(style) - // Repopulate waypoints - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(waypointsToFeatureCollectionFC(waypoints.values)) - // Re-enable location component - activateLocationComponentForStyle(context, map, style) - // Update node data - return updateNodeDataSources( - map, style, nodes, mapFilterState, enabledRoles, ourNodeNum, - isLocationTrackingEnabled, clusteringEnabled, currentClustersShown, density - ) -} - -// Show/hide cluster layers vs plain nodes based on zoom, density, and toggle -private fun setClusterVisibilityHysteresis( - map: MapLibreMap, - style: Style, - filteredNodes: List, - enableClusters: Boolean, - currentlyShown: Boolean, - showPrecisionCircle: Boolean = false, -): Boolean { - try { - val zoom = map.cameraPosition.zoom - // Render like the MapLibre example: - // - Always show unclustered nodes (filtered by not has("point_count")) - // - Show cluster circle/count layers only when clustering is enabled - val showClusters = enableClusters - - // Enforce intended visibility - // Cluster circle/count - style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - - // When clustering is enabled: show clustered source layers (which filter out clusters) - // When clustering is disabled: show non-clustered source layers (which show ALL nodes) - style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) - style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) - style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) - - // Precision circle visibility (always controlled by toggle, independent of clustering) - style.getLayer(PRECISION_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showPrecisionCircle) "visible" else "none")) - - Timber.tag("MapLibrePOC").d("Node layer visibility: clustered=%s, nocluster=%s, precision=%s", showClusters, !showClusters, showPrecisionCircle) - if (showClusters != currentlyShown) { - Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) - } - return showClusters - } catch (_: Throwable) { - return currentlyShown - } -} - -/** - * Convert a Unicode code point (int) to an emoji string - */ -internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { - String(Character.toChars(unicodeCodePoint)) -} catch (e: IllegalArgumentException) { - Timber.tag("MapLibrePOC").w(e, "Invalid unicode code point: $unicodeCodePoint") - "\uD83D\uDCCD" // 📍 default pin emoji -} - -/** - * Convert emoji to Bitmap for use as a MapLibre marker icon - */ -internal fun unicodeEmojiToBitmap(icon: Int): Bitmap { - val unicodeEmoji = convertIntToEmoji(icon) - val paint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - textSize = 64f - color = android.graphics.Color.BLACK - textAlign = Paint.Align.CENTER - } - - val baseline = -paint.ascent() - val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() - val height = (baseline + paint.descent() + 0.5f).toInt() - val image = createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(image) - canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) - - return image -} \ No newline at end of file diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt new file mode 100644 index 0000000000..d33cc7cce8 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.core + +import org.maplibre.android.maps.Style +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.Point +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.getPrecisionMeters +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColorHex +import org.meshtastic.feature.map.maplibre.utils.safeSubstring +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback +import org.meshtastic.feature.map.maplibre.utils.stripEmojisForMapLabel +import org.meshtastic.proto.MeshProtos.Waypoint +import timber.log.Timber + +/** Converts nodes to GeoJSON FeatureCollection JSON string with label selection */ +fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set): String { + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = + stripEmojisForMapLabel(short) + ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + val shortEsc = escapeJson(shortForMap) + val show = if (labelNums.contains(node.num)) 1 else 0 + val role = node.user.role.name + val color = roleColorHex(node) + val longEsc = escapeJson(node.user.longName ?: "") + val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show,"precisionMeters":$precisionMeters}}""" + } + return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" +} + +/** Converts nodes to GeoJSON FeatureCollection object with label selection */ +fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums: Set): FeatureCollection { + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLon = Double.POSITIVE_INFINITY + var maxLon = Double.NEGATIVE_INFINITY + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lon < minLon) minLon = lon + if (lon > maxLon) maxLon = lon + val point = Point.fromLngLat(lon, lat) + val f = Feature.fromGeometry(point) + f.addStringProperty("kind", "node") + f.addNumberProperty("num", node.num) + f.addStringProperty("name", node.user.longName ?: "") + val short = safeSubstring(protoShortName(node) ?: shortNameFallback(node), 4) + // Strip emojis for MapLibre rendering; if emoji-only, fall back to hex ID + val shortForMap = + stripEmojisForMapLabel(short) + ?: run { + val hex = node.num.toString(16).uppercase() + if (hex.length >= 4) hex.takeLast(4) else hex + } + f.addStringProperty("short", shortForMap) + f.addStringProperty("role", node.user.role.name) + f.addStringProperty("color", roleColorHex(node)) + f.addNumberProperty("showLabel", if (labelNums.contains(node.num)) 1 else 0) + val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 + f.addNumberProperty("precisionMeters", precisionMeters) + f + } + Timber.tag("MapLibrePOC") + .d("FC bounds: lat=[%.5f, %.5f] lon=[%.5f, %.5f] count=%d", minLat, maxLat, minLon, maxLon, features.size) + return FeatureCollection.fromFeatures(features) +} + +/** Converts waypoints to GeoJSON FeatureCollection */ +fun waypointsToFeatureCollectionFC( + waypoints: Collection, +): FeatureCollection { + val features = + waypoints.mapNotNull { pkt -> + val w: Waypoint = pkt.data.waypoint ?: return@mapNotNull null + val lat = w.latitudeI * DEG_D + val lon = w.longitudeI * DEG_D + if (lat == 0.0 && lon == 0.0) return@mapNotNull null + if (lat !in -90.0..90.0 || lon !in -180.0..180.0) return@mapNotNull null + Feature.fromGeometry(Point.fromLngLat(lon, lat)).also { f -> + f.addStringProperty("kind", "waypoint") + f.addNumberProperty("id", w.id) + f.addStringProperty("name", w.name ?: "Waypoint ${w.id}") + f.addNumberProperty("icon", w.icon) + } + } + return FeatureCollection.fromFeatures(features) +} + +/** Safely sets GeoJSON on a source, parsing and validating first */ +fun safeSetGeoJson(style: Style, sourceId: String, json: String) { + try { + val fc = FeatureCollection.fromJson(json) + val count = fc.features()?.size ?: -1 + Timber.tag("MapLibrePOC").d("Setting %s: %d features", sourceId, count) + (style.getSource(sourceId) as? GeoJsonSource)?.setGeoJson(fc) + } catch (e: Throwable) { + Timber.tag("MapLibrePOC").e(e, "Failed to parse/set GeoJSON for %s", sourceId) + } +} + +/** Escapes a string for safe inclusion in JSON */ +fun escapeJson(input: String): String { + val sb = StringBuilder() + for (c in input) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\b' -> sb.append("\\b") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> { + if (c < ' ') { + sb.append(String.format("\\u%04x", c.code)) + } else { + sb.append(c) + } + } + } + } + return sb.toString() +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt new file mode 100644 index 0000000000..3cc210b19a --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.core + +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.all +import org.maplibre.android.style.expressions.Expression.coalesce +import org.maplibre.android.style.expressions.Expression.eq +import org.maplibre.android.style.expressions.Expression.exponential +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.gt +import org.maplibre.android.style.expressions.Expression.has +import org.maplibre.android.style.expressions.Expression.interpolate +import org.maplibre.android.style.expressions.Expression.linear +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.not +import org.maplibre.android.style.expressions.Expression.product +import org.maplibre.android.style.expressions.Expression.step +import org.maplibre.android.style.expressions.Expression.stop +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.expressions.Expression.toString +import org.maplibre.android.style.expressions.Expression.zoom +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textAnchor +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur +import org.maplibre.android.style.layers.PropertyFactory.textHaloColor +import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth +import org.maplibre.android.style.layers.PropertyFactory.textOffset +import org.maplibre.android.style.layers.PropertyFactory.textSize +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.PRECISION_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import timber.log.Timber + +/** Ensures all necessary sources and layers exist in the style */ +fun ensureSourcesAndLayers(style: Style) { + Timber.tag("MapLibrePOC") + .d("ensureSourcesAndLayers() begin. Existing layers=%d, sources=%d", style.layers.size, style.sources.size) + + // Add sources if they don't exist + if (style.getSource(NODES_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added nodes plain GeoJsonSource") + } + if (style.getSource(NODES_CLUSTER_SOURCE_ID) == null) { + val options = + GeoJsonOptions().withCluster(true).withClusterRadius(50).withClusterMaxZoom(14).withClusterMinPoints(2) + style.addSource(GeoJsonSource(NODES_CLUSTER_SOURCE_ID, emptyFeatureCollectionJson(), options)) + Timber.tag("MapLibrePOC").d("Added nodes clustered GeoJsonSource") + } + if (style.getSource(WAYPOINTS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added waypoints GeoJsonSource") + } + + // Add cluster layers + if (style.getLayer(CLUSTER_CIRCLE_LAYER_ID) == null) { + val clusterLayer = + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties(circleColor("#6D4C41"), circleRadius(14f)) + .withFilter(has("point_count")) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(clusterLayer, OSM_LAYER_ID) + } else { + style.addLayer(clusterLayer) + } + Timber.tag("MapLibrePOC").d("Added cluster CircleLayer") + } + if (style.getLayer(CLUSTER_COUNT_LAYER_ID) == null) { + val countLayer = + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(countLayer, OSM_LAYER_ID) + } else { + style.addLayer(countLayer) + } + Timber.tag("MapLibrePOC").d("Added cluster count SymbolLayer") + } + + // Precision circle layer + if (style.getLayer(PRECISION_CIRCLE_LAYER_ID) == null) { + val layer = + CircleLayer(PRECISION_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleRadius( + interpolate( + exponential(2.0), + zoom(), + stop(0, product(get("precisionMeters"), literal(0.0000025))), + stop(5, product(get("precisionMeters"), literal(0.00008))), + stop(10, product(get("precisionMeters"), literal(0.0025))), + stop(15, product(get("precisionMeters"), literal(0.08))), + stop(18, product(get("precisionMeters"), literal(0.64))), + stop(20, product(get("precisionMeters"), literal(2.56))), + ), + ), + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleOpacity(0.15f), + circleStrokeColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleStrokeWidth(1.5f), + visibility("none"), + ) + .withFilter(all(not(has("point_count")), gt(get("precisionMeters"), literal(0)))) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added precision circle layer") + } + + // Node layers (clustered) + if (style.getLayer(NODES_LAYER_ID) == null) { + val layer = + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f))), + circleOpacity(1.0f), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added nodes CircleLayer") + } + if (style.getLayer(NODE_TEXT_LAYER_ID) == null) { + val textLayer = + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize(interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f))), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))) + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(textLayer, OSM_LAYER_ID) + } else { + style.addLayer(textLayer) + } + Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") + } + + // Waypoints layer + if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { + val layer = + CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + .withProperties( + circleColor("#FF5722"), + circleRadius(8f), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(2f), + ) + if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) + Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") + } + + Timber.tag("MapLibrePOC") + .d("ensureSourcesAndLayers() end. Layers=%d, Sources=%d", style.layers.size, style.sources.size) + val order = + try { + style.layers.joinToString(" > ") { it.id } + } catch (_: Throwable) { + "" + } + Timber.tag("MapLibrePOC").d("Layer order: %s", order) + logStyleState("ensureSourcesAndLayers(end)", style) +} + +/** Show/hide cluster layers vs plain nodes based on zoom, density, and toggle */ +fun setClusterVisibilityHysteresis( + map: MapLibreMap, + style: Style, + filteredNodes: List, + enableClusters: Boolean, + currentlyShown: Boolean, + showPrecisionCircle: Boolean = false, +): Boolean { + try { + val zoom = map.cameraPosition.zoom + val showClusters = enableClusters + + // Enforce intended visibility + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + + // When clustering is enabled: show clustered source layers (which filter out clusters) + // When clustering is disabled: show non-clustered source layers (which show ALL nodes) + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(if (showClusters) "visible" else "none")) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(if (showClusters) "none" else "visible")) + + // Precision circle visibility (always controlled by toggle, independent of clustering) + style + .getLayer(PRECISION_CIRCLE_LAYER_ID) + ?.setProperties(visibility(if (showPrecisionCircle) "visible" else "none")) + + Timber.tag("MapLibrePOC") + .d( + "Node layer visibility: clustered=%s, nocluster=%s, precision=%s", + showClusters, + !showClusters, + showPrecisionCircle, + ) + if (showClusters != currentlyShown) { + Timber.tag("MapLibrePOC").d("Cluster visibility=%s (zoom=%.2f)", showClusters, zoom) + } + return showClusters + } catch (_: Throwable) { + return currentlyShown + } +} + +/** Log current style state: presence and visibility of key layers/sources */ +fun logStyleState(whenTag: String, style: Style) { + try { + val layersToCheck = + listOf( + OSM_LAYER_ID, + CLUSTER_CIRCLE_LAYER_ID, + CLUSTER_COUNT_LAYER_ID, + NODES_LAYER_ID, + NODE_TEXT_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + NODE_TEXT_LAYER_NOCLUSTER_ID, + PRECISION_CIRCLE_LAYER_ID, + WAYPOINTS_LAYER_ID, + ) + val sourcesToCheck = listOf(NODES_SOURCE_ID, NODES_CLUSTER_SOURCE_ID, WAYPOINTS_SOURCE_ID, OSM_SOURCE_ID) + val layerStates = + layersToCheck.joinToString(", ") { id -> + val layer = style.getLayer(id) + if (layer == null) "$id=∅" else "$id=${layer.visibility?.value}" + } + val sourceStates = + sourcesToCheck.joinToString(", ") { id -> if (style.getSource(id) == null) "$id=∅" else "$id=✓" } + Timber.tag("MapLibrePOC").d("[%s] layers={%s} sources={%s}", whenTag, layerStates, sourceStates) + } catch (e: Throwable) { + Timber.tag("MapLibrePOC").w(e, "Failed to log style state") + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt new file mode 100644 index 0000000000..2c0b1baa52 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLocationManager.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.core + +import android.content.Context +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.style.layers.TransitionOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** Activates the location component after a style change */ +fun activateLocationComponentForStyle(context: Context, map: MapLibreMap, style: Style) { + try { + if (hasAnyLocationPermission(context)) { + val locationComponent = map.locationComponent + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder(context, style) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + locationComponent.renderMode = RenderMode.COMPASS + } + } catch (_: SecurityException) { + Timber.tag("MapLibrePOC").w("Location permissions not granted") + } +} + +/** Updates node data sources after filtering or style changes */ +fun updateNodeDataSources( + map: MapLibreMap, + style: Style, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNodeNum, isLocationTrackingEnabled) + val labelSet = selectLabelsForViewport(map, filtered, density) + val json = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) + return setClusterVisibilityHysteresis( + map, + style, + filtered, + clusteringEnabled, + currentClustersShown, + mapFilterState.showPrecisionCircle, + ) +} + +/** Reinitializes style after a style switch (base map change or custom tiles) */ +fun reinitializeStyleAfterSwitch( + context: Context, + map: MapLibreMap, + style: Style, + waypoints: Map, + nodes: List, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int?, + isLocationTrackingEnabled: Boolean, + clusteringEnabled: Boolean, + currentClustersShown: Boolean, + density: Float, +): Boolean { + style.setTransition(TransitionOptions(1000, 0)) + ensureSourcesAndLayers(style) + // Repopulate waypoints + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Re-enable location component + activateLocationComponentForStyle(context, map, style) + // Update node data + return updateNodeDataSources( + map, + style, + nodes, + mapFilterState, + enabledRoles, + ourNodeNum, + isLocationTrackingEnabled, + clusteringEnabled, + currentClustersShown, + density, + ) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt new file mode 100644 index 0000000000..933aeae432 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.core + +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.all +import org.maplibre.android.style.expressions.Expression.coalesce +import org.maplibre.android.style.expressions.Expression.eq +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.has +import org.maplibre.android.style.expressions.Expression.interpolate +import org.maplibre.android.style.expressions.Expression.linear +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.not +import org.maplibre.android.style.expressions.Expression.step +import org.maplibre.android.style.expressions.Expression.stop +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.expressions.Expression.toString +import org.maplibre.android.style.expressions.Expression.zoom +import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.rasterOpacity +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textAnchor +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textHaloBlur +import org.maplibre.android.style.layers.PropertyFactory.textHaloColor +import org.maplibre.android.style.layers.PropertyFactory.textHaloWidth +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textMaxWidth +import org.maplibre.android.style.layers.PropertyFactory.textOffset +import org.maplibre.android.style.layers.PropertyFactory.textSize +import org.maplibre.android.style.layers.PropertyFactory.visibility +import org.maplibre.android.style.layers.RasterLayer +import org.maplibre.android.style.layers.SymbolLayer +import org.maplibre.android.style.sources.GeoJsonOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.sources.RasterSource +import org.maplibre.android.style.sources.TileSet +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.STYLE_URL +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID + +/** Builds a complete MapLibre style with all necessary sources and layers */ +fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Style.Builder { + // Load a complete vector style first (has fonts, glyphs, sprites MapLibre needs) + val tileUrl = customTileUrl ?: base.urlTemplate + val builder = + Style.Builder() + .fromUri(STYLE_URL) + // Add our raster overlay on top + .withSource( + RasterSource( + OSM_SOURCE_ID, + TileSet("osm", tileUrl).apply { + minZoom = 0f + maxZoom = 22f + }, + 128, + ), + ) + .withLayer(RasterLayer(OSM_LAYER_ID, OSM_SOURCE_ID).withProperties(rasterOpacity(1.0f))) + // Sources + .withSource(GeoJsonSource(NODES_SOURCE_ID, emptyFeatureCollectionJson())) + .withSource( + GeoJsonSource( + NODES_CLUSTER_SOURCE_ID, + emptyFeatureCollectionJson(), + GeoJsonOptions() + .withCluster(true) + .withClusterRadius(50) + .withClusterMaxZoom(14) + .withClusterMinPoints(2) + .withLineMetrics(false) + .withTolerance(0.375f), // Smooth clustering transitions + ), + ) + .withSource(GeoJsonSource(WAYPOINTS_SOURCE_ID, emptyFeatureCollectionJson())) + // Layers - order ensures they are above raster + .withLayer( + CircleLayer(CLUSTER_CIRCLE_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor("#6D4C41"), + circleRadius(14f), + circleOpacity(1.0f), // Needed for transitions + ) + .withFilter(has("point_count")), + ) + .withLayer( + SymbolLayer(CLUSTER_COUNT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(toString(get("point_count"))), + textColor("#FFFFFF"), + textHaloColor("#000000"), + textHaloWidth(1.5f), + textHaloBlur(0.5f), + textSize(12f), + textAllowOverlap(true), + textIgnorePlacement(true), + ) + .withFilter(has("point_count")), + ) + .withLayer( + CircleLayer(NODES_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f), stop(18, 3f)), + ), + circleOpacity(1.0f), // Needed for transitions + ) + .withFilter(not(has("point_count"))), + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_ID, NODES_CLUSTER_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f)), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + ) + .withFilter(all(not(has("point_count")), eq(get("showLabel"), literal(1)))), + ) + // Non-clustered node layers (shown when clustering is disabled) + .withLayer( + CircleLayer(NODES_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + circleColor(coalesce(toColor(get("color")), toColor(literal("#2E7D32")))), + circleRadius( + interpolate(linear(), zoom(), stop(8, 4f), stop(12, 6f), stop(16, 8f), stop(18, 9.5f)), + ), + circleStrokeColor("#FFFFFF"), // White border + circleStrokeWidth( + interpolate(linear(), zoom(), stop(8, 1.5f), stop(12, 2f), stop(16, 2.5f), stop(18, 3f)), + ), + circleOpacity(1.0f), // Needed for transitions + visibility("none"), // Hidden by default, shown when clustering disabled + ), + ) + .withLayer( + SymbolLayer(NODE_TEXT_LAYER_NOCLUSTER_ID, NODES_SOURCE_ID) + .withProperties( + textField(get("short")), + textColor("#1B1B1B"), + textHaloColor("#FFFFFF"), + textHaloWidth(3.0f), + textHaloBlur(0.7f), + textSize( + interpolate(linear(), zoom(), stop(8, 9f), stop(12, 11f), stop(15, 13f), stop(18, 16f)), + ), + textMaxWidth(4f), + textAllowOverlap(step(zoom(), literal(false), stop(11, literal(true)))), + textIgnorePlacement(step(zoom(), literal(false), stop(11, literal(true)))), + textOffset(arrayOf(0f, -1.4f)), + textAnchor("bottom"), + visibility("none"), // Hidden by default, shown when clustering disabled + ) + .withFilter(eq(get("showLabel"), literal(1))), + ) + .withLayer( + CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) + .withProperties(circleColor("#2E7D32"), circleRadius(5f)), + ) + return builder +} + +/** Returns an empty GeoJSON FeatureCollection as a JSON string */ +fun emptyFeatureCollectionJson(): String = """{"type":"FeatureCollection","features":[]}""" diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt new file mode 100644 index 0000000000..e4d9502a61 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** Control buttons displayed on the right side of the map */ +@Composable +fun MapLibreControlButtons( + isLocationTrackingEnabled: Boolean, + onLocationTrackingToggle: () -> Unit, + hasLocationPermission: Boolean, + followBearing: Boolean, + onFollowBearingToggle: () -> Unit, + onCompassClick: () -> Unit, + onFilterClick: () -> Unit, + onLegendClick: () -> Unit, + onStyleClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.End) { + // Location tracking button with visual feedback + FloatingActionButton( + onClick = { + if (hasLocationPermission) { + onLocationTrackingToggle() + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", !isLocationTrackingEnabled) + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + }, + containerColor = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.MyLocation, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + + // Compass button with visual feedback + FloatingActionButton( + onClick = { + if (isLocationTrackingEnabled) { + onFollowBearingToggle() + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", !followBearing) + } else { + onCompassClick() + } + }, + containerColor = + if (followBearing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.Explore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (followBearing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onFilterClick, icon = Icons.Outlined.Tune, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onLegendClick, icon = Icons.Outlined.Info, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onStyleClick, icon = Icons.Outlined.Layers, contentDescription = null) + } +} + +/** Filter dropdown menu for the map */ +@Composable +fun MapFilterMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + mapFilterState: BaseMapViewModel.MapFilterState, + mapViewModel: MapViewModel, + nodes: List, + enabledRoles: Set, + onRoleToggle: (ConfigProtos.Config.DeviceConfig.Role) -> Unit, + mapRef: MapLibreMap?, + onClusteringToggle: () -> Unit, + clusteringEnabled: Boolean, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + mapViewModel.toggleOnlyFavorites() + onDismissRequest() + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + } + }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + mapViewModel.toggleShowPrecisionCircleOnMap() + onDismissRequest() + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + onClusteringToggle() + onDismissRequest() + }, + trailingIcon = { Checkbox(checked = clusteringEnabled, onCheckedChange = { onClusteringToggle() }) }, + ) + HorizontalDivider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { onRoleToggle(role) }, + trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onRoleToggle(role) }) }, + ) + } + } +} + +/** Map style selection dropdown menu */ +@Composable +fun MapStyleMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + baseStyles: List, + baseStyleIndex: Int, + usingCustomTiles: Boolean, + customTileUrl: String, + onStyleSelect: (Int) -> Unit, + onCustomTileClick: () -> Unit, +) { + DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + onStyleSelect(index) + onDismissRequest() + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { + Text(if (customTileUrl.isEmpty()) "Custom Tile URL..." else "Custom: ${customTileUrl.take(30)}...") + }, + onClick = { + onDismissRequest() + onCustomTileClick() + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt new file mode 100644 index 0000000000..48e4356794 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.maplibre.utils.distanceKmBetween +import org.meshtastic.feature.map.maplibre.utils.formatSecondsAgo + +/** Bottom sheet showing selected node details */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NodeDetailsBottomSheet( + selectedNode: Node, + ourNode: Node?, + onNavigateToNodeDetails: (Int) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + NodeChip(node = selectedNode) + val longName = selectedNode.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) + val coords = selectedNode.gpsString() + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) + Text(text = "Coordinates: $coords") + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button( + onClick = { + onNavigateToNodeDetails(selectedNode.num) + onDismiss() + }, + ) { + Text("View full node") + } + } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt new file mode 100644 index 0000000000..1a27e2ac92 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreOverlays.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import android.graphics.PointF +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColor +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback + +/** Expanded cluster overlay showing nodes in a radial pattern */ +@Composable +fun ExpandedClusterOverlay( + centerPx: PointF, + members: List, + density: Float, + onNodeClick: (Node) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.clickable(onClick = onDismiss)) { + val centerX = (centerPx.x / density).dp + val centerY = (centerPx.y / density).dp + val radiusPx = 72f * density + val itemSize = 40.dp + val n = members.size.coerceAtLeast(1) + members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / density).dp + val yDp = (y / density).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + Surface( + modifier = + Modifier.align(Alignment.TopStart) + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { onNodeClick(node) }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } + } + } + } +} + +/** Bottom sheet showing a list of cluster members */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClusterListBottomSheet( + members: List, + mapRef: MapLibreMap?, + onNodeSelect: (Node) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = + Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { + onNodeSelect(node) + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip( + node = node, + onClick = { + onNodeSelect(node) + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} + +/** Legend showing role colors */ +@Composable +fun MapLegend(nodes: List, onDismiss: () -> Unit) { + Surface(modifier = Modifier.padding(16.dp), shape = MaterialTheme.shapes.medium, shadowElevation = 4.dp) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "Legend", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.size(8.dp)) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp)) { + val sampleNode = nodes.first { it.user.role == role } + Surface(modifier = Modifier.size(16.dp), shape = CircleShape, color = roleColor(sampleNode)) {} + Spacer(modifier = Modifier.width(8.dp)) + Text(text = role.name.lowercase().replaceFirstChar { it.uppercase() }) + } + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton(onClick = onDismiss) { Text("Close") } + } + } +} + +/** Dialog for custom tile URL input */ +@Composable +fun CustomTileUrlDialog( + customTileUrlInput: String, + onCustomTileUrlInputChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = onCustomTileUrlInputChange, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { TextButton(onClick = onConfirm) { Text("Apply") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt new file mode 100644 index 0000000000..83388b7dd4 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -0,0 +1,1259 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +// Import modularized MapLibre components + +import android.annotation.SuppressLint +import android.graphics.RectF +import android.os.SystemClock +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.TransitionOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Point +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.component.EditWaypointDialog +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle +import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle +import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers +import org.meshtastic.feature.map.maplibre.core.logStyleState +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.reinitializeStyleAfterSwitch +import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson +import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis +import org.meshtastic.feature.map.maplibre.core.waypointsToFeatureCollectionFC +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.distanceKmBetween +import org.meshtastic.feature.map.maplibre.utils.formatSecondsAgo +import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColor +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.MeshProtos.Waypoint +import org.meshtastic.proto.copy +import org.meshtastic.proto.waypoint +import timber.log.Timber +import kotlin.math.cos +import kotlin.math.sin + +@SuppressLint("MissingPermission") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDetails: (Int) -> Unit = {}) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var selectedInfo by remember { mutableStateOf(null) } + var selectedNodeNum by remember { mutableStateOf(null) } + val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + var isLocationTrackingEnabled by remember { mutableStateOf(false) } + var followBearing by remember { mutableStateOf(false) } + var hasLocationPermission by remember { mutableStateOf(false) } + data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) + var expandedCluster by remember { mutableStateOf(null) } + var clusterListMembers by remember { mutableStateOf?>(null) } + var mapRef by remember { mutableStateOf(null) } + var mapViewRef by remember { mutableStateOf(null) } + var didInitialCenter by remember { mutableStateOf(false) } + var showLegend by remember { mutableStateOf(false) } + var enabledRoles by remember { mutableStateOf>(emptySet()) } + var clusteringEnabled by remember { mutableStateOf(true) } + var editingWaypoint by remember { mutableStateOf(null) } + var mapTypeMenuExpanded by remember { mutableStateOf(false) } + var showCustomTileDialog by remember { mutableStateOf(false) } + var customTileUrl by remember { mutableStateOf("") } + var customTileUrlInput by remember { mutableStateOf("") } + var usingCustomTiles by remember { mutableStateOf(false) } + // Base map style rotation + val baseStyles = remember { enumValues().toList() } + var baseStyleIndex by remember { mutableStateOf(0) } + val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] + // Remember last applied cluster visibility to reduce flashing + var clustersShown by remember { mutableStateOf(false) } + var lastClusterEvalMs by remember { mutableStateOf(0L) } + + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() + val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() + val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + + // Check location permission + hasLocationPermission = hasAnyLocationPermission(context) + + // Apply location tracking settings when state changes + LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { + mapRef?.let { map -> + map.style?.let { style -> + try { + if (hasLocationPermission) { + val locationComponent = map.locationComponent + + // Enable/disable location component based on tracking state + if (isLocationTrackingEnabled) { + // Enable and show location component + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + + // Set render mode + locationComponent.renderMode = + if (followBearing) { + RenderMode.COMPASS + } else { + RenderMode.NORMAL + } + + // Set camera mode + locationComponent.cameraMode = + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + // Disable location component to hide the blue dot + if (locationComponent.isLocationComponentEnabled) { + locationComponent.isLocationComponentEnabled = false + } + } + + Timber.tag("MapLibrePOC") + .d( + "Location component updated: enabled=%s, follow=%s", + isLocationTrackingEnabled, + followBearing, + ) + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + MapLibre.getInstance(context) + Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") + MapView(context).apply { + mapViewRef = this + getMapAsync { map -> + mapRef = map + Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") + // Set initial base raster style using MapLibre test-app pattern + map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> + Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) + style.setTransition(TransitionOptions(0, 0)) + logStyleState("after-style-load(pre-ensure)", style) + ensureSourcesAndLayers(style) + // Push current data immediately after style load + try { + val density = context.resources.displayMetrics.density + val bounds = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + .filter { n -> + val p = n.validPosition ?: return@filter false + bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = + map.projection.toScreenLocation( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + ) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Set clustered source only (like MapLibre example) + val filteredNodes = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + Timber.tag("MapLibrePOC") + .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source + Timber.tag("MapLibrePOC") + .d( + "Initial data set after style load. nodes=%d waypoints=%d", + nodes.size, + waypoints.size, + ) + logStyleState("after-style-load(post-sources)", style) + } catch (t: Throwable) { + Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") + } + // Keep base vector layers; OSM raster will sit below node layers for labels/roads + // Enable location component (if permissions granted) + activateLocationComponentForStyle(context, map, style) + Timber.tag("MapLibrePOC").d("Location component initialization attempted") + // Initial center on user's device location if available, else our node + if (!didInitialCenter) { + try { + val loc = map.locationComponent.lastKnownLocation + if (loc != null) { + val target = LatLng(loc.latitude, loc.longitude) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 12.0)) + didInitialCenter = true + } else { + ourNode?.validPosition?.let { p -> + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 12.0, + ), + ) + didInitialCenter = true + } + ?: run { + // Fallback: center to bounds of current nodes if available + val filtered = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() + var any = false + filtered.forEach { n -> + n.validPosition?.let { vp -> + boundsBuilder.include( + LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D), + ) + any = true + } + } + if (any) { + val b = boundsBuilder.build() + map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) + } else { + map.moveCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5), + ) + } + didInitialCenter = true + } + } + } catch (_: Throwable) {} + } + map.addOnMapClickListener { latLng -> + // Any tap on the map clears overlays unless replaced below + expandedCluster = null + clusterListMembers = null + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (24 * context.resources.displayMetrics.density) + val rect = + android.graphics.RectF( + (screenPoint.x - r).toFloat(), + (screenPoint.y - r).toFloat(), + (screenPoint.x + r).toFloat(), + (screenPoint.y + r).toFloat(), + ) + val features = + map.queryRenderedFeatures( + rect, + CLUSTER_CIRCLE_LAYER_ID, + NODES_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + WAYPOINTS_LAYER_ID, + ) + Timber.tag("MapLibrePOC") + .d( + "Map click at (%.5f, %.5f) -> %d features", + latLng.latitude, + latLng.longitude, + features.size, + ) + val f = features.firstOrNull() + // If cluster tapped, expand using true cluster leaves from the source + if (f != null && f.hasProperty("point_count")) { + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = + fc.features()?.mapNotNull { feat -> + try { + feat.getNumberProperty("num")?.toInt() + } catch (_: Throwable) { + null + } + } ?: emptyList() + val members = nodes.filter { nums.contains(it.num) } + if (members.isNotEmpty()) { + // Center the radial overlay on the actual cluster point (not the raw click) + val clusterCenter = + (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + clusterListMembers = members + } else { + // Show radial overlay for small clusters + expandedCluster = + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + } + } + return@addOnMapClickListener true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return@addOnMapClickListener true + } + } + selectedInfo = + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + val n = nodes.firstOrNull { node -> node.num == num } + selectedNodeNum = num + n?.let { node -> + "Node ${node.user.longName.ifBlank { + node.num.toString() + }} (${node.gpsString()})" + } ?: "Node $num" + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + waypoints.values + .find { pkt -> pkt.data.waypoint?.id == id } + ?.let { pkt -> editingWaypoint = pkt.data.waypoint } + "Waypoint: ${it.getStringProperty("name") ?: id}" + } + else -> null + } + } + true + } + // Long-press to create waypoint + map.addOnMapLongClickListener { latLng -> + if (isConnected) { + val newWaypoint = waypoint { + latitudeI = (latLng.latitude / DEG_D).toInt() + longitudeI = (latLng.longitude / DEG_D).toInt() + } + editingWaypoint = newWaypoint + Timber.tag("MapLibrePOC") + .d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") + } + true + } + // Update clustering visibility on camera idle (zoom changes) + map.addOnCameraIdleListener { + val st = map.style ?: return@addOnCameraIdleListener + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener + lastClusterEvalMs = now + val filtered = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + Timber.tag("MapLibrePOC") + .d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + filtered, + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + Timber.tag("MapLibrePOC") + .d( + "onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", + labelSet.size, + labelSet.take(5).joinToString(","), + jsonIdle.length, + ) + // Update both clustered and non-clustered sources + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = + map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) + Timber.tag("MapLibrePOC") + .d("onCameraIdle: rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) {} + } + // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions + map.addOnCameraMoveListener { + if (expandedCluster != null || clusterListMembers != null) { + expandedCluster = null + clusterListMembers = null + } + } + } + } + } + }, + update = { mapView: MapView -> + mapView.getMapAsync { map -> + val style = map.style + if (style == null) { + Timber.tag("MapLibrePOC").w("Style not yet available in update()") + return@getMapAsync + } + // Apply bearing render mode toggle + try { + map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + } catch (_: Throwable) { + /* ignore */ + } + + // Handle location tracking state changes + if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { + try { + val locationComponent = map.locationComponent + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + map.style!!, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + locationComponent.cameraMode = + if (isLocationTrackingEnabled) { + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + org.maplibre.android.location.modes.CameraMode.NONE + } + Timber.tag("MapLibrePOC") + .d( + "Location tracking: enabled=%s, follow=%s, mode=%s", + isLocationTrackingEnabled, + followBearing, + locationComponent.cameraMode, + ) + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) + val density = context.resources.displayMetrics.density + val bounds2 = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + val filteredNow = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source + // Apply visibility now + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + logStyleState("update(block)", style) + } + }, + ) + + selectedInfo?.let { info -> + Surface( + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth().padding(12.dp), + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = info, style = MaterialTheme.typography.bodyMedium) + } + } + } + + // Role legend (based on roles present in current nodes) + val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } + if (showLegend && rolesPresent.isNotEmpty()) { + Surface( + modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + rolesPresent.take(6).forEach { role -> + val fakeNode = + Node( + num = 0, + user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), + ) + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = roleColor(fakeNode), + modifier = Modifier.size(12.dp), + ) {} + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = role.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + // Map controls: recenter/follow and filter menu + var mapFilterExpanded by remember { mutableStateOf(false) } + Column( + modifier = + Modifier.align(Alignment.TopEnd) + .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), + ) { + // My Location button with visual feedback + FloatingActionButton( + onClick = { + if (hasLocationPermission) { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + followBearing = false + } + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + }, + containerColor = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.MyLocation, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Compass button with visual feedback + FloatingActionButton( + onClick = { + if (isLocationTrackingEnabled) { + followBearing = !followBearing + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) + } else { + // Enable tracking when compass is clicked + if (hasLocationPermission) { + isLocationTrackingEnabled = true + followBearing = true + Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + } + }, + containerColor = + if (followBearing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.Explore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (followBearing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + Box { + MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) + DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + mapViewModel.toggleOnlyFavorites() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + } + }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + mapViewModel.toggleShowPrecisionCircleOnMap() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + }, + ) + androidx.compose.material3.Divider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role + } + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role + } + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + ) + }, + ) + } + androidx.compose.material3.Divider() + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + clusteringEnabled = !clusteringEnabled + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + trailingIcon = { + Checkbox( + checked = clusteringEnabled, + onCheckedChange = { + clusteringEnabled = it + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + ) + }, + ) + } + } + MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) + // Map style selector + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + baseStyleIndex = index + usingCustomTiles = false + mapTypeMenuExpanded = false + val next = baseStyles[baseStyleIndex % baseStyles.size] + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.setStyle(buildMeshtasticStyle(next)) { st -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + } + } + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { + Text( + if (customTileUrl.isEmpty()) { + "Custom Tile URL..." + } else { + "Custom: ${customTileUrl.take(30)}..." + }, + ) + }, + onClick = { + mapTypeMenuExpanded = false + customTileUrlInput = customTileUrl + showCustomTileDialog = true + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + } + } + + // Custom tile URL dialog + if (showCustomTileDialog) { + AlertDialog( + onDismissRequest = { showCustomTileDialog = false }, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = { customTileUrlInput = it }, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + customTileUrl = customTileUrlInput.trim() + if (customTileUrl.isNotEmpty()) { + usingCustomTiles = true + // Apply custom tiles (use first base style as template but we'll override the raster + // source) + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) + map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> + Timber.tag("MapLibrePOC").d("Custom tiles applied") + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + } + } + } + showCustomTileDialog = false + }, + ) { + Text("Apply") + } + }, + dismissButton = { TextButton(onClick = { showCustomTileDialog = false }) { Text("Cancel") } }, + ) + } + + // Expanded cluster radial overlay + expandedCluster?.let { ec -> + val d = context.resources.displayMetrics.density + val centerX = (ec.centerPx.x / d).dp + val centerY = (ec.centerPx.y / d).dp + val radiusPx = 72f * d + val itemSize = 40.dp + val n = ec.members.size.coerceAtLeast(1) + ec.members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / d).dp + val yDp = (y / d).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + Surface( + modifier = + Modifier.align(Alignment.TopStart) + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { + selectedNodeNum = node.num + expandedCluster = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } + } + } + } + + // Bottom sheet with node details and actions + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + // Cluster list bottom sheet (for large clusters) + clusterListMembers?.let { members -> + ModalBottomSheet( + onDismissRequest = { clusterListMembers = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = + Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip( + node = node, + onClick = { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } + } + if (selectedNode != null) { + ModalBottomSheet(onDismissRequest = { selectedNodeNum = null }, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + NodeChip(node = selectedNode) + val longName = selectedNode.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) + val coords = selectedNode.gpsString() + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) + Text(text = "Coordinates: $coords") + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button( + onClick = { + onNavigateToNodeDetails(selectedNode.num) + selectedNodeNum = null + }, + ) { + Text("View full node") + } + } + } + } + } + // Waypoint editing dialog + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } + } + if (updatedWp.icon == 0) { + finalWp = finalWp.copy { icon = 0x1F4CD } + } + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null + }, + onDeleteClicked = { wpToDelete -> + if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { + val deleteMarkerWp = wpToDelete.copy { expire = 1 } + mapViewModel.sendWaypoint(deleteMarkerWp) + } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null + }, + onDismissRequest = { editingWaypoint = null }, + ) + } + } + + // Forward lifecycle events to MapView + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding + when (event) { + Lifecycle.Event.ON_START -> {} + Lifecycle.Event.ON_RESUME -> {} + Lifecycle.Event.ON_PAUSE -> {} + Lifecycle.Event.ON_STOP -> {} + Lifecycle.Event.ON_DESTROY -> {} + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt new file mode 100644 index 0000000000..af2fb93690 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.proto.ConfigProtos + +/** Check if the app has any location permission */ +fun hasAnyLocationPermission(context: Context): Boolean { + val fine = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + val coarse = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + return fine || coarse +} + +/** Get short name from node (deprecated, kept for compatibility) */ +fun shortName(node: Node): String = shortNameFallback(node) + +/** Get protocol-defined short name if present */ +fun protoShortName(node: Node): String? { + val s = node.user.shortName + return if (s.isNullOrBlank()) null else s +} + +/** Fallback short name generation */ +fun shortNameFallback(node: Node): String { + val long = node.user.longName + if (!long.isNullOrBlank()) return safeSubstring(long, 4) + val hex = node.num.toString(16).uppercase() + return if (hex.length >= 4) hex.takeLast(4) else hex +} + +/** Safely take up to maxLength characters, respecting emoji boundaries */ +fun safeSubstring(text: String, maxLength: Int): String { + if (text.length <= maxLength) return text + + // Use grapheme cluster breaking to respect emoji boundaries + var count = 0 + var lastSafeIndex = 0 + + val breakIterator = java.text.BreakIterator.getCharacterInstance() + breakIterator.setText(text) + + var start = breakIterator.first() + var end = breakIterator.next() + + while (end != java.text.BreakIterator.DONE && count < maxLength) { + lastSafeIndex = end + count++ + end = breakIterator.next() + } + + return if (lastSafeIndex > 0) text.substring(0, lastSafeIndex) else text.take(maxLength) +} + +/** + * Remove emojis from text for MapLibre rendering Returns null if the text is emoji-only (so caller can use fallback + * like hex ID) + */ +fun stripEmojisForMapLabel(text: String): String? { + if (text.isEmpty()) return null + + // Filter to keep only characters that MapLibre can reliably render + val filtered = + text + .filter { ch -> + ch.code in 0x20..0x7E || // Basic ASCII printable characters + ch.code in 0xA0..0xFF // Latin-1 supplement (accented characters) + } + .trim() + + // If filtering removed everything, return null (caller should use fallback) + return if (filtered.isEmpty()) null else filtered +} + +/** Select one label per grid cell in the current viewport, prioritizing favorites and recent nodes */ +fun selectLabelsForViewport(map: MapLibreMap, nodes: List, density: Float): Set { + val bounds = map.projection.visibleRegion.latLngBounds + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + val lat = p.latitudeI * DEG_D + val lon = p.longitudeI * DEG_D + bounds.contains(LatLng(lat, lon)) + } + if (visible.isEmpty()) return emptySet() + + // Priority: favorites first, then more recently heard + val sorted = visible.sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }) + + // Dynamic cell size by zoom so more labels appear as you zoom in + val zoom = map.cameraPosition.zoom + val baseCellDp = + when { + zoom < 10 -> 96f + zoom < 11 -> 88f + zoom < 12 -> 80f + zoom < 13 -> 72f + zoom < 14 -> 64f + zoom < 15 -> 56f + zoom < 16 -> 48f + else -> 36f + } + val cellSizePx = (baseCellDp * density).toInt().coerceAtLeast(32) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cellSizePx).toInt() + val cy = (pt.y / cellSizePx).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) { + chosen.add(n.num) + } + } + return chosen +} + +/** Human friendly "x min ago" from epoch seconds */ +fun formatSecondsAgo(lastHeardEpochSeconds: Int): String { + val now = System.currentTimeMillis() / 1000 + val delta = (now - lastHeardEpochSeconds).coerceAtLeast(0) + val minutes = delta / 60 + val hours = minutes / 60 + val days = hours / 24 + return when { + delta < 60 -> "$delta s ago" + minutes < 60 -> "$minutes min ago" + hours < 24 -> "$hours h ago" + else -> "$days d ago" + } +} + +/** Simple haversine distance between two nodes in kilometers */ +fun distanceKmBetween(a: Node, b: Node): Double? { + val pa = a.validPosition ?: return null + val pb = b.validPosition ?: return null + val lat1 = pa.latitudeI * DEG_D + val lon1 = pa.longitudeI * DEG_D + val lat2 = pb.latitudeI * DEG_D + val lon2 = pb.longitudeI * DEG_D + val radius = 6371.0 // km (Earth's radius) + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + val s1 = kotlin.math.sin(dLat / 2) + val s2 = kotlin.math.sin(dLon / 2) + val aTerm = s1 * s1 + kotlin.math.cos(Math.toRadians(lat1)) * kotlin.math.cos(Math.toRadians(lat2)) * s2 * s2 + val c = 2 * kotlin.math.atan2(kotlin.math.sqrt(aTerm), kotlin.math.sqrt(1 - aTerm)) + return radius * c +} + +/** Get hex color for a node based on its role */ +fun roleColorHex(node: Node): String = when (node.user.role) { + ConfigProtos.Config.DeviceConfig.Role.ROUTER -> "#D32F2F" // red (infrastructure) + ConfigProtos.Config.DeviceConfig.Role.ROUTER_CLIENT -> "#00897B" // teal + ConfigProtos.Config.DeviceConfig.Role.REPEATER -> "#7B1FA2" // purple + ConfigProtos.Config.DeviceConfig.Role.TRACKER -> "#8E24AA" // purple (lighter) + ConfigProtos.Config.DeviceConfig.Role.SENSOR -> "#1E88E5" // blue + ConfigProtos.Config.DeviceConfig.Role.TAK, + ConfigProtos.Config.DeviceConfig.Role.TAK_TRACKER, + -> "#F57C00" // orange (TAK) + ConfigProtos.Config.DeviceConfig.Role.CLIENT -> "#2E7D32" // green + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE -> "#1976D2" // blue (client base) + ConfigProtos.Config.DeviceConfig.Role.CLIENT_MUTE -> "#9E9D24" // olive + ConfigProtos.Config.DeviceConfig.Role.CLIENT_HIDDEN -> "#546E7A" // blue-grey + ConfigProtos.Config.DeviceConfig.Role.LOST_AND_FOUND -> "#AD1457" // magenta + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE -> "#E57373" // light red (late router) + null, + ConfigProtos.Config.DeviceConfig.Role.UNRECOGNIZED, + -> "#2E7D32" // default green +} + +/** Get Color object for a node based on its role */ +fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) + +/** Apply filters to node list */ +fun applyFilters( + all: List, + filter: BaseMapViewModel.MapFilterState, + enabledRoles: Set, + ourNodeNum: Int? = null, + isLocationTrackingEnabled: Boolean = false, +): List { + var out = all + if (filter.onlyFavorites) out = out.filter { it.isFavorite } + if (enabledRoles.isNotEmpty()) out = out.filter { enabledRoles.contains(it.user.role) } + return out +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt new file mode 100644 index 0000000000..97c54c037b --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreWaypointUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.utils + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import androidx.core.graphics.createBitmap +import timber.log.Timber + +/** Convert a Unicode code point (int) to an emoji string */ +internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try { + String(Character.toChars(unicodeCodePoint)) +} catch (e: IllegalArgumentException) { + Timber.tag("MapLibrePOC").w(e, "Invalid unicode code point: $unicodeCodePoint") + "\uD83D\uDCCD" // 📍 default pin emoji +} + +/** Convert emoji to Bitmap for use as a MapLibre marker icon */ +internal fun unicodeEmojiToBitmap(icon: Int): Bitmap { + val unicodeEmoji = convertIntToEmoji(icon) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = 64f + color = android.graphics.Color.BLACK + textAlign = Paint.Align.CENTER + } + + val baseline = -paint.ascent() + val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt() + val height = (baseline + paint.descent() + 0.5f).toInt() + val image = createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(image) + canvas.drawText(unicodeEmoji, width / 2f, baseline, paint) + + return image +} From 608ce9a28c004864125507356c86f31e7bd34771 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 11:01:00 -0800 Subject: [PATCH 10/62] refactor --- .../feature/map/maplibre/MapLibrePOC.kt | 1241 +++++++++++++++++ 1 file changed, 1241 insertions(+) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt new file mode 100644 index 0000000000..9b58139618 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt @@ -0,0 +1,1241 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre + +// Import modularized MapLibre components + +import android.annotation.SuppressLint +import android.graphics.RectF +import android.os.SystemClock +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Explore +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.TransitionOptions +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Point +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.component.EditWaypointDialog +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID +import org.meshtastic.proto.ConfigProtos +import org.meshtastic.proto.MeshProtos.Waypoint +import org.meshtastic.proto.copy +import org.meshtastic.proto.waypoint +import timber.log.Timber +import kotlin.math.cos +import kotlin.math.sin + +@SuppressLint("MissingPermission") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDetails: (Int) -> Unit = {}) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var selectedInfo by remember { mutableStateOf(null) } + var selectedNodeNum by remember { mutableStateOf(null) } + val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() + val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() + var isLocationTrackingEnabled by remember { mutableStateOf(false) } + var followBearing by remember { mutableStateOf(false) } + var hasLocationPermission by remember { mutableStateOf(false) } + data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) + var expandedCluster by remember { mutableStateOf(null) } + var clusterListMembers by remember { mutableStateOf?>(null) } + var mapRef by remember { mutableStateOf(null) } + var mapViewRef by remember { mutableStateOf(null) } + var didInitialCenter by remember { mutableStateOf(false) } + var showLegend by remember { mutableStateOf(false) } + var enabledRoles by remember { mutableStateOf>(emptySet()) } + var clusteringEnabled by remember { mutableStateOf(true) } + var editingWaypoint by remember { mutableStateOf(null) } + var mapTypeMenuExpanded by remember { mutableStateOf(false) } + var showCustomTileDialog by remember { mutableStateOf(false) } + var customTileUrl by remember { mutableStateOf("") } + var customTileUrlInput by remember { mutableStateOf("") } + var usingCustomTiles by remember { mutableStateOf(false) } + // Base map style rotation + val baseStyles = remember { enumValues().toList() } + var baseStyleIndex by remember { mutableStateOf(0) } + val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] + // Remember last applied cluster visibility to reduce flashing + var clustersShown by remember { mutableStateOf(false) } + var lastClusterEvalMs by remember { mutableStateOf(0L) } + + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() + val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() + val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() + val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + + // Check location permission + hasLocationPermission = hasAnyLocationPermission(context) + + // Apply location tracking settings when state changes + LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { + mapRef?.let { map -> + map.style?.let { style -> + try { + if (hasLocationPermission) { + val locationComponent = map.locationComponent + + // Enable/disable location component based on tracking state + if (isLocationTrackingEnabled) { + // Enable and show location component + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + style, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + + // Set render mode + locationComponent.renderMode = + if (followBearing) { + RenderMode.COMPASS + } else { + RenderMode.NORMAL + } + + // Set camera mode + locationComponent.cameraMode = + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + // Disable location component to hide the blue dot + if (locationComponent.isLocationComponentEnabled) { + locationComponent.isLocationComponentEnabled = false + } + } + + Timber.tag("MapLibrePOC") + .d( + "Location component updated: enabled=%s, follow=%s", + isLocationTrackingEnabled, + followBearing, + ) + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + MapLibre.getInstance(context) + Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") + MapView(context).apply { + mapViewRef = this + getMapAsync { map -> + mapRef = map + Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") + // Set initial base raster style using MapLibre test-app pattern + map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> + Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) + style.setTransition(TransitionOptions(0, 0)) + logStyleState("after-style-load(pre-ensure)", style) + ensureSourcesAndLayers(style) + // Push current data immediately after style load + try { + val density = context.resources.displayMetrics.density + val bounds = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + .filter { n -> + val p = n.validPosition ?: return@filter false + bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = + map.projection.toScreenLocation( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + ) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Set clustered source only (like MapLibre example) + val filteredNodes = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + Timber.tag("MapLibrePOC") + .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source + Timber.tag("MapLibrePOC") + .d( + "Initial data set after style load. nodes=%d waypoints=%d", + nodes.size, + waypoints.size, + ) + logStyleState("after-style-load(post-sources)", style) + } catch (t: Throwable) { + Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") + } + // Keep base vector layers; OSM raster will sit below node layers for labels/roads + // Enable location component (if permissions granted) + activateLocationComponentForStyle(context, map, style) + Timber.tag("MapLibrePOC").d("Location component initialization attempted") + // Initial center on user's device location if available, else our node + if (!didInitialCenter) { + try { + val loc = map.locationComponent.lastKnownLocation + if (loc != null) { + val target = LatLng(loc.latitude, loc.longitude) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 12.0)) + didInitialCenter = true + } else { + ourNode?.validPosition?.let { p -> + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 12.0, + ), + ) + didInitialCenter = true + } + ?: run { + // Fallback: center to bounds of current nodes if available + val filtered = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() + var any = false + filtered.forEach { n -> + n.validPosition?.let { vp -> + boundsBuilder.include( + LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D), + ) + any = true + } + } + if (any) { + val b = boundsBuilder.build() + map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) + } else { + map.moveCamera( + CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5), + ) + } + didInitialCenter = true + } + } + } catch (_: Throwable) {} + } + map.addOnMapClickListener { latLng -> + // Any tap on the map clears overlays unless replaced below + expandedCluster = null + clusterListMembers = null + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (24 * context.resources.displayMetrics.density) + val rect = + android.graphics.RectF( + (screenPoint.x - r).toFloat(), + (screenPoint.y - r).toFloat(), + (screenPoint.x + r).toFloat(), + (screenPoint.y + r).toFloat(), + ) + val features = + map.queryRenderedFeatures( + rect, + CLUSTER_CIRCLE_LAYER_ID, + NODES_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + WAYPOINTS_LAYER_ID, + ) + Timber.tag("MapLibrePOC") + .d( + "Map click at (%.5f, %.5f) -> %d features", + latLng.latitude, + latLng.longitude, + features.size, + ) + val f = features.firstOrNull() + // If cluster tapped, expand using true cluster leaves from the source + if (f != null && f.hasProperty("point_count")) { + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = + fc.features()?.mapNotNull { feat -> + try { + feat.getNumberProperty("num")?.toInt() + } catch (_: Throwable) { + null + } + } ?: emptyList() + val members = nodes.filter { nums.contains(it.num) } + if (members.isNotEmpty()) { + // Center the radial overlay on the actual cluster point (not the raw click) + val clusterCenter = + (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + clusterListMembers = members + } else { + // Show radial overlay for small clusters + expandedCluster = + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + } + } + return@addOnMapClickListener true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return@addOnMapClickListener true + } + } + selectedInfo = + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + val n = nodes.firstOrNull { node -> node.num == num } + selectedNodeNum = num + n?.let { node -> + "Node ${node.user.longName.ifBlank { + node.num.toString() + }} (${node.gpsString()})" + } ?: "Node $num" + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + waypoints.values + .find { pkt -> pkt.data.waypoint?.id == id } + ?.let { pkt -> editingWaypoint = pkt.data.waypoint } + "Waypoint: ${it.getStringProperty("name") ?: id}" + } + else -> null + } + } + true + } + // Long-press to create waypoint + map.addOnMapLongClickListener { latLng -> + if (isConnected) { + val newWaypoint = waypoint { + latitudeI = (latLng.latitude / DEG_D).toInt() + longitudeI = (latLng.longitude / DEG_D).toInt() + } + editingWaypoint = newWaypoint + Timber.tag("MapLibrePOC") + .d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") + } + true + } + // Update clustering visibility on camera idle (zoom changes) + map.addOnCameraIdleListener { + val st = map.style ?: return@addOnCameraIdleListener + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener + lastClusterEvalMs = now + val filtered = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + Timber.tag("MapLibrePOC") + .d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + filtered, + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + Timber.tag("MapLibrePOC") + .d( + "onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", + labelSet.size, + labelSet.take(5).joinToString(","), + jsonIdle.length, + ) + // Update both clustered and non-clustered sources + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = + map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) + Timber.tag("MapLibrePOC") + .d("onCameraIdle: rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) {} + } + // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions + map.addOnCameraMoveListener { + if (expandedCluster != null || clusterListMembers != null) { + expandedCluster = null + clusterListMembers = null + } + } + } + } + } + }, + update = { mapView: MapView -> + mapView.getMapAsync { map -> + val style = map.style + if (style == null) { + Timber.tag("MapLibrePOC").w("Style not yet available in update()") + return@getMapAsync + } + // Apply bearing render mode toggle + try { + map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + } catch (_: Throwable) { + /* ignore */ + } + + // Handle location tracking state changes + if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { + try { + val locationComponent = map.locationComponent + if (!locationComponent.isLocationComponentEnabled) { + locationComponent.activateLocationComponent( + org.maplibre.android.location.LocationComponentActivationOptions.builder( + context, + map.style!!, + ) + .useDefaultLocationEngine(true) + .build(), + ) + locationComponent.isLocationComponentEnabled = true + } + locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL + locationComponent.cameraMode = + if (isLocationTrackingEnabled) { + if (followBearing) { + org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS + } else { + org.maplibre.android.location.modes.CameraMode.TRACKING + } + } else { + org.maplibre.android.location.modes.CameraMode.NONE + } + Timber.tag("MapLibrePOC") + .d( + "Location tracking: enabled=%s, follow=%s, mode=%s", + isLocationTrackingEnabled, + followBearing, + locationComponent.cameraMode, + ) + } catch (e: Exception) { + Timber.tag("MapLibrePOC").w(e, "Failed to update location component") + } + } + Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) + val density = context.resources.displayMetrics.density + val bounds2 = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + val filteredNow = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source + // Apply visibility now + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + logStyleState("update(block)", style) + } + }, + ) + + selectedInfo?.let { info -> + Surface( + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth().padding(12.dp), + tonalElevation = 6.dp, + shadowElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = info, style = MaterialTheme.typography.bodyMedium) + } + } + } + + // Role legend (based on roles present in current nodes) + val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } + if (showLegend && rolesPresent.isNotEmpty()) { + Surface( + modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + rolesPresent.take(6).forEach { role -> + val fakeNode = + Node( + num = 0, + user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), + ) + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = roleColor(fakeNode), + modifier = Modifier.size(12.dp), + ) {} + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = role.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + // Map controls: recenter/follow and filter menu + var mapFilterExpanded by remember { mutableStateOf(false) } + Column( + modifier = + Modifier.align(Alignment.TopEnd) + .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), + ) { + // My Location button with visual feedback + FloatingActionButton( + onClick = { + if (hasLocationPermission) { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + followBearing = false + } + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + }, + containerColor = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.MyLocation, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (isLocationTrackingEnabled) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Compass button with visual feedback + FloatingActionButton( + onClick = { + if (isLocationTrackingEnabled) { + followBearing = !followBearing + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) + } else { + // Enable tracking when compass is clicked + if (hasLocationPermission) { + isLocationTrackingEnabled = true + followBearing = true + Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } + } + }, + containerColor = + if (followBearing) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Icon( + imageVector = Icons.Outlined.Explore, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = + if (followBearing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + Box { + MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) + DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + mapViewModel.toggleOnlyFavorites() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), + ) + } + }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + mapViewModel.toggleShowPrecisionCircleOnMap() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + }, + ) + androidx.compose.material3.Divider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role + } + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role + } + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + ) + }, + ) + } + androidx.compose.material3.Divider() + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + clusteringEnabled = !clusteringEnabled + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + trailingIcon = { + Checkbox( + checked = clusteringEnabled, + onCheckedChange = { + clusteringEnabled = it + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) + } + } + }, + ) + }, + ) + } + } + MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) + // Map style selector + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + baseStyleIndex = index + usingCustomTiles = false + mapTypeMenuExpanded = false + val next = baseStyles[baseStyleIndex % baseStyles.size] + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.setStyle(buildMeshtasticStyle(next)) { st -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + } + } + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { + Text( + if (customTileUrl.isEmpty()) { + "Custom Tile URL..." + } else { + "Custom: ${customTileUrl.take(30)}..." + }, + ) + }, + onClick = { + mapTypeMenuExpanded = false + customTileUrlInput = customTileUrl + showCustomTileDialog = true + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + } + } + + // Custom tile URL dialog + if (showCustomTileDialog) { + AlertDialog( + onDismissRequest = { showCustomTileDialog = false }, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = { customTileUrlInput = it }, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + customTileUrl = customTileUrlInput.trim() + if (customTileUrl.isNotEmpty()) { + usingCustomTiles = true + // Apply custom tiles (use first base style as template but we'll override the raster + // source) + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) + map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> + Timber.tag("MapLibrePOC").d("Custom tiles applied") + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + } + } + } + showCustomTileDialog = false + }, + ) { + Text("Apply") + } + }, + dismissButton = { TextButton(onClick = { showCustomTileDialog = false }) { Text("Cancel") } }, + ) + } + + // Expanded cluster radial overlay + expandedCluster?.let { ec -> + val d = context.resources.displayMetrics.density + val centerX = (ec.centerPx.x / d).dp + val centerY = (ec.centerPx.y / d).dp + val radiusPx = 72f * d + val itemSize = 40.dp + val n = ec.members.size.coerceAtLeast(1) + ec.members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / d).dp + val yDp = (y / d).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + Surface( + modifier = + Modifier.align(Alignment.TopStart) + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { + selectedNodeNum = node.num + expandedCluster = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } + } + } + } + + // Bottom sheet with node details and actions + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + // Cluster list bottom sheet (for large clusters) + clusterListMembers?.let { members -> + ModalBottomSheet( + onDismissRequest = { clusterListMembers = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = + Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip( + node = node, + onClick = { + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } + } + if (selectedNode != null) { + ModalBottomSheet(onDismissRequest = { selectedNodeNum = null }, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + NodeChip(node = selectedNode) + val longName = selectedNode.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) + val coords = selectedNode.gpsString() + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) + Text(text = "Coordinates: $coords") + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button( + onClick = { + onNavigateToNodeDetails(selectedNode.num) + selectedNodeNum = null + }, + ) { + Text("View full node") + } + } + } + } + } + // Waypoint editing dialog + editingWaypoint?.let { waypointToEdit -> + EditWaypointDialog( + waypoint = waypointToEdit, + onSendClicked = { updatedWp -> + var finalWp = updatedWp + if (updatedWp.id == 0) { + finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } + } + if (updatedWp.icon == 0) { + finalWp = finalWp.copy { icon = 0x1F4CD } + } + mapViewModel.sendWaypoint(finalWp) + editingWaypoint = null + }, + onDeleteClicked = { wpToDelete -> + if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { + val deleteMarkerWp = wpToDelete.copy { expire = 1 } + mapViewModel.sendWaypoint(deleteMarkerWp) + } + mapViewModel.deleteWaypoint(wpToDelete.id) + editingWaypoint = null + }, + onDismissRequest = { editingWaypoint = null }, + ) + } + } + + // Forward lifecycle events to MapView + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding + when (event) { + Lifecycle.Event.ON_START -> {} + Lifecycle.Event.ON_RESUME -> {} + Lifecycle.Event.ON_PAUSE -> {} + Lifecycle.Event.ON_STOP -> {} + Lifecycle.Event.ON_DESTROY -> {} + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} From 9ccfdbd2272bdc338a29352849c6112c9ab81075 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 14:04:38 -0800 Subject: [PATCH 11/62] initial kml geojson kmz support --- .../map/component/CustomMapLayersSheet.kt | 128 ++ .../feature/map/maplibre/MapLibreConstants.kt | 34 +- .../feature/map/maplibre/MapLibrePOC.kt | 1241 ----------------- .../map/maplibre/core/MapLibreLayerManager.kt | 94 ++ .../map/maplibre/ui/MapLibreControlButtons.kt | 5 + .../feature/map/maplibre/ui/MapLibrePOC.kt | 157 +++ .../map/maplibre/utils/MapLibreLayerUtils.kt | 265 ++++ .../org/meshtastic/feature/map/MapView.kt | 4 +- .../meshtastic/feature/map/MapViewModel.kt | 57 +- .../meshtastic/feature/map/MapLayerItem.kt | 34 + 10 files changed, 711 insertions(+), 1308 deletions(-) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt delete mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt create mode 100644 feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt new file mode 100644 index 0000000000..637a1e6536 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 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.feature.map.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.add_layer +import org.meshtastic.core.strings.hide_layer +import org.meshtastic.core.strings.manage_map_layers +import org.meshtastic.core.strings.map_layer_formats +import org.meshtastic.core.strings.no_map_layers_loaded +import org.meshtastic.core.strings.remove_layer +import org.meshtastic.core.strings.show_layer +import org.meshtastic.feature.map.MapLayerItem + +@Suppress("LongMethod") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun CustomMapLayersSheet( + mapLayers: List, + onToggleVisibility: (String) -> Unit, + onRemoveLayer: (String) -> Unit, + onAddLayerClicked: () -> Unit, +) { + LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(Res.string.manage_map_layers), + style = MaterialTheme.typography.headlineSmall, + ) + HorizontalDivider() + } + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), + text = stringResource(Res.string.map_layer_formats), + style = MaterialTheme.typography.bodySmall, + ) + } + + if (mapLayers.isEmpty()) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(Res.string.no_map_layers_loaded), + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + items(mapLayers, key = { it.id }) { layer -> + ListItem( + headlineContent = { Text(layer.name) }, + trailingContent = { + Row { + IconButton(onClick = { onToggleVisibility(layer.id) }) { + Icon( + imageVector = + if (layer.isVisible) { + Icons.Filled.Visibility + } else { + Icons.Filled.VisibilityOff + }, + contentDescription = + stringResource( + if (layer.isVisible) { + Res.string.hide_layer + } else { + Res.string.show_layer + }, + ), + ) + } + IconButton(onClick = { onRemoveLayer(layer.id) }) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(Res.string.remove_layer), + ) + } + } + }, + ) + HorizontalDivider() + } + } + item { + Button(modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) { + Text(stringResource(Res.string.add_layer)) + } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt index 9d1e999113..9b70436f77 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -59,29 +59,13 @@ enum class BaseMapStyle(val label: String, val urlTemplate: String) { } /** Converts precision bits to meters for accuracy circles */ -fun getPrecisionMeters(precisionBits: Int): Double? = when (precisionBits) { - 10 -> 23345.484932 - 11 -> 11672.7369 - 12 -> 5836.36288 - 13 -> 2918.175876 - 14 -> 1459.0823719999053 - 15 -> 729.5370149076749 - 16 -> 364.76796802673495 - 17 -> 182.38363847854606 - 18 -> 91.19178201473192 - 19 -> 45.59587874512555 - 20 -> 22.797938919871483 - 21 -> 11.398969292955733 - 22 -> 5.699484588175269 - 23 -> 2.8497422889870207 - 24 -> 1.424871149078816 - 25 -> 0.7124355732781771 - 26 -> 0.3562177850463231 - 27 -> 0.17810889188369584 - 28 -> 0.08905444562935878 - 29 -> 0.04452722265708971 - 30 -> 0.022263611293647812 - 31 -> 0.011131805632411625 - 32 -> 0.005565902808395108 - else -> null +fun getPrecisionMeters(precisionBits: Int): Double? { + // Use the same formula as the core UI component for consistency + // Formula: 23905787.925008 * 0.5^bits + // Returns null for invalid precision bits (typically < 10 or > 32) + return if (precisionBits in 10..32) { + org.meshtastic.core.ui.component.precisionBitsToMeters(precisionBits) + } else { + null + } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt deleted file mode 100644 index 9b58139618..0000000000 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibrePOC.kt +++ /dev/null @@ -1,1241 +0,0 @@ -/* - * Copyright (c) 2025 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.feature.map.maplibre - -// Import modularized MapLibre components - -import android.annotation.SuppressLint -import android.graphics.RectF -import android.os.SystemClock -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.maplibre.android.MapLibre -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.location.modes.RenderMode -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView -import org.maplibre.android.style.expressions.Expression.get -import org.maplibre.android.style.layers.TransitionOptions -import org.maplibre.android.style.sources.GeoJsonSource -import org.maplibre.geojson.Point -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.feature.map.MapViewModel -import org.meshtastic.feature.map.component.EditWaypointDialog -import org.meshtastic.feature.map.component.MapButton -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX -import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID -import org.meshtastic.proto.ConfigProtos -import org.meshtastic.proto.MeshProtos.Waypoint -import org.meshtastic.proto.copy -import org.meshtastic.proto.waypoint -import timber.log.Timber -import kotlin.math.cos -import kotlin.math.sin - -@SuppressLint("MissingPermission") -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDetails: (Int) -> Unit = {}) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var selectedInfo by remember { mutableStateOf(null) } - var selectedNodeNum by remember { mutableStateOf(null) } - val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() - val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() - var isLocationTrackingEnabled by remember { mutableStateOf(false) } - var followBearing by remember { mutableStateOf(false) } - var hasLocationPermission by remember { mutableStateOf(false) } - data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) - var expandedCluster by remember { mutableStateOf(null) } - var clusterListMembers by remember { mutableStateOf?>(null) } - var mapRef by remember { mutableStateOf(null) } - var mapViewRef by remember { mutableStateOf(null) } - var didInitialCenter by remember { mutableStateOf(false) } - var showLegend by remember { mutableStateOf(false) } - var enabledRoles by remember { mutableStateOf>(emptySet()) } - var clusteringEnabled by remember { mutableStateOf(true) } - var editingWaypoint by remember { mutableStateOf(null) } - var mapTypeMenuExpanded by remember { mutableStateOf(false) } - var showCustomTileDialog by remember { mutableStateOf(false) } - var customTileUrl by remember { mutableStateOf("") } - var customTileUrlInput by remember { mutableStateOf("") } - var usingCustomTiles by remember { mutableStateOf(false) } - // Base map style rotation - val baseStyles = remember { enumValues().toList() } - var baseStyleIndex by remember { mutableStateOf(0) } - val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] - // Remember last applied cluster visibility to reduce flashing - var clustersShown by remember { mutableStateOf(false) } - var lastClusterEvalMs by remember { mutableStateOf(0L) } - - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() - val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() - val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() - val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } - - // Check location permission - hasLocationPermission = hasAnyLocationPermission(context) - - // Apply location tracking settings when state changes - LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { - mapRef?.let { map -> - map.style?.let { style -> - try { - if (hasLocationPermission) { - val locationComponent = map.locationComponent - - // Enable/disable location component based on tracking state - if (isLocationTrackingEnabled) { - // Enable and show location component - if (!locationComponent.isLocationComponentEnabled) { - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - style, - ) - .useDefaultLocationEngine(true) - .build(), - ) - locationComponent.isLocationComponentEnabled = true - } - - // Set render mode - locationComponent.renderMode = - if (followBearing) { - RenderMode.COMPASS - } else { - RenderMode.NORMAL - } - - // Set camera mode - locationComponent.cameraMode = - if (followBearing) { - org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS - } else { - org.maplibre.android.location.modes.CameraMode.TRACKING - } - } else { - // Disable location component to hide the blue dot - if (locationComponent.isLocationComponentEnabled) { - locationComponent.isLocationComponentEnabled = false - } - } - - Timber.tag("MapLibrePOC") - .d( - "Location component updated: enabled=%s, follow=%s", - isLocationTrackingEnabled, - followBearing, - ) - } - } catch (e: Exception) { - Timber.tag("MapLibrePOC").w(e, "Failed to update location component") - } - } - } - } - - Box(modifier = Modifier.fillMaxSize()) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { - MapLibre.getInstance(context) - Timber.tag("MapLibrePOC").d("Creating MapView + initializing MapLibre") - MapView(context).apply { - mapViewRef = this - getMapAsync { map -> - mapRef = map - Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") - // Set initial base raster style using MapLibre test-app pattern - map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> - Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) - style.setTransition(TransitionOptions(0, 0)) - logStyleState("after-style-load(pre-ensure)", style) - ensureSourcesAndLayers(style) - // Push current data immediately after style load - try { - val density = context.resources.displayMetrics.density - val bounds = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - .filter { n -> - val p = n.validPosition ?: return@filter false - bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - ) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - waypointsToFeatureCollectionFC(waypoints.values), - ) - // Set clustered source only (like MapLibre example) - val filteredNodes = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) - Timber.tag("MapLibrePOC") - .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source - Timber.tag("MapLibrePOC") - .d( - "Initial data set after style load. nodes=%d waypoints=%d", - nodes.size, - waypoints.size, - ) - logStyleState("after-style-load(post-sources)", style) - } catch (t: Throwable) { - Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") - } - // Keep base vector layers; OSM raster will sit below node layers for labels/roads - // Enable location component (if permissions granted) - activateLocationComponentForStyle(context, map, style) - Timber.tag("MapLibrePOC").d("Location component initialization attempted") - // Initial center on user's device location if available, else our node - if (!didInitialCenter) { - try { - val loc = map.locationComponent.lastKnownLocation - if (loc != null) { - val target = LatLng(loc.latitude, loc.longitude) - map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 12.0)) - didInitialCenter = true - } else { - ourNode?.validPosition?.let { p -> - map.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 12.0, - ), - ) - didInitialCenter = true - } - ?: run { - // Fallback: center to bounds of current nodes if available - val filtered = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - val boundsBuilder = org.maplibre.android.geometry.LatLngBounds.Builder() - var any = false - filtered.forEach { n -> - n.validPosition?.let { vp -> - boundsBuilder.include( - LatLng(vp.latitudeI * DEG_D, vp.longitudeI * DEG_D), - ) - any = true - } - } - if (any) { - val b = boundsBuilder.build() - map.animateCamera(CameraUpdateFactory.newLatLngBounds(b, 64)) - } else { - map.moveCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(0.0, 0.0), 2.5), - ) - } - didInitialCenter = true - } - } - } catch (_: Throwable) {} - } - map.addOnMapClickListener { latLng -> - // Any tap on the map clears overlays unless replaced below - expandedCluster = null - clusterListMembers = null - val screenPoint = map.projection.toScreenLocation(latLng) - // Use a small hitbox to improve taps on small circles - val r = (24 * context.resources.displayMetrics.density) - val rect = - android.graphics.RectF( - (screenPoint.x - r).toFloat(), - (screenPoint.y - r).toFloat(), - (screenPoint.x + r).toFloat(), - (screenPoint.y + r).toFloat(), - ) - val features = - map.queryRenderedFeatures( - rect, - CLUSTER_CIRCLE_LAYER_ID, - NODES_LAYER_ID, - NODES_LAYER_NOCLUSTER_ID, - WAYPOINTS_LAYER_ID, - ) - Timber.tag("MapLibrePOC") - .d( - "Map click at (%.5f, %.5f) -> %d features", - latLng.latitude, - latLng.longitude, - features.size, - ) - val f = features.firstOrNull() - // If cluster tapped, expand using true cluster leaves from the source - if (f != null && f.hasProperty("point_count")) { - val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 - val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) - val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - if (src != null) { - val fc = src.getClusterLeaves(f, limit, 0L) - val nums = - fc.features()?.mapNotNull { feat -> - try { - feat.getNumberProperty("num")?.toInt() - } catch (_: Throwable) { - null - } - } ?: emptyList() - val members = nodes.filter { nums.contains(it.num) } - if (members.isNotEmpty()) { - // Center the radial overlay on the actual cluster point (not the raw click) - val clusterCenter = - (f.geometry() as? Point)?.let { p -> - map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) - } ?: screenPoint - if (pointCount > CLUSTER_RADIAL_MAX) { - // Show list for large clusters - clusterListMembers = members - } else { - // Show radial overlay for small clusters - expandedCluster = - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) - } - } - return@addOnMapClickListener true - } else { - map.animateCamera(CameraUpdateFactory.zoomIn()) - return@addOnMapClickListener true - } - } - selectedInfo = - f?.let { - val kind = it.getStringProperty("kind") - when (kind) { - "node" -> { - val num = it.getNumberProperty("num")?.toInt() ?: -1 - val n = nodes.firstOrNull { node -> node.num == num } - selectedNodeNum = num - n?.let { node -> - "Node ${node.user.longName.ifBlank { - node.num.toString() - }} (${node.gpsString()})" - } ?: "Node $num" - } - "waypoint" -> { - val id = it.getNumberProperty("id")?.toInt() ?: -1 - // Open edit dialog for waypoint - waypoints.values - .find { pkt -> pkt.data.waypoint?.id == id } - ?.let { pkt -> editingWaypoint = pkt.data.waypoint } - "Waypoint: ${it.getStringProperty("name") ?: id}" - } - else -> null - } - } - true - } - // Long-press to create waypoint - map.addOnMapLongClickListener { latLng -> - if (isConnected) { - val newWaypoint = waypoint { - latitudeI = (latLng.latitude / DEG_D).toInt() - longitudeI = (latLng.longitude / DEG_D).toInt() - } - editingWaypoint = newWaypoint - Timber.tag("MapLibrePOC") - .d("Long press created waypoint at ${latLng.latitude}, ${latLng.longitude}") - } - true - } - // Update clustering visibility on camera idle (zoom changes) - map.addOnCameraIdleListener { - val st = map.style ?: return@addOnCameraIdleListener - // Debounce to avoid rapid toggling during kinetic flings/tiles loading - val now = SystemClock.uptimeMillis() - if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener - lastClusterEvalMs = now - val filtered = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - Timber.tag("MapLibrePOC") - .d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - filtered, - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - // Compute which nodes get labels in viewport and update source - val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - Timber.tag("MapLibrePOC") - .d( - "onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", - labelSet.size, - labelSet.take(5).joinToString(","), - jsonIdle.length, - ) - // Update both clustered and non-clustered sources - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) - safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) - logStyleState("onCameraIdle(post-update)", st) - try { - val w = mapViewRef?.width ?: 0 - val h = mapViewRef?.height ?: 0 - val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) - val rendered = - map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) - Timber.tag("MapLibrePOC") - .d("onCameraIdle: rendered features in viewport=%d", rendered.size) - } catch (_: Throwable) {} - } - // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions - map.addOnCameraMoveListener { - if (expandedCluster != null || clusterListMembers != null) { - expandedCluster = null - clusterListMembers = null - } - } - } - } - } - }, - update = { mapView: MapView -> - mapView.getMapAsync { map -> - val style = map.style - if (style == null) { - Timber.tag("MapLibrePOC").w("Style not yet available in update()") - return@getMapAsync - } - // Apply bearing render mode toggle - try { - map.locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL - } catch (_: Throwable) { - /* ignore */ - } - - // Handle location tracking state changes - if (isLocationTrackingEnabled && hasAnyLocationPermission(context)) { - try { - val locationComponent = map.locationComponent - if (!locationComponent.isLocationComponentEnabled) { - locationComponent.activateLocationComponent( - org.maplibre.android.location.LocationComponentActivationOptions.builder( - context, - map.style!!, - ) - .useDefaultLocationEngine(true) - .build(), - ) - locationComponent.isLocationComponentEnabled = true - } - locationComponent.renderMode = if (followBearing) RenderMode.COMPASS else RenderMode.NORMAL - locationComponent.cameraMode = - if (isLocationTrackingEnabled) { - if (followBearing) { - org.maplibre.android.location.modes.CameraMode.TRACKING_COMPASS - } else { - org.maplibre.android.location.modes.CameraMode.TRACKING - } - } else { - org.maplibre.android.location.modes.CameraMode.NONE - } - Timber.tag("MapLibrePOC") - .d( - "Location tracking: enabled=%s, follow=%s, mode=%s", - isLocationTrackingEnabled, - followBearing, - locationComponent.cameraMode, - ) - } catch (e: Exception) { - Timber.tag("MapLibrePOC").w(e, "Failed to update location component") - } - } - Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) - val density = context.resources.displayMetrics.density - val bounds2 = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = - nodes.filter { n -> - val p = n.validPosition ?: return@filter false - bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - waypointsToFeatureCollectionFC(waypoints.values), - ) - val filteredNow = - applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) - safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source - // Apply visibility now - clustersShown = - setClusterVisibilityHysteresis( - map, - style, - applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - logStyleState("update(block)", style) - } - }, - ) - - selectedInfo?.let { info -> - Surface( - modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth().padding(12.dp), - tonalElevation = 6.dp, - shadowElevation = 6.dp, - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text(text = info, style = MaterialTheme.typography.bodyMedium) - } - } - } - - // Role legend (based on roles present in current nodes) - val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } - if (showLegend && rolesPresent.isNotEmpty()) { - Surface( - modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), - tonalElevation = 4.dp, - shadowElevation = 4.dp, - ) { - Column(modifier = Modifier.padding(8.dp)) { - rolesPresent.take(6).forEach { role -> - val fakeNode = - Node( - num = 0, - user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), - ) - Row( - modifier = Modifier.padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = CircleShape, - color = roleColor(fakeNode), - modifier = Modifier.size(12.dp), - ) {} - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = role.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } - - // Map controls: recenter/follow and filter menu - var mapFilterExpanded by remember { mutableStateOf(false) } - Column( - modifier = - Modifier.align(Alignment.TopEnd) - .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button - verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), - ) { - // My Location button with visual feedback - FloatingActionButton( - onClick = { - if (hasLocationPermission) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followBearing = false - } - Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - }, - containerColor = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.MyLocation, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - // Compass button with visual feedback - FloatingActionButton( - onClick = { - if (isLocationTrackingEnabled) { - followBearing = !followBearing - Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) - } else { - // Enable tracking when compass is clicked - if (hasLocationPermission) { - isLocationTrackingEnabled = true - followBearing = true - Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - } - }, - containerColor = - if (followBearing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.Explore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (followBearing) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - Box { - MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) - DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { - DropdownMenuItem( - text = { Text("Only favorites") }, - onClick = { - mapViewModel.toggleOnlyFavorites() - mapFilterExpanded = false - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { - mapViewModel.toggleOnlyFavorites() - // Refresh both sources when filters change - mapRef?.style?.let { st -> - val filtered = - applyFilters( - nodes, - mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), - enabledRoles, - ) - (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), - ) - (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), - ) - } - }, - ) - }, - ) - DropdownMenuItem( - text = { Text("Show precision circle") }, - onClick = { - mapViewModel.toggleShowPrecisionCircleOnMap() - mapFilterExpanded = false - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - }, - ) - androidx.compose.material3.Divider() - Text( - text = "Roles", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - ) - val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } - roles.forEach { role -> - val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) - DropdownMenuItem( - text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, - onClick = { - enabledRoles = - if (enabledRoles.isEmpty()) { - setOf(role) - } else if (enabledRoles.contains(role)) { - enabledRoles - role - } else { - enabledRoles + role - } - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - trailingIcon = { - Checkbox( - checked = checked, - onCheckedChange = { - enabledRoles = - if (enabledRoles.isEmpty()) { - setOf(role) - } else if (enabledRoles.contains(role)) { - enabledRoles - role - } else { - enabledRoles + role - } - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - ) - }, - ) - } - androidx.compose.material3.Divider() - DropdownMenuItem( - text = { Text("Enable clustering") }, - onClick = { - clusteringEnabled = !clusteringEnabled - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - trailingIcon = { - Checkbox( - checked = clusteringEnabled, - onCheckedChange = { - clusteringEnabled = it - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - ) - }, - ) - } - } - MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) - // Map style selector - Box { - MapButton( - onClick = { mapTypeMenuExpanded = true }, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) - DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { - Text( - text = "Map Style", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - baseStyles.forEachIndexed { index, style -> - DropdownMenuItem( - text = { Text(style.label) }, - onClick = { - baseStyleIndex = index - usingCustomTiles = false - mapTypeMenuExpanded = false - val next = baseStyles[baseStyleIndex % baseStyles.size] - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) - map.setStyle(buildMeshtasticStyle(next)) { st -> - Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) - val density = context.resources.displayMetrics.density - clustersShown = - reinitializeStyleAfterSwitch( - context, - map, - st, - waypoints, - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - clusteringEnabled, - clustersShown, - density, - ) - } - } - }, - trailingIcon = { - if (index == baseStyleIndex && !usingCustomTiles) { - Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") - } - }, - ) - } - androidx.compose.material3.HorizontalDivider() - DropdownMenuItem( - text = { - Text( - if (customTileUrl.isEmpty()) { - "Custom Tile URL..." - } else { - "Custom: ${customTileUrl.take(30)}..." - }, - ) - }, - onClick = { - mapTypeMenuExpanded = false - customTileUrlInput = customTileUrl - showCustomTileDialog = true - }, - trailingIcon = { - if (usingCustomTiles && customTileUrl.isNotEmpty()) { - Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") - } - }, - ) - } - } - } - - // Custom tile URL dialog - if (showCustomTileDialog) { - AlertDialog( - onDismissRequest = { showCustomTileDialog = false }, - title = { Text("Custom Tile URL") }, - text = { - Column { - Text( - text = "Enter tile URL with {z}/{x}/{y} placeholders:", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - Text( - text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp), - ) - OutlinedTextField( - value = customTileUrlInput, - onValueChange = { customTileUrlInput = it }, - label = { Text("Tile URL") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { - customTileUrl = customTileUrlInput.trim() - if (customTileUrl.isNotEmpty()) { - usingCustomTiles = true - // Apply custom tiles (use first base style as template but we'll override the raster - // source) - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) - map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> - Timber.tag("MapLibrePOC").d("Custom tiles applied") - val density = context.resources.displayMetrics.density - clustersShown = - reinitializeStyleAfterSwitch( - context, - map, - st, - waypoints, - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - clusteringEnabled, - clustersShown, - density, - ) - } - } - } - showCustomTileDialog = false - }, - ) { - Text("Apply") - } - }, - dismissButton = { TextButton(onClick = { showCustomTileDialog = false }) { Text("Cancel") } }, - ) - } - - // Expanded cluster radial overlay - expandedCluster?.let { ec -> - val d = context.resources.displayMetrics.density - val centerX = (ec.centerPx.x / d).dp - val centerY = (ec.centerPx.y / d).dp - val radiusPx = 72f * d - val itemSize = 40.dp - val n = ec.members.size.coerceAtLeast(1) - ec.members.forEachIndexed { idx, node -> - val theta = (2.0 * Math.PI * idx / n) - val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() - val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() - val xDp = (x / d).dp - val yDp = (y / d).dp - val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) - val itemHeight = 36.dp - val itemWidth = (40 + label.length * 10).dp - Surface( - modifier = - Modifier.align(Alignment.TopStart) - .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) - .size(width = itemWidth, height = itemHeight) - .clickable { - selectedNodeNum = node.num - expandedCluster = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - shape = CircleShape, - color = roleColor(node), - shadowElevation = 6.dp, - ) { - Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } - } - } - } - - // Bottom sheet with node details and actions - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } - // Cluster list bottom sheet (for large clusters) - clusterListMembers?.let { members -> - ModalBottomSheet( - onDismissRequest = { clusterListMembers = null }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) - LazyColumn { - items(members) { node -> - Row( - modifier = - Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - verticalAlignment = Alignment.CenterVertically, - ) { - NodeChip( - node = node, - onClick = { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - ) - Spacer(modifier = Modifier.width(12.dp)) - val longName = node.user.longName - if (!longName.isNullOrBlank()) { - Text(text = longName, style = MaterialTheme.typography.bodyLarge) - } - } - } - } - } - } - } - if (selectedNode != null) { - ModalBottomSheet(onDismissRequest = { selectedNodeNum = null }, sheetState = sheetState) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - NodeChip(node = selectedNode) - val longName = selectedNode.user.longName - if (!longName.isNullOrBlank()) { - Text( - text = longName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } - val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) - val coords = selectedNode.gpsString() - Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) - Text(text = "Coordinates: $coords") - val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } - if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") - Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { - Button( - onClick = { - onNavigateToNodeDetails(selectedNode.num) - selectedNodeNum = null - }, - ) { - Text("View full node") - } - } - } - } - } - // Waypoint editing dialog - editingWaypoint?.let { waypointToEdit -> - EditWaypointDialog( - waypoint = waypointToEdit, - onSendClicked = { updatedWp -> - var finalWp = updatedWp - if (updatedWp.id == 0) { - finalWp = finalWp.copy { id = mapViewModel.generatePacketId() ?: 0 } - } - if (updatedWp.icon == 0) { - finalWp = finalWp.copy { icon = 0x1F4CD } - } - mapViewModel.sendWaypoint(finalWp) - editingWaypoint = null - }, - onDeleteClicked = { wpToDelete -> - if (wpToDelete.lockedTo == 0 && isConnected && wpToDelete.id != 0) { - val deleteMarkerWp = wpToDelete.copy { expire = 1 } - mapViewModel.sendWaypoint(deleteMarkerWp) - } - mapViewModel.deleteWaypoint(wpToDelete.id) - editingWaypoint = null - }, - onDismissRequest = { editingWaypoint = null }, - ) - } - } - - // Forward lifecycle events to MapView - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - // Note: AndroidView handles View lifecycle, but MapView benefits from explicit forwarding - when (event) { - Lifecycle.Event.ON_START -> {} - Lifecycle.Event.ON_RESUME -> {} - Lifecycle.Event.ON_PAUSE -> {} - Lifecycle.Event.ON_STOP -> {} - Lifecycle.Event.ON_DESTROY -> {} - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } -} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index 3cc210b19a..0092cac5ca 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -37,11 +37,18 @@ import org.maplibre.android.style.expressions.Expression.toColor import org.maplibre.android.style.expressions.Expression.toString import org.maplibre.android.style.expressions.Expression.zoom import org.maplibre.android.style.layers.CircleLayer +import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.PropertyFactory.circleColor import org.maplibre.android.style.layers.PropertyFactory.circleOpacity import org.maplibre.android.style.layers.PropertyFactory.circleRadius import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.fillColor +import org.maplibre.android.style.layers.PropertyFactory.fillOpacity +import org.maplibre.android.style.layers.PropertyFactory.lineColor +import org.maplibre.android.style.layers.PropertyFactory.lineOpacity +import org.maplibre.android.style.layers.PropertyFactory.lineWidth import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap import org.maplibre.android.style.layers.PropertyFactory.textAnchor import org.maplibre.android.style.layers.PropertyFactory.textColor @@ -57,6 +64,7 @@ import org.maplibre.android.style.layers.PropertyFactory.visibility import org.maplibre.android.style.layers.SymbolLayer import org.maplibre.android.style.sources.GeoJsonOptions import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.FeatureCollection import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID @@ -224,6 +232,92 @@ fun ensureSourcesAndLayers(style: Style) { logStyleState("ensureSourcesAndLayers(end)", style) } +/** Ensures a GeoJSON source and layers exist for an imported map layer */ +fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: String?, isVisible: Boolean) { + val sourceId = "imported-layer-source-$layerId" + val pointLayerId = "imported-layer-points-$layerId" + val lineLayerId = "imported-layer-lines-$layerId" + val fillLayerId = "imported-layer-fills-$layerId" + + try { + // Add or update source + val existingSource = style.getSource(sourceId) + if (existingSource == null) { + // Create new source + if (geoJson != null) { + style.addSource(GeoJsonSource(sourceId, geoJson)) + } else { + style.addSource(GeoJsonSource(sourceId, FeatureCollection.fromFeatures(emptyList()))) + } + } else if (geoJson != null && existingSource is GeoJsonSource) { + // Update existing source + existingSource.setGeoJson(geoJson) + } + + // Add point layer (CircleLayer for points) + if (style.getLayer(pointLayerId) == null) { + val pointLayer = CircleLayer(pointLayerId, sourceId) + pointLayer.setProperties( + circleColor("#3388ff"), + circleRadius(5f), + circleOpacity(0.8f), + circleStrokeColor("#ffffff"), + circleStrokeWidth(1f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(pointLayer, OSM_LAYER_ID) + } else { + style.getLayer(pointLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + + // Add line layer (LineLayer for LineStrings) + if (style.getLayer(lineLayerId) == null) { + val lineLayer = LineLayer(lineLayerId, sourceId) + lineLayer.setProperties( + lineColor("#3388ff"), + lineWidth(2f), + lineOpacity(0.8f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(lineLayer, OSM_LAYER_ID) + } else { + style.getLayer(lineLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + + // Add fill layer (FillLayer for Polygons) + if (style.getLayer(fillLayerId) == null) { + val fillLayer = FillLayer(fillLayerId, sourceId) + fillLayer.setProperties( + fillColor("#3388ff"), + fillOpacity(0.3f), + visibility(if (isVisible) "visible" else "none"), + ) + style.addLayerAbove(fillLayer, OSM_LAYER_ID) + } else { + style.getLayer(fillLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerManager").e(e, "Error ensuring imported layer source and layers for $layerId") + } +} + +/** Removes an imported layer's source and layers */ +fun removeImportedLayerSourceAndLayers(style: Style, layerId: String) { + val sourceId = "imported-layer-source-$layerId" + val pointLayerId = "imported-layer-points-$layerId" + val lineLayerId = "imported-layer-lines-$layerId" + val fillLayerId = "imported-layer-fills-$layerId" + + try { + style.getLayer(pointLayerId)?.let { style.removeLayer(it) } + style.getLayer(lineLayerId)?.let { style.removeLayer(it) } + style.getLayer(fillLayerId)?.let { style.removeLayer(it) } + style.getSource(sourceId)?.let { style.removeSource(it) } + } catch (e: Exception) { + Timber.tag("MapLibreLayerManager").e(e, "Error removing imported layer source and layers for $layerId") + } +} + /** Show/hide cluster layers vs plain nodes based on zoom, density, and toggle */ fun setClusterVisibilityHysteresis( map: MapLibreMap, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt index e4d9502a61..47b9075a9b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt @@ -66,6 +66,7 @@ fun MapLibreControlButtons( onFilterClick: () -> Unit, onLegendClick: () -> Unit, onStyleClick: () -> Unit, + onLayersClick: () -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier, horizontalAlignment = Alignment.End) { @@ -142,6 +143,10 @@ fun MapLibreControlButtons( Spacer(modifier = Modifier.size(8.dp)) MapButton(onClick = onStyleClick, icon = Icons.Outlined.Layers, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onLayersClick, icon = Icons.Outlined.Explore, contentDescription = null) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 83388b7dd4..4cd91f6381 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -22,6 +22,8 @@ package org.meshtastic.feature.map.maplibre.ui import android.annotation.SuppressLint import android.graphics.RectF import android.os.SystemClock +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,6 +79,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.maplibre.android.MapLibre import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng @@ -88,7 +92,10 @@ import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Point import org.meshtastic.core.database.model.Node import org.meshtastic.core.ui.component.NodeChip +import org.meshtastic.feature.map.LayerType +import org.meshtastic.feature.map.MapLayerItem import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.maplibre.BaseMapStyle @@ -104,17 +111,24 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle +import org.meshtastic.feature.map.maplibre.core.ensureImportedLayerSourceAndLayers import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.logStyleState import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection import org.meshtastic.feature.map.maplibre.core.reinitializeStyleAfterSwitch +import org.meshtastic.feature.map.maplibre.core.removeImportedLayerSourceAndLayers import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis import org.meshtastic.feature.map.maplibre.core.waypointsToFeatureCollectionFC import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.copyFileToInternalStorage +import org.meshtastic.feature.map.maplibre.utils.deleteFileFromInternalStorage import org.meshtastic.feature.map.maplibre.utils.distanceKmBetween import org.meshtastic.feature.map.maplibre.utils.formatSecondsAgo +import org.meshtastic.feature.map.maplibre.utils.getFileName import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission +import org.meshtastic.feature.map.maplibre.utils.loadLayerGeoJson +import org.meshtastic.feature.map.maplibre.utils.loadPersistedLayers import org.meshtastic.feature.map.maplibre.utils.protoShortName import org.meshtastic.feature.map.maplibre.utils.roleColor import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport @@ -163,14 +177,128 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe var clustersShown by remember { mutableStateOf(false) } var lastClusterEvalMs by remember { mutableStateOf(0L) } + // Map layer management + var mapLayers by remember { mutableStateOf>(emptyList()) } + var showLayersBottomSheet by remember { mutableStateOf(false) } + var layerGeoJsonCache by remember { mutableStateOf>(emptyMap()) } + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } + val coroutineScope = rememberCoroutineScope() // Check location permission hasLocationPermission = hasAnyLocationPermission(context) + // Load persisted map layers on startup + LaunchedEffect(Unit) { mapLayers = loadPersistedLayers(context) } + + // Helper functions for layer management + fun toggleLayerVisibility(layerId: String) { + mapLayers = + mapLayers.map { + if (it.id == layerId) { + it.copy(isVisible = !it.isVisible) + } else { + it + } + } + } + + fun removeLayer(layerId: String) { + coroutineScope.launch { + val layerToRemove = mapLayers.find { it.id == layerId } + layerToRemove?.uri?.let { uri -> deleteFileFromInternalStorage(uri) } + mapLayers = mapLayers.filterNot { it.id == layerId } + layerGeoJsonCache = layerGeoJsonCache - layerId + } + } + + // File picker launcher for adding map layers + val filePickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + result.data?.data?.let { uri -> + val fileName = uri.getFileName(context) + coroutineScope.launch { + val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.size + 1}" + val extension = + fileName?.substringAfterLast('.', "")?.lowercase() + ?: context.contentResolver.getType(uri)?.split('/')?.last() + val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz") + val geoJsonExtensions = listOf("geojson", "json") + val layerType = + when (extension) { + in kmlExtensions -> LayerType.KML + in geoJsonExtensions -> LayerType.GEOJSON + else -> null + } + if (layerType != null) { + val finalFileName = fileName ?: "layer_${java.util.UUID.randomUUID()}.$extension" + val localFileUri = copyFileToInternalStorage(context, uri, finalFileName) + if (localFileUri != null) { + val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) + mapLayers = mapLayers + newItem + } + } + } + } + } + } + + fun openFilePicker() { + val intent = + android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply { + type = "*/*" + putExtra( + android.content.Intent.EXTRA_MIME_TYPES, + arrayOf( + "application/vnd.google-earth.kml+xml", + "application/vnd.google-earth.kmz", + "application/geo+json", + "application/json", + ), + ) + } + filePickerLauncher.launch(intent) + } + + // Load and render imported map layers + LaunchedEffect(mapLayers, mapRef) { + mapRef?.let { map -> + map.style?.let { style -> + coroutineScope.launch { + // Load GeoJSON for layers that don't have it cached + mapLayers.forEach { layer -> + if (!layerGeoJsonCache.containsKey(layer.id)) { + val geoJson = loadLayerGeoJson(context, layer) + if (geoJson != null) { + layerGeoJsonCache = layerGeoJsonCache + (layer.id to geoJson) + } + } + } + + // Ensure all layers are rendered + mapLayers.forEach { layer -> + val geoJson = layerGeoJsonCache[layer.id] + ensureImportedLayerSourceAndLayers(style, layer.id, geoJson, layer.isVisible) + } + + // Remove layers that are no longer in the list + val currentLayerIds = mapLayers.map { it.id }.toSet() + val cachedLayerIds = layerGeoJsonCache.keys.toSet() + cachedLayerIds + .filter { it !in currentLayerIds } + .forEach { removedLayerId -> + removeImportedLayerSourceAndLayers(style, removedLayerId) + layerGeoJsonCache = layerGeoJsonCache - removedLayerId + } + } + } + } + } + // Apply location tracking settings when state changes LaunchedEffect(isLocationTrackingEnabled, followBearing, hasLocationPermission) { mapRef?.let { map -> @@ -938,6 +1066,17 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } } MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton( + onClick = { showLayersBottomSheet = true }, + icon = Icons.Outlined.Explore, + contentDescription = null, + ) + + Spacer(modifier = Modifier.size(8.dp)) + // Map style selector Box { MapButton( @@ -1126,6 +1265,24 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } } + // Layer management bottom sheet + if (showLayersBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showLayersBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + CustomMapLayersSheet( + mapLayers = mapLayers, + onToggleVisibility = ::toggleLayerVisibility, + onRemoveLayer = ::removeLayer, + onAddLayerClicked = { + showLayersBottomSheet = false + openFilePicker() + }, + ) + } + } + // Bottom sheet with node details and actions val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt new file mode 100644 index 0000000000..544986277c --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.utils + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.net.toFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.maplibre.geojson.Feature +import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString +import org.maplibre.geojson.Point +import org.maplibre.geojson.Polygon +import org.meshtastic.feature.map.LayerType +import org.meshtastic.feature.map.MapLayerItem +import org.w3c.dom.Element +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import javax.xml.parsers.DocumentBuilderFactory + +/** Loads persisted map layers from internal storage */ +suspend fun loadPersistedLayers(context: Context): List = withContext(Dispatchers.IO) { + try { + val layersDir = File(context.filesDir, "map_layers") + if (layersDir.exists() && layersDir.isDirectory) { + val persistedLayerFiles = layersDir.listFiles() + persistedLayerFiles?.mapNotNull { file -> + if (file.isFile) { + val layerType = + when (file.extension.lowercase()) { + "kml", + "kmz", + -> LayerType.KML + "geojson", + "json", + -> LayerType.GEOJSON + else -> null + } + layerType?.let { + val uri = Uri.fromFile(file) + MapLayerItem(name = file.nameWithoutExtension, uri = uri, isVisible = true, layerType = it) + } + } else { + null + } + } ?: emptyList() + } else { + emptyList() + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error loading persisted map layers") + emptyList() + } +} + +/** Copies a file from URI to internal storage */ +suspend fun copyFileToInternalStorage(context: Context, uri: Uri, fileName: String): Uri? = + withContext(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + val directory = File(context.filesDir, "map_layers") + if (!directory.exists()) { + directory.mkdirs() + } + val outputFile = File(directory, fileName) + val outputStream = FileOutputStream(outputFile) + inputStream?.use { input -> outputStream.use { output -> input.copyTo(output) } } + Uri.fromFile(outputFile) + } catch (e: IOException) { + Timber.tag("MapLibreLayerUtils").e(e, "Error copying file to internal storage") + null + } + } + +/** Deletes a file from internal storage */ +suspend fun deleteFileFromInternalStorage(uri: Uri) = withContext(Dispatchers.IO) { + try { + val file = uri.toFile() + if (file.exists()) { + file.delete() + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error deleting file from internal storage") + } +} + +/** Gets InputStream from URI */ +@Suppress("Recycle") +suspend fun getInputStreamFromUri(context: Context, uri: Uri): InputStream? = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri) + } catch (_: Exception) { + Timber.d("MapLibreLayerUtils: Error opening InputStream from URI: $uri") + null + } +} + +/** Converts KML content to GeoJSON string */ +suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { + try { + val uri = layerItem.uri ?: return@withContext null + val inputStream = getInputStreamFromUri(context, uri) ?: return@withContext null + + inputStream.use { stream -> + // Handle KMZ (ZIP) files + val content = + if (layerItem.layerType == LayerType.KML && uri.toString().endsWith(".kmz", ignoreCase = true)) { + // TODO: Extract KML from KMZ ZIP file + // For now, return empty GeoJSON + Timber.tag("MapLibreLayerUtils").w("KMZ files not yet fully supported") + return@withContext """{"type":"FeatureCollection","features":[]}""" + } else { + stream.bufferedReader().use { it.readText() } + } + + parseKmlToGeoJson(content) + } + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error converting KML to GeoJSON") + null + } +} + +/** Parses KML XML and converts to GeoJSON */ +private fun parseKmlToGeoJson(kmlContent: String): String { + try { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val builder = factory.newDocumentBuilder() + val doc = builder.parse(kmlContent.byteInputStream()) + + val features = mutableListOf() + + // Parse Placemarks (points, lines, polygons) + val placemarks = doc.getElementsByTagName("Placemark") + for (i in 0 until placemarks.length) { + val placemark = placemarks.item(i) as? Element ?: continue + val name = placemark.getElementsByTagName("name").item(0)?.textContent ?: "" + + // Try Point + val point = placemark.getElementsByTagName("Point").item(0) as? Element + point?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val parts = coordStr.split(",") + if (parts.size >= 2) { + val lon = parts[0].toDoubleOrNull() ?: return@let + val lat = parts[1].toDoubleOrNull() ?: return@let + val point = Point.fromLngLat(lon, lat) + val feature = Feature.fromGeometry(point) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + + // Try LineString + val lineString = placemark.getElementsByTagName("LineString").item(0) as? Element + lineString?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val points = parseCoordinates(coordStr) + if (points.size >= 2) { + val lineString = LineString.fromLngLats(points) + val feature = Feature.fromGeometry(lineString) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + + // Try Polygon + val polygon = placemark.getElementsByTagName("Polygon").item(0) as? Element + polygon?.let { + val outerBoundary = it.getElementsByTagName("outerBoundaryIs").item(0) as? Element + outerBoundary?.let { + val linearRing = it.getElementsByTagName("LinearRing").item(0) as? Element + linearRing?.let { + val coordinates = it.getElementsByTagName("coordinates").item(0)?.textContent?.trim() + coordinates?.let { coordStr -> + val points = parseCoordinates(coordStr) + if (points.size >= 3) { + // Close the polygon + val closedPoints = points + points[0] + val polygon = Polygon.fromLngLats(listOf(closedPoints)) + val feature = Feature.fromGeometry(polygon) + feature.addStringProperty("name", name) + features.add(feature) + } + } + } + } + } + } + + val featureCollection = FeatureCollection.fromFeatures(features) + return featureCollection.toJson() + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error parsing KML") + return """{"type":"FeatureCollection","features":[]}""" + } +} + +/** Parses coordinate string into list of Points */ +private fun parseCoordinates(coordStr: String): List = coordStr.split(" ").mapNotNull { line -> + val parts = line.trim().split(",") + if (parts.size >= 2) { + val lon = parts[0].toDoubleOrNull() + val lat = parts[1].toDoubleOrNull() + if (lon != null && lat != null) { + Point.fromLngLat(lon, lat) + } else { + null + } + } else { + null + } +} + +/** Loads GeoJSON from a layer item (converting KML if needed) */ +suspend fun loadLayerGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { + when (layerItem.layerType) { + LayerType.KML -> convertKmlToGeoJson(context, layerItem) + LayerType.GEOJSON -> { + val uri = layerItem.uri ?: return@withContext null + getInputStreamFromUri(context, uri)?.use { stream -> stream.bufferedReader().use { it.readText() } } + } + } +} + +/** Extension function to get file name from URI */ +fun Uri.getFileName(context: Context): String? { + var name = this.lastPathSegment + if (this.scheme == "content") { + context.contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + name = cursor.getString(nameIndex) + } + } + } + } + return name +} diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index dbf9b2df78..c93e719027 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -464,7 +464,7 @@ fun MapView( mapViewModel.loadMapLayerIfNeeded(map, layerItem) when (layerItem.layerType) { LayerType.KML -> { - layerItem.kmlLayerData?.let { kmlLayer -> + mapViewModel.getKmlLayer(layerItem.id)?.let { kmlLayer -> if (layerItem.isVisible && !kmlLayer.isLayerOnMap) { kmlLayer.addLayerToMap() } else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) { @@ -474,7 +474,7 @@ fun MapView( } LayerType.GEOJSON -> { - layerItem.geoJsonLayerData?.let { geoJsonLayer -> + mapViewModel.getGeoJsonLayer(layerItem.id)?.let { geoJsonLayer -> if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) { geoJsonLayer.addLayerToMap() } else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) { diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt index f08a2c0d97..b064848188 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -251,6 +250,9 @@ constructor( private val _mapLayers = MutableStateFlow>(emptyList()) val mapLayers: StateFlow> = _mapLayers.asStateFlow() + // Store Google Maps specific layer data separately + private val layerDataMap = mutableMapOf>() + init { viewModelScope.launch { customTileProviderRepository.getCustomTileProviders().first() @@ -432,11 +434,13 @@ constructor( fun removeMapLayer(layerId: String) { viewModelScope.launch { val layerToRemove = _mapLayers.value.find { it.id == layerId } + val layerData = layerDataMap[layerId] when (layerToRemove?.layerType) { - LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap() - LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap() + LayerType.KML -> layerData?.first?.removeLayerFromMap() + LayerType.GEOJSON -> layerData?.second?.removeLayerFromMap() null -> {} } + layerDataMap.remove(layerId) layerToRemove?.uri?.let { uri -> deleteFileToInternalStorage(uri) googleMapsPrefs.hiddenLayerUrls -= uri.toString() @@ -471,8 +475,12 @@ constructor( } } + fun getKmlLayer(layerId: String): KmlLayer? = layerDataMap[layerId]?.first + + fun getGeoJsonLayer(layerId: String): GeoJsonLayer? = layerDataMap[layerId]?.second + suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) { - if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return + if (layerDataMap[layerItem.id] != null) return try { when (layerItem.layerType) { LayerType.KML -> loadKmlLayerIfNeeded(layerItem, map) @@ -491,15 +499,8 @@ constructor( if (!layerItem.isVisible) removeLayerFromMap() } } - _mapLayers.update { currentLayers -> - currentLayers.map { - if (it.id == layerItem.id) { - it.copy(kmlLayerData = kmlLayer) - } else { - it - } - } - } + val current = layerDataMap[layerItem.id] ?: Pair(null, null) + layerDataMap[layerItem.id] = Pair(kmlLayer, current.second) } private suspend fun loadGeoJsonLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) { @@ -508,35 +509,11 @@ constructor( val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() }) GeoJsonLayer(map, jsonObject).apply { if (!layerItem.isVisible) removeLayerFromMap() } } - _mapLayers.update { currentLayers -> - currentLayers.map { - if (it.id == layerItem.id) { - it.copy(geoJsonLayerData = geoJsonLayer) - } else { - it - } - } - } + val current = layerDataMap[layerItem.id] ?: Pair(null, null) + layerDataMap[layerItem.id] = Pair(current.first, geoJsonLayer) } fun clearLoadedLayerData() { - _mapLayers.update { currentLayers -> - currentLayers.map { it.copy(kmlLayerData = null, geoJsonLayerData = null) } - } + layerDataMap.clear() } } - -enum class LayerType { - KML, - GEOJSON, -} - -data class MapLayerItem( - val id: String = UUID.randomUUID().toString(), - val name: String, - val uri: Uri? = null, - var isVisible: Boolean = true, - var kmlLayerData: KmlLayer? = null, - var geoJsonLayerData: GeoJsonLayer? = null, - val layerType: LayerType, -) diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt new file mode 100644 index 0000000000..23388e3510 --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 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.feature.map + +import android.net.Uri +import java.util.UUID + +enum class LayerType { + KML, + GEOJSON, +} + +data class MapLayerItem( + val id: String = UUID.randomUUID().toString(), + val name: String, + val uri: Uri? = null, + var isVisible: Boolean = true, + val layerType: LayerType, +) From 3b37f9e77a96028d74292c4701eba793b7080f8b Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 18 Nov 2025 16:00:01 -0800 Subject: [PATCH 12/62] initial kml, geojson, kmz, caching support, match button style of google maps --- .../org/meshtastic/feature/map/MapView.kt | 827 ++++++++++-------- .../feature/map/component/MapButton.kt | 21 +- .../map/component/TileCacheManagementSheet.kt | 249 ++++++ .../map/maplibre/ui/MapLibreControlButtons.kt | 91 +- .../feature/map/maplibre/ui/MapLibrePOC.kt | 293 ++++--- .../map/maplibre/utils/MapLibreLayerUtils.kt | 38 +- .../utils/MapLibreTileCacheManager.kt | 598 +++++++++++++ 7 files changed, 1575 insertions(+), 542 deletions(-) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 281fe10d00..58299cb9a7 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -32,6 +32,8 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lens @@ -211,6 +213,163 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int) } } +// Dialog helper functions - defined before MapView so they can be called from within it +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MapsDialog( + title: String? = null, + onDismiss: () -> Unit, + positiveButton: (@Composable () -> Unit)? = null, + negativeButton: (@Composable () -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.wrapContentWidth().wrapContentHeight(), + shape = MaterialTheme.shapes.large, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column { + title?.let { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), + text = it, + style = MaterialTheme.typography.titleLarge, + ) + } + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } + if (positiveButton != null || negativeButton != null) { + Row(Modifier.align(Alignment.End)) { + positiveButton?.invoke() + negativeButton?.invoke() + } + } + } + } + } +} + +private enum class CacheManagerOption(val label: StringResource) { + CurrentCacheSize(label = Res.string.map_cache_size), + DownloadRegion(label = Res.string.map_download_region), + ClearTiles(label = Res.string.map_clear_tiles), + Cancel(label = Res.string.cancel), +} + +@Composable +private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { + val selected = remember { mutableStateOf(selectedMapStyle) } + + MapsDialog(onDismiss = onDismiss) { + CustomTileSource.mTileSources.values.forEachIndexed { index, style -> + ListItem( + text = style, + trailingIcon = if (index == selected.value) Icons.Rounded.Check else null, + onClick = { + selected.value = index + onSelectMapStyle(index) + onDismiss() + }, + ) + } + } +} + +@Composable +private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { + MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { + CacheManagerOption.entries.forEach { option -> + ListItem(text = stringResource(option.label), trailingIcon = null) { + onClickOption(option) + onDismiss() + } + } + } +} + +@Composable +private fun CacheInfoDialog(mapView: org.osmdroid.views.MapView, onDismiss: () -> Unit) { + val (cacheCapacity, currentCacheUsage) = + remember(mapView) { + val cacheManager = CacheManager(mapView) + cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() + } + + MapsDialog( + title = stringResource(Res.string.map_cache_manager), + onDismiss = onDismiss, + negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, + ) { + Text( + modifier = Modifier.padding(16.dp), + text = + stringResource( + Res.string.map_cache_info, + cacheCapacity / (1024.0 * 1024.0), + currentCacheUsage / (1024.0 * 1024.0), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val cache = SqlTileWriterExt() + + val sourceList by derivedStateOf { cache.sources.map { it.source as String } } + + val selected = remember { mutableStateListOf() } + + MapsDialog( + title = stringResource(Res.string.map_tile_source), + positiveButton = { + TextButton( + enabled = selected.isNotEmpty(), + onClick = { + selected.forEach { selectedIndex -> + val source = sourceList[selectedIndex] + scope.launch { + context.showToast( + if (cache.purgeCache(source)) { + getString(Res.string.map_purge_success, source) + } else { + getString(Res.string.map_purge_fail) + }, + ) + } + } + + onDismiss() + }, + ) { + Text(text = stringResource(Res.string.clear)) + } + }, + negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, + onDismiss = onDismiss, + ) { + sourceList.forEachIndexed { index, source -> + val isSelected = selected.contains(index) + BasicListItem( + text = source, + trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, + onClick = { + if (isSelected) { + selected.remove(index) + } else { + selected.add(index) + } + }, + ) {} + } + } +} + /** * Main composable for displaying the map view, including nodes, waypoints, and user location. It handles user * interactions for map manipulation, filtering, and offline caching. @@ -241,7 +400,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: var showCurrentCacheInfo by remember { mutableStateOf(false) } var showPurgeTileSourceDialog by remember { mutableStateOf(false) } var showMapStyleDialog by remember { mutableStateOf(false) } - var showMapLibrePOC by remember { mutableStateOf(false) } + // Map engine selection: false = osmdroid, true = MapLibre + var useMapLibre by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -262,7 +422,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: Timber.d("mapStyleId from prefs: $id") return CustomTileSource.getTileSource(id).also { zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false + showDownloadButton = + if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false } } @@ -272,12 +433,17 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val geoPoints = nodesWithPosition.map { GeoPoint(it.latitude, it.longitude) } BoundingBox.fromGeoPoints(geoPoints) } + // Only create osmdroid map if not using MapLibre val map = - rememberMapViewWithLifecycle( - applicationId = mapViewModel.applicationId, - box = initialCameraView, - tileSource = loadOnlineTileSourceBase(), - ) + if (!useMapLibre) { + rememberMapViewWithLifecycle( + applicationId = mapViewModel.applicationId, + box = initialCameraView, + tileSource = loadOnlineTileSourceBase(), + ) + } else { + null + } val nodeClusterer = remember { RadiusMarkerClusterer(context) } @@ -293,12 +459,18 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: MyLocationNewOverlay(this).apply { enableMyLocation() enableFollowLocation() - getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_location_dot_24) + getBitmapFromVectorDrawable( + context, + org.meshtastic.core.ui.R.drawable.ic_map_location_dot_24 + ) ?.let { setPersonIcon(it) setPersonAnchor(0.5f, 0.5f) } - getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_navigation_24)?.let { + getBitmapFromVectorDrawable( + context, + org.meshtastic.core.ui.R.drawable.ic_map_navigation_24 + )?.let { setDirectionIcon(it) setDirectionAnchor(0.5f, 0.5f) } @@ -316,8 +488,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: // Effect to toggle MyLocation after permission is granted LaunchedEffect(locationPermissionsState.allPermissionsGranted) { - if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { - map.toggleMyLocation() + if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission && !useMapLibre) { + map?.toggleMyLocation() triggerLocationToggleAfterPermission = false } } @@ -326,14 +498,18 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val markerIcon = remember { - AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24) + AppCompatResources.getDrawable( + context, + org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24 + ) } fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value val displayUnits = mapViewModel.config.display.units - val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly + val mapFilterStateValue = + mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { return@mapNotNull null @@ -463,7 +639,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: MarkerWithLabel(this, label, emoji).apply { id = "${pt.id}" title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" - snippet = "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr" + snippet = + "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr" position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) setVisible(false) // This seems to be always false, was this intended? setOnLongClickListener { @@ -513,7 +690,16 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: invalidate() } - with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } + // Only update osmdroid markers when using osmdroid + if (!useMapLibre && map != null) { + with(map) { + UpdateMarkers( + onNodesChanged(nodes), + onWaypointChanged(waypoints.values), + nodeClusterer + ) + } + } fun MapView.generateBoxOverlay() { overlays.removeAll { it is Polygon } @@ -522,13 +708,18 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) val polygon = Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } + points = Polygon.pointsAsRect(downloadRegionBoundingBox) + .map { GeoPoint(it.latitude, it.longitude) } } overlays.add(polygon) invalidate() val tileCount: Int = CacheManager(this) - .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) + .possibleTilesInArea( + downloadRegionBoundingBox, + zoomLevelMin.toInt(), + zoomLevelMax.toInt() + ) cacheEstimate = com.meshtastic.core.strings.getString(Res.string.map_cache_tiles, tileCount) } @@ -546,6 +737,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: fun startDownload() { val boundingBox = downloadRegionBoundingBox ?: return + val osmdroidMap = map ?: return try { val outputName = buildString { append(Configuration.getInstance().osmdroidBasePath.absolutePath) @@ -553,7 +745,7 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: append("mainFile.sqlite") } val writer = SqliteArchiveTileWriter(outputName) - val cacheManager = CacheManager(map, writer) + val cacheManager = CacheManager(osmdroidMap, writer) cacheManager.downloadAreaAsync( context, boundingBox, @@ -579,398 +771,275 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: Scaffold( floatingActionButton = { - DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } + // Only show download button for osmdroid + if (!useMapLibre) { + DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { + showCacheManagerDialog = true + } + } }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - AndroidView( - factory = { - map.apply { - setDestroyMode(false) - addMapListener(boxOverlayListener) - } - }, - modifier = Modifier.fillMaxSize(), - update = { mapView -> mapView.drawOverlays() }, // Renamed map to mapView to avoid conflict - ) - if (downloadRegionBoundingBox != null) { - CacheLayout( - cacheEstimate = cacheEstimate, - onExecuteJob = { startDownload() }, - onCancelDownload = { - downloadRegionBoundingBox = null - map.overlays.removeAll { it is Polygon } - map.invalidate() - }, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - Column( - modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - MapButton( - onClick = { showMapStyleDialog = true }, - icon = Icons.Outlined.Layers, - contentDescription = Res.string.map_style_selection, + Crossfade( + targetState = useMapLibre, + animationSpec = tween(durationMillis = 300), + label = "MapEngineSwitch", + ) { isMapLibre -> + if (isMapLibre) { + // MapLibre implementation + org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( + mapViewModel = mapViewModel, + onNavigateToNodeDetails = navigateToNodeDetails, ) - MapButton( - onClick = { showMapLibrePOC = true }, - icon = Icons.Outlined.Layers, - contentDescription = Res.string.map_style_selection, + } else { + // osmdroid implementation + map?.let { osmdroidMap -> + AndroidView( + factory = { + osmdroidMap.apply { + setDestroyMode(false) + addMapListener(boxOverlayListener) + } + }, + modifier = Modifier.fillMaxSize(), + update = { mapView -> mapView.drawOverlays() }, + ) + } ?: Box(modifier = Modifier.fillMaxSize()) + } + } + // Only show osmdroid-specific UI when using osmdroid + if (!useMapLibre) { + if (downloadRegionBoundingBox != null) { + CacheLayout( + cacheEstimate = cacheEstimate, + onExecuteJob = { startDownload() }, + onCancelDownload = { + downloadRegionBoundingBox = null + map?.overlays?.removeAll { it is Polygon } + map?.invalidate() + }, + modifier = Modifier.align(Alignment.BottomCenter), ) - Box(modifier = Modifier) { + } else { + Column( + modifier = Modifier.padding(top = 16.dp, end = 16.dp) + .align(Alignment.TopEnd), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { MapButton( - onClick = { mapFilterExpanded = true }, - icon = Icons.Outlined.Tune, - contentDescription = Res.string.map_filter, + onClick = { showMapStyleDialog = true }, + icon = Icons.Outlined.Layers, + contentDescription = Res.string.map_style_selection, ) - DropdownMenu( - expanded = mapFilterExpanded, - onDismissRequest = { mapFilterExpanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surface), - ) { - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.only_favorites), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleOnlyFavorites() }, - ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.PinDrop, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_waypoints), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.showWaypoints, - onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) - } - }, - onClick = { mapViewModel.toggleShowWaypointsOnMap() }, + MapButton( + onClick = { useMapLibre = true }, + icon = Icons.Outlined.Layers, + contentDescription = "Switch to MapLibre", + ) + Box(modifier = Modifier) { + MapButton( + onClick = { mapFilterExpanded = true }, + icon = Icons.Outlined.Tune, + contentDescription = Res.string.map_filter, ) - DropdownMenuItem( - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Lens, - contentDescription = null, - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = stringResource(Res.string.show_precision_circle), - modifier = Modifier.weight(1f), - ) - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - modifier = Modifier.padding(start = 8.dp), - ) + DropdownMenu( + expanded = mapFilterExpanded, + onDismissRequest = { mapFilterExpanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Star, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.only_favorites), + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { mapViewModel.toggleOnlyFavorites() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleOnlyFavorites() }, + ) + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PinDrop, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.show_waypoints), + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = mapFilterState.showWaypoints, + onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowWaypointsOnMap() }, + ) + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Lens, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.show_precision_circle), + modifier = Modifier.weight(1f), + ) + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + ) + } + } + if (hasGps) { + MapButton( + icon = + if (myLocationOverlay == null) { + Icons.Outlined.MyLocation + } else { + Icons.Default.LocationDisabled + }, + contentDescription = stringResource(Res.string.toggle_my_position), + onClick = { + if (locationPermissionsState.allPermissionsGranted) { + map?.toggleMyLocation() + } else { + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() } }, - onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() }, ) } } - if (hasGps) { - MapButton( - icon = - if (myLocationOverlay == null) { - Icons.Outlined.MyLocation - } else { - Icons.Default.LocationDisabled - }, - contentDescription = stringResource(Res.string.toggle_my_position), - ) { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } - } - } } } } - } - - if (showMapStyleDialog) { - MapStyleDialog( - selectedMapStyle = mapViewModel.mapStyleId, - onDismiss = { showMapStyleDialog = false }, - onSelectMapStyle = { - mapViewModel.mapStyleId = it - map.setTileSource(loadOnlineTileSourceBase()) - }, - ) - } - - if (showCacheManagerDialog) { - CacheManagerDialog( - onClickOption = { option -> - when (option) { - CacheManagerOption.CurrentCacheSize -> { - scope.launch { context.showToast(Res.string.calculating) } - showCurrentCacheInfo = true - } - CacheManagerOption.DownloadRegion -> map.generateBoxOverlay() - - CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true - CacheManagerOption.Cancel -> Unit - } - showCacheManagerDialog = false - }, - onDismiss = { showCacheManagerDialog = false }, - ) - } - - if (showCurrentCacheInfo) { - CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) - } - - if (showPurgeTileSourceDialog) { - PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) - } - if (showMapLibrePOC) { - androidx.compose.ui.window.Dialog( - onDismissRequest = { showMapLibrePOC = false }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box(modifier = Modifier.fillMaxSize()) { - org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( - onNavigateToNodeDetails = { num -> - navigateToNodeDetails(num) - showMapLibrePOC = false + // Only show osmdroid-specific dialogs when using osmdroid + if (!useMapLibre) { + if (showMapStyleDialog) { + MapStyleDialog( + selectedMapStyle = mapViewModel.mapStyleId, + onDismiss = { showMapStyleDialog = false }, + onSelectMapStyle = { styleId: Int -> + mapViewModel.mapStyleId = styleId + map?.setTileSource(loadOnlineTileSourceBase()) }, ) - IconButton( - modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), - onClick = { showMapLibrePOC = false }, - ) { - Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(Res.string.close)) - } } - } - } - - if (showEditWaypointDialog != null) { - EditWaypointDialog( - waypoint = showEditWaypointDialog ?: return, // Safe call - onSendClicked = { waypoint -> - Timber.d("User clicked send waypoint ${waypoint.id}") - showEditWaypointDialog = null - mapViewModel.sendWaypoint( - waypoint.copy { - if (id == 0) id = mapViewModel.generatePacketId() ?: return@EditWaypointDialog - if (name == "") name = "Dropped Pin" - if (expire == 0) expire = Int.MAX_VALUE - lockedTo = if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 - if (waypoint.icon == 0) icon = 128205 - }, - ) - }, - onDeleteClicked = { waypoint -> - Timber.d("User clicked delete waypoint ${waypoint.id}") - showEditWaypointDialog = null - showDeleteMarkerDialog(waypoint) - }, - onDismissRequest = { - Timber.d("User clicked cancel marker edit dialog") - showEditWaypointDialog = null - }, - ) - } -} - -@Composable -private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) { - val selected = remember { mutableStateOf(selectedMapStyle) } - MapsDialog(onDismiss = onDismiss) { - CustomTileSource.mTileSources.values.forEachIndexed { index, style -> - ListItem( - text = style, - trailingIcon = if (index == selected.value) Icons.Rounded.Check else null, - onClick = { - selected.value = index - onSelectMapStyle(index) - onDismiss() - }, - ) - } - } -} + if (showCacheManagerDialog) { + CacheManagerDialog( + onClickOption = { option: CacheManagerOption -> + when (option) { + CacheManagerOption.CurrentCacheSize -> { + scope.launch { context.showToast(Res.string.calculating) } + showCurrentCacheInfo = true + } -private enum class CacheManagerOption(val label: StringResource) { - CurrentCacheSize(label = Res.string.map_cache_size), - DownloadRegion(label = Res.string.map_download_region), - ClearTiles(label = Res.string.map_clear_tiles), - Cancel(label = Res.string.cancel), -} + CacheManagerOption.DownloadRegion -> map?.generateBoxOverlay() -@Composable -private fun CacheManagerDialog(onClickOption: (CacheManagerOption) -> Unit, onDismiss: () -> Unit) { - MapsDialog(title = stringResource(Res.string.map_offline_manager), onDismiss = onDismiss) { - CacheManagerOption.entries.forEach { option -> - ListItem(text = stringResource(option.label), trailingIcon = null) { - onClickOption(option) - onDismiss() + CacheManagerOption.ClearTiles -> showPurgeTileSourceDialog = true + CacheManagerOption.Cancel -> Unit + } + showCacheManagerDialog = false + }, + onDismiss = { showCacheManagerDialog = false }, + ) } - } - } -} -@Composable -private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) { - val (cacheCapacity, currentCacheUsage) = - remember(mapView) { - val cacheManager = CacheManager(mapView) - cacheManager.cacheCapacity() to cacheManager.currentCacheUsage() - } - - MapsDialog( - title = stringResource(Res.string.map_cache_manager), - onDismiss = onDismiss, - negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } }, - ) { - Text( - modifier = Modifier.padding(16.dp), - text = - stringResource( - Res.string.map_cache_info, - cacheCapacity / (1024.0 * 1024.0), - currentCacheUsage / (1024.0 * 1024.0), - ), - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { - val scope = rememberCoroutineScope() - val context = LocalContext.current - val cache = SqlTileWriterExt() - - val sourceList by derivedStateOf { cache.sources.map { it.source as String } } - - val selected = remember { mutableStateListOf() } - - MapsDialog( - title = stringResource(Res.string.map_tile_source), - positiveButton = { - TextButton( - enabled = selected.isNotEmpty(), - onClick = { - selected.forEach { selectedIndex -> - val source = sourceList[selectedIndex] - scope.launch { - context.showToast( - if (cache.purgeCache(source)) { - getString(Res.string.map_purge_success, source) - } else { - getString(Res.string.map_purge_fail) - }, - ) - } - } + if (showCurrentCacheInfo && map != null) { + CacheInfoDialog(mapView = map, onDismiss = { showCurrentCacheInfo = false }) + } - onDismiss() - }, - ) { - Text(text = stringResource(Res.string.clear)) + if (showPurgeTileSourceDialog) { + PurgeTileSourceDialog(onDismiss = { showPurgeTileSourceDialog = false }) } - }, - negativeButton = { TextButton(onClick = onDismiss) { Text(text = stringResource(Res.string.cancel)) } }, - onDismiss = onDismiss, - ) { - sourceList.forEachIndexed { index, source -> - val isSelected = selected.contains(index) - BasicListItem( - text = source, - trailingContent = { Checkbox(checked = isSelected, onCheckedChange = {}) }, - onClick = { - if (isSelected) { - selected.remove(index) - } else { - selected.add(index) - } - }, - ) {} } - } -} -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun MapsDialog( - title: String? = null, - onDismiss: () -> Unit, - positiveButton: (@Composable () -> Unit)? = null, - negativeButton: (@Composable () -> Unit)? = null, - content: @Composable ColumnScope.() -> Unit, -) { - BasicAlertDialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.wrapContentWidth().wrapContentHeight(), - shape = MaterialTheme.shapes.large, - color = AlertDialogDefaults.containerColor, - tonalElevation = AlertDialogDefaults.TonalElevation, - ) { - Column { - title?.let { - Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp), - text = it, - style = MaterialTheme.typography.titleLarge, + // Add a button to switch back to osmdroid when using MapLibre + // Note: This is placed outside the Crossfade so it's always accessible + if (useMapLibre) { + Box(modifier = Modifier.fillMaxSize()) { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp), + onClick = { + useMapLibre = false + // Cleanup: osmdroid map will be recreated when switching back + }, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Switch back to osmdroid", + tint = MaterialTheme.colorScheme.onSurface, ) } - - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { content() } - if (positiveButton != null || negativeButton != null) { - Row(Modifier.align(Alignment.End)) { - positiveButton?.invoke() - negativeButton?.invoke() - } - } } } + + showEditWaypointDialog?.let { waypoint -> + EditWaypointDialog( + waypoint = waypoint, + onSendClicked = { waypoint -> + Timber.d("User clicked send waypoint ${waypoint.id}") + showEditWaypointDialog = null + mapViewModel.sendWaypoint( + waypoint.copy { + if (id == 0) id = + mapViewModel.generatePacketId() ?: return@EditWaypointDialog + if (name == "") name = "Dropped Pin" + if (expire == 0) expire = Int.MAX_VALUE + lockedTo = + if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 + if (waypoint.icon == 0) icon = 128205 + }, + ) + }, + onDeleteClicked = { waypoint -> + Timber.d("User clicked delete waypoint ${waypoint.id}") + showEditWaypointDialog = null + showDeleteMarkerDialog(waypoint) + }, + onDismissRequest = { + Timber.d("User clicked cancel marker edit dialog") + showEditWaypointDialog = null + }, + ) + } } -} +} \ No newline at end of file diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 5ced0960df..33d26133ed 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -20,10 +20,12 @@ package org.meshtastic.feature.map.component import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Layers -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -49,9 +51,20 @@ fun MapButton( } @Composable -fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { - FloatingActionButton(onClick = onClick, modifier = modifier) { - Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp)) +fun MapButton( + icon: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + iconTint: Color? = null, +) { + FilledIconButton(onClick = onClick, modifier = modifier) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor, + ) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt new file mode 100644 index 0000000000..3a2bea2fab --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 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.feature.map.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.maplibre.android.geometry.LatLngBounds +import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager +import timber.log.Timber +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Suppress("LongMethod") +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun TileCacheManagementSheet( + cacheManager: MapLibreTileCacheManager, + currentBounds: LatLngBounds?, + currentZoom: Double?, + styleUrl: String?, + onDismiss: () -> Unit, +) { + var hotAreas by remember { mutableStateOf>(emptyList()) } + var cacheSizeBytes by remember { mutableStateOf(null) } + var lastUpdateTime by remember { mutableStateOf(null) } + var isCaching by remember { mutableStateOf(false) } + + fun refreshData() { + hotAreas = cacheManager.getHotAreas() + CoroutineScope(Dispatchers.IO).launch { + cacheSizeBytes = cacheManager.getCacheSizeBytes() + } + } + + LaunchedEffect(Unit) { + refreshData() + } + + LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = "Tile Cache Management", + style = MaterialTheme.typography.headlineSmall, + ) + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Cache Statistics", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Hot Areas: ${hotAreas.size}", + style = MaterialTheme.typography.bodyMedium, + ) + cacheSizeBytes?.let { size -> + val sizeMb = size / (1024.0 * 1024.0) + Text( + text = "Estimated Cache Size: ${String.format("%.2f", sizeMb)} MB", + style = MaterialTheme.typography.bodyMedium, + ) + } + lastUpdateTime?.let { time -> + val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) + Text( + text = "Last Update: ${dateFormat.format(Date(time))}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + HorizontalDivider() + } + + // Cache current area button + item { + val canCache = currentBounds != null && currentZoom != null && styleUrl != null + Button( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + onClick = { + if (canCache) { + isCaching = true + CoroutineScope(Dispatchers.IO).launch { + try { + cacheManager.cacheCurrentArea(currentBounds!!, currentZoom!!, styleUrl!!) + refreshData() + } catch (e: Exception) { + Timber.tag("TileCacheManagementSheet").e(e, "Error caching area: ${e.message}") + } finally { + isCaching = false + } + } + } + }, + enabled = canCache && !isCaching, + ) { + Text( + when { + isCaching -> "Caching..." + styleUrl == null -> "Caching unavailable (custom tiles)" + else -> "Cache This Area Now" + }, + ) + } + } + if (styleUrl == null) { + item { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + text = "Tile caching is only available when using standard map styles, not custom raster tiles.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), + text = "Cached Areas", + style = MaterialTheme.typography.titleSmall, + ) + } + + if (hotAreas.isEmpty()) { + item { + Text( + modifier = Modifier.padding(16.dp), + text = "No cached areas yet. Areas you view frequently will be automatically cached.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + items(hotAreas, key = { it.id }) { area -> + ListItem( + headlineContent = { + Text( + text = "Area ${area.id.take(8)}", + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Column { + Text( + text = "Visits: ${area.visitCount} | Time: ${area.totalTimeSec}s", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "Zoom: ${String.format("%.1f", area.zoom)} | " + + "Center: ${String.format("%.4f", area.centerLat)}, ${String.format("%.4f", area.centerLon)}", + style = MaterialTheme.typography.bodySmall, + ) + if (area.offlineRegionId != null) { + Text( + text = "✓ Cached (Region ID: ${area.offlineRegionId})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + text = "⏳ Caching in progress...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + }, + trailingContent = { + if (area.offlineRegionId != null) { + IconButton( + onClick = { + // TODO: Implement delete for individual region + }, + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete cached area", + ) + } + } + }, + ) + HorizontalDivider() + } + } + + item { + Button( + modifier = Modifier.fillMaxWidth().padding(16.dp), + onClick = { + // Clear all cache + CoroutineScope(Dispatchers.IO).launch { + cacheManager.clearCache() + hotAreas = cacheManager.getHotAreas() + cacheSizeBytes = cacheManager.getCacheSizeBytes() + } + }, + ) { + Text("Clear All Cache") + } + } + } +} + diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt index 47b9075a9b..d82cf114f3 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt @@ -22,27 +22,36 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.style.sources.GeoJsonSource import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.map.component.MapButton @@ -70,40 +79,9 @@ fun MapLibreControlButtons( modifier: Modifier = Modifier, ) { Column(modifier = modifier, horizontalAlignment = Alignment.End) { - // Location tracking button with visual feedback - FloatingActionButton( - onClick = { - if (hasLocationPermission) { - onLocationTrackingToggle() - Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", !isLocationTrackingEnabled) - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - }, - containerColor = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.MyLocation, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - Spacer(modifier = Modifier.size(8.dp)) - - // Compass button with visual feedback - FloatingActionButton( + // Compass button (matches Google Maps style - appears first, rotates with map bearing) + val compassIcon = if (followBearing) Icons.Filled.Navigation else Icons.Outlined.Navigation + MapButton( onClick = { if (isLocationTrackingEnabled) { onFollowBearingToggle() @@ -112,25 +90,10 @@ fun MapLibreControlButtons( onCompassClick() } }, - containerColor = - if (followBearing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.Explore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (followBearing) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } + icon = compassIcon, + contentDescription = null, + iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { !followBearing }, + ) Spacer(modifier = Modifier.size(8.dp)) @@ -138,15 +101,29 @@ fun MapLibreControlButtons( Spacer(modifier = Modifier.size(8.dp)) - MapButton(onClick = onLegendClick, icon = Icons.Outlined.Info, contentDescription = null) + MapButton(onClick = onStyleClick, icon = Icons.Outlined.Map, contentDescription = null) Spacer(modifier = Modifier.size(8.dp)) - MapButton(onClick = onStyleClick, icon = Icons.Outlined.Layers, contentDescription = null) + MapButton(onClick = onLayersClick, icon = Icons.Outlined.Layers, contentDescription = null) Spacer(modifier = Modifier.size(8.dp)) - MapButton(onClick = onLayersClick, icon = Icons.Outlined.Explore, contentDescription = null) + // Location tracking button (matches Google Maps style) + if (hasLocationPermission) { + MapButton( + onClick = { + onLocationTrackingToggle() + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", !isLocationTrackingEnabled) + }, + icon = if (isLocationTrackingEnabled) Icons.Filled.LocationDisabled else Icons.Outlined.MyLocation, + contentDescription = null, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + + MapButton(onClick = onLegendClick, icon = Icons.Outlined.Info, contentDescription = null) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 4cd91f6381..77d307faf8 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -39,11 +39,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -51,9 +56,12 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalFloatingToolbar import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.draw.rotate +import org.meshtastic.core.ui.theme.StatusColors.StatusRed import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -98,7 +106,9 @@ import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.component.TileCacheManagementSheet import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX @@ -129,6 +139,7 @@ import org.meshtastic.feature.map.maplibre.utils.getFileName import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission import org.meshtastic.feature.map.maplibre.utils.loadLayerGeoJson import org.meshtastic.feature.map.maplibre.utils.loadPersistedLayers +import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager import org.meshtastic.feature.map.maplibre.utils.protoShortName import org.meshtastic.feature.map.maplibre.utils.roleColor import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport @@ -182,6 +193,11 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe var showLayersBottomSheet by remember { mutableStateOf(false) } var layerGeoJsonCache by remember { mutableStateOf>(emptyMap()) } + // Tile cache management - initialize after MapLibre is initialized + var tileCacheManager by remember { mutableStateOf(null) } + var showCacheBottomSheet by remember { mutableStateOf(false) } + var lastCacheUpdateTime by remember { mutableStateOf(null) } + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() @@ -194,6 +210,76 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe // Load persisted map layers on startup LaunchedEffect(Unit) { mapLayers = loadPersistedLayers(context) } + // Initialize tile cache manager after MapLibre is initialized + LaunchedEffect(Unit) { + try { + // Ensure MapLibre is initialized first + MapLibre.getInstance(context) + if (tileCacheManager == null) { + tileCacheManager = MapLibreTileCacheManager(context) + Timber.tag("MapLibrePOC").d("Tile cache manager initialized") + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").e(e, "Failed to initialize tile cache manager") + } + } + + // Periodic cache updates + LaunchedEffect(tileCacheManager) { + tileCacheManager?.let { manager -> + while (true) { + val intervalMs = manager.getUpdateIntervalMs() + kotlinx.coroutines.delay(intervalMs) + try { + manager.updateCachedRegions() + lastCacheUpdateTime = System.currentTimeMillis() + Timber.tag("MapLibrePOC").d("Cache update completed at ${lastCacheUpdateTime}") + } catch (e: Exception) { + Timber.tag("MapLibrePOC").e(e, "Failed to update cached regions") + } + } + } + } + + // Periodic hot area tracking - record viewport every 5 seconds even when camera is idle + LaunchedEffect(tileCacheManager, mapRef) { + if (tileCacheManager == null) { + Timber.tag("MapLibrePOC").d("Periodic hot area tracking: tileCacheManager is null, waiting...") + return@LaunchedEffect + } + if (mapRef == null) { + Timber.tag("MapLibrePOC").d("Periodic hot area tracking: mapRef is null, waiting...") + return@LaunchedEffect + } + + val manager = tileCacheManager!! + val map = mapRef!! + + Timber.tag("MapLibrePOC").d("Starting periodic hot area tracking (every 5 seconds)") + + while (true) { + kotlinx.coroutines.delay(5000) // Check every 5 seconds + try { + val style = map.style + if (style != null) { + val bounds = map.projection.visibleRegion.latLngBounds + val zoom = map.cameraPosition.zoom + // Only cache if using a standard style URL (not custom raster tiles) + val styleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL + Timber.tag("MapLibrePOC").d("Periodic hot area check: zoom=%.2f, bounds=[%.4f,%.4f,%.4f,%.4f], styleUrl=$styleUrl", + zoom, bounds.latitudeNorth, bounds.latitudeSouth, bounds.longitudeEast, bounds.longitudeWest) + if (styleUrl != null) { + manager.recordViewport(bounds, zoom, styleUrl) + } + } else { + Timber.tag("MapLibrePOC").d("Periodic hot area check: style is null, skipping") + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").e(e, "Failed to record viewport in periodic check") + } + } + } + // Helper functions for layer management fun toggleLayerVisibility(layerId: String) { mapLayers = @@ -652,6 +738,21 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe Timber.tag("MapLibrePOC") .d("onCameraIdle: rendered features in viewport=%d", rendered.size) } catch (_: Throwable) {} + + // Track viewport for tile caching (hot areas) + tileCacheManager?.let { manager -> + try { + val bounds = map.projection.visibleRegion.latLngBounds + val zoom = map.cameraPosition.zoom + // Only cache if using a standard style URL (not custom raster tiles) + val styleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL + if (styleUrl != null) { + manager.recordViewport(bounds, zoom, styleUrl) + } + } catch (e: Exception) { + Timber.tag("MapLibrePOC").e(e, "Failed to record viewport for tile caching") + } + } } // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { @@ -811,85 +912,39 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } } - // Map controls: recenter/follow and filter menu + // Map controls: horizontal toolbar at the top (matches Google Maps style) var mapFilterExpanded by remember { mutableStateOf(false) } - Column( - modifier = - Modifier.align(Alignment.TopEnd) - .padding(top = 72.dp, end = 16.dp), // Increased top padding to avoid exit button - verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), - ) { - // My Location button with visual feedback - FloatingActionButton( - onClick = { - if (hasLocationPermission) { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followBearing = false - } - Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") - } - }, - containerColor = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.MyLocation, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (isLocationTrackingEnabled) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - } - - // Compass button with visual feedback - FloatingActionButton( - onClick = { - if (isLocationTrackingEnabled) { - followBearing = !followBearing - Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) - } else { - // Enable tracking when compass is clicked - if (hasLocationPermission) { - isLocationTrackingEnabled = true - followBearing = true - Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + HorizontalFloatingToolbar( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 72.dp), // Top padding to avoid exit button + expanded = true, + content = { + // Compass button (matches Google Maps style - appears first, rotates with map bearing) + val compassBearing = mapRef?.cameraPosition?.bearing?.toFloat() ?: 0f + val compassIcon = if (followBearing) Icons.Filled.Navigation else Icons.Outlined.Navigation + MapButton( + onClick = { + if (isLocationTrackingEnabled) { + followBearing = !followBearing + Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") + // Enable tracking when compass is clicked + if (hasLocationPermission) { + isLocationTrackingEnabled = true + followBearing = true + Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") + } else { + Timber.tag("MapLibrePOC").w("Location permission not granted") + } } - } - }, - containerColor = - if (followBearing) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - ) { - Icon( - imageVector = Icons.Outlined.Explore, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = - if (followBearing) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface }, + icon = compassIcon, + contentDescription = null, + iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { !followBearing }, + modifier = Modifier.rotate(-compassBearing), ) - } - Box { - MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) + Box { + MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { DropdownMenuItem( text = { Text("Only favorites") }, @@ -1064,27 +1119,15 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe }, ) } - } - MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) - - Spacer(modifier = Modifier.size(8.dp)) - - MapButton( - onClick = { showLayersBottomSheet = true }, - icon = Icons.Outlined.Explore, - contentDescription = null, - ) - - Spacer(modifier = Modifier.size(8.dp)) - - // Map style selector - Box { - MapButton( - onClick = { mapTypeMenuExpanded = true }, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) - DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + } + // Map style selector (matches Google Maps - Map icon) + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Map, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { Text( text = "Map Style", style = MaterialTheme.typography.titleMedium, @@ -1151,8 +1194,41 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe }, ) } - } - } + } + + // Map layers button (matches Google Maps - Layers icon) + MapButton( + onClick = { showLayersBottomSheet = true }, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) + + // Location tracking button (matches Google Maps style) + if (hasLocationPermission) { + MapButton( + onClick = { + isLocationTrackingEnabled = !isLocationTrackingEnabled + if (!isLocationTrackingEnabled) { + followBearing = false + } + Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) + }, + icon = if (isLocationTrackingEnabled) Icons.Filled.LocationDisabled else Icons.Outlined.MyLocation, + contentDescription = null, + ) + } + + // Cache management button + MapButton( + onClick = { showCacheBottomSheet = true }, + icon = Icons.Outlined.Storage, + contentDescription = null, + ) + + // Legend button + MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) + }, + ) // Custom tile URL dialog if (showCustomTileDialog) { @@ -1283,6 +1359,27 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } } + // Tile cache management bottom sheet + if (showCacheBottomSheet && tileCacheManager != null) { + ModalBottomSheet( + onDismissRequest = { showCacheBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + val currentBounds = mapRef?.projection?.visibleRegion?.latLngBounds + val currentZoom = mapRef?.cameraPosition?.zoom + // Only allow caching if using a standard style URL (not custom raster tiles) + // Custom raster tiles are built programmatically and don't have a style URL + val currentStyleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL + TileCacheManagementSheet( + cacheManager = tileCacheManager!!, + currentBounds = currentBounds, + currentZoom = currentZoom, + styleUrl = currentStyleUrl, + onDismiss = { showCacheBottomSheet = false }, + ) + } + } + // Bottom sheet with node details and actions val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt index 544986277c..f3aa8aeef7 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -36,6 +36,7 @@ import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream +import java.util.zip.ZipInputStream import javax.xml.parsers.DocumentBuilderFactory /** Loads persisted map layers from internal storage */ @@ -125,14 +126,16 @@ suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): Stri // Handle KMZ (ZIP) files val content = if (layerItem.layerType == LayerType.KML && uri.toString().endsWith(".kmz", ignoreCase = true)) { - // TODO: Extract KML from KMZ ZIP file - // For now, return empty GeoJSON - Timber.tag("MapLibreLayerUtils").w("KMZ files not yet fully supported") - return@withContext """{"type":"FeatureCollection","features":[]}""" + extractKmlFromKmz(stream) } else { stream.bufferedReader().use { it.readText() } } + if (content == null) { + Timber.tag("MapLibreLayerUtils").w("Failed to extract KML content") + return@withContext null + } + parseKmlToGeoJson(content) } } catch (e: Exception) { @@ -141,6 +144,33 @@ suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): Stri } } +/** Extracts KML content from KMZ (ZIP) file */ +private fun extractKmlFromKmz(inputStream: InputStream): String? { + return try { + val zipInputStream = ZipInputStream(inputStream) + var entry = zipInputStream.nextEntry + + // Look for KML file in the ZIP (usually named "doc.kml" or similar) + while (entry != null) { + val fileName = entry.name.lowercase() + if (fileName.endsWith(".kml")) { + val kmlContent = zipInputStream.bufferedReader().use { it.readText() } + zipInputStream.closeEntry() + Timber.tag("MapLibreLayerUtils").d("Extracted KML from KMZ: ${entry.name}") + return kmlContent + } + zipInputStream.closeEntry() + entry = zipInputStream.nextEntry + } + + Timber.tag("MapLibreLayerUtils").w("No KML file found in KMZ archive") + null + } catch (e: Exception) { + Timber.tag("MapLibreLayerUtils").e(e, "Error extracting KML from KMZ") + null + } +} + /** Parses KML XML and converts to GeoJSON */ private fun parseKmlToGeoJson(kmlContent: String): String { try { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt new file mode 100644 index 0000000000..d22ecaf469 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt @@ -0,0 +1,598 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.utils + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.JsonObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.maplibre.android.geometry.LatLngBounds +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.offline.OfflineManager +import org.maplibre.android.offline.OfflineRegion +import org.maplibre.android.offline.OfflineRegionError +import org.maplibre.android.offline.OfflineRegionStatus +import org.maplibre.android.offline.OfflineTilePyramidRegionDefinition +import timber.log.Timber +import java.util.UUID +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * Manages tile caching for "hot areas" - frequently visited map regions. + * Automatically caches tiles for areas where users spend time viewing the map. + */ +class MapLibreTileCacheManager(private val context: Context) { + // Lazy initialization - only create OfflineManager after MapLibre is initialized + private val offlineManager: OfflineManager by lazy { OfflineManager.getInstance(context) } + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val gson = Gson() + + companion object { + private const val PREFS_NAME = "maplibre_tile_cache" + private const val KEY_HOT_AREAS = "hot_areas" + private const val KEY_UPDATE_INTERVAL_MS = "update_interval_ms" + private const val KEY_MAX_CACHE_SIZE_MB = "max_cache_size_mb" + private const val KEY_HOT_AREA_THRESHOLD_SEC = "hot_area_threshold_sec" + private const val KEY_MIN_ZOOM = "min_zoom" + private const val KEY_MAX_ZOOM = "max_zoom" + + // Default values + private const val DEFAULT_UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000 // 7 days + private const val DEFAULT_MAX_CACHE_SIZE_MB = 500L // 500 MB + private const val DEFAULT_HOT_AREA_THRESHOLD_SEC = 60L // 1 minute + private const val DEFAULT_MIN_ZOOM = 10.0 + private const val DEFAULT_MAX_ZOOM = 16.0 + + private const val JSON_FIELD_REGION_NAME = "name" + private const val JSON_FIELD_REGION_ID = "id" + private const val JSON_FIELD_CENTER_LAT = "centerLat" + private const val JSON_FIELD_CENTER_LON = "centerLon" + private const val JSON_FIELD_ZOOM = "zoom" + private const val JSON_FIELD_LAST_VISIT = "lastVisit" + private const val JSON_FIELD_VISIT_COUNT = "visitCount" + private const val JSON_FIELD_TOTAL_TIME_SEC = "totalTimeSec" + private const val JSON_CHARSET = "UTF-8" + } + + data class HotArea( + val id: String, + val centerLat: Double, + val centerLon: Double, + val zoom: Double, + val bounds: LatLngBounds, + var lastVisit: Long, + var visitCount: Int, + var totalTimeSec: Long, + var offlineRegionId: Long? = null, + ) + + /** + * Records a camera position/viewport as a potential hot area. + * If the user spends enough time in this area, it will be cached. + */ + fun recordViewport( + bounds: LatLngBounds, + zoom: Double, + styleUrl: String, + ) { + val centerLat = (bounds.latitudeNorth + bounds.latitudeSouth) / 2.0 + val centerLon = (bounds.longitudeEast + bounds.longitudeWest) / 2.0 + + Timber.tag("MapLibreTileCacheManager").d("recordViewport called: center=[%.4f,%.4f], zoom=%.2f", centerLat, centerLon, zoom) + + // Find existing hot area within threshold (same general location) + val existingArea = findHotArea(centerLat, centerLon, zoom) + val now = System.currentTimeMillis() + + Timber.tag("MapLibreTileCacheManager").d("findHotArea result: ${if (existingArea != null) "found area ${existingArea.id.take(8)}" else "no match, creating new"}") + + if (existingArea != null) { + // Update existing area - track actual elapsed time since last visit + val timeSinceLastVisit = (now - existingArea.lastVisit) / 1000 // Convert to seconds + existingArea.lastVisit = now + existingArea.visitCount++ + // Add actual elapsed time (capped at 5 seconds per call to avoid huge jumps if app was backgrounded) + existingArea.totalTimeSec += minOf(timeSinceLastVisit, 5) + + // Check if threshold is met and region doesn't exist yet + val thresholdSec = getHotAreaThresholdSec() + if (existingArea.totalTimeSec >= thresholdSec && existingArea.offlineRegionId == null) { + Timber.tag("MapLibreTileCacheManager").d( + "Hot area threshold met (${existingArea.totalTimeSec}s >= ${thresholdSec}s), creating offline region: ${existingArea.id}", + ) + createOfflineRegionForHotArea(existingArea, styleUrl) + } else { + Timber.tag("MapLibreTileCacheManager").d( + "Hot area progress: ${existingArea.totalTimeSec}s / ${thresholdSec}s (area: ${existingArea.id.take(8)})", + ) + } + saveHotAreas() + } else { + // Create new hot area + Timber.tag("MapLibreTileCacheManager").d("Creating new hot area: center=[%.4f,%.4f], zoom=%.2f", centerLat, centerLon, zoom) + val newArea = HotArea( + id = UUID.randomUUID().toString(), + centerLat = centerLat, + centerLon = centerLon, + zoom = zoom, + bounds = bounds, + lastVisit = now, + visitCount = 1, + totalTimeSec = 1, + ) + addHotArea(newArea) + } + } + + /** + * Manually caches the current viewport immediately, bypassing the hot area threshold. + * Useful for "Cache this area now" functionality. + */ + fun cacheCurrentArea( + bounds: LatLngBounds, + zoom: Double, + styleUrl: String, + ) { + val centerLat = (bounds.latitudeNorth + bounds.latitudeSouth) / 2.0 + val centerLon = (bounds.longitudeEast + bounds.longitudeWest) / 2.0 + + // Check if this area is already cached + val existingArea = findHotArea(centerLat, centerLon, zoom) + val now = System.currentTimeMillis() + + if (existingArea != null && existingArea.offlineRegionId != null) { + // Already cached, just update visit info + existingArea.lastVisit = now + existingArea.visitCount++ + saveHotAreas() + Timber.tag("MapLibreTileCacheManager").d("Area already cached: ${existingArea.id}") + return + } + + // Create or update hot area and immediately cache it + val area = if (existingArea != null) { + existingArea.apply { + lastVisit = now + visitCount++ + totalTimeSec = getHotAreaThresholdSec() // Set to threshold to ensure caching + } + } else { + HotArea( + id = UUID.randomUUID().toString(), + centerLat = centerLat, + centerLon = centerLon, + zoom = zoom, + bounds = bounds, + lastVisit = now, + visitCount = 1, + totalTimeSec = getHotAreaThresholdSec(), // Set to threshold to ensure caching + ).also { addHotArea(it) } + } + + // Immediately create offline region + if (area.offlineRegionId == null) { + Timber.tag("MapLibreTileCacheManager").d("Manually caching area: ${area.id}") + createOfflineRegionForHotArea(area, styleUrl) + } + + saveHotAreas() + } + + /** + * Finds a hot area near the given coordinates and zoom level + */ + private fun findHotArea(lat: Double, lon: Double, zoom: Double): HotArea? { + val areas = loadHotAreas() + val zoomDiffThreshold = 2.0 // Within 2 zoom levels + + Timber.tag("MapLibreTileCacheManager").d("findHotArea: searching ${areas.size} areas for lat=%.4f, lon=%.4f, zoom=%.2f", lat, lon, zoom) + + val result = areas.firstOrNull { area -> + val latDiff = abs(area.centerLat - lat) + val lonDiff = abs(area.centerLon - lon) + val zoomDiff = abs(area.zoom - zoom) + + // Within ~1km and similar zoom level + val matches = latDiff < 0.01 && lonDiff < 0.01 && zoomDiff < zoomDiffThreshold + if (matches) { + Timber.tag("MapLibreTileCacheManager").d(" Match found: area ${area.id.take(8)}, latDiff=%.4f, lonDiff=%.4f, zoomDiff=%.2f", latDiff, lonDiff, zoomDiff) + } + matches + } + + if (result == null && areas.isNotEmpty()) { + Timber.tag("MapLibreTileCacheManager").d(" No match. Closest area: lat=%.4f, lon=%.4f, zoom=%.2f", areas[0].centerLat, areas[0].centerLon, areas[0].zoom) + } + + return result + } + + /** + * Creates an offline region for a hot area + */ + private fun createOfflineRegionForHotArea(area: HotArea, styleUrl: String) { + // Validate style URL - must be a valid HTTP/HTTPS URL ending in .json + if (!styleUrl.startsWith("http://") && !styleUrl.startsWith("https://")) { + Timber.tag("MapLibreTileCacheManager").e("Invalid style URL (must be HTTP/HTTPS): $styleUrl") + return + } + if (!styleUrl.endsWith(".json") && !styleUrl.contains("style.json")) { + Timber.tag("MapLibreTileCacheManager").w("Style URL may not be valid (should end in .json or contain style.json): $styleUrl") + } + + // Validate bounds + val bounds = area.bounds + if (bounds.latitudeNorth <= bounds.latitudeSouth) { + Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: north (%.4f) must be > south (%.4f)", bounds.latitudeNorth, bounds.latitudeSouth) + return + } + if (bounds.longitudeEast <= bounds.longitudeWest) { + Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: east (%.4f) must be > west (%.4f)", bounds.longitudeEast, bounds.longitudeWest) + return + } + if (bounds.latitudeNorth > 90.0 || bounds.latitudeSouth < -90.0) { + Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: latitude out of range [%.4f, %.4f]", bounds.latitudeSouth, bounds.latitudeNorth) + return + } + if (bounds.longitudeEast > 180.0 || bounds.longitudeWest < -180.0) { + Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: longitude out of range [%.4f, %.4f]", bounds.longitudeWest, bounds.longitudeEast) + return + } + + val minZoom = getMinZoom() + val maxZoom = getMaxZoom() + if (minZoom < 0 || maxZoom < minZoom) { + Timber.tag("MapLibreTileCacheManager").e("Invalid zoom range: min=%.1f, max=%.1f", minZoom, maxZoom) + return + } + + val pixelRatio = context.resources.displayMetrics.density + if (pixelRatio <= 0) { + Timber.tag("MapLibreTileCacheManager").e("Invalid pixel ratio: %.2f", pixelRatio) + return + } + + Timber.tag("MapLibreTileCacheManager").d("Creating offline region: styleUrl=$styleUrl, bounds=[%.4f,%.4f,%.4f,%.4f], zoom=[%.1f-%.1f], pixelRatio=%.2f", + bounds.latitudeNorth, bounds.latitudeSouth, + bounds.longitudeEast, bounds.longitudeWest, + minZoom, maxZoom, pixelRatio) + + try { + val definition = OfflineTilePyramidRegionDefinition( + styleUrl, + bounds, + minZoom, + maxZoom, + pixelRatio, + ) + + val metadata = encodeHotAreaMetadata(area) + + offlineManager.createOfflineRegion( + definition, + metadata, + object : OfflineManager.CreateOfflineRegionCallback { + override fun onCreate(offlineRegion: OfflineRegion) { + Timber.tag("MapLibreTileCacheManager").d("Offline region created: ${offlineRegion.id} for hot area: ${area.id}") + area.offlineRegionId = offlineRegion.id + saveHotAreas() + + // Start download + startDownload(offlineRegion) + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to create offline region: $error") + } + }, + ) + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Exception creating offline region: ${e.message}") + } + } + + /** + * Starts downloading tiles for an offline region + */ + private fun startDownload(region: OfflineRegion) { + try { + region.setObserver(object : OfflineRegion.OfflineRegionObserver { + override fun onStatusChanged(status: OfflineRegionStatus) { + try { + val percentage = if (status.requiredResourceCount > 0) { + (100.0 * status.completedResourceCount / status.requiredResourceCount).toInt() + } else { + 0 + } + + Timber.tag("MapLibreTileCacheManager").d( + "Offline region ${region.id} progress: $percentage% " + + "(${status.completedResourceCount}/${status.requiredResourceCount})", + ) + + if (status.isComplete) { + Timber.tag("MapLibreTileCacheManager").d("Offline region ${region.id} download complete") + region.setObserver(null) + } + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Error in onStatusChanged: ${e.message}") + // Remove observer on error to prevent further callbacks + try { + region.setObserver(null) + } catch (ex: Exception) { + Timber.tag("MapLibreTileCacheManager").e(ex, "Error removing observer: ${ex.message}") + } + } + } + + override fun onError(error: OfflineRegionError) { + Timber.tag("MapLibreTileCacheManager").e("Offline region ${region.id} error: reason=${error.reason}, message=${error.message}") + // Remove observer on error to prevent further callbacks + try { + region.setObserver(null) + region.setDownloadState(OfflineRegion.STATE_INACTIVE) + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Error handling offline region error: ${e.message}") + } + } + + override fun mapboxTileCountLimitExceeded(limit: Long) { + Timber.tag("MapLibreTileCacheManager").w("Tile count limit exceeded: $limit") + // Remove observer on error to prevent further callbacks + try { + region.setObserver(null) + region.setDownloadState(OfflineRegion.STATE_INACTIVE) + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Error handling tile limit exceeded: ${e.message}") + } + } + }) + + region.setDownloadState(OfflineRegion.STATE_ACTIVE) + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Exception starting download for region ${region.id}: ${e.message}") + } + } + + /** + * Updates all cached regions by invalidating their ambient cache + */ + suspend fun updateCachedRegions() = withContext(Dispatchers.IO) { + Timber.tag("MapLibreTileCacheManager").d("Updating cached regions...") + + offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + if (offlineRegions == null || offlineRegions.isEmpty()) { + Timber.tag("MapLibreTileCacheManager").d("No offline regions to update") + return + } + + Timber.tag("MapLibreTileCacheManager").d("Found ${offlineRegions.size} offline regions to update") + + offlineRegions.forEach { region -> + // Invalidate ambient cache to force re-download + offlineManager.invalidateAmbientCache(object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Invalidated cache for region ${region.id}") + // Resume download to update tiles + region.setDownloadState(OfflineRegion.STATE_ACTIVE) + } + + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to invalidate cache: $message") + } + }) + } + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + } + }) + } + + /** + * Gets the total cache size in bytes + */ + suspend fun getCacheSizeBytes(): Long = withContext(Dispatchers.IO) { + // MapLibre doesn't expose cache size directly, so we estimate based on regions + // This is a rough approximation + var totalSize = 0L + offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + if (offlineRegions != null) { + offlineRegions.forEach { region -> + // Estimate ~100KB per region (very rough) + totalSize += 100 * 1024 + } + } + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + } + }) + totalSize + } + + /** + * Clears all cached regions + */ + suspend fun clearCache() = withContext(Dispatchers.IO) { + Timber.tag("MapLibreTileCacheManager").d("Clearing cache...") + + offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + if (offlineRegions == null || offlineRegions.isEmpty()) { + Timber.tag("MapLibreTileCacheManager").d("No offline regions to clear") + return + } + + offlineRegions.forEach { region -> + region.delete(object : OfflineRegion.OfflineRegionDeleteCallback { + override fun onDelete() { + Timber.tag("MapLibreTileCacheManager").d("Deleted offline region ${region.id}") + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to delete region ${region.id}: $error") + } + }) + } + + // Clear hot areas + clearHotAreas() + } + + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + } + }) + } + + /** + * Gets list of all hot areas + */ + fun getHotAreas(): List { + // Invalidate cache to ensure we get the latest from SharedPreferences + hotAreasCache = null + return loadHotAreas() + } + + // Preferences getters/setters + fun getUpdateIntervalMs(): Long = prefs.getLong(KEY_UPDATE_INTERVAL_MS, DEFAULT_UPDATE_INTERVAL_MS) + fun setUpdateIntervalMs(intervalMs: Long) = prefs.edit().putLong(KEY_UPDATE_INTERVAL_MS, intervalMs).apply() + + fun getMaxCacheSizeMb(): Long = prefs.getLong(KEY_MAX_CACHE_SIZE_MB, DEFAULT_MAX_CACHE_SIZE_MB) + fun setMaxCacheSizeMb(sizeMb: Long) = prefs.edit().putLong(KEY_MAX_CACHE_SIZE_MB, sizeMb).apply() + + fun getHotAreaThresholdSec(): Long = prefs.getLong(KEY_HOT_AREA_THRESHOLD_SEC, DEFAULT_HOT_AREA_THRESHOLD_SEC) + fun setHotAreaThresholdSec(thresholdSec: Long) = prefs.edit().putLong(KEY_HOT_AREA_THRESHOLD_SEC, thresholdSec).apply() + + fun getMinZoom(): Double = prefs.getFloat(KEY_MIN_ZOOM, DEFAULT_MIN_ZOOM.toFloat()).toDouble() + fun setMinZoom(zoom: Double) = prefs.edit().putFloat(KEY_MIN_ZOOM, zoom.toFloat()).apply() + + fun getMaxZoom(): Double = prefs.getFloat(KEY_MAX_ZOOM, DEFAULT_MAX_ZOOM.toFloat()).toDouble() + fun setMaxZoom(zoom: Double) = prefs.edit().putFloat(KEY_MAX_ZOOM, zoom.toFloat()).apply() + + // Hot area persistence + private fun loadHotAreas(): MutableList { + if (hotAreasCache != null) { + return hotAreasCache!! + } + val json = prefs.getString(KEY_HOT_AREAS, "[]") ?: "[]" + return try { + val jsonArray = gson.fromJson(json, Array::class.java) + val areas = jsonArray.map { json -> + HotArea( + id = json.id, + centerLat = json.centerLat, + centerLon = json.centerLon, + zoom = json.zoom, + bounds = LatLngBounds.from( + json.boundsNorth, + json.boundsEast, + json.boundsSouth, + json.boundsWest, + ), + lastVisit = json.lastVisit, + visitCount = json.visitCount, + totalTimeSec = json.totalTimeSec, + offlineRegionId = json.offlineRegionId, + ) + }.toMutableList() + hotAreasCache = areas + Timber.tag("MapLibreTileCacheManager").d("Loaded ${areas.size} hot areas from SharedPreferences") + areas + } catch (e: Exception) { + Timber.tag("MapLibreTileCacheManager").e(e, "Failed to load hot areas") + mutableListOf().also { hotAreasCache = it } + } + } + + // In-memory cache of hot areas to avoid reloading from SharedPreferences every time + private var hotAreasCache: MutableList? = null + + private fun saveHotAreas() { + val areas = hotAreasCache ?: loadHotAreas() + val jsonArray = areas.map { area -> + HotAreaJson( + id = area.id, + centerLat = area.centerLat, + centerLon = area.centerLon, + zoom = area.zoom, + boundsNorth = area.bounds.latitudeNorth, + boundsSouth = area.bounds.latitudeSouth, + boundsEast = area.bounds.longitudeEast, + boundsWest = area.bounds.longitudeWest, + lastVisit = area.lastVisit, + visitCount = area.visitCount, + totalTimeSec = area.totalTimeSec, + offlineRegionId = area.offlineRegionId, + ) + } + val json = gson.toJson(jsonArray) + prefs.edit().putString(KEY_HOT_AREAS, json).apply() + Timber.tag("MapLibreTileCacheManager").d("Saved ${areas.size} hot areas to SharedPreferences") + } + + private fun addHotArea(area: HotArea) { + val areas = hotAreasCache ?: loadHotAreas() + areas.add(area) + hotAreasCache = areas + saveHotAreas() + } + + private fun clearHotAreas() { + hotAreasCache = null + prefs.edit().remove(KEY_HOT_AREAS).apply() + } + + private fun encodeHotAreaMetadata(area: HotArea): ByteArray { + val jsonObject = JsonObject() + jsonObject.addProperty(JSON_FIELD_REGION_NAME, "Hot Area: ${area.id.take(8)}") + jsonObject.addProperty(JSON_FIELD_REGION_ID, area.id) + jsonObject.addProperty(JSON_FIELD_CENTER_LAT, area.centerLat) + jsonObject.addProperty(JSON_FIELD_CENTER_LON, area.centerLon) + jsonObject.addProperty(JSON_FIELD_ZOOM, area.zoom) + jsonObject.addProperty(JSON_FIELD_LAST_VISIT, area.lastVisit) + jsonObject.addProperty(JSON_FIELD_VISIT_COUNT, area.visitCount) + jsonObject.addProperty(JSON_FIELD_TOTAL_TIME_SEC, area.totalTimeSec) + return jsonObject.toString().toByteArray(charset(JSON_CHARSET)) + } + + private data class HotAreaJson( + val id: String, + val centerLat: Double, + val centerLon: Double, + val zoom: Double, + val boundsNorth: Double, + val boundsSouth: Double, + val boundsEast: Double, + val boundsWest: Double, + val lastVisit: Long, + val visitCount: Int, + val totalTimeSec: Long, + val offlineRegionId: Long?, + ) +} + From 836cad50e34148e4646fc4f9f1b554daa9694caa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:18:50 -0600 Subject: [PATCH 13/62] chore(deps): update com.google.firebase:firebase-bom to v34.6.0 (#3704) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 6653fa566f..3ba249a951 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,7 +83,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling # Google android-desugarJdkLibs = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.5" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } -firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.5.0" } +firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.6.0" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } guava = { module = "com.google.guava:guava", version = "33.5.0-jre" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } From 58bc55cc0925d0e97a0e557e74a6fb1a982065f2 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:03:46 -0800 Subject: [PATCH 14/62] feat: jump to oldest unread message upon opening a thread, display divider between read/unread (#3693) --- .../core/data/repository/PacketRepository.kt | 16 + .../23.json | 745 ++++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 3 +- .../meshtastic/core/database/entity/Packet.kt | 7 +- .../composeResources/values/strings.xml | 1 + .../core/ui/component/ScrollExtensions.kt | 68 ++ .../ui/component/ScrollToTopExtensions.kt | 46 -- .../feature/messaging/DeliveryInfoDialog.kt | 90 +++ .../meshtastic/feature/messaging/Message.kt | 99 ++- .../feature/messaging/MessageList.kt | 434 ++++++---- .../feature/messaging/MessageScreenEvent.kt | 4 +- .../feature/messaging/MessageViewModel.kt | 20 +- 12 files changed, 1323 insertions(+), 210 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt delete mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index 54a88d0e39..0e56004bb9 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -57,6 +57,22 @@ constructor( suspend fun clearUnreadCount(contact: String, timestamp: Long) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val current = dao.getContactSettings(contact) + val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE + if (lastReadTimestamp <= existingTimestamp) { + return@withContext + } + val updated = + (current ?: ContactSettings(contact_key = contact)).copy( + lastReadMessageUuid = messageUuid, + lastReadMessageTimestamp = lastReadTimestamp, + ) + dao.upsertContactSettings(listOf(updated)) + } + suspend fun getQueuedPackets(): List? = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json new file mode 100644 index 0000000000..3307d14b0b --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json @@ -0,0 +1,745 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "51f5f6ebc6ef9d279deb9944746fad68", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + } + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `reply_id` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`hwModel`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hwModel" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '51f5f6ebc6ef9d279deb9944746fad68')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 6060d577de..3cd57ad33b 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -77,8 +77,9 @@ import org.meshtastic.core.database.entity.ReactionEntity AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21), AutoMigration(from = 21, to = 22), + AutoMigration(from = 22, to = 23), ], - version = 22, + version = 23, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index bc719fdc16..833a0b6a02 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -97,7 +97,12 @@ data class Packet( @Suppress("ConstructorParameterNaming") @Entity(tableName = "contact_settings") -data class ContactSettings(@PrimaryKey val contact_key: String, val muteUntil: Long = 0L) { +data class ContactSettings( + @PrimaryKey val contact_key: String, + val muteUntil: Long = 0L, + @ColumnInfo(name = "last_read_message_uuid") val lastReadMessageUuid: Long? = null, + @ColumnInfo(name = "last_read_message_timestamp") val lastReadMessageTimestamp: Long? = null, +) { val isMuted get() = System.currentTimeMillis() <= muteUntil } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index d95c2a689f..268b5affcf 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -239,6 +239,7 @@ This will remove all log packets and database entries from your device - It is a full reset, and is permanent. Clear Message delivery status + New messages below Direct message notifications Broadcast message notifications Alert notifications diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt new file mode 100644 index 0000000000..03996b0c89 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 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.core.ui.component + +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val SCROLL_TO_TOP_INDEX = 0 +private const val FAST_SCROLL_THRESHOLD = 10 + +/** + * Executes the smart scroll-to-top policy. + * + * Policy: + * - If the first visible item is already at index 0, do nothing. + * - Otherwise, smoothly animate the list back to the first item. + */ +fun LazyListState.smartScrollToTop(coroutineScope: CoroutineScope) { + smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = SCROLL_TO_TOP_INDEX) +} + +/** + * Scrolls to the [targetIndex] while applying the same fast-scroll optimisation used by [smartScrollToTop]. + * + * If the destination is far away, the list first jumps closer to the goal (within [FAST_SCROLL_THRESHOLD] items) to + * avoid long smooth animations and then animates the final segment. + * + * @param coroutineScope Scope used to perform the scroll operations. + * @param targetIndex Absolute index that should end up at the top of the viewport. + */ +fun LazyListState.smartScrollToIndex(coroutineScope: CoroutineScope, targetIndex: Int) { + if (targetIndex < 0 || firstVisibleItemIndex == targetIndex) { + return + } + coroutineScope.launch { + val totalItems = layoutInfo.totalItemsCount + if (totalItems == 0) { + return@launch + } + val clampedTarget = targetIndex.coerceIn(0, totalItems - 1) + val difference = firstVisibleItemIndex - clampedTarget + val jumpIndex = + when { + difference > FAST_SCROLL_THRESHOLD -> + (clampedTarget + FAST_SCROLL_THRESHOLD).coerceAtMost(totalItems - 1) + difference < -FAST_SCROLL_THRESHOLD -> (clampedTarget - FAST_SCROLL_THRESHOLD).coerceAtLeast(0) + else -> null + } + jumpIndex?.let { scrollToItem(it) } + animateScrollToItem(index = clampedTarget) + } +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt deleted file mode 100644 index 6eb4ac1f1e..0000000000 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2025 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.core.ui.component - -import androidx.compose.foundation.lazy.LazyListState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlin.math.max - -private const val SCROLL_TO_TOP_INDEX = 0 -private const val FAST_SCROLL_THRESHOLD = 10 - -/** - * Executes the smart scroll-to-top policy. - * - * Policy: - * - If the first visible item is already at index 0, do nothing. - * - Otherwise, smoothly animate the list back to the first item. - */ -fun LazyListState.smartScrollToTop(coroutineScope: CoroutineScope) { - if (firstVisibleItemIndex == SCROLL_TO_TOP_INDEX) { - return - } - coroutineScope.launch { - if (firstVisibleItemIndex > FAST_SCROLL_THRESHOLD) { - val jumpIndex = max(SCROLL_TO_TOP_INDEX, firstVisibleItemIndex - FAST_SCROLL_THRESHOLD) - scrollToItem(jumpIndex) - } - animateScrollToItem(index = SCROLL_TO_TOP_INDEX) - } -} diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt new file mode 100644 index 0000000000..6e7f703a4c --- /dev/null +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 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.feature.messaging + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.close +import org.meshtastic.core.strings.relayed_by +import org.meshtastic.core.strings.resend + +@Composable +fun DeliveryInfo( + title: StringResource, + resendOption: Boolean, + text: StringResource? = null, + relayNodeName: String? = null, + onConfirm: (() -> Unit) = {}, + onDismiss: () -> Unit = {}, +) = AlertDialog( + onDismissRequest = onDismiss, + dismissButton = { + FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(Res.string.close)) + } + }, + confirmButton = { + if (resendOption) { + FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(Res.string.resend)) + } + } + }, + title = { + Text( + text = stringResource(title), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + text?.let { + Text( + text = stringResource(it), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + relayNodeName?.let { + Text( + text = stringResource(Res.string.relayed_by, it), + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface, +) diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 95e69d344a..5af3c7a1cc 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -73,6 +73,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -127,6 +128,7 @@ import org.meshtastic.core.strings.unknown_channel import org.meshtastic.core.ui.component.NodeKeyStatusIcon import org.meshtastic.core.ui.component.SecurityIcon import org.meshtastic.core.ui.component.SharedContactDialog +import org.meshtastic.core.ui.component.smartScrollToIndex import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.proto.AppOnlyProtos import java.nio.charset.StandardCharsets @@ -165,6 +167,7 @@ fun MessageScreen( val channels by viewModel.channels.collectAsStateWithLifecycle() val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList()) + val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap()) // UI State managed within this Composable var replyingToPacketId by rememberSaveable { mutableStateOf(null) } @@ -201,14 +204,63 @@ fun MessageScreen( val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } } - val listState = - rememberLazyListState( - initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) }, - ) + val listState = rememberLazyListState() + + val lastReadMessageTimestamp by + remember(contactKey, contactSettings) { + derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp } + } + + var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) } + + val hasUnreadMessages = messages.any { !it.read && !it.fromLocal } + + val earliestUnreadIndex by + remember(messages, lastReadMessageTimestamp) { + derivedStateOf { findEarliestUnreadIndex(messages, lastReadMessageTimestamp) } + } + + val initialUnreadUuidState = rememberSaveable(contactKey) { mutableStateOf(null) } + + LaunchedEffect(messages, earliestUnreadIndex, hasUnreadMessages) { + if (!hasUnreadMessages) { + initialUnreadUuidState.value = null + return@LaunchedEffect + } + val currentUuid = initialUnreadUuidState.value + val fallbackUuid = earliestUnreadIndex?.let { idx -> messages.getOrNull(idx)?.uuid } + if (currentUuid != null) { + val uuidStillPresent = messages.any { it.uuid == currentUuid } + if (!uuidStillPresent) { + initialUnreadUuidState.value = fallbackUuid + } + } else { + initialUnreadUuidState.value = fallbackUuid + } + } + + val initialUnreadMessageUuid = initialUnreadUuidState.value + + val initialUnreadIndex by + remember(messages, initialUnreadMessageUuid) { + derivedStateOf { + initialUnreadMessageUuid?.let { uuid -> messages.indexOfFirst { it.uuid == uuid } }?.takeIf { it >= 0 } + } + } + + LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) { + if (!hasPerformedInitialScroll && messages.isNotEmpty()) { + val targetIndex = (initialUnreadIndex ?: earliestUnreadIndex ?: 0).coerceIn(0, messages.lastIndex) + if (listState.firstVisibleItemIndex != targetIndex) { + listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) + } + hasPerformedInitialScroll = true + } + } val onEvent: (MessageScreenEvent) -> Unit = remember(viewModel, contactKey, messageInputState, ourNode) { - { event -> + fun handle(event: MessageScreenEvent) { when (event) { is MessageScreenEvent.SendMessage -> { viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId) @@ -226,7 +278,7 @@ fun MessageScreen( } is MessageScreenEvent.ClearUnreadCount -> - viewModel.clearUnreadCount(contactKey, event.lastReadMessageId) + viewModel.clearUnreadCount(contactKey, event.messageUuid, event.lastReadTimestamp) is MessageScreenEvent.NodeDetails -> navigateToNodeDetails(event.node.num) @@ -240,6 +292,8 @@ fun MessageScreen( } } } + + ::handle } if (showDeleteDialog) { @@ -299,19 +353,30 @@ fun MessageScreen( Column(Modifier.padding(paddingValues)) { Box(modifier = Modifier.weight(1f)) { MessageList( - nodes = nodes, - ourNode = ourNode, modifier = Modifier.fillMaxSize(), listState = listState, - messages = messages, - selectedIds = selectedMessageIds, - onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) }, - onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, - onDeleteMessages = { viewModel.deleteMessages(it) }, - onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) }, - contactKey = contactKey, - onReply = { message -> replyingToPacketId = message?.packetId }, - onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) }, + state = + MessageListState( + nodes = nodes, + ourNode = ourNode, + messages = messages, + selectedIds = selectedMessageIds, + hasUnreadMessages = hasUnreadMessages, + initialUnreadMessageUuid = initialUnreadMessageUuid, + fallbackUnreadIndex = earliestUnreadIndex, + contactKey = contactKey, + ), + handlers = + MessageListHandlers( + onUnreadChanged = { messageUuid, timestamp -> + onEvent(MessageScreenEvent.ClearUnreadCount(messageUuid, timestamp)) + }, + onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, + onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) }, + onDeleteMessages = { viewModel.deleteMessages(it) }, + onSendMessage = { text, key -> viewModel.sendMessage(text, key) }, + onReply = { message -> replyingToPacketId = message?.packetId }, + ), ) // Show FAB if we can scroll towards the newest messages (index 0). if (listState.canScrollBackward) { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt index ae2e5d3c3b..e9de5c43e0 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt @@ -17,7 +17,8 @@ package org.meshtastic.feature.messaging -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -25,9 +26,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,15 +41,15 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.Reaction @@ -58,165 +57,318 @@ import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.strings.Res -import org.meshtastic.core.strings.close -import org.meshtastic.core.strings.relayed_by -import org.meshtastic.core.strings.resend +import org.meshtastic.core.strings.new_messages_below import org.meshtastic.feature.messaging.component.MessageItem import org.meshtastic.feature.messaging.component.ReactionDialog +import kotlin.collections.buildList -@Composable -fun DeliveryInfo( - title: StringResource, - text: StringResource? = null, - relayNodeName: String? = null, - onConfirm: (() -> Unit) = {}, - onDismiss: () -> Unit = {}, - resendOption: Boolean, -) = AlertDialog( - onDismissRequest = onDismiss, - dismissButton = { - FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) { - Text(text = stringResource(Res.string.close)) - } - }, - confirmButton = { - if (resendOption) { - FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) { - Text(text = stringResource(Res.string.resend)) - } - } - }, - title = { - Text( - text = stringResource(title), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - }, - text = { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - text?.let { - Text( - text = stringResource(it), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - relayNodeName?.let { - Text( - text = stringResource(Res.string.relayed_by, it), - modifier = Modifier.padding(top = 8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - }, - shape = RoundedCornerShape(16.dp), - containerColor = MaterialTheme.colorScheme.surface, +internal data class MessageListState( + val nodes: List, + val ourNode: Node?, + val messages: List, + val selectedIds: MutableState>, + val hasUnreadMessages: Boolean, + val initialUnreadMessageUuid: Long?, + val fallbackUnreadIndex: Int?, + val contactKey: String, +) + +internal data class MessageListHandlers( + val onUnreadChanged: (Long, Long) -> Unit, + val onSendReaction: (String, Int) -> Unit, + val onClickChip: (Node) -> Unit, + val onDeleteMessages: (List) -> Unit, + val onSendMessage: (String, String) -> Unit, + val onReply: (Message?) -> Unit, ) -@Suppress("LongMethod") @Composable internal fun MessageList( - nodes: List, - ourNode: Node?, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), - messages: List, - selectedIds: MutableState>, - onUnreadChanged: (Long) -> Unit, - onSendReaction: (String, Int) -> Unit, - onClickChip: (Node) -> Unit, - onDeleteMessages: (List) -> Unit, - onSendMessage: (messageText: String, contactKey: String) -> Unit, - contactKey: String, - onReply: (Message?) -> Unit, + state: MessageListState, + handlers: MessageListHandlers, ) { val haptics = LocalHapticFeedback.current - val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } - AutoScrollToBottom(listState, messages) - UpdateUnreadCount(listState, messages, onUnreadChanged) + val inSelectionMode by remember { derivedStateOf { state.selectedIds.value.isNotEmpty() } } + val unreadDividerIndex by + remember(state.messages, state.initialUnreadMessageUuid, state.fallbackUnreadIndex) { + derivedStateOf { + state.initialUnreadMessageUuid?.let { uuid -> + state.messages.indexOfFirst { it.uuid == uuid }.takeIf { it >= 0 } + } ?: state.fallbackUnreadIndex + } + } + val showUnreadDivider = state.hasUnreadMessages && unreadDividerIndex != null + AutoScrollToBottom(listState, state.messages, state.hasUnreadMessages) + UpdateUnreadCount(listState, state.messages, handlers.onUnreadChanged) var showStatusDialog by remember { mutableStateOf(null) } - if (showStatusDialog != null) { - val msg = showStatusDialog ?: return - val (title, text) = msg.getStatusStringRes() - val relayNodeName by - remember(msg.relayNode, nodes) { - derivedStateOf { - msg.relayNode?.let { relayNodeId -> Packet.getRelayNode(relayNodeId, nodes)?.user?.longName } - } - } - DeliveryInfo( - title = title, - text = text, - relayNodeName = relayNodeName, - onConfirm = { - val deleteList: List = listOf(msg.uuid) - onDeleteMessages(deleteList) + showStatusDialog?.let { message -> + MessageStatusDialog( + message = message, + nodes = state.nodes, + resendOption = message.status?.equals(MessageStatus.ERROR) ?: false, + onResend = { + handlers.onDeleteMessages(listOf(message.uuid)) + handlers.onSendMessage(message.text, state.contactKey) showStatusDialog = null - onSendMessage(msg.text, contactKey) }, onDismiss = { showStatusDialog = null }, - resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false, ) } var showReactionDialog by remember { mutableStateOf?>(null) } - if (showReactionDialog != null) { - val reactions = showReactionDialog ?: return - ReactionDialog(reactions) { showReactionDialog = null } - } - - fun MutableState>.toggle(uuid: Long) = if (value.contains(uuid)) { - value -= uuid - } else { - value += uuid - } + showReactionDialog?.let { reactions -> ReactionDialog(reactions) { showReactionDialog = null } } val coroutineScope = rememberCoroutineScope() - LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { - items(messages, key = { it.uuid }) { msg -> - if (ourNode != null) { - val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - val node by remember { derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } } - - MessageItem( - modifier = Modifier.animateItem(), - node = node, + val messageRows = + rememberMessageRows( + messages = state.messages, + showUnreadDivider = showUnreadDivider, + unreadDividerIndex = unreadDividerIndex, + initialUnreadMessageUuid = state.initialUnreadMessageUuid, + ) + + MessageListContent( + listState = listState, + messageRows = messageRows, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + onShowStatusDialog = { showStatusDialog = it }, + onShowReactions = { showReactionDialog = it }, + coroutineScope = coroutineScope, + haptics = haptics, + modifier = modifier, + ) +} + +private sealed interface MessageListRow { + data class ChatMessage(val index: Int, val message: Message) : MessageListRow + + data class UnreadDivider(val key: String) : MessageListRow +} + +@Composable +private fun MessageRowContent( + row: MessageListRow, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + listState: LazyListState, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, +) { + when (row) { + is MessageListRow.UnreadDivider -> UnreadMessagesDivider() + is MessageListRow.ChatMessage -> + state.ourNode?.let { ourNode -> + ChatMessageRow( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + listState = listState, + coroutineScope = coroutineScope, + haptics = haptics, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, ourNode = ourNode, - message = msg, - selected = selected, - onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, - onLongClick = { - selectedIds.toggle(msg.uuid) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onClickChip = onClickChip, - onStatusClick = { showStatusDialog = msg }, - onReply = { onReply(msg) }, - emojis = msg.emojis, - sendReaction = { onSendReaction(it, msg.packetId) }, - onShowReactions = { showReactionDialog = msg.emojis }, - onNavigateToOriginalMessage = { - coroutineScope.launch { - val targetIndex = messages.indexOfFirst { it.packetId == msg.replyId } - if (targetIndex != -1) { - listState.animateScrollToItem(index = targetIndex) - } - } - }, ) } + } +} + +@Composable +private fun ChatMessageRow( + row: MessageListRow.ChatMessage, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + listState: LazyListState, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, + ourNode: Node, +) { + val message = row.message + val selected by + remember(message.uuid, state.selectedIds.value) { + derivedStateOf { state.selectedIds.value.contains(message.uuid) } + } + val node by + remember(message.node.num, state.nodes) { + derivedStateOf { state.nodes.find { it.num == message.node.num } ?: message.node } + } + + MessageItem( + node = node, + ourNode = ourNode, + message = message, + selected = selected, + onClick = { + if (inSelectionMode) { + state.selectedIds.value = + if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + } + }, + onLongClick = { + state.selectedIds.value = + if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onClickChip = handlers.onClickChip, + onStatusClick = { onShowStatusDialog(message) }, + onReply = { handlers.onReply(message) }, + emojis = message.emojis, + sendReaction = { handlers.onSendReaction(it, message.packetId) }, + onShowReactions = { onShowReactions(message.emojis) }, + onNavigateToOriginalMessage = { + coroutineScope.launch { + val targetIndex = state.messages.indexOfFirst { it.packetId == message.replyId } + if (targetIndex != -1) { + listState.animateScrollToItem(index = targetIndex) + } + } + }, + ) +} + +@Composable +private fun MessageListContent( + listState: LazyListState, + messageRows: List, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { + items( + items = messageRows, + key = { row -> + when (row) { + is MessageListRow.ChatMessage -> row.message.uuid + is MessageListRow.UnreadDivider -> row.key + } + }, + ) { row -> + MessageRowContent( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + listState = listState, + coroutineScope = coroutineScope, + haptics = haptics, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, + ) } } } @Composable -private fun AutoScrollToBottom(listState: LazyListState, list: List, itemThreshold: Int = 3) = with(listState) { - val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } } +private fun MessageStatusDialog( + message: Message, + nodes: List, + resendOption: Boolean, + onResend: () -> Unit, + onDismiss: () -> Unit, +) { + val (title, text) = message.getStatusStringRes() + val relayNodeName by + remember(message.relayNode, nodes) { + derivedStateOf { + message.relayNode?.let { relayNodeId -> Packet.getRelayNode(relayNodeId, nodes)?.user?.longName } + } + } + DeliveryInfo( + title = title, + resendOption = resendOption, + text = text, + relayNodeName = relayNodeName, + onConfirm = onResend, + onDismiss = onDismiss, + ) +} + +@Composable +private fun rememberMessageRows( + messages: List, + showUnreadDivider: Boolean, + unreadDividerIndex: Int?, + initialUnreadMessageUuid: Long?, +) = remember(messages, showUnreadDivider, unreadDividerIndex, initialUnreadMessageUuid) { + buildList { + messages.forEachIndexed { index, message -> + add(MessageListRow.ChatMessage(index = index, message = message)) + if (showUnreadDivider && unreadDividerIndex == index) { + val key = initialUnreadMessageUuid?.let { "unread-divider-$it" } ?: "unread-divider-index-$index" + add(MessageListRow.UnreadDivider(key = key)) + } + } + } +} + +/** + * Calculates the index of the first unread remote message. + * + * We track unread state with two sources: the persisted timestamp of the last read message and the in-memory + * `Message.read` flag. The timestamp helps when the local flag state is stale (e.g. after app restarts), while the flag + * catches messages that are already marked read locally. We take the maximum of the two indices to target the oldest + * unread entry that still needs attention. The message list is newest-first, so we deliberately use `lastOrNull` for + * the timestamp branch to land on the oldest unread item after the stored mark. + */ +internal fun findEarliestUnreadIndex(messages: List, lastReadMessageTimestamp: Long?): Int? { + val remoteMessages = messages.withIndex().filter { !it.value.fromLocal } + if (remoteMessages.isEmpty()) { + return null + } + val timestampIndex = + lastReadMessageTimestamp?.let { timestamp -> + remoteMessages.lastOrNull { it.value.receivedTime > timestamp }?.index + } + val readFlagIndex = messages.indexOfLast { !it.read && !it.fromLocal }.takeIf { it != -1 } + return listOfNotNull(timestampIndex, readFlagIndex).maxOrNull() +} + +@Composable +private fun UnreadMessagesDivider(modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.new_messages_below), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun AutoScrollToBottom( + listState: LazyListState, + list: List, + hasUnreadMessages: Boolean, + itemThreshold: Int = 3, +) = with(listState) { + val shouldAutoScroll by + remember(hasUnreadMessages) { + derivedStateOf { !hasUnreadMessages && firstVisibleItemIndex < itemThreshold } + } if (shouldAutoScroll) { LaunchedEffect(list) { if (!isScrollInProgress) { @@ -228,15 +380,21 @@ private fun AutoScrollToBottom(listState: LazyListState, list: List, item @OptIn(FlowPreview::class) @Composable -private fun UpdateUnreadCount(listState: LazyListState, messages: List, onUnreadChanged: (Long) -> Unit) { +private fun UpdateUnreadCount( + listState: LazyListState, + messages: List, + onUnreadChanged: (Long, Long) -> Unit, +) { LaunchedEffect(messages) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(timeoutMillis = 500L) .collectLatest { index -> - val lastUnreadIndex = messages.indexOfLast { !it.read } + val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal } if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) { val visibleMessage = messages[index] - onUnreadChanged(visibleMessage.receivedTime) + if (!visibleMessage.read && !visibleMessage.fromLocal) { + onUnreadChanged(visibleMessage.uuid, visibleMessage.receivedTime) + } } } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt index 62bf93aa8e..14e3d886c8 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt @@ -30,8 +30,8 @@ internal sealed interface MessageScreenEvent { /** Delete one or more selected messages. */ data class DeleteMessages(val ids: List) : MessageScreenEvent - /** Mark messages up to a certain ID as read. */ - data class ClearUnreadCount(val lastReadMessageId: Long) : MessageScreenEvent + /** Mark messages up to a certain message as read. */ + data class ClearUnreadCount(val messageUuid: Long, val lastReadTimestamp: Long) : MessageScreenEvent /** Handle an action from a node's context menu. */ data class NodeDetails(val node: Node) : MessageScreenEvent diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 0864b9dcf9..07c0e85970 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.QuickChatActionRepository import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket @@ -78,6 +79,9 @@ constructor( val quickChatActions = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) + val contactSettings: StateFlow> = + packetRepository.getContactSettings().stateInWhileSubscribed(initialValue = emptyMap()) + private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null) private val messagesForContactKey: StateFlow> = contactKeyForMessages @@ -158,11 +162,17 @@ constructor( fun deleteMessages(uuidList: List) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) } - fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.clearUnreadCount(contact, timestamp) - val unreadCount = packetRepository.getUnreadCount(contact) - if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact) - } + fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = + viewModelScope.launch(Dispatchers.IO) { + val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE + if (lastReadTimestamp <= existingTimestamp) { + return@launch + } + packetRepository.clearUnreadCount(contact, lastReadTimestamp) + packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) + val unreadCount = packetRepository.getUnreadCount(contact) + if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact) + } private fun favoriteNode(node: Node) = viewModelScope.launch { try { From 7e6579f829e9e13078dc44593cb71e9883d95980 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:22:48 -0800 Subject: [PATCH 15/62] fix: address backfill issue on tcp connections; add logging (#3676) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../geeksville/mesh/service/MeshService.kt | 256 ++++++++++++++++-- .../service/StoreForwardHistoryRequestTest.kt | 67 +++++ .../meshtastic/core/prefs/mesh/MeshPrefs.kt | 28 ++ 3 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 2b0a401893..2a8dca5a16 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -26,12 +26,15 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.RemoteException +import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.core.app.ServiceCompat import androidx.core.location.LocationCompat import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.concurrent.handledLaunch import com.geeksville.mesh.model.NO_DEVICE_SELECTED import com.geeksville.mesh.repository.network.MQTTRepository +import com.geeksville.mesh.repository.radio.InterfaceId import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.util.ignoreException import com.geeksville.mesh.util.toRemoteExceptions @@ -50,7 +53,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import org.meshtastic.core.analytics.DataPair import org.meshtastic.core.analytics.platform.PlatformAnalytics import org.meshtastic.core.common.hasLocationPermission @@ -118,6 +123,8 @@ import org.meshtastic.proto.position import org.meshtastic.proto.telemetry import org.meshtastic.proto.user import timber.log.Timber +import java.util.ArrayDeque +import java.util.Locale import java.util.UUID import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -220,12 +227,66 @@ class MeshService : Service() { private const val DEFAULT_CONFIG_ONLY_NONCE = 69420 private const val DEFAULT_NODE_INFO_NONCE = 69421 - private const val WANT_CONFIG_DELAY = 250L + private const val WANT_CONFIG_DELAY = 50L + private const val HISTORY_TAG = "HistoryReplay" + private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24 + private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 + private const val MAX_EARLY_PACKET_BUFFER = 128 + + @VisibleForTesting + internal fun buildStoreForwardHistoryRequest( + lastRequest: Int, + historyReturnWindow: Int, + historyReturnMax: Int, + ): StoreAndForwardProtos.StoreAndForward { + val historyBuilder = StoreAndForwardProtos.StoreAndForward.History.newBuilder() + if (lastRequest > 0) historyBuilder.lastRequest = lastRequest + if (historyReturnWindow > 0) historyBuilder.window = historyReturnWindow + if (historyReturnMax > 0) historyBuilder.historyMessages = historyReturnMax + return StoreAndForwardProtos.StoreAndForward.newBuilder() + .setRr(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY) + .setHistory(historyBuilder) + .build() + } + + @VisibleForTesting + internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { + val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES + val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES + return resolvedWindow to resolvedMax + } } private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private inline fun historyLog( + priority: Int = Log.INFO, + throwable: Throwable? = null, + crossinline message: () -> String, + ) { + if (!BuildConfig.DEBUG) return + val timber = Timber.tag(HISTORY_TAG) + val msg = message() + if (throwable != null) { + timber.log(priority, throwable, msg) + } else { + timber.log(priority, msg) + } + } + + private fun activeDeviceAddress(): String? = + meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() } + + private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) { + InterfaceId.BLUETOOTH.id -> "BLE" + InterfaceId.TCP.id -> "TCP" + InterfaceId.SERIAL.id -> "Serial" + InterfaceId.MOCK.id -> "Mock" + InterfaceId.NOP.id -> "NOP" + else -> "Unknown" + } + private var locationFlow: Job? = null private var mqttMessageFlow: Job? = null @@ -312,7 +373,17 @@ class MeshService : Service() { // Switch to the IO thread serviceScope.handledLaunch { radioInterfaceService.connect() } radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) - radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope) + radioInterfaceService.receivedData + .onStart { + historyLog { "rxCollector START transport=${currentTransport()} scope=${serviceScope.hashCode()}" } + } + .onCompletion { cause -> + historyLog(Log.WARN) { + "rxCollector STOP transport=${currentTransport()} cause=${cause?.message ?: "completed"}" + } + } + .onEach(::onReceiveFromRadio) + .launchIn(serviceScope) radioInterfaceService.connectionError .onEach { error -> Timber.e("BLE Connection Error: ${error.message}") } .launchIn(serviceScope) @@ -424,6 +495,7 @@ class MeshService : Service() { myNodeInfo = null nodeDBbyNodeNum.clear() haveNodeDB = false + earlyReceivedPackets.clear() } private var myNodeInfo: MyNodeEntity? = null @@ -1040,7 +1112,79 @@ class MeshService : Service() { updateNodeInfo(fromNum) { it.paxcounter = p } } + /** + * Ask the connected radio to replay any packets it buffered while the client was offline. + * + * Radios deliver history via the Store & Forward protocol regardless of transport, so we piggyback on that + * mechanism after BLE/Wi‑Fi reconnects. + */ + private fun requestHistoryReplay(trigger: String) { + val address = activeDeviceAddress() + val failure = + when { + address == null -> "no_active_address" + myNodeNum == null -> "no_my_node" + else -> null + } + if (failure != null) { + historyLog { "requestHistory skipped trigger=$trigger reason=$failure" } + return + } + + val safeAddress = address!! + val myNum = myNodeNum!! + val storeForwardConfig = moduleConfig.storeForward + val lastRequest = meshPrefs.getStoreForwardLastRequest(safeAddress) + val (window, max) = + resolveHistoryRequestParameters(storeForwardConfig.historyReturnWindow, storeForwardConfig.historyReturnMax) + val windowSource = if (storeForwardConfig.historyReturnWindow > 0) "config" else "default" + val maxSource = if (storeForwardConfig.historyReturnMax > 0) "config" else "default" + val sourceSummary = "window=$window($windowSource) max=$max($maxSource)" + val request = + buildStoreForwardHistoryRequest( + lastRequest = lastRequest, + historyReturnWindow = window, + historyReturnMax = max, + ) + val logContext = "trigger=$trigger transport=${currentTransport(safeAddress)} addr=$safeAddress" + historyLog { "requestHistory $logContext lastRequest=$lastRequest $sourceSummary" } + + runCatching { + packetHandler.sendToRadio( + newMeshPacketTo(myNum).buildMeshPacket(priority = MeshPacket.Priority.BACKGROUND) { + portnumValue = Portnums.PortNum.STORE_FORWARD_APP_VALUE + payload = ByteString.copyFrom(request.toByteArray()) + }, + ) + } + .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed $logContext" } } + } + + private fun updateStoreForwardLastRequest(source: String, lastRequest: Int) { + if (lastRequest <= 0) return + val address = activeDeviceAddress() ?: return + val current = meshPrefs.getStoreForwardLastRequest(address) + val transport = currentTransport(address) + val logContext = "source=$source transport=$transport address=$address" + if (lastRequest != current) { + meshPrefs.setStoreForwardLastRequest(address, lastRequest) + historyLog { "historyMarker updated $logContext from=$current to=$lastRequest" } + } else { + historyLog(Log.DEBUG) { "historyMarker unchanged $logContext value=$lastRequest" } + } + } + private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { + Timber.d("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") + val transport = currentTransport() + val lastRequest = + if (s.variantCase == StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY) { + s.history.lastRequest + } else { + 0 + } + val baseContext = "transport=$transport from=${dataPacket.from}" + historyLog { "rxStoreForward $baseContext variant=${s.variantCase} rr=${s.rr} lastRequest=$lastRequest" } when (s.variantCase) { StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { val u = @@ -1052,6 +1196,11 @@ class MeshService : Service() { } StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { + val history = s.history + val historySummary = + "routerHistory $baseContext messages=${history.historyMessages} " + + "window=${history.window} lastRequest=${history.lastRequest}" + historyLog(Log.DEBUG) { historySummary } val text = """ Total messages: ${s.history.historyMessages} @@ -1065,12 +1214,17 @@ class MeshService : Service() { dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) rememberDataPacket(u) + updateStoreForwardLastRequest("router_history", s.history.lastRequest) } StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { dataPacket.to = DataPacket.ID_BROADCAST } + val textLog = + "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} " + + "to=${dataPacket.to} decision=remember" + historyLog(Log.DEBUG) { textLog } val u = dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) rememberDataPacket(u) @@ -1080,29 +1234,59 @@ class MeshService : Service() { } } + private val earlyReceivedPackets = ArrayDeque() + // If apps try to send packets when our radio is sleeping, we queue them here instead private val offlineSentPackets = mutableListOf() // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { + val preparedPacket = + packet + .toBuilder() + .apply { + // If the rxTime was not set by the device, update with current time + if (packet.rxTime == 0) setRxTime(currentSecond()) + } + .build() Timber.d("[packet]: ${packet.toOneLineString()}") if (haveNodeDB) { - processReceivedMeshPacket( - packet - .toBuilder() - .apply { - // If the rxTime was not set by the device, update with current time - if (packet.rxTime == 0) setRxTime(currentSecond()) + processReceivedMeshPacket(preparedPacket) + return + } + + val queueSize = earlyReceivedPackets.size + if (queueSize >= MAX_EARLY_PACKET_BUFFER) { + val dropped = earlyReceivedPackets.removeFirst() + historyLog(Log.WARN) { + val portLabel = + if (dropped.hasDecoded()) { + Portnums.PortNum.forNumber(dropped.decoded.portnumValue)?.name + ?: dropped.decoded.portnumValue.toString() + } else { + "unknown" } - .build(), - ) - } else { - Timber.w("Ignoring early received packet: ${packet.toOneLineString()}") - // earlyReceivedPackets.add(packet) - // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, - // but if the device is - // messed up it might try to send forever + "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel" + } } + + earlyReceivedPackets.addLast(preparedPacket) + val portLabel = + if (preparedPacket.hasDecoded()) { + Portnums.PortNum.forNumber(preparedPacket.decoded.portnumValue)?.name + ?: preparedPacket.decoded.portnumValue.toString() + } else { + "unknown" + } + historyLog { "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel" } + } + + private fun flushEarlyReceivedPackets(reason: String) { + if (earlyReceivedPackets.isEmpty()) return + val packets = earlyReceivedPackets.toList() + earlyReceivedPackets.clear() + historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" } + packets.forEach(::processReceivedMeshPacket) } private fun sendNow(p: DataPacket) { @@ -1240,6 +1424,7 @@ class MeshService : Service() { private var connectTimeMsec = 0L // Called when we gain/lose connection to our radio + @Suppress("CyclomaticComplexMethod") private fun onConnectionChanged(c: ConnectionState) { Timber.d("onConnectionChanged: ${connectionStateHolder.getState()} -> $c") @@ -1292,6 +1477,10 @@ class MeshService : Service() { fun startConnect() { Timber.d("Starting connect") + historyLog { + val address = meshPrefs.deviceAddress ?: "null" + "onReconnect transport=${currentTransport()} node=$address" + } try { connectTimeMsec = System.currentTimeMillis() startConfigOnly() @@ -1426,24 +1615,32 @@ class MeshService : Service() { */ private fun onReceiveFromRadio(bytes: ByteArray) { runCatching { MeshProtos.FromRadio.parseFrom(bytes) } - .onSuccess { proto -> proto.route() } + .onSuccess { proto -> + if (proto.payloadVariantCase == PayloadVariantCase.PAYLOADVARIANT_NOT_SET) { + Timber.w( + "Received FromRadio with PAYLOADVARIANT_NOT_SET. rawBytes=${bytes.toHexString()} proto=$proto", + ) + } + proto.route() + } .onFailure { primaryException -> runCatching { val logRecord = MeshProtos.LogRecord.parseFrom(bytes) handleLogRecord(logRecord) } .onFailure { _ -> - val packet = bytes.toHexString() Timber.e( primaryException, - "Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord.", + "Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " + + "Not a valid FromRadio or LogRecord.", ) } } } /** Extension function to convert a ByteArray to a hex string for logging. Example output: "0x0a,0x1f,0x..." */ - private fun ByteArray.toHexString(): String = this.joinToString(",") { byte -> String.format("0x%02x", byte) } + private fun ByteArray.toHexString(): String = + this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) } // A provisional MyNodeInfo that we will install if all of our node config downloads go okay private var newMyNodeInfo: MyNodeEntity? = null @@ -1778,6 +1975,12 @@ class MeshService : Service() { serviceBroadcasts.broadcastConnection() sendAnalytics() reportConnection() + historyLog { + val ports = + rememberDataType.joinToString(",") { port -> Portnums.PortNum.forNumber(port)?.name ?: port.toString() } + "subscribePorts afterReconnect ports=$ports" + } + requestHistoryReplay("onHasSettings") packetHandler.sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) } @@ -1849,6 +2052,7 @@ class MeshService : Service() { newNodes.clear() serviceScope.handledLaunch { nodeRepository.installConfig(myNodeInfo!!, nodeDBbyNodeNum.values.toList()) } haveNodeDB = true + flushEarlyReceivedPackets("node_info_complete") sendAnalytics() onHasSettings() } @@ -2038,12 +2242,24 @@ class MeshService : Service() { Timber.d( "SetDeviceAddress: Device address changed from ${currentAddr.anonymize} to ${deviceAddr.anonymize}", ) + val currentLabel = currentAddr ?: "null" + val nextLabel = deviceAddr ?: "null" + val nextTransport = currentTransport(deviceAddr) + historyLog { "dbSwitch request current=$currentLabel next=$nextLabel transportNext=$nextTransport" } meshPrefs.deviceAddress = deviceAddr serviceScope.handledLaunch { // Clear only in-memory caches to avoid cross-device bleed discardNodeDB() // Switch active on-disk DB to device-specific database databaseManager.switchActiveDatabase(deviceAddr) + val activeAddress = databaseManager.currentAddress.value + val activeLabel = activeAddress ?: "null" + val transportLabel = currentTransport() + val meshAddress = meshPrefs.deviceAddress ?: "null" + val nodeId = myNodeInfo?.myNodeNum?.toString() ?: "unknown" + val dbSummary = + "dbSwitch activeAddress=$activeLabel nodeId=$nodeId transport=$transportLabel addr=$meshAddress" + historyLog { dbSummary } // Do not clear packet DB here; messages are per-device and should persist clearNotifications() } diff --git a/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt new file mode 100644 index 0000000000..52f6b16708 --- /dev/null +++ b/app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 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 com.geeksville.mesh.service + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.meshtastic.proto.StoreAndForwardProtos + +class StoreForwardHistoryRequestTest { + + @Test + fun `buildStoreForwardHistoryRequest copies positive parameters`() { + val request = + MeshService.buildStoreForwardHistoryRequest( + lastRequest = 42, + historyReturnWindow = 15, + historyReturnMax = 25, + ) + + assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) + assertEquals(42, request.history.lastRequest) + assertEquals(15, request.history.window) + assertEquals(25, request.history.historyMessages) + } + + @Test + fun `buildStoreForwardHistoryRequest omits non-positive parameters`() { + val request = + MeshService.buildStoreForwardHistoryRequest(lastRequest = 0, historyReturnWindow = -1, historyReturnMax = 0) + + assertEquals(StoreAndForwardProtos.StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) + assertEquals(0, request.history.lastRequest) + assertEquals(0, request.history.window) + assertEquals(0, request.history.historyMessages) + } + + @Test + fun `resolveHistoryRequestParameters uses config values when positive`() { + val (window, max) = MeshService.resolveHistoryRequestParameters(window = 30, max = 10) + + assertEquals(30, window) + assertEquals(10, max) + } + + @Test + fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() { + val (window, max) = MeshService.resolveHistoryRequestParameters(window = 0, max = -5) + + assertEquals(1440, window) + assertEquals(100, max) + } +} diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt index a0499a7e6b..fb121a692d 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefs.kt @@ -21,6 +21,7 @@ import android.content.SharedPreferences import androidx.core.content.edit import org.meshtastic.core.prefs.NullableStringPrefDelegate import org.meshtastic.core.prefs.di.MeshSharedPreferences +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -30,6 +31,10 @@ interface MeshPrefs { fun shouldProvideNodeLocation(nodeNum: Int?): Boolean fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) + + fun getStoreForwardLastRequest(address: String?): Int + + fun setStoreForwardLastRequest(address: String?, value: Int) } @Singleton @@ -43,7 +48,30 @@ class MeshPrefsImpl @Inject constructor(@MeshSharedPreferences private val prefs prefs.edit { putBoolean(provideLocationKey(nodeNum), value) } } + override fun getStoreForwardLastRequest(address: String?): Int = prefs.getInt(storeForwardKey(address), 0) + + override fun setStoreForwardLastRequest(address: String?, value: Int) { + prefs.edit { + if (value <= 0) { + remove(storeForwardKey(address)) + } else { + putInt(storeForwardKey(address), value) + } + } + } + private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum" + + private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" + + private fun normalizeAddress(address: String?): String { + val raw = address?.trim()?.takeIf { it.isNotEmpty() } + return when { + raw == null -> "DEFAULT" + raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT" + else -> raw.uppercase(Locale.US).replace(":", "") + } + } } private const val NO_DEVICE_SELECTED = "n" From 4a07e4d50e107aa9cdd11c0a6ca628f0a1e23a2f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:03:32 -0600 Subject: [PATCH 16/62] feat(build): Add distinct names for debug builds (#3707) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/src/fdroidDebug/res/values/strings.xml | 19 +++++++++++++++++++ app/src/googleDebug/res/values/strings.xml | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 app/src/fdroidDebug/res/values/strings.xml create mode 100644 app/src/googleDebug/res/values/strings.xml diff --git a/app/src/fdroidDebug/res/values/strings.xml b/app/src/fdroidDebug/res/values/strings.xml new file mode 100644 index 0000000000..2571a435c3 --- /dev/null +++ b/app/src/fdroidDebug/res/values/strings.xml @@ -0,0 +1,19 @@ + + + Fdroid Debug + diff --git a/app/src/googleDebug/res/values/strings.xml b/app/src/googleDebug/res/values/strings.xml new file mode 100644 index 0000000000..dccab15c73 --- /dev/null +++ b/app/src/googleDebug/res/values/strings.xml @@ -0,0 +1,19 @@ + + + Google Debug + From 43548e6a893ae2a9a68da456603f58b4fa4b57de Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 20:04:16 -0600 Subject: [PATCH 17/62] chore(deps): update google maps compose to v6.12.2 (#3706) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 3ba249a951..4d07aa7af1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ kotlinx-serialization = "1.9.0" # Google hilt = "2.57.2" -maps-compose = "6.12.1" +maps-compose = "6.12.2" # Networking ktor = "3.3.2" From edd9b2910e4e9250f589e84c74e832610932c58a Mon Sep 17 00:00:00 2001 From: b8b8 <156552149+b8b8@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:32:17 -0800 Subject: [PATCH 18/62] Update strings.xml (#3711) Signed-off-by: b8b8 <156552149+b8b8@users.noreply.github.com> Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- core/strings/src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 268b5affcf..0c6e84a2b5 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -930,7 +930,7 @@ Device configuration "[Remote] %1$s" Send Device Telemetry - Enable/Disable the device telemetry module to send metrics to the mesh + Enable/Disable the device telemetry module to send metrics to the mesh. These are nominal values. Congested meshes will automatically scale to longer intervals based on number of online nodes. Meshes less than 10 nodes will scale to faster intervals. Any 1 Hour 8 Hours From c096572e4240199ee6f63c700242f27d9bd37e21 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Sun, 16 Nov 2025 15:29:00 +1100 Subject: [PATCH 19/62] add back arrow to the channelConfig screen (#3713) --- .../settings/radio/channel/ChannelConfigScreen.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 65a57c2e8f..1e20496fc1 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -62,6 +62,7 @@ import org.meshtastic.core.strings.channel_name import org.meshtastic.core.strings.channels import org.meshtastic.core.strings.press_and_drag import org.meshtastic.core.strings.send +import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.PreferenceFooter import org.meshtastic.core.ui.component.dragContainer import org.meshtastic.core.ui.component.dragDropItemsIndexed @@ -159,6 +160,17 @@ private fun ChannelConfigScreen( } Scaffold( + topBar = { + MainAppBar( + title = title, + canNavigateUp = true, + onNavigateUp = onBack, + ourNode = null, + showNodeChip = false, + actions = {}, + onClickChip = {}, + ) + }, floatingActionButton = { if (maxChannels > settingsListInput.size) { FloatingActionButton( From f70c118b444c10881d3bb05b092fdc640549e4c6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:29:22 -0600 Subject: [PATCH 20/62] refactor(ble): Migrate to Nordic BLE Library for scanning and bonding (#3712) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/model/BTScanModel.kt | 268 ++++++------------ .../geeksville/mesh/model/DeviceListEntry.kt | 66 +++++ .../bluetooth/BluetoothLeScanner.kt | 48 ---- .../bluetooth/BluetoothRepository.kt | 151 ++++++---- .../bluetooth/BluetoothRepositoryModule.kt | 29 +- .../repository/bluetooth/BluetoothState.kt | 4 +- .../repository/radio/NordicBleInterface.kt | 13 +- .../mesh/ui/connections/ConnectionsScreen.kt | 17 +- 8 files changed, 275 insertions(+), 321 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index f443b275b7..e09146993e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -17,9 +17,7 @@ package com.geeksville.mesh.model -import android.annotation.SuppressLint import android.app.Application -import android.bluetooth.BluetoothDevice import android.content.Context import android.hardware.usb.UsbManager import android.os.RemoteException @@ -29,18 +27,14 @@ import androidx.lifecycle.viewModelScope import com.geeksville.mesh.repository.bluetooth.BluetoothRepository import com.geeksville.mesh.repository.network.NetworkRepository import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString -import com.geeksville.mesh.repository.radio.InterfaceId import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.usb.UsbRepository import com.geeksville.mesh.service.MeshService -import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -54,51 +48,11 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.meshtastic -import org.meshtastic.core.strings.pairing_completed -import org.meshtastic.core.strings.pairing_failed_try_again import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import timber.log.Timber import javax.inject.Inject -/** - * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is - * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for - * exhaustive `when` expressions in the code, making it more robust and readable. - * - * @param name The display name of the device. - * @param fullAddress The unique address of the device, prefixed with a type identifier. - * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB). - */ -sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) { - val address: String - get() = fullAddress.substring(1) - - override fun toString(): String = - "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - - @Suppress("MissingPermission") - data class Ble(val device: BluetoothDevice) : - DeviceListEntry( - name = device.name ?: "unnamed-${device.address}", - fullAddress = "x${device.address}", - bonded = device.bondState == BluetoothDevice.BOND_BONDED, - ) - - data class Usb( - private val radioInterfaceService: RadioInterfaceService, - private val usbManager: UsbManager, - val driver: UsbSerialDriver, - ) : DeviceListEntry( - name = driver.device.deviceName, - fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName), - bonded = usbManager.hasPermission(driver.device), - ) - - data class Tcp(override val name: String, override val fullAddress: String) : - DeviceListEntry(name, fullAddress, true) - - data class Mock(override val name: String) : DeviceListEntry(name, "m", true) -} +// ... (DeviceListEntry sealed class remains the same) ... @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") @@ -117,14 +71,18 @@ constructor( private val context: Context get() = application.applicationContext - val errorText = MutableLiveData(null) - val showMockInterface: StateFlow get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() - private val bleDevicesFlow: StateFlow> = + val errorText = MutableLiveData(null) + private val bondedBleDevicesFlow: StateFlow> = bluetoothRepository.state - .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) }.sortedBy { it.name } } + .map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + private val scannedBleDevicesFlow: StateFlow> = + bluetoothRepository.scannedDevices + .map { peripherals -> peripherals.map { DeviceListEntry.Ble(it) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Flow for discovered TCP devices, using recent addresses for potential name enrichment @@ -151,7 +109,29 @@ constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + /** A combined list of bonded and scanned BLE devices for the UI. */ + val bleDevicesForUi: StateFlow> = + combine(bondedBleDevicesFlow, scannedBleDevicesFlow) { bonded, scanned -> + val bondedAddresses = bonded.map { it.fullAddress }.toSet() + val uniqueScanned = scanned.filterNot { it.fullAddress in bondedAddresses } + (bonded + uniqueScanned).sortedBy { it.name } + } + .stateInWhileSubscribed(initialValue = emptyList()) + + private val usbDevicesFlow: StateFlow> = + usbRepository.serialDevicesWithDrivers + .map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mockDevice = DeviceListEntry.Mock("Demo Mode") + // Flow for recent TCP devices, filtered to exclude any currently discovered devices + val usbDevicesForUi: StateFlow> = + combine(usbDevicesFlow, showMockInterface) { usb, showMock -> + usb + if (showMock) listOf(mockDevice) else emptyList() + } + .stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList()) + private val filteredRecentTcpDevicesFlow: StateFlow> = combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) { recentList, @@ -165,16 +145,6 @@ constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - private val usbDevicesFlow: StateFlow> = - usbRepository.serialDevicesWithDrivers - .map { usb -> usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) } } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - val mockDevice = DeviceListEntry.Mock("Demo Mode") - - val bleDevicesForUi: StateFlow> = - bleDevicesFlow.stateInWhileSubscribed(initialValue = emptyList()) - /** UI StateFlow for discovered TCP devices. */ val discoveredTcpDevicesForUi: StateFlow> = processedDiscoveredTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf()) @@ -183,11 +153,14 @@ constructor( val recentTcpDevicesForUi: StateFlow> = filteredRecentTcpDevicesFlow.stateInWhileSubscribed(initialValue = listOf()) - val usbDevicesForUi: StateFlow> = - combine(usbDevicesFlow, showMockInterface) { usb, showMock -> - usb + if (showMock) listOf(mockDevice) else emptyList() - } - .stateInWhileSubscribed(initialValue = if (showMockInterface.value) listOf(mockDevice) else emptyList()) + val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow + + val selectedNotNullFlow: StateFlow = + selectedAddressFlow + .map { it ?: NO_DEVICE_SELECTED } + .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) + + val spinner: StateFlow = bluetoothRepository.isScanning init { serviceRepository.statusMessage.onEach { errorText.value = it }.launchIn(viewModelScope) @@ -196,6 +169,7 @@ constructor( override fun onCleared() { super.onCleared() + bluetoothRepository.stopScan() Timber.d("BTScanModel cleared") } @@ -203,66 +177,18 @@ constructor( errorText.value = text } - private var scanJob: Job? = null - - val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow - - val selectedNotNullFlow: StateFlow = - selectedAddressFlow - .map { it ?: NO_DEVICE_SELECTED } - .stateInWhileSubscribed(initialValue = selectedAddressFlow.value ?: NO_DEVICE_SELECTED) - - val scanResult = MutableLiveData>(mutableMapOf()) - - fun clearScanResults() { - stopScan() - scanResult.value = mutableMapOf() - } - fun stopScan() { - if (scanJob != null) { - Timber.d("stopping scan") - try { - scanJob?.cancel() - } catch (ex: Throwable) { - Timber.w("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") - } finally { - scanJob = null - } - } - _spinner.value = false + Timber.d("stopping scan") + bluetoothRepository.stopScan() } fun refreshPermissions() { - // Refresh the Bluetooth state to ensure we have the latest permissions bluetoothRepository.refreshState() } - @SuppressLint("MissingPermission") fun startScan() { - Timber.d("starting classic scan") - - _spinner.value = true - scanJob = - bluetoothRepository - .scan() - .onEach { result -> - val fullAddress = - radioInterfaceService.toInterfaceAddress(InterfaceId.BLUETOOTH, result.device.address) - // prevent log spam because we'll get lots of redundant scan results - val oldDevs = scanResult.value!! - val oldEntry = oldDevs[fullAddress] - // Don't spam the GUI with endless updates for non changing nodes - if ( - oldEntry == null || oldEntry.bonded != (result.device.bondState == BluetoothDevice.BOND_BONDED) - ) { - val entry = DeviceListEntry.Ble(result.device) - oldDevs[entry.fullAddress] = entry - scanResult.value = oldDevs - } - } - .catch { ex -> serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") } - .launchIn(viewModelScope) + Timber.d("starting ble scan") + bluetoothRepository.startScan() } private fun changeDeviceAddress(address: String) { @@ -270,34 +196,26 @@ constructor( serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) } } catch (ex: RemoteException) { Timber.e(ex, "changeDeviceSelection failed, probably it is shutting down") - // ignore the failure and the GUI won't be updating anyways } } - @SuppressLint("MissingPermission") - private fun requestBonding(it: DeviceListEntry) { - val device = bluetoothRepository.getRemoteDevice(it.address) ?: return - Timber.i("Starting bonding for ${device.anonymize}") - - bluetoothRepository - .createBond(device) - .onEach { state -> - Timber.d("Received bond state changed $state") - if (state != BluetoothDevice.BOND_BONDING) { - Timber.d("Bonding completed, state=$state") - if (state == BluetoothDevice.BOND_BONDED) { - setErrorText(getString(Res.string.pairing_completed)) - changeDeviceAddress("x${device.address}") - } else { - setErrorText(getString(Res.string.pairing_failed_try_again)) - } - } - } - .catch { ex -> - // We ignore missing BT adapters, because it lets us run on the emulator - Timber.w("Failed creating Bluetooth bond: ${ex.message}") + /** Initiates the bonding process and connects to the device upon success. */ + private fun requestBonding(entry: DeviceListEntry.Ble) { + Timber.i("Starting bonding for ${entry.peripheral.address.anonymize}") + viewModelScope.launch { + @Suppress("TooGenericExceptionCaught") + try { + bluetoothRepository.bond(entry.peripheral) + Timber.i("Bonding complete for ${entry.peripheral.address.anonymize}, selecting device...") + changeDeviceAddress(entry.fullAddress) + } catch (ex: SecurityException) { + Timber.e(ex, "Bonding failed for ${entry.peripheral.address.anonymize} Permissions not granted") + serviceRepository.setErrorMessage("Bonding failed: ${ex.message} Permissions not granted") + } catch (ex: Exception) { + Timber.e(ex, "Bonding failed for ${entry.peripheral.address.anonymize}") + serviceRepository.setErrorMessage("Bonding failed: ${ex.message}") } - .launchIn(viewModelScope) + } } private fun requestPermission(it: DeviceListEntry.Usb) { @@ -323,54 +241,46 @@ constructor( viewModelScope.launch { recentAddressesDataSource.remove(address) } } - // Called by the GUI when a new device has been selected by the user - // @returns true if we were able to change to that item - fun onSelected(it: DeviceListEntry): Boolean { - // Using a `when` expression on the sealed class is much cleaner and safer than if/else chains. - // It ensures that all device types are handled, and the compiler can catch any omissions. - return when (it) { - is DeviceListEntry.Ble -> { - if (it.bonded) { - changeDeviceAddress(it.fullAddress) - true - } else { - requestBonding(it) - false - } - } - - is DeviceListEntry.Usb -> { - if (it.bonded) { - changeDeviceAddress(it.fullAddress) - true - } else { - requestPermission(it) - false - } - } - - is DeviceListEntry.Tcp -> { - viewModelScope.launch { - addRecentAddress(it.fullAddress, it.name) - changeDeviceAddress(it.fullAddress) - } + /** + * Called by the GUI when a new device has been selected by the user. + * + * @return true if the connection was initiated immediately. + */ + fun onSelected(it: DeviceListEntry): Boolean = when (it) { + is DeviceListEntry.Ble -> { + if (it.bonded) { + changeDeviceAddress(it.fullAddress) true + } else { + requestBonding(it) + false } - - is DeviceListEntry.Mock -> { + } + is DeviceListEntry.Usb -> { + if (it.bonded) { changeDeviceAddress(it.fullAddress) true + } else { + requestPermission(it) + false } } + is DeviceListEntry.Tcp -> { + viewModelScope.launch { + addRecentAddress(it.fullAddress, it.name) + changeDeviceAddress(it.fullAddress) + } + true + } + is DeviceListEntry.Mock -> { + changeDeviceAddress(it.fullAddress) + true + } } fun disconnect() { changeDeviceAddress(NO_DEVICE_SELECTED) } - - private val _spinner = MutableStateFlow(false) - val spinner: StateFlow - get() = _spinner.asStateFlow() } const val NO_DEVICE_SELECTED = "n" diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt new file mode 100644 index 0000000000..406b29e4e1 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 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 com.geeksville.mesh.model + +import android.hardware.usb.UsbManager +import com.geeksville.mesh.repository.radio.InterfaceId +import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.hoho.android.usbserial.driver.UsbSerialDriver +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.core.BondState +import org.meshtastic.core.model.util.anonymize + +/** + * A sealed class is used here to represent the different types of devices that can be displayed in the list. This is + * more type-safe and idiomatic than using a base class with boolean flags (e.g., isBLE, isUSB). It allows for + * exhaustive `when` expressions in the code, making it more robust and readable. + * + * @param name The display name of the device. + * @param fullAddress The unique address of the device, prefixed with a type identifier. + * @param bonded Indicates whether the device is bonded (for BLE) or has permission (for USB). + */ +sealed class DeviceListEntry(open val name: String, open val fullAddress: String, open val bonded: Boolean) { + val address: String + get() = fullAddress.substring(1) + + override fun toString(): String = + "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" + + @Suppress("MissingPermission") + data class Ble(val peripheral: Peripheral) : + DeviceListEntry( + name = peripheral.name ?: "unnamed-${peripheral.address}", + fullAddress = "x${peripheral.address}", + bonded = peripheral.bondState.value == BondState.BONDED, + ) + + data class Usb( + private val radioInterfaceService: RadioInterfaceService, + private val usbManager: UsbManager, + val driver: UsbSerialDriver, + ) : DeviceListEntry( + name = driver.device.deviceName, + fullAddress = radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, driver.device.deviceName), + bonded = usbManager.hasPermission(driver.device), + ) + + data class Tcp(override val name: String, override val fullAddress: String) : + DeviceListEntry(name, fullAddress, true) + + data class Mock(override val name: String) : DeviceListEntry(name, "m", true) +} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt deleted file mode 100644 index 0f081a92ce..0000000000 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothLeScanner.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 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 com.geeksville.mesh.repository.bluetooth - -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanCallback -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import androidx.annotation.RequiresPermission -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -@RequiresPermission("android.permission.BLUETOOTH_SCAN") -internal fun BluetoothLeScanner.scan( - filters: List = emptyList(), - scanSettings: ScanSettings = ScanSettings.Builder().build(), -): Flow = callbackFlow { - val callback = object : ScanCallback() { - override fun onScanResult(callbackType: Int, result: ScanResult) { - trySend(result) - } - - override fun onScanFailed(errorCode: Int) { - cancel("onScanFailed() called with errorCode: $errorCode") - } - } - startScan(filters, scanSettings, callback) - - awaitClose { stopScan(callback) } -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt index 5e1548abff..fe649c0a7d 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -17,29 +17,36 @@ package com.geeksville.mesh.repository.bluetooth +import android.annotation.SuppressLint import android.app.Application import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice -import android.bluetooth.le.BluetoothLeScanner -import android.bluetooth.le.ScanFilter -import android.bluetooth.le.ScanResult -import android.bluetooth.le.ScanSettings -import androidx.annotation.RequiresPermission import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import com.geeksville.mesh.repository.radio.BleConstants.BLE_NAME_PATTERN +import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID import com.geeksville.mesh.util.registerReceiverCompat -import kotlinx.coroutines.flow.Flow +import dagger.Lazy +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.Peripheral +import no.nordicsemi.kotlin.ble.client.distinctByPeripheral +import no.nordicsemi.kotlin.ble.core.Manager import org.meshtastic.core.common.hasBluetoothPermission import org.meshtastic.core.di.CoroutineDispatchers import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.toKotlinUuid /** Repository responsible for maintaining and updating the state of Bluetooth availability. */ @Singleton @@ -47,10 +54,10 @@ class BluetoothRepository @Inject constructor( private val application: Application, - private val bluetoothAdapterLazy: dagger.Lazy, - private val bluetoothBroadcastReceiverLazy: dagger.Lazy, + private val bluetoothBroadcastReceiverLazy: Lazy, private val dispatchers: CoroutineDispatchers, private val processLifecycle: Lifecycle, + private val centralManager: CentralManager, ) { private val _state = MutableStateFlow( @@ -62,6 +69,14 @@ constructor( ) val state: StateFlow = _state.asStateFlow() + private val _scannedDevices = MutableStateFlow>(emptyList()) + val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning: StateFlow = _isScanning.asStateFlow() + + private var scanJob: Job? = null + init { processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() @@ -78,58 +93,86 @@ constructor( /** @return true for a valid Bluetooth address, false otherwise */ fun isValid(bleAddress: String): Boolean = BluetoothAdapter.checkBluetoothAddress(bleAddress) - fun getRemoteDevice(address: String): BluetoothDevice? = bluetoothAdapterLazy - .get() - ?.takeIf { application.hasBluetoothPermission() && isValid(address) } - ?.getRemoteDevice(address) - - private fun getBluetoothLeScanner(): BluetoothLeScanner? = - bluetoothAdapterLazy.get()?.takeIf { application.hasBluetoothPermission() }?.bluetoothLeScanner - - fun scan(): Flow { - val filter = - ScanFilter.Builder() - // Samsung doesn't seem to filter properly by service so this can't work - // see - // https://stackoverflow.com/questions/57981986/altbeacon-android-beacon-library-not-working-after-device-has-screen-off-for-a-s/57995960#57995960 - // and https://stackoverflow.com/a/45590493 - // .setServiceUuid(ParcelUuid(BluetoothInterface.BTM_SERVICE_UUID)) - .build() - - val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() - - return getBluetoothLeScanner()?.scan(listOf(filter), settings)?.filter { - it.device.name?.matches(Regex(BLE_NAME_PATTERN)) == true - } ?: emptyFlow() + /** Starts a BLE scan for Meshtastic devices. The results are published to the [scannedDevices] flow. */ + @OptIn(ExperimentalUuidApi::class) + @SuppressLint("MissingPermission") + fun startScan() { + if (isScanning.value) return + + scanJob?.cancel() + _scannedDevices.value = emptyList() + + scanJob = + processLifecycle.coroutineScope.launch(dispatchers.default) { + centralManager + .scan(5.seconds) { ServiceUuid(BTM_SERVICE_UUID.toKotlinUuid()) } + .distinctByPeripheral() + .map { it.peripheral } + .onStart { _isScanning.value = true } + .onCompletion { _isScanning.value = false } + .catch { ex -> + Timber.w(ex, "Bluetooth scan failed") + _isScanning.value = false + } + .collect { peripheral -> + // Add or update the peripheral in our list + val currentList = _scannedDevices.value + _scannedDevices.value = + (currentList.filterNot { it.address == peripheral.address } + peripheral) + } + } } - @RequiresPermission("android.permission.BLUETOOTH_CONNECT") - fun createBond(device: BluetoothDevice): Flow = device.createBond(application) + /** Stops the currently active BLE scan. */ + fun stopScan() { + scanJob?.cancel() + scanJob = null + _isScanning.value = false + } + /** + * Initiates bonding with the given peripheral. This is a suspending function that completes when the bonding + * process is finished. After successful bonding, the repository's state is refreshed to include the new bonded + * device. + * + * @param peripheral The peripheral to bond with. + * @throws SecurityException if required Bluetooth permissions are not granted. + * @throws Exception if the bonding process fails. + */ + @SuppressLint("MissingPermission") + suspend fun bond(peripheral: Peripheral) { + peripheral.createBond() + refreshState() + } + + @OptIn(ExperimentalUuidApi::class) internal suspend fun updateBluetoothState() { val hasPerms = application.hasBluetoothPermission() - val newState: BluetoothState = - bluetoothAdapterLazy.get()?.let { adapter -> - val enabled = adapter.isEnabled - val bondedDevices = adapter.takeIf { hasPerms }?.bondedDevices ?: emptySet() - - BluetoothState( - hasPermissions = hasPerms, - enabled = enabled, - bondedDevices = - if (!enabled) { - emptyList() - } else { - bondedDevices.filter { it.name?.matches(Regex(BLE_NAME_PATTERN)) == true } - }, - ) - } ?: BluetoothState() + val enabled = centralManager.state.value == Manager.State.POWERED_ON + val newState = + BluetoothState( + hasPermissions = hasPerms, + enabled = enabled, + bondedDevices = getBondedAppPeripherals(enabled), + ) _state.emit(newState) Timber.d("Detected our bluetooth access=$newState") } - companion object { - const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$" + private fun getBondedAppPeripherals(enabled: Boolean): List = if (enabled) { + centralManager.getBondedPeripherals().filter(::isMatchingPeripheral) + } else { + emptyList() + } + + /** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */ + @OptIn(ExperimentalUuidApi::class) + private fun isMatchingPeripheral(peripheral: Peripheral): Boolean { + val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false + val hasRequiredService = + peripheral.services(listOf(BTM_SERVICE_UUID.toKotlinUuid())).value?.isNotEmpty() ?: false + + return nameMatches || hasRequiredService } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt index 203e8ca3e7..2c1e5e5697 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepositoryModule.kt @@ -17,9 +17,6 @@ package com.geeksville.mesh.repository.bluetooth -import android.app.Application -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager import android.content.Context import dagger.Module import dagger.Provides @@ -35,23 +32,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -interface BluetoothRepositoryModule { - companion object { - @Provides - fun provideBluetoothManager(application: Application): BluetoothManager? = - application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager? +object BluetoothRepositoryModule { + @Provides + @Singleton + fun provideCentralManager(@ApplicationContext context: Context, coroutineScope: CoroutineScope): CentralManager = + CentralManager.native(context, coroutineScope) - @Provides fun provideBluetoothAdapter(service: BluetoothManager?): BluetoothAdapter? = service?.adapter - - @Provides - @Singleton - fun provideCentralManager( - @ApplicationContext context: Context, - coroutineScope: CoroutineScope, - ): CentralManager = CentralManager.native(context, coroutineScope) - - @Provides - @Singleton - fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - } + @Provides + @Singleton + fun provideSingletonCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt index c2b2465d27..50edd9366c 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothState.kt @@ -17,7 +17,7 @@ package com.geeksville.mesh.repository.bluetooth -import android.bluetooth.BluetoothDevice +import no.nordicsemi.kotlin.ble.client.android.Peripheral import org.meshtastic.core.model.util.anonymize /** A snapshot in time of the state of the bluetooth subsystem. */ @@ -27,7 +27,7 @@ data class BluetoothState( /** If we have adequate permissions and bluetooth is enabled */ val enabled: Boolean = false, /** If enabled, a list of the currently bonded devices */ - val bondedDevices: List = emptyList(), + val bondedDevices: List = emptyList(), ) { override fun toString(): String = "BluetoothState(hasPermissions=$hasPermissions, enabled=$enabled, bondedDevices=${bondedDevices.map { diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index 2c933f8c9b..08816bb6e0 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -18,11 +18,11 @@ package com.geeksville.mesh.repository.radio import android.annotation.SuppressLint -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMNUM_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_FROMRADIO_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_LOGRADIO_CHARACTER -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_SERVICE_UUID -import com.geeksville.mesh.repository.radio.BleUuidConstants.BTM_TORADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMNUM_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_FROMRADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_LOGRADIO_CHARACTER +import com.geeksville.mesh.repository.radio.BleConstants.BTM_SERVICE_UUID +import com.geeksville.mesh.repository.radio.BleConstants.BTM_TORADIO_CHARACTER import com.geeksville.mesh.service.RadioNotConnectedException import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -322,7 +322,8 @@ constructor( } } -object BleUuidConstants { +object BleConstants { + const val BLE_NAME_PATTERN = "^.*_([0-9a-fA-F]{4})$" val BTM_SERVICE_UUID: UUID = UUID.fromString("6ba1b218-15a8-461f-9fa8-5dcae273eafd") val BTM_TORADIO_CHARACTER: UUID = UUID.fromString("f75c76d2-129e-4dad-a1dd-7866124401e7") val BTM_FROMNUM_CHARACTER: UUID = UUID.fromString("ed9da18c-a800-4f66-a670-aa7547e34453") diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index d7b858cf91..cac2623df6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -52,7 +52,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.model.BTScanModel -import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.ui.connections.components.BLEDevices import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo @@ -117,8 +116,7 @@ fun ConnectionsScreen( val bluetoothState by connectionsViewModel.bluetoothState.collectAsStateWithLifecycle() val regionUnset = config.lora.region == ConfigProtos.Config.LoRaConfig.RegionCode.UNSET - val bondedBleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() - val scannedBleDevices by scanModel.scanResult.observeAsState(emptyMap()) + val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle() val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle() val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle() val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle() @@ -235,13 +233,11 @@ fun ConnectionsScreen( Column(modifier = Modifier.fillMaxSize()) { when (selectedDeviceType) { DeviceType.BLE -> { + val (bonded, available) = bleDevices.partition { it.bonded } BLEDevices( connectionState = connectionState, - bondedDevices = bondedBleDevices, - availableDevices = - scannedBleDevices.values.toList().filterNot { available -> - bondedBleDevices.any { it.address == available.address } - }, + bondedDevices = bonded, + availableDevices = available, selectedDevice = selectedDevice, scanModel = scanModel, bluetoothEnabled = bluetoothState.enabled, @@ -273,10 +269,9 @@ fun ConnectionsScreen( // Warning Not Paired val hasShownNotPairedWarning by connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle() + val (bonded, _) = bleDevices.partition { it.bonded } val showWarningNotPaired = - !connectionState.isConnected() && - !hasShownNotPairedWarning && - bondedBleDevices.none { it is DeviceListEntry.Ble && it.bonded } + !connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty() if (showWarningNotPaired) { Text( text = stringResource(Res.string.warning_not_paired), From bdf3b2cfee7a30d06e84d9e817edb55bc37b1705 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:33:41 -0600 Subject: [PATCH 21/62] refactor(coroutines): Use SupervisorJobs (#3714) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../repository/radio/RadioInterfaceService.kt | 6 +- .../core/datastore/di/DataStoreModule.kt | 107 +++++++++++------- 2 files changed, 66 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index f78516889a..c85a270695 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -31,7 +31,7 @@ import com.geeksville.mesh.util.ignoreException import com.geeksville.mesh.util.toRemoteExceptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -105,7 +105,7 @@ constructor( val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") } /** We recreate this scope each time we stop an interface */ - var serviceScope = CoroutineScope(Dispatchers.IO + Job()) + var serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var radioIf: IRadioInterface = NopInterface("") @@ -298,7 +298,7 @@ constructor( // cancel any old jobs and get ready for the new ones serviceScope.cancel("stopping interface") - serviceScope = CoroutineScope(Dispatchers.IO + Job()) + serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) if (logSends) { sentPacketsLog.close() diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt index 0519549333..47e2c86640 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt @@ -48,68 +48,87 @@ import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer import org.meshtastic.proto.AppOnlyProtos.ChannelSet import org.meshtastic.proto.LocalOnlyProtos.LocalConfig import org.meshtastic.proto.LocalOnlyProtos.LocalModuleConfig +import javax.inject.Qualifier import javax.inject.Singleton private const val USER_PREFERENCES_NAME = "user_preferences" +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class DataStoreScope + @InstallIn(SingletonComponent::class) @Module object DataStoreModule { + + @Provides + @Singleton + @DataStoreScope + fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + @Singleton @Provides - fun providePreferencesDataStore(@ApplicationContext appContext: Context): DataStore = - PreferenceDataStoreFactory.create( - corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), - migrations = - listOf( - SharedPreferencesMigration(context = appContext, sharedPreferencesName = USER_PREFERENCES_NAME), - SharedPreferencesMigration( - context = appContext, - sharedPreferencesName = "ui-prefs", - keysToMigrate = - setOf( - KEY_APP_INTRO_COMPLETED, - KEY_THEME, - KEY_NODE_SORT, - KEY_INCLUDE_UNKNOWN, - KEY_ONLY_ONLINE, - KEY_ONLY_DIRECT, - KEY_SHOW_IGNORED, - ), + fun providePreferencesDataStore( + @ApplicationContext appContext: Context, + @DataStoreScope scope: CoroutineScope, + ): DataStore = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), + migrations = + listOf( + SharedPreferencesMigration(context = appContext, sharedPreferencesName = USER_PREFERENCES_NAME), + SharedPreferencesMigration( + context = appContext, + sharedPreferencesName = "ui-prefs", + keysToMigrate = + setOf( + KEY_APP_INTRO_COMPLETED, + KEY_THEME, + KEY_NODE_SORT, + KEY_INCLUDE_UNKNOWN, + KEY_ONLY_ONLINE, + KEY_ONLY_DIRECT, + KEY_SHOW_IGNORED, ), ), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) }, - ) + ), + scope = scope, + produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) }, + ) @Singleton @Provides - fun provideLocalConfigDataStore(@ApplicationContext appContext: Context): DataStore = - DataStoreFactory.create( - serializer = LocalConfigSerializer, - produceFile = { appContext.dataStoreFile("local_config.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + fun provideLocalConfigDataStore( + @ApplicationContext appContext: Context, + @DataStoreScope scope: CoroutineScope, + ): DataStore = DataStoreFactory.create( + serializer = LocalConfigSerializer, + produceFile = { appContext.dataStoreFile("local_config.pb") }, + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }), + scope = scope, + ) @Singleton @Provides - fun provideModuleConfigDataStore(@ApplicationContext appContext: Context): DataStore = - DataStoreFactory.create( - serializer = ModuleConfigSerializer, - produceFile = { appContext.dataStoreFile("module_config.pb") }, - corruptionHandler = - ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig.getDefaultInstance() }), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + fun provideModuleConfigDataStore( + @ApplicationContext appContext: Context, + @DataStoreScope scope: CoroutineScope, + ): DataStore = DataStoreFactory.create( + serializer = ModuleConfigSerializer, + produceFile = { appContext.dataStoreFile("module_config.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig.getDefaultInstance() }), + scope = scope, + ) @Singleton @Provides - fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore = - DataStoreFactory.create( - serializer = ChannelSetSerializer, - produceFile = { appContext.dataStoreFile("channel_set.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), - ) + fun provideChannelSetDataStore( + @ApplicationContext appContext: Context, + @DataStoreScope scope: CoroutineScope, + ): DataStore = DataStoreFactory.create( + serializer = ChannelSetSerializer, + produceFile = { appContext.dataStoreFile("channel_set.pb") }, + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }), + scope = scope, + ) } From 7bfefbb90568c369188ab741d8f15e6689175007 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 15 Nov 2025 22:45:35 -0600 Subject: [PATCH 22/62] chore: Update VERSION_NAME_BASE to 2.7.7 (#3715) --- config.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.properties b/config.properties index 8fcc36a4b1..c5e4494749 100644 --- a/config.properties +++ b/config.properties @@ -27,7 +27,7 @@ COMPILE_SDK=36 # Base version name for local development and fallback # On CI, this is overridden by the Git tag # Before a release, update this to the new Git tag version -VERSION_NAME_BASE=2.7.6 +VERSION_NAME_BASE=2.7.7 # Minimum firmware versions supported by this app MIN_FW_VERSION=2.5.14 From de127ec9ac46d29e7b45909c1fdcb9c202850761 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Mon, 17 Nov 2025 02:37:44 +1100 Subject: [PATCH 23/62] fix #3509: MQTT reporting interval not being selected, and sent to node (#3717) --- .../settings/radio/component/MQTTConfigItemList.kt | 2 +- .../settings/radio/component/MapReportingPreference.kt | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt index 436de4d285..2e204658b7 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt @@ -72,7 +72,7 @@ fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: val consentValid = if (formState.value.mapReportingEnabled) { formState.value.mapReportSettings.shouldReportLocation && - mqttConfig.mapReportSettings.publishIntervalSecs >= MIN_INTERVAL_SECS + formState.value.mapReportSettings.publishIntervalSecs >= MIN_INTERVAL_SECS } else { true } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt index 30c835860c..ac1e3a9846 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreference.kt @@ -51,6 +51,7 @@ import org.meshtastic.core.strings.map_reporting_summary import org.meshtastic.core.ui.component.DropDownPreference import org.meshtastic.core.ui.component.SwitchPreference import org.meshtastic.core.ui.component.precisionBitsToMeters +import org.meshtastic.feature.settings.util.FixedUpdateIntervals import org.meshtastic.feature.settings.util.IntervalConfiguration import org.meshtastic.feature.settings.util.toDisplayString import kotlin.math.roundToInt @@ -131,10 +132,11 @@ fun MapReportingPreference( DropDownPreference( modifier = Modifier.padding(bottom = 16.dp), title = stringResource(Res.string.map_reporting_interval_seconds), - items = publishItems.map { it.value to it.toDisplayString() }, - selectedItem = publishIntervalSecs, + items = publishItems.map { it to it.toDisplayString() }, + selectedItem = + FixedUpdateIntervals.fromValue(publishIntervalSecs.toLong()) ?: publishItems.first(), enabled = enabled, - onItemSelected = { onPublishIntervalSecsChanged(it.toInt()) }, + onItemSelected = { onPublishIntervalSecsChanged(it.value.toInt()) }, ) } } From d5ee9c858e94cd5db3406006e533aad21035cf35 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 16 Nov 2025 15:50:49 +0000 Subject: [PATCH 24/62] chore(deps): update com.squareup.okhttp3:logging-interceptor to v5.3.1 (#3718) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 4d07aa7af1..83d621a46a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -111,7 +111,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktorfit = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } -okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.0" } +okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.1" } # Testing androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } From 790495456054b2dd9c102b440ba8cb2045081297 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Mon, 17 Nov 2025 11:52:25 +1100 Subject: [PATCH 25/62] feat #3642: Add infrastructure to the list of filters. (#3716) --- .../core/datastore/UiPreferencesDataSource.kt | 8 ++ .../composeResources/values/strings.xml | 3 +- .../node/component/NodeFilterTextField.kt | 15 +++ .../feature/node/list/NodeActions.kt | 62 ++++++++++ .../node/list/NodeFilterPreferences.kt | 49 ++++++++ .../feature/node/list/NodeListScreen.kt | 14 ++- .../feature/node/list/NodeListViewModel.kt | 109 ++++++++---------- 7 files changed, 192 insertions(+), 68 deletions(-) create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt index 6ba6367439..69a49a521c 100644 --- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt +++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt @@ -39,6 +39,7 @@ internal const val KEY_THEME = "theme" // Node list filters/sort internal const val KEY_NODE_SORT = "node-sort-option" internal const val KEY_INCLUDE_UNKNOWN = "include-unknown" +internal const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure" internal const val KEY_ONLY_ONLINE = "only-online" internal const val KEY_ONLY_DIRECT = "only-direct" internal const val KEY_SHOW_IGNORED = "show-ignored" @@ -57,6 +58,8 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto val nodeSort: StateFlow = dataStore.prefStateFlow(key = NODE_SORT, default = -1) val includeUnknown: StateFlow = dataStore.prefStateFlow(key = INCLUDE_UNKNOWN, default = false) + val excludeInfrastructure: StateFlow = + dataStore.prefStateFlow(key = EXCLUDE_INFRASTRUCTURE, default = false) val onlyOnline: StateFlow = dataStore.prefStateFlow(key = ONLY_ONLINE, default = false) val onlyDirect: StateFlow = dataStore.prefStateFlow(key = ONLY_DIRECT, default = false) val showIgnored: StateFlow = dataStore.prefStateFlow(key = SHOW_IGNORED, default = false) @@ -77,6 +80,10 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto dataStore.setPref(key = INCLUDE_UNKNOWN, value = value) } + fun setExcludeInfrastructure(value: Boolean) { + dataStore.setPref(key = EXCLUDE_INFRASTRUCTURE, value = value) + } + fun setOnlyOnline(value: Boolean) { dataStore.setPref(key = ONLY_ONLINE, value = value) } @@ -104,6 +111,7 @@ class UiPreferencesDataSource @Inject constructor(private val dataStore: DataSto val THEME = intPreferencesKey(KEY_THEME) val NODE_SORT = intPreferencesKey(KEY_NODE_SORT) val INCLUDE_UNKNOWN = booleanPreferencesKey(KEY_INCLUDE_UNKNOWN) + val EXCLUDE_INFRASTRUCTURE = booleanPreferencesKey(KEY_EXCLUDE_INFRASTRUCTURE) val ONLY_ONLINE = booleanPreferencesKey(KEY_ONLY_ONLINE) val ONLY_DIRECT = booleanPreferencesKey(KEY_ONLY_DIRECT) val SHOW_IGNORED = booleanPreferencesKey(KEY_SHOW_IGNORED) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 0c6e84a2b5..1eec5b1ab9 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -32,6 +32,7 @@ clear node filter Filter by Include unknown + Exclude infrastructure Hide offline nodes Only show direct nodes You are viewing ignored nodes,\nPress to return to the node list. @@ -46,7 +47,7 @@ via MQTT via MQTT via Favorite - Ignored Nodes + Only show ignored Nodes Unrecognized Waiting to be acknowledged Queued for sending diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index bafd089ce8..ec9e8fcefc 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -64,6 +64,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.desc_node_filter_clear +import org.meshtastic.core.strings.node_filter_exclude_infrastructure import org.meshtastic.core.strings.node_filter_ignored import org.meshtastic.core.strings.node_filter_include_unknown import org.meshtastic.core.strings.node_filter_only_direct @@ -85,6 +86,8 @@ fun NodeFilterTextField( onSortSelect: (NodeSortOption) -> Unit, includeUnknown: Boolean, onToggleIncludeUnknown: () -> Unit, + excludeInfrastructure: Boolean, + onToggleExcludeInfrastructure: () -> Unit, onlyOnline: Boolean, onToggleOnlyOnline: () -> Unit, onlyDirect: Boolean, @@ -105,6 +108,8 @@ fun NodeFilterTextField( NodeFilterToggles( includeUnknown = includeUnknown, onToggleIncludeUnknown = onToggleIncludeUnknown, + excludeInfrastructure = excludeInfrastructure, + onToggleExcludeInfrastructure = onToggleExcludeInfrastructure, onlyOnline = onlyOnline, onToggleOnlyOnline = onToggleOnlyOnline, onlyDirect = onlyDirect, @@ -212,6 +217,12 @@ private fun NodeSortButton( DropdownMenuTitle(text = stringResource(Res.string.node_filter_title)) + DropdownMenuCheck( + text = stringResource(Res.string.node_filter_exclude_infrastructure), + checked = toggles.excludeInfrastructure, + onClick = toggles.onToggleExcludeInfrastructure, + ) + DropdownMenuCheck( text = stringResource(Res.string.node_filter_include_unknown), checked = toggles.includeUnknown, @@ -298,6 +309,8 @@ private fun NodeFilterTextFieldPreview() { onSortSelect = {}, includeUnknown = false, onToggleIncludeUnknown = {}, + excludeInfrastructure = false, + onToggleExcludeInfrastructure = {}, onlyOnline = false, onToggleOnlyOnline = {}, onlyDirect = false, @@ -312,6 +325,8 @@ private fun NodeFilterTextFieldPreview() { data class NodeFilterToggles( val includeUnknown: Boolean, val onToggleIncludeUnknown: () -> Unit, + val excludeInfrastructure: Boolean, + val onToggleExcludeInfrastructure: () -> Unit, val onlyOnline: Boolean, val onToggleOnlyOnline: () -> Unit, val onlyDirect: Boolean, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt new file mode 100644 index 0000000000..85b7dfbd24 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 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.feature.node.list + +import android.os.RemoteException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.service.ServiceAction +import org.meshtastic.core.service.ServiceRepository +import timber.log.Timber +import javax.inject.Inject + +class NodeActions +@Inject +constructor( + private val serviceRepository: ServiceRepository, + private val nodeRepository: NodeRepository, +) { + suspend fun favoriteNode(node: Node) { + try { + serviceRepository.onServiceAction(ServiceAction.Favorite(node)) + } catch (ex: RemoteException) { + Timber.e(ex, "Favorite node error") + } + } + + suspend fun ignoreNode(node: Node) { + try { + serviceRepository.onServiceAction(ServiceAction.Ignore(node)) + } catch (ex: RemoteException) { + Timber.e(ex, "Ignore node error") + } + } + + suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) { + Timber.i("Removing node '$nodeNum'") + try { + val packetId = serviceRepository.meshService?.packetId ?: return@withContext + serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) + nodeRepository.deleteNode(nodeNum) + } catch (ex: RemoteException) { + Timber.e("Remove node error: ${ex.message}") + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt new file mode 100644 index 0000000000..51620b78e8 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 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.feature.node.list + +import org.meshtastic.core.datastore.UiPreferencesDataSource +import javax.inject.Inject + +class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) { + val includeUnknown = uiPreferencesDataSource.includeUnknown + val excludeInfrastructure = uiPreferencesDataSource.excludeInfrastructure + val onlyOnline = uiPreferencesDataSource.onlyOnline + val onlyDirect = uiPreferencesDataSource.onlyDirect + val showIgnored = uiPreferencesDataSource.showIgnored + + fun toggleIncludeUnknown() { + uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value) + } + + fun toggleExcludeInfrastructure() { + uiPreferencesDataSource.setExcludeInfrastructure(!excludeInfrastructure.value) + } + + fun toggleOnlyOnline() { + uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value) + } + + fun toggleOnlyDirect() { + uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value) + } + + fun toggleShowIgnored() { + uiPreferencesDataSource.setShowIgnored(!showIgnored.value) + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 99c0335fab..cc37bbe682 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -159,17 +159,21 @@ fun NodeListScreen( .background(MaterialTheme.colorScheme.surfaceDim) .padding(8.dp), filterText = state.filter.filterText, - onTextChange = viewModel::setNodeFilterText, + onTextChange = { viewModel.nodeFilterText = it }, currentSortOption = state.sort, onSortSelect = viewModel::setSortOption, includeUnknown = state.filter.includeUnknown, - onToggleIncludeUnknown = viewModel::toggleIncludeUnknown, + onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() }, + excludeInfrastructure = state.filter.excludeInfrastructure, + onToggleExcludeInfrastructure = { + viewModel.nodeFilterPreferences.toggleExcludeInfrastructure() + }, onlyOnline = state.filter.onlyOnline, - onToggleOnlyOnline = viewModel::toggleOnlyOnline, + onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() }, onlyDirect = state.filter.onlyDirect, - onToggleOnlyDirect = viewModel::toggleOnlyDirect, + onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() }, showIgnored = state.filter.showIgnored, - onToggleShowIgnored = viewModel::toggleShowIgnored, + onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() }, ignoredNodeCount = ignoredNodeCount, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index 6ff0f2ba69..b0f85cb86a 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -17,11 +17,9 @@ package org.meshtastic.feature.node.list -import android.os.RemoteException import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,11 +33,11 @@ import org.meshtastic.core.data.repository.RadioConfigRepository import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.NodeSortOption import org.meshtastic.core.datastore.UiPreferencesDataSource -import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed +import org.meshtastic.feature.node.model.isEffectivelyUnmessageable import org.meshtastic.proto.AdminProtos -import timber.log.Timber +import org.meshtastic.proto.ConfigProtos import javax.inject.Inject @HiltViewModel @@ -50,6 +48,8 @@ constructor( radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val uiPreferencesDataSource: UiPreferencesDataSource, + val nodeActions: NodeActions, + val nodeFilterPreferences: NodeFilterPreferences, ) : ViewModel() { val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo @@ -66,23 +66,24 @@ constructor( private val nodeSortOption = uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } } - private val nodeFilterText = MutableStateFlow("") - private val includeUnknown = uiPreferencesDataSource.includeUnknown - private val onlyOnline = uiPreferencesDataSource.onlyOnline - private val onlyDirect = uiPreferencesDataSource.onlyDirect - private val showIgnored = uiPreferencesDataSource.showIgnored + private val _nodeFilterText = MutableStateFlow("") + private val includeUnknown = nodeFilterPreferences.includeUnknown + private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure + private val onlyOnline = nodeFilterPreferences.onlyOnline + private val onlyDirect = nodeFilterPreferences.onlyDirect + private val showIgnored = nodeFilterPreferences.showIgnored private val nodeFilter: Flow = - combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) { - filterText, - includeUnknown, - onlyOnline, - onlyDirect, - showIgnored, - -> - NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) + combine(_nodeFilterText, includeUnknown, excludeInfrastructure, onlyOnline, onlyDirect, showIgnored) { values -> + NodeFilterState( + filterText = values[0] as String, + includeUnknown = values[1] as Boolean, + excludeInfrastructure = values[2] as Boolean, + onlyOnline = values[3] as Boolean, + onlyDirect = values[4] as Boolean, + showIgnored = values[5] as Boolean, + ) } - val nodesUiState: StateFlow = combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile -> NodesUiState( @@ -105,32 +106,36 @@ constructor( onlyOnline = filter.onlyOnline, onlyDirect = filter.onlyDirect, ) - .map { list -> list.filter { it.isIgnored == filter.showIgnored } } + .map { list -> + list + .filter { it.isIgnored == filter.showIgnored } + .filter { node -> + if (filter.excludeInfrastructure) { + val role = node.user.role + val infrastructureRoles = + listOf( + ConfigProtos.Config.DeviceConfig.Role.ROUTER, + ConfigProtos.Config.DeviceConfig.Role.REPEATER, + ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE, + ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE, + ) + role !in infrastructureRoles && !node.isEffectivelyUnmessageable + } else { + true + } + } + } } .stateInWhileSubscribed(initialValue = emptyList()) val unfilteredNodeList: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) - fun setNodeFilterText(text: String) { - nodeFilterText.value = text - } - - fun toggleIncludeUnknown() { - uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value) - } - - fun toggleOnlyOnline() { - uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value) - } - - fun toggleOnlyDirect() { - uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value) - } - - fun toggleShowIgnored() { - uiPreferencesDataSource.setShowIgnored(!showIgnored.value) - } + var nodeFilterText: String + get() = _nodeFilterText.value + set(value) { + _nodeFilterText.value = value + } fun setSortOption(sort: NodeSortOption) { uiPreferencesDataSource.setNodeSort(sort.ordinal) @@ -140,32 +145,11 @@ constructor( _sharedContactRequested.value = sharedContact } - fun favoriteNode(node: Node) = viewModelScope.launch { - try { - serviceRepository.onServiceAction(ServiceAction.Favorite(node)) - } catch (ex: RemoteException) { - Timber.e(ex, "Favorite node error") - } - } + fun favoriteNode(node: Node) = viewModelScope.launch { nodeActions.favoriteNode(node) } - fun ignoreNode(node: Node) = viewModelScope.launch { - try { - serviceRepository.onServiceAction(ServiceAction.Ignore(node)) - } catch (ex: RemoteException) { - Timber.e(ex, "Ignore node error") - } - } + fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) } - fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) { - Timber.i("Removing node '$nodeNum'") - try { - val packetId = serviceRepository.meshService?.packetId ?: return@launch - serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) - nodeRepository.deleteNode(nodeNum) - } catch (ex: RemoteException) { - Timber.e("Remove node error: ${ex.message}") - } - } + fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) } } data class NodesUiState( @@ -178,6 +162,7 @@ data class NodesUiState( data class NodeFilterState( val filterText: String = "", val includeUnknown: Boolean = false, + val excludeInfrastructure: Boolean = false, val onlyOnline: Boolean = false, val onlyDirect: Boolean = false, val showIgnored: Boolean = false, From a0e7452f7606e1476ed41c63d48e02bbe69aacf6 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 17 Nov 2025 06:35:33 -0600 Subject: [PATCH 26/62] fix(bluetooth): Check for permissions before accessing bonded devices (#3720) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../bluetooth/BluetoothRepository.kt | 12 +++++++----- .../ui/connections/components/BLEDevices.kt | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt index fe649c0a7d..6d75d142c3 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/bluetooth/BluetoothRepository.kt @@ -160,11 +160,13 @@ constructor( Timber.d("Detected our bluetooth access=$newState") } - private fun getBondedAppPeripherals(enabled: Boolean): List = if (enabled) { - centralManager.getBondedPeripherals().filter(::isMatchingPeripheral) - } else { - emptyList() - } + @SuppressLint("MissingPermission") + private fun getBondedAppPeripherals(enabled: Boolean): List = + if (enabled && application.hasBluetoothPermission()) { + centralManager.getBondedPeripherals().filter(::isMatchingPeripheral) + } else { + emptyList() + } /** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */ @OptIn(ExperimentalUuidApi::class) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index a91d6d8d3a..dbdd9a34f0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -19,7 +19,6 @@ package com.geeksville.mesh.ui.connections.components import android.Manifest import android.content.Intent -import android.os.Build import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -39,6 +38,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -50,6 +50,7 @@ import com.geeksville.mesh.model.DeviceListEntry import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.Res @@ -64,6 +65,7 @@ import org.meshtastic.core.strings.permission_missing_31 import org.meshtastic.core.strings.scan import org.meshtastic.core.strings.scanning_bluetooth import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.ui.util.showToast /** * Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth @@ -90,23 +92,22 @@ fun BLEDevices( // Define permissions needed for Bluetooth scanning based on Android version. val bluetoothPermissionsList = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) - } else { - // ACCESS_FINE_LOCATION is required for Bluetooth scanning on pre-S devices. - listOf(Manifest.permission.ACCESS_FINE_LOCATION) - } + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) } + val context = LocalContext.current + val permsMissing = stringResource(Res.string.permission_missing) + val coroutineScope = rememberCoroutineScope() + val permissionsState = rememberMultiplePermissionsState( permissions = bluetoothPermissionsList, onPermissionsResult = { if (it.values.all { granted -> granted } && bluetoothEnabled) { - scanModel.startScan() scanModel.refreshPermissions() + scanModel.startScan() } else { - // If permissions are not granted, we can show a message or handle it accordingly. + coroutineScope.launch { context.showToast(permsMissing) } } }, ) From 8aab2d55981bfbd4ccc9b88f5d8ae4d9e9c6f10d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:15:22 -0600 Subject: [PATCH 27/62] feat(connections): `Connecting` state refactor (#3722) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/detekt-baseline.xml | 5 +- .../repository/radio/RadioInterfaceService.kt | 10 +- .../geeksville/mesh/service/MeshService.kt | 176 ++++++++++-------- .../mesh/service/MeshServiceBroadcasts.kt | 2 +- .../MeshServiceConnectionStateHolder.kt | 9 +- .../geeksville/mesh/service/PacketHandler.kt | 6 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 16 +- .../mesh/ui/connections/ConnectionsScreen.kt | 28 +-- .../ui/connections/components/BLEDevices.kt | 40 ++-- .../components/ConnectionsNavIcon.kt | 58 +++--- .../connections/components/DeviceListItem.kt | 27 ++- .../components/DeviceListSection.kt | 47 +++++ .../connections/components/NetworkDevices.kt | 44 ++--- .../ui/connections/components/UsbDevices.kt | 28 ++- .../com/geeksville/mesh/ui/sharing/Channel.kt | 2 +- .../core/service/ConnectionState.kt | 19 +- .../core/service/ServiceRepository.kt | 4 +- .../composeResources/values/strings.xml | 2 + .../feature/node/component/NodeItem.kt | 15 +- .../feature/node/component/NodeStatusIcons.kt | 78 +++++--- .../feature/node/list/NodeListScreen.kt | 4 +- .../settings/radio/RadioConfigViewModel.kt | 2 +- 22 files changed, 369 insertions(+), 253 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 6bc93d1762..12f0bb0e03 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -33,7 +33,6 @@ FinalNewline:SerialInterfaceFactory.kt$com.geeksville.mesh.repository.radio.SerialInterfaceFactory.kt FinalNewline:TCPInterfaceFactory.kt$com.geeksville.mesh.repository.radio.TCPInterfaceFactory.kt FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt - ImplicitDefaultLocale:MeshService.kt$MeshService$String.format("0x%02x", byte) LambdaParameterEventTrailing:Channel.kt$onConfirm LambdaParameterInRestartableEffect:Channel.kt$onConfirm LargeClass:MeshService.kt$MeshService : Service @@ -67,9 +66,8 @@ MagicNumber:UIState.kt$4 MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker MaxLineLength:MeshService.kt$MeshService$"Config complete id mismatch: received=$configCompleteId expected one of [$configOnlyNonce,$nodeInfoNonce]" - MaxLineLength:MeshService.kt$MeshService$"Failed to parse radio packet (len=${bytes.size} contents=$packet). Not a valid FromRadio or LogRecord." MaxLineLength:MeshService.kt$MeshService$"setOwner Id: $id longName: ${longName.anonymize} shortName: $shortName isLicensed: $isLicensed isUnmessagable: $isUnmessagable" - MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})" + MaxLineLength:MeshService.kt$MeshService.<no name provided>$"sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromNum: ${fromNumCharacteristic?.uuid}, ${fromNumCharacteristic?.instanceId}" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found fromRadio: ${fromRadioCharacteristic?.uuid}, ${fromRadioCharacteristic?.instanceId}" MaxLineLength:NordicBleInterface.kt$NordicBleInterface$"[$address] Found logRadio: ${logRadioCharacteristic?.uuid}, ${logRadioCharacteristic?.instanceId}" @@ -120,7 +118,6 @@ SwallowedException:NsdManager.kt$ex: IllegalArgumentException SwallowedException:ServiceClient.kt$ServiceClient$ex: IllegalArgumentException SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException - TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable TooGenericExceptionCaught:Exceptions.kt$ex: Throwable TooGenericExceptionCaught:MQTTRepository.kt$MQTTRepository$ex: Exception TooGenericExceptionCaught:MeshService.kt$MeshService$ex: Exception diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index c85a270695..bb39b1ef6d 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -77,7 +77,7 @@ constructor( private val analytics: PlatformAnalytics, ) { - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() private val _receivedData = MutableSharedFlow() @@ -248,13 +248,13 @@ constructor( } fun onConnect() { - if (_connectionState.value != ConnectionState.CONNECTED) { - broadcastConnectionChanged(ConnectionState.CONNECTED) + if (_connectionState.value != ConnectionState.Connected) { + broadcastConnectionChanged(ConnectionState.Connected) } } fun onDisconnect(isPermanent: Boolean) { - val newTargetState = if (isPermanent) ConnectionState.DISCONNECTED else ConnectionState.DEVICE_SLEEP + val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep if (_connectionState.value != newTargetState) { broadcastConnectionChanged(newTargetState) } @@ -319,7 +319,7 @@ constructor( * @return true if the device changed, false if no change */ private fun setBondedDeviceAddress(address: String?): Boolean = - if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.CONNECTED) { + if (getBondedDeviceAddress() == address && isStarted && _connectionState.value == ConnectionState.Connected) { Timber.w("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device") false } else { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 2a8dca5a16..e26980e92a 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -94,6 +94,7 @@ import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.connected_count +import org.meshtastic.core.strings.connecting import org.meshtastic.core.strings.critical_alert import org.meshtastic.core.strings.device_sleeping import org.meshtastic.core.strings.disconnected @@ -1426,98 +1427,110 @@ class MeshService : Service() { // Called when we gain/lose connection to our radio @Suppress("CyclomaticComplexMethod") private fun onConnectionChanged(c: ConnectionState) { - Timber.d("onConnectionChanged: ${connectionStateHolder.getState()} -> $c") + if (connectionStateHolder.connectionState.value == c && c !is ConnectionState.Connected) return + Timber.d("onConnectionChanged: ${connectionStateHolder.connectionState.value} -> $c") - // Perform all the steps needed once we start waiting for device sleep to complete - fun startDeviceSleep() { - packetHandler.stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() + // Cancel any existing timeouts + sleepTimeout?.cancel() + sleepTimeout = null - if (connectTimeMsec != 0L) { - val now = System.currentTimeMillis() - connectTimeMsec = 0L + when (c) { + is ConnectionState.Connecting -> { + connectionStateHolder.setState(ConnectionState.Connecting) + } - analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0)) + is ConnectionState.Connected -> { + handleConnected() } - // Have our timeout fire in the appropriate number of seconds - sleepTimeout = - serviceScope.handledLaunch { - try { - // If we have a valid timeout, wait that long (+30 seconds) otherwise, just - // wait 30 seconds - val timeout = (localConfig.power?.lsSecs ?: 0) + 30 - - Timber.d("Waiting for sleeping device, timeout=$timeout secs") - delay(timeout * 1000L) - Timber.w("Device timeout out, setting disconnected") - onConnectionChanged(ConnectionState.DISCONNECTED) - } catch (_: CancellationException) { - Timber.d("device sleep timeout cancelled") - } - } + is ConnectionState.DeviceSleep -> { + handleDeviceSleep() + } - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() + is ConnectionState.Disconnected -> { + handleDisconnected() + } } + updateServiceStatusNotification() + } - fun startDisconnect() { - Timber.d("Starting disconnect") - packetHandler.stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() + private fun handleDisconnected() { + connectionStateHolder.setState(ConnectionState.Disconnected) + Timber.d("Starting disconnect") + packetHandler.stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() - analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes)) - analytics.track("num_nodes", DataPair("num_nodes", numNodes)) + analytics.track("mesh_disconnect", DataPair("num_nodes", numNodes), DataPair("num_online", numOnlineNodes)) + analytics.track("num_nodes", DataPair("num_nodes", numNodes)) - // broadcast an intent with our new connection state - serviceBroadcasts.broadcastConnection() + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } + + private fun handleDeviceSleep() { + connectionStateHolder.setState(ConnectionState.DeviceSleep) + packetHandler.stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + if (connectTimeMsec != 0L) { + val now = System.currentTimeMillis() + connectTimeMsec = 0L + + analytics.track("connected_seconds", DataPair("connected_seconds", (now - connectTimeMsec) / 1000.0)) } - fun startConnect() { - Timber.d("Starting connect") - historyLog { - val address = meshPrefs.deviceAddress ?: "null" - "onReconnect transport=${currentTransport()} node=$address" - } - try { - connectTimeMsec = System.currentTimeMillis() - startConfigOnly() - } catch (ex: InvalidProtocolBufferException) { - Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again") - } catch (ex: RadioNotConnectedException) { - Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}") - } catch (ex: RemoteException) { - connectionStateHolder.setState(ConnectionState.DEVICE_SLEEP) - startDeviceSleep() - throw ex + // Have our timeout fire in the appropriate number of seconds + sleepTimeout = + serviceScope.handledLaunch { + try { + // If we have a valid timeout, wait that long (+30 seconds) otherwise, just + // wait 30 seconds + val timeout = (localConfig.power?.lsSecs ?: 0) + 30 + + Timber.d("Waiting for sleeping device, timeout=$timeout secs") + delay(timeout * 1000L) + Timber.w("Device timeout out, setting disconnected") + onConnectionChanged(ConnectionState.Disconnected) + } catch (_: CancellationException) { + Timber.d("device sleep timeout cancelled") + } } - } - // Cancel any existing timeouts - sleepTimeout?.let { - it.cancel() - sleepTimeout = null - } + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } - connectionStateHolder.setState(c) - when (c) { - ConnectionState.CONNECTED -> startConnect() - ConnectionState.DEVICE_SLEEP -> startDeviceSleep() - ConnectionState.DISCONNECTED -> startDisconnect() + private fun handleConnected() { + connectionStateHolder.setState(ConnectionState.Connecting) + serviceBroadcasts.broadcastConnection() + Timber.d("Starting connect") + historyLog { + val address = meshPrefs.deviceAddress ?: "null" + "onReconnect transport=${currentTransport()} node=$address" + } + try { + connectTimeMsec = System.currentTimeMillis() + startConfigOnly() + } catch (ex: InvalidProtocolBufferException) { + Timber.e(ex, "Invalid protocol buffer sent by device - update device software and try again") + } catch (ex: RadioNotConnectedException) { + Timber.e("Lost connection to radio during init - waiting for reconnect ${ex.message}") + } catch (ex: RemoteException) { + onConnectionChanged(ConnectionState.DeviceSleep) + throw ex } - - updateServiceStatusNotification() } private fun updateServiceStatusNotification(telemetry: TelemetryProtos.Telemetry? = null): Notification { val notificationSummary = - when (connectionStateHolder.getState()) { - ConnectionState.CONNECTED -> getString(Res.string.connected_count).format(numOnlineNodes) + when (connectionStateHolder.connectionState.value) { + is ConnectionState.Connected -> getString(Res.string.connected_count).format(numOnlineNodes) - ConnectionState.DISCONNECTED -> getString(Res.string.disconnected) - ConnectionState.DEVICE_SLEEP -> getString(Res.string.device_sleeping) + is ConnectionState.Disconnected -> getString(Res.string.disconnected) + is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) + is ConnectionState.Connecting -> getString(Res.string.connecting) } return serviceNotifications.updateServiceStateNotification( summaryString = notificationSummary, @@ -1533,15 +1546,16 @@ class MeshService : Service() { val effectiveState = when (newState) { - ConnectionState.CONNECTED -> ConnectionState.CONNECTED - ConnectionState.DEVICE_SLEEP -> + is ConnectionState.Connected -> ConnectionState.Connected + is ConnectionState.DeviceSleep -> if (lsEnabled) { - ConnectionState.DEVICE_SLEEP + ConnectionState.DeviceSleep } else { - ConnectionState.DISCONNECTED + ConnectionState.Disconnected } - ConnectionState.DISCONNECTED -> ConnectionState.DISCONNECTED + is ConnectionState.Connecting -> ConnectionState.Connecting + is ConnectionState.Disconnected -> ConnectionState.Disconnected } onConnectionChanged(effectiveState) } @@ -1972,7 +1986,6 @@ class MeshService : Service() { private fun onHasSettings() { processQueuedPackets() startMqttClientProxy() - serviceBroadcasts.broadcastConnection() sendAnalytics() reportConnection() historyLog { @@ -2055,6 +2068,9 @@ class MeshService : Service() { flushEarlyReceivedPackets("node_info_complete") sendAnalytics() onHasSettings() + connectionStateHolder.setState(ConnectionState.Connected) + serviceBroadcasts.broadcastConnection() + updateServiceStatusNotification() } } @@ -2323,7 +2339,7 @@ class MeshService : Service() { if (p.id == 0) p.id = generatePacketId() val bytes = p.bytes!! Timber.i( - "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.getState()})", + "sendData dest=${p.to}, id=${p.id} <- ${bytes.size} bytes (connectionState=${connectionStateHolder.connectionState.value})", ) if (p.dataType == 0) throw Exception("Port numbers must be non-zero!") if (bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { @@ -2332,7 +2348,7 @@ class MeshService : Service() { } else { p.status = MessageStatus.QUEUED } - if (connectionStateHolder.getState() == ConnectionState.CONNECTED) { + if (connectionStateHolder.connectionState.value == ConnectionState.Connected) { try { sendNow(p) } catch (ex: Exception) { @@ -2450,7 +2466,7 @@ class MeshService : Service() { } override fun connectionState(): String = toRemoteExceptions { - val r = connectionStateHolder.getState() + val r = connectionStateHolder.connectionState.value Timber.i("in connectionState=$r") r.toString() } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt index 68b8305421..5f148da726 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt @@ -75,7 +75,7 @@ constructor( /** Broadcast our current connection status */ fun broadcastConnection() { - val connectionState = connectionStateHolder.getState() + val connectionState = connectionStateHolder.connectionState.value val intent = Intent(MeshService.ACTION_MESH_CONNECTED).putExtra(EXTRA_CONNECTED, connectionState.toString()) serviceRepository.setConnectionState(connectionState) explicitBroadcast(intent) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt index 3be8f75947..34b269d501 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceConnectionStateHolder.kt @@ -17,17 +17,18 @@ package com.geeksville.mesh.service +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import org.meshtastic.core.service.ConnectionState import javax.inject.Inject import javax.inject.Singleton @Singleton class MeshServiceConnectionStateHolder @Inject constructor() { - private var connectionState = ConnectionState.DISCONNECTED + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState = _connectionState.asStateFlow() fun setState(state: ConnectionState) { - connectionState = state + _connectionState.value = state } - - fun getState() = connectionState } diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index 160491ebe4..dd48866132 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -135,7 +135,7 @@ constructor( queueJob = scope.handledLaunch { Timber.d("packet queueJob started") - while (connectionStateHolder.getState() == ConnectionState.CONNECTED) { + while (connectionStateHolder.connectionState.value == ConnectionState.Connected) { // take the first packet from the queue head val packet = queuedPackets.poll() ?: break try { @@ -181,7 +181,9 @@ constructor( val future = CompletableFuture() queueResponse[packet.id] = future try { - if (connectionStateHolder.getState() != ConnectionState.CONNECTED) throw RadioNotConnectedException() + if (connectionStateHolder.connectionState.value != ConnectionState.Connected) { + throw RadioNotConnectedException() + } sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) } catch (ex: Exception) { Timber.e(ex, "sendToRadio error: ${ex.message}") diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 1ad2aa61a9..b6a2196dc2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -114,6 +114,7 @@ import org.meshtastic.core.strings.bottom_nav_settings import org.meshtastic.core.strings.client_notification import org.meshtastic.core.strings.compromised_keys import org.meshtastic.core.strings.connected +import org.meshtastic.core.strings.connecting import org.meshtastic.core.strings.connections import org.meshtastic.core.strings.conversations import org.meshtastic.core.strings.device_sleeping @@ -170,13 +171,13 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) LaunchedEffect(connectionState, notificationPermissionState) { - if (connectionState == ConnectionState.CONNECTED && !notificationPermissionState.status.isGranted) { + if (connectionState == ConnectionState.Connected && !notificationPermissionState.status.isGranted) { notificationPermissionState.launchPermissionRequest() } } } - if (connectionState == ConnectionState.CONNECTED) { + if (connectionState == ConnectionState.Connected) { sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() }) } @@ -297,10 +298,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode Text( if (isConnectionsRoute) { when (connectionState) { - ConnectionState.CONNECTED -> stringResource(Res.string.connected) - ConnectionState.DEVICE_SLEEP -> + ConnectionState.Connected -> stringResource(Res.string.connected) + ConnectionState.Connecting -> stringResource(Res.string.connecting) + ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping) - ConnectionState.DISCONNECTED -> stringResource(Res.string.disconnected) + ConnectionState.Disconnected -> stringResource(Res.string.disconnected) } } else { stringResource(destination.label) @@ -447,7 +449,7 @@ private fun VersionChecks(viewModel: UIViewModel) { val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4")) LaunchedEffect(connectionState, firmwareEdition) { - if (connectionState == ConnectionState.CONNECTED) { + if (connectionState == ConnectionState.Connected) { firmwareEdition?.let { edition -> Timber.d("FirmwareEdition: ${edition.name}") when (edition) { @@ -465,7 +467,7 @@ private fun VersionChecks(viewModel: UIViewModel) { // Check if the device is running an old app version or firmware version LaunchedEffect(connectionState, myNodeInfo) { - if (connectionState == ConnectionState.CONNECTED) { + if (connectionState == ConnectionState.Connected) { myNodeInfo?.let { info -> val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not() if (isOld) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index cac2623df6..14eee02f0e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Language +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -68,6 +72,7 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.connected import org.meshtastic.core.strings.connected_device import org.meshtastic.core.strings.connected_sleeping +import org.meshtastic.core.strings.connecting import org.meshtastic.core.strings.connections import org.meshtastic.core.strings.must_set_region import org.meshtastic.core.strings.not_connected @@ -93,7 +98,7 @@ fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_C * Composable screen for managing device connections (BLE, TCP, USB). It handles permission requests for location and * displays connection status. */ -@OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class) @Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber", "ModifierMissing", "ComposableParamOrder") @Composable fun ConnectionsScreen( @@ -109,7 +114,7 @@ fun ConnectionsScreen( val scrollState = rememberScrollState() val scanStatusText by scanModel.errorText.observeAsState("") val connectionState by - connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.DISCONNECTED) + connectionsViewModel.connectionState.collectAsStateWithLifecycle(ConnectionState.Disconnected) val scanning by scanModel.spinner.collectAsStateWithLifecycle(false) val ourNode by connectionsViewModel.ourNodeInfo.collectAsStateWithLifecycle() val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle() @@ -157,12 +162,13 @@ fun ConnectionsScreen( LaunchedEffect(connectionState, regionUnset) { when (connectionState) { - ConnectionState.CONNECTED -> { + ConnectionState.Connected -> { if (regionUnset) Res.string.must_set_region else Res.string.connected } + ConnectionState.Connecting -> Res.string.connecting - ConnectionState.DISCONNECTED -> Res.string.not_connected - ConnectionState.DEVICE_SLEEP -> Res.string.connected_sleeping + ConnectionState.Disconnected -> Res.string.not_connected + ConnectionState.DeviceSleep -> Res.string.connected_sleeping }.let { scanModel.setErrorText(getString(it)) } } @@ -189,6 +195,11 @@ fun ConnectionsScreen( .padding(paddingValues) .padding(16.dp), ) { + AnimatedVisibility(visible = connectionState == ConnectionState.Connecting) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp)) + } + } AnimatedVisibility( visible = connectionState.isConnected(), modifier = Modifier.padding(bottom = 16.dp), @@ -288,12 +299,7 @@ fun ConnectionsScreen( } Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - Text( - text = scanStatusText.orEmpty(), - modifier = Modifier.fillMaxWidth(), - fontSize = 10.sp, - textAlign = TextAlign.End, - ) + Text(text = scanStatusText.orEmpty(), fontSize = 10.sp, textAlign = TextAlign.End) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index dbdd9a34f0..f624076074 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -32,7 +32,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.BluetoothDisabled import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -64,7 +65,6 @@ import org.meshtastic.core.strings.permission_missing import org.meshtastic.core.strings.permission_missing_31 import org.meshtastic.core.strings.scan import org.meshtastic.core.strings.scanning_bluetooth -import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.util.showToast /** @@ -72,11 +72,13 @@ import org.meshtastic.core.ui.util.showToast * permissions using `accompanist-permissions`. * * @param connectionState The current connection state of the MeshService. - * @param btDevices List of discovered BLE devices. + * @param bondedDevices List of discovered BLE devices. + * @param availableDevices * @param selectedDevice The full address of the currently selected device. * @param scanModel The ViewModel responsible for Bluetooth scanning logic. + * @param bluetoothEnabled */ -@OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun BLEDevices( @@ -159,7 +161,9 @@ fun BLEDevices( } if (isScanning) { - CircularProgressIndicator(modifier = Modifier.size(24.dp).align(Alignment.Center)) + CircularWavyProgressIndicator( + modifier = Modifier.size(24.dp).align(Alignment.Center), + ) } } } @@ -177,14 +181,14 @@ fun BLEDevices( actionButton = scanButton, ) } else { - bondedDevices.Section( + bondedDevices.DeviceListSection( title = stringResource(Res.string.bluetooth_paired_devices), connectionState = connectionState, selectedDevice = selectedDevice, onSelect = scanModel::onSelected, ) - availableDevices.Section( + availableDevices.DeviceListSection( title = stringResource(Res.string.bluetooth_available_devices), connectionState = connectionState, selectedDevice = selectedDevice, @@ -226,25 +230,3 @@ private fun checkPermissionsAndScan( permissionsState.launchMultiplePermissionRequest() } } - -@Composable -private fun List.Section( - title: String, - connectionState: ConnectionState, - selectedDevice: String, - onSelect: (DeviceListEntry) -> Unit, -) { - if (isNotEmpty()) { - TitledCard(title = title) { - forEach { device -> - val connected = connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice - DeviceListItem( - connected = connected, - device = device, - onSelect = { onSelect(device) }, - modifier = Modifier, - ) - } - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt index 7d79d2a849..6e51ae673d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt @@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.connections.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Bluetooth +import androidx.compose.material.icons.rounded.Cached import androidx.compose.material.icons.rounded.Snooze import androidx.compose.material.icons.rounded.Usb import androidx.compose.material.icons.rounded.Wifi @@ -28,7 +29,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -41,31 +44,15 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.NoDevice import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.StatusColors.StatusGreen +import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @Composable fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: ConnectionState, deviceType: DeviceType?) { - val tint = - when (connectionState) { - ConnectionState.DISCONNECTED -> colorScheme.StatusRed - ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow - else -> colorScheme.StatusGreen - } + val tint = getTint(connectionState) - val (backgroundIcon, connectionTypeIcon) = - when (connectionState) { - ConnectionState.DISCONNECTED -> MeshtasticIcons.NoDevice to null - ConnectionState.DEVICE_SLEEP -> MeshtasticIcons.Device to Icons.Rounded.Snooze - else -> - MeshtasticIcons.Device to - when (deviceType) { - DeviceType.BLE -> Icons.Rounded.Bluetooth - DeviceType.TCP -> Icons.Rounded.Wifi - DeviceType.USB -> Icons.Rounded.Usb - else -> null - } - } + val (backgroundIcon, connectionTypeIcon) = getIconPair(deviceType = deviceType, connectionState = connectionState) val foregroundPainter = connectionTypeIcon?.let { rememberVectorPainter(it) } @@ -85,11 +72,40 @@ fun ConnectionsNavIcon(modifier: Modifier = Modifier, connectionState: Connectio ) } +@Composable +private fun getTint(connectionState: ConnectionState): Color = when (connectionState) { + ConnectionState.Connecting -> colorScheme.StatusOrange + ConnectionState.Disconnected -> colorScheme.StatusRed + ConnectionState.DeviceSleep -> colorScheme.StatusYellow + else -> colorScheme.StatusGreen +} + class ConnectionStateProvider : PreviewParameterProvider { override val values: Sequence = - sequenceOf(ConnectionState.CONNECTED, ConnectionState.DEVICE_SLEEP, ConnectionState.DISCONNECTED) + sequenceOf( + ConnectionState.Connected, + ConnectionState.Connecting, + ConnectionState.DeviceSleep, + ConnectionState.Disconnected, + ) } +@Composable +fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair = + when (connectionState) { + ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null + ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze + ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached + else -> + MeshtasticIcons.Device to + when (deviceType) { + DeviceType.BLE -> Icons.Rounded.Bluetooth + DeviceType.TCP -> Icons.Rounded.Wifi + DeviceType.USB -> Icons.Rounded.Usb + else -> null + } + } + class DeviceTypeProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) } @@ -105,5 +121,5 @@ private fun ConnectionsNavIconPreviewConnectionStates( @Preview(showBackground = true) @Composable private fun ConnectionsNavIconPreviewDeviceTypes(@PreviewParameter(DeviceTypeProvider::class) deviceType: DeviceType) { - ConnectionsNavIcon(connectionState = ConnectionState.CONNECTED, deviceType = deviceType) + ConnectionsNavIcon(connectionState = ConnectionState.Connected, deviceType = deviceType) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt index 42c8f2219c..adc66d8d3f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt @@ -19,12 +19,16 @@ package com.geeksville.mesh.ui.connections.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Bluetooth import androidx.compose.material.icons.rounded.BluetoothConnected import androidx.compose.material.icons.rounded.Usb import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults @@ -33,25 +37,36 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.geeksville.mesh.model.DeviceListEntry import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.add import org.meshtastic.core.strings.bluetooth import org.meshtastic.core.strings.network import org.meshtastic.core.strings.serial +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () -> Unit, modifier: Modifier = Modifier) { +fun DeviceListItem( + connectionState: ConnectionState, + device: DeviceListEntry, + onSelect: () -> Unit, + modifier: Modifier = Modifier, +) { val icon = when (device) { is DeviceListEntry.Ble -> - if (connected) { + if (connectionState.isConnected()) { Icons.Rounded.BluetoothConnected + } else if (connectionState.isConnecting()) { + Icons.AutoMirrored.Rounded.BluetoothSearching } else { Icons.Rounded.Bluetooth } + is DeviceListEntry.Usb -> Icons.Rounded.Usb is DeviceListEntry.Tcp -> Icons.Rounded.Wifi is DeviceListEntry.Mock -> Icons.Rounded.Add @@ -80,7 +95,13 @@ fun DeviceListItem(connected: Boolean, device: DeviceListEntry, onSelect: () -> Text(device.address) } }, - trailingContent = { RadioButton(selected = connected, onClick = null) }, + trailingContent = { + if (connectionState.isConnecting()) { + CircularWavyProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + RadioButton(selected = connectionState.isConnected(), onClick = null) + } + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt new file mode 100644 index 0000000000..768f5bbc89 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 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 com.geeksville.mesh.ui.connections.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.geeksville.mesh.model.DeviceListEntry +import org.meshtastic.core.service.ConnectionState +import org.meshtastic.core.ui.component.TitledCard + +@Composable +fun List.DeviceListSection( + title: String, + connectionState: ConnectionState, + selectedDevice: String, + onSelect: (DeviceListEntry) -> Unit, + modifier: Modifier = Modifier, +) { + if (isNotEmpty()) { + TitledCard(title = title, modifier = modifier) { + forEach { device -> + DeviceListItem( + connectionState = + connectionState.takeIf { device.fullAddress == selectedDevice } ?: ConnectionState.Disconnected, + device = device, + onSelect = { onSelect(device) }, + modifier = Modifier.Companion, + ) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt index 1463422812..62bc67423f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt @@ -17,7 +17,6 @@ package com.geeksville.mesh.ui.connections.components -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -67,7 +66,6 @@ import org.meshtastic.core.strings.ip_address import org.meshtastic.core.strings.ip_port import org.meshtastic.core.strings.no_network_devices import org.meshtastic.core.strings.recent_network_devices -import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.core.ui.theme.AppTheme @OptIn(ExperimentalMaterial3Api::class) @@ -130,39 +128,21 @@ fun NetworkDevices( else -> { if (recentNetworkDevices.isNotEmpty()) { - TitledCard(title = stringResource(Res.string.recent_network_devices)) { - recentNetworkDevices.forEach { device -> - DeviceListItem( - connected = - connectionState == ConnectionState.CONNECTED && - device.fullAddress == selectedDevice, - device = device, - onSelect = { scanModel.onSelected(device) }, - modifier = - Modifier.combinedClickable( - onClick = { scanModel.onSelected(device) }, - onLongClick = { - deviceToDelete = device - showDeleteDialog = true - }, - ), - ) - } - } + recentNetworkDevices.DeviceListSection( + title = stringResource(Res.string.recent_network_devices), + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = scanModel::onSelected, + ) } if (discoveredNetworkDevices.isNotEmpty()) { - TitledCard(title = stringResource(Res.string.discovered_network_devices)) { - discoveredNetworkDevices.forEach { device -> - DeviceListItem( - connected = - connectionState == ConnectionState.CONNECTED && - device.fullAddress == selectedDevice, - device = device, - onSelect = { scanModel.onSelected(device) }, - ) - } - } + discoveredNetworkDevices.DeviceListSection( + title = stringResource(Res.string.discovered_network_devices), + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = scanModel::onSelected, + ) } addButton() diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt index df4a9cc189..182735b6f5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt @@ -20,7 +20,6 @@ package com.geeksville.mesh.ui.connections.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.UsbOff import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import com.geeksville.mesh.model.BTScanModel import com.geeksville.mesh.model.DeviceListEntry @@ -28,7 +27,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.no_usb_devices -import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.core.strings.usb_devices import org.meshtastic.core.ui.theme.AppTheme @Composable @@ -38,7 +37,7 @@ fun UsbDevices( selectedDevice: String, scanModel: BTScanModel, ) { - UsbDevices( + UsbDevicesInternal( connectionState = connectionState, usbDevices = usbDevices, selectedDevice = selectedDevice, @@ -47,7 +46,7 @@ fun UsbDevices( } @Composable -private fun UsbDevices( +private fun UsbDevicesInternal( connectionState: ConnectionState, usbDevices: List, selectedDevice: String, @@ -58,17 +57,12 @@ private fun UsbDevices( EmptyStateContent(imageVector = Icons.Rounded.UsbOff, text = stringResource(Res.string.no_usb_devices)) else -> - TitledCard(title = null) { - usbDevices.forEach { device -> - DeviceListItem( - connected = - connectionState == ConnectionState.CONNECTED && device.fullAddress == selectedDevice, - device = device, - onSelect = { onDeviceSelected(device) }, - modifier = Modifier, - ) - } - } + usbDevices.DeviceListSection( + title = stringResource(Res.string.usb_devices), + connectionState = connectionState, + selectedDevice = selectedDevice, + onSelect = onDeviceSelected, + ) } } @@ -76,8 +70,8 @@ private fun UsbDevices( @Composable private fun UsbDevicesPreview() { AppTheme { - UsbDevices( - connectionState = ConnectionState.CONNECTED, + UsbDevicesInternal( + connectionState = ConnectionState.Connected, usbDevices = emptyList(), selectedDevice = "", onDeviceSelected = {}, diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index fcbbeaf3c7..094a3e4ea6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -153,7 +153,7 @@ fun ChannelScreen( val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() - val enabled = connectionState == ConnectionState.CONNECTED && !viewModel.isManaged + val enabled = connectionState == ConnectionState.Connected && !viewModel.isManaged val channels by viewModel.channels.collectAsStateWithLifecycle() var channelSet by remember(channels) { mutableStateOf(channels) } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt index 394c760dac..0e8beedae3 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt @@ -17,17 +17,24 @@ package org.meshtastic.core.service -enum class ConnectionState { +sealed class ConnectionState { /** We are disconnected from the device, and we should be trying to reconnect. */ - DISCONNECTED, + data object Disconnected : ConnectionState() + + /** We are currently attempting to connect to the device. */ + data object Connecting : ConnectionState() /** We are connected to the device and communicating normally. */ - CONNECTED, + data object Connected : ConnectionState() /** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */ - DEVICE_SLEEP, + data object DeviceSleep : ConnectionState() + + fun isConnected() = this == Connected + + fun isConnecting() = this == Connecting - ; + fun isDisconnected() = this == Disconnected - fun isConnected() = this != DISCONNECTED + fun isDeviceSleep() = this == DeviceSleep } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 03041c4d6d..08864a28c5 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -41,7 +41,7 @@ class ServiceRepository @Inject constructor() { } // Connection state to our radio device - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) + private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow get() = _connectionState @@ -81,7 +81,7 @@ class ServiceRepository @Inject constructor() { get() = _statusMessage fun setStatusMessage(text: String) { - if (connectionState.value != ConnectionState.CONNECTED) { + if (connectionState.value != ConnectionState.Connected) { _statusMessage.value = text } } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 1eec5b1ab9..a46b1541a8 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -210,6 +210,7 @@ Current connections: Wifi IP: Ethernet IP: + Connecting Not connected Connected to radio, but it is sleeping Application update required @@ -953,4 +954,5 @@ Unset - 0 Relayed by: %1$s Preserve Favorites? + USB Devices diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index c7d97869cd..66720af7de 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -49,6 +49,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.database.model.isUnmessageableRole import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.elevation_suffix import org.meshtastic.core.strings.unknown_username @@ -72,7 +73,7 @@ fun NodeItem( onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, currentTimeMillis: Long, - isConnected: Boolean = false, + connectionState: ConnectionState, ) { val isFavorite = remember(thatNode) { thatNode.isFavorite } val isIgnored = thatNode.isIgnored @@ -140,7 +141,7 @@ fun NodeItem( isThisNode = isThisNode, isFavorite = isFavorite, isUnmessageable = unmessageable, - isConnected = isConnected, + connectionState = connectionState, ) } @@ -221,7 +222,14 @@ fun NodeInfoSimplePreview() { AppTheme { val thisNode = NodePreviewParameterProvider().values.first() val thatNode = NodePreviewParameterProvider().values.last() - NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, currentTimeMillis = System.currentTimeMillis()) + NodeItem( + thisNode = thisNode, + thatNode = thatNode, + 0, + true, + currentTimeMillis = System.currentTimeMillis(), + connectionState = ConnectionState.Connected, + ) } } @@ -236,6 +244,7 @@ fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatN distanceUnits = 1, tempInFahrenheit = true, currentTimeMillis = System.currentTimeMillis(), + connectionState = ConnectionState.Connected, ) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index fe4370abf3..bfdaf6bc47 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -23,8 +23,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.NoCell import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.twotone.Cloud import androidx.compose.material.icons.twotone.CloudDone import androidx.compose.material.icons.twotone.CloudOff +import androidx.compose.material.icons.twotone.CloudSync import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -40,21 +42,29 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.service.ConnectionState import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.connected +import org.meshtastic.core.strings.connecting +import org.meshtastic.core.strings.device_sleeping import org.meshtastic.core.strings.disconnected import org.meshtastic.core.strings.favorite -import org.meshtastic.core.strings.not_connected import org.meshtastic.core.strings.unmessageable import org.meshtastic.core.strings.unmonitored_or_infrastructure import org.meshtastic.core.ui.theme.StatusColors.StatusGreen +import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) { +fun NodeStatusIcons( + isThisNode: Boolean, + isUnmessageable: Boolean, + isFavorite: Boolean, + connectionState: ConnectionState, +) { Row(modifier = Modifier.padding(4.dp)) { if (isThisNode) { TooltipBox( @@ -63,10 +73,11 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B PlainTooltip { Text( stringResource( - if (isConnected) { - Res.string.connected - } else { - Res.string.disconnected + when (connectionState) { + ConnectionState.Connected -> Res.string.connected + ConnectionState.Connecting -> Res.string.connecting + ConnectionState.Disconnected -> Res.string.disconnected + ConnectionState.DeviceSleep -> Res.string.device_sleeping }, ), ) @@ -74,21 +85,39 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B }, state = rememberTooltipState(), ) { - if (isConnected) { - @Suppress("MagicNumber") - Icon( - imageVector = Icons.TwoTone.CloudDone, - contentDescription = stringResource(Res.string.connected), - modifier = Modifier.size(24.dp), // Smaller size for badge - tint = MaterialTheme.colorScheme.StatusGreen, - ) - } else { - Icon( - imageVector = Icons.TwoTone.CloudOff, - contentDescription = stringResource(Res.string.not_connected), - modifier = Modifier.size(24.dp), // Smaller size for badge - tint = MaterialTheme.colorScheme.StatusRed, - ) + when (connectionState) { + ConnectionState.Connected -> { + Icon( + imageVector = Icons.TwoTone.CloudDone, + contentDescription = stringResource(Res.string.connected), + modifier = Modifier.size(24.dp), // Smaller size for badge + tint = MaterialTheme.colorScheme.StatusGreen, + ) + } + ConnectionState.Connecting -> { + Icon( + imageVector = Icons.TwoTone.CloudSync, + contentDescription = stringResource(Res.string.connecting), + modifier = Modifier.size(24.dp), // Smaller size for badge + tint = MaterialTheme.colorScheme.StatusOrange, + ) + } + ConnectionState.Disconnected -> { + Icon( + imageVector = Icons.TwoTone.CloudOff, + contentDescription = stringResource(Res.string.connecting), + modifier = Modifier.size(24.dp), // Smaller size for badge + tint = MaterialTheme.colorScheme.StatusRed, + ) + } + ConnectionState.DeviceSleep -> { + Icon( + imageVector = Icons.TwoTone.Cloud, + contentDescription = stringResource(Res.string.device_sleeping), + modifier = Modifier.size(24.dp), // Smaller size for badge + tint = MaterialTheme.colorScheme.StatusYellow, + ) + } } } } @@ -130,5 +159,10 @@ fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: B @Preview @Composable private fun StatusIconsPreview() { - NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false) + NodeStatusIcons( + isThisNode = true, + isUnmessageable = true, + isFavorite = true, + connectionState = ConnectionState.Connected, + ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index cc37bbe682..c177650931 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -140,7 +140,7 @@ fun NodeListScreen( sharedContact = sharedContact, modifier = Modifier.animateFloatingActionButton( - visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable, + visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable, alignment = Alignment.BottomEnd, ), onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) }, @@ -217,7 +217,7 @@ fun NodeListScreen( onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, currentTimeMillis = currentTimeMillis, - isConnected = connectionState.isConnected(), + connectionState = connectionState, ) val isThisNode = remember(node) { ourNode?.num == node.num } if (!isThisNode) { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 0b5e6f235a..3b2bfeb4c1 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -169,7 +169,7 @@ constructor( serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope) combine(serviceRepository.connectionState, radioConfigState) { connState, configState -> - _radioConfigState.update { it.copy(connected = connState == ConnectionState.CONNECTED) } + _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) } } .launchIn(viewModelScope) From 031d5a22c5907fb86e9ecdd104cf527ca8d60c11 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:15:44 -0600 Subject: [PATCH 28/62] feat(ui): Display BLE signal strength for connected device (#3721) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../repository/radio/RadioInterfaceService.kt | 7 ------ .../mesh/ui/connections/ConnectionsScreen.kt | 10 ++++---- .../ui/connections/ConnectionsViewModel.kt | 9 -------- .../components/CurrentlyConnectedInfo.kt | 23 ++++++++++++++++++- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index bb39b1ef6d..d163ae5933 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -90,13 +90,6 @@ constructor( private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr) val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() - private val _isRssiPollingEnabled = MutableStateFlow(false) - val isRssiPollingEnabled: StateFlow = _isRssiPollingEnabled.asStateFlow() - - fun setRssiPolling(enabled: Boolean) { - _isRssiPollingEnabled.value = enabled - } - private val logSends = false private val logReceives = false private lateinit var sentPacketsLog: BinaryLogFile diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index 14eee02f0e..bab494f186 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -56,6 +55,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.model.BTScanModel +import com.geeksville.mesh.model.DeviceListEntry import com.geeksville.mesh.ui.connections.components.BLEDevices import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo @@ -126,11 +126,6 @@ fun ConnectionsScreen( val recentTcpDevices by scanModel.recentTcpDevicesForUi.collectAsStateWithLifecycle() val usbDevices by scanModel.usbDevicesForUi.collectAsStateWithLifecycle() - DisposableEffect(Unit) { - connectionsViewModel.onStart() - onDispose { connectionsViewModel.onStop() } - } - /* Animate waiting for the configurations */ var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { @@ -209,6 +204,9 @@ fun ConnectionsScreen( TitledCard(title = stringResource(Res.string.connected_device)) { CurrentlyConnectedInfo( node = node, + bleDevice = + bleDevices.firstOrNull { it.fullAddress == selectedDevice } + as DeviceListEntry.Ble?, onNavigateToNodeDetails = onNavigateToNodeDetails, onClickDisconnect = { scanModel.disconnect() }, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt index d899de13a7..f8f661891d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt @@ -19,7 +19,6 @@ package com.geeksville.mesh.ui.connections import androidx.lifecycle.ViewModel import com.geeksville.mesh.repository.bluetooth.BluetoothRepository -import com.geeksville.mesh.repository.radio.RadioInterfaceService import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -40,18 +39,10 @@ class ConnectionsViewModel constructor( radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, - private val radioInterfaceService: RadioInterfaceService, nodeRepository: NodeRepository, bluetoothRepository: BluetoothRepository, private val uiPrefs: UiPrefs, ) : ViewModel() { - fun onStart() { - radioInterfaceService.setRssiPolling(true) - } - - fun onStop() { - radioInterfaceService.setRssiPolling(false) - } val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance()) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt index 42fe80b89a..626fafad24 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/CurrentlyConnectedInfo.kt @@ -28,6 +28,11 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,27 +40,40 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import com.geeksville.mesh.model.DeviceListEntry +import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.disconnect import org.meshtastic.core.strings.firmware_version import org.meshtastic.core.ui.component.MaterialBatteryInfo +import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.PaxcountProtos import org.meshtastic.proto.TelemetryProtos +import kotlin.time.Duration.Companion.seconds -/** Converts Bluetooth RSSI to a 0-4 bar signal strength level. */ @Composable fun CurrentlyConnectedInfo( node: Node, onNavigateToNodeDetails: (Int) -> Unit, onClickDisconnect: () -> Unit, modifier: Modifier = Modifier, + bleDevice: DeviceListEntry.Ble? = null, ) { + var rssi by remember { mutableIntStateOf(0) } + LaunchedEffect(bleDevice) { + if (bleDevice != null) { + while (bleDevice.peripheral.isConnected) { + rssi = bleDevice.peripheral.readRssi() + delay(10.seconds) + } + } + } Column(modifier = modifier) { Row( modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 8.dp), @@ -63,6 +81,9 @@ fun CurrentlyConnectedInfo( verticalAlignment = Alignment.CenterVertically, ) { MaterialBatteryInfo(level = node.batteryLevel, voltage = node.voltage) + if (bleDevice is DeviceListEntry.Ble) { + MaterialBluetoothSignalInfo(rssi) + } } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Column( From aca34f1598b5fdd99acec91c157bd18458261a00 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:46:45 -0600 Subject: [PATCH 29/62] feat(bluetooth): Request location permission for BLE scan pre S (#3724) --- .../mesh/ui/connections/components/BLEDevices.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index f624076074..4e82cdd0c2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.connections.components import android.Manifest import android.content.Intent +import android.os.Build import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -94,7 +95,11 @@ fun BLEDevices( // Define permissions needed for Bluetooth scanning based on Android version. val bluetoothPermissionsList = remember { - listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + } else { + listOf(Manifest.permission.ACCESS_FINE_LOCATION) + } } val context = LocalContext.current From a502629b5c1069a422985590791422685812ce3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:55:16 -0600 Subject: [PATCH 30/62] chore(deps): update gradle to v9.2.1 (#3723) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bad7c2462f..23449a2b54 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 0da991b6ac9e1687c32ce7337dd8e8f42257a598 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 18 Nov 2025 07:26:15 -0600 Subject: [PATCH 31/62] feat(ui): Improve scan status text display (#3725) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/ui/connections/ConnectionsScreen.kt | 208 +++++++++--------- 1 file changed, 110 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt index bab494f186..c21d250d7a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt @@ -36,6 +36,8 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Language +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -48,7 +50,9 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -160,6 +164,7 @@ fun ConnectionsScreen( ConnectionState.Connected -> { if (regionUnset) Res.string.must_set_region else Res.string.connected } + ConnectionState.Connecting -> Res.string.connecting ConnectionState.Disconnected -> Res.string.not_connected @@ -180,124 +185,131 @@ fun ConnectionsScreen( ) }, ) { paddingValues -> - Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize().weight(1f)) { - Column( - modifier = - Modifier.fillMaxSize() - .verticalScroll(scrollState) - .height(IntrinsicSize.Max) - .padding(paddingValues) - .padding(16.dp), - ) { - AnimatedVisibility(visible = connectionState == ConnectionState.Connecting) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp)) - } + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .height(IntrinsicSize.Max) + .padding(paddingValues) + .padding(16.dp), + ) { + AnimatedVisibility(visible = connectionState == ConnectionState.Connecting) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularWavyProgressIndicator(modifier = Modifier.size(96.dp).padding(16.dp)) } - AnimatedVisibility( - visible = connectionState.isConnected(), - modifier = Modifier.padding(bottom = 16.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ourNode?.let { node -> - TitledCard(title = stringResource(Res.string.connected_device)) { - CurrentlyConnectedInfo( - node = node, - bleDevice = - bleDevices.firstOrNull { it.fullAddress == selectedDevice } - as DeviceListEntry.Ble?, - onNavigateToNodeDetails = onNavigateToNodeDetails, - onClickDisconnect = { scanModel.disconnect() }, - ) - } + } + AnimatedVisibility( + visible = connectionState.isConnected(), + modifier = Modifier.padding(bottom = 16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ourNode?.let { node -> + TitledCard(title = stringResource(Res.string.connected_device)) { + CurrentlyConnectedInfo( + node = node, + bleDevice = + bleDevices.firstOrNull { it.fullAddress == selectedDevice } + as DeviceListEntry.Ble?, + onNavigateToNodeDetails = onNavigateToNodeDetails, + onClickDisconnect = { scanModel.disconnect() }, + ) } + } - if (regionUnset && selectedDevice != "m") { - TitledCard(title = null) { - ListItem( - leadingIcon = Icons.Rounded.Language, - text = stringResource(Res.string.set_your_region), - ) { - isWaiting = true - radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) - } + if (regionUnset && selectedDevice != "m") { + TitledCard(title = null) { + ListItem( + leadingIcon = Icons.Rounded.Language, + text = stringResource(Res.string.set_your_region), + ) { + isWaiting = true + radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) } } } } + } - var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } - LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } } + var selectedDeviceType by remember { mutableStateOf(DeviceType.BLE) } + LaunchedEffect(Unit) { DeviceType.fromAddress(selectedDevice)?.let { selectedDeviceType = it } } - ConnectionsSegmentedBar( - selectedDeviceType = selectedDeviceType, - modifier = Modifier.fillMaxWidth(), - ) { - selectedDeviceType = it - } + ConnectionsSegmentedBar(selectedDeviceType = selectedDeviceType, modifier = Modifier.fillMaxWidth()) { + selectedDeviceType = it + } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) - Column(modifier = Modifier.fillMaxSize()) { - when (selectedDeviceType) { - DeviceType.BLE -> { - val (bonded, available) = bleDevices.partition { it.bonded } - BLEDevices( - connectionState = connectionState, - bondedDevices = bonded, - availableDevices = available, - selectedDevice = selectedDevice, - scanModel = scanModel, - bluetoothEnabled = bluetoothState.enabled, - ) - } + Column(modifier = Modifier.fillMaxSize()) { + when (selectedDeviceType) { + DeviceType.BLE -> { + val (bonded, available) = bleDevices.partition { it.bonded } + BLEDevices( + connectionState = connectionState, + bondedDevices = bonded, + availableDevices = available, + selectedDevice = selectedDevice, + scanModel = scanModel, + bluetoothEnabled = bluetoothState.enabled, + ) + } - DeviceType.TCP -> { - NetworkDevices( - connectionState = connectionState, - discoveredNetworkDevices = discoveredTcpDevices, - recentNetworkDevices = recentTcpDevices, - selectedDevice = selectedDevice, - scanModel = scanModel, - ) - } + DeviceType.TCP -> { + NetworkDevices( + connectionState = connectionState, + discoveredNetworkDevices = discoveredTcpDevices, + recentNetworkDevices = recentTcpDevices, + selectedDevice = selectedDevice, + scanModel = scanModel, + ) + } - DeviceType.USB -> { - UsbDevices( - connectionState = connectionState, - usbDevices = usbDevices, - selectedDevice = selectedDevice, - scanModel = scanModel, - ) - } + DeviceType.USB -> { + UsbDevices( + connectionState = connectionState, + usbDevices = usbDevices, + selectedDevice = selectedDevice, + scanModel = scanModel, + ) } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Warning Not Paired - val hasShownNotPairedWarning by - connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle() - val (bonded, _) = bleDevices.partition { it.bonded } - val showWarningNotPaired = - !connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty() - if (showWarningNotPaired) { - Text( - text = stringResource(Res.string.warning_not_paired), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(16.dp)) + // Warning Not Paired + val hasShownNotPairedWarning by + connectionsViewModel.hasShownNotPairedWarning.collectAsStateWithLifecycle() + val (bonded, _) = bleDevices.partition { it.bonded } + val showWarningNotPaired = + !connectionState.isConnected() && !hasShownNotPairedWarning && bonded.isEmpty() + if (showWarningNotPaired) { + Text( + text = stringResource(Res.string.warning_not_paired), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) - LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() } - } + LaunchedEffect(Unit) { connectionsViewModel.suppressNoPairedWarning() } } } } - - Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - Text(text = scanStatusText.orEmpty(), fontSize = 10.sp, textAlign = TextAlign.End) + scanStatusText?.let { + Card( + modifier = Modifier.padding(8.dp).align(Alignment.BottomStart), + colors = + CardDefaults.cardColors() + .copy(containerColor = CardDefaults.cardColors().containerColor.copy(alpha = 0.5f)), + ) { + Text( + text = it, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.End, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } } } } From 5161168b0f873b678998454d2a6851693348e192 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Tue, 18 Nov 2025 05:27:11 -0800 Subject: [PATCH 32/62] feat: polish jump to unread message (#3710) --- .../meshtastic/feature/messaging/Message.kt | 8 +- .../feature/messaging/MessageList.kt | 127 ++++++++---------- .../feature/messaging/UnreadUiDefaults.kt | 49 +++++++ 3 files changed, 111 insertions(+), 73 deletions(-) create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 5af3c7a1cc..0504feac2e 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -250,7 +250,13 @@ fun MessageScreen( LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) { if (!hasPerformedInitialScroll && messages.isNotEmpty()) { - val targetIndex = (initialUnreadIndex ?: earliestUnreadIndex ?: 0).coerceIn(0, messages.lastIndex) + val unreadStart = initialUnreadIndex ?: earliestUnreadIndex + val targetIndex = + when { + unreadStart == null -> 0 + unreadStart <= 0 -> 0 + else -> (unreadStart - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0) + } if (listState.firstVisibleItemIndex != targetIndex) { listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt index e9de5c43e0..a9f35b5211 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -82,6 +83,15 @@ internal data class MessageListHandlers( val onReply: (Message?) -> Unit, ) +private fun MutableState>.toggle(uuid: Long) { + value = + if (value.contains(uuid)) { + value - uuid + } else { + value + uuid + } +} + @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -136,10 +146,10 @@ internal fun MessageList( state = state, handlers = handlers, inSelectionMode = inSelectionMode, - onShowStatusDialog = { showStatusDialog = it }, - onShowReactions = { showReactionDialog = it }, coroutineScope = coroutineScope, haptics = haptics, + onShowStatusDialog = { showStatusDialog = it }, + onShowReactions = { showReactionDialog = it }, modifier = modifier, ) } @@ -151,50 +161,60 @@ private sealed interface MessageListRow { } @Composable -private fun MessageRowContent( - row: MessageListRow, +private fun MessageListContent( + listState: LazyListState, + messageRows: List, state: MessageListState, handlers: MessageListHandlers, inSelectionMode: Boolean, - listState: LazyListState, coroutineScope: CoroutineScope, haptics: HapticFeedback, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, + modifier: Modifier = Modifier, ) { - when (row) { - is MessageListRow.UnreadDivider -> UnreadMessagesDivider() - is MessageListRow.ChatMessage -> - state.ourNode?.let { ourNode -> - ChatMessageRow( - row = row, - state = state, - handlers = handlers, - inSelectionMode = inSelectionMode, - listState = listState, - coroutineScope = coroutineScope, - haptics = haptics, - onShowStatusDialog = onShowStatusDialog, - onShowReactions = onShowReactions, - ourNode = ourNode, - ) + LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { + items( + items = messageRows, + key = { row -> + when (row) { + is MessageListRow.ChatMessage -> row.message.uuid + is MessageListRow.UnreadDivider -> row.key + } + }, + ) { row -> + when (row) { + is MessageListRow.UnreadDivider -> UnreadMessagesDivider(modifier = Modifier.animateItem()) + is MessageListRow.ChatMessage -> + renderChatMessageRow( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + coroutineScope = coroutineScope, + haptics = haptics, + listState = listState, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, + ) } + } } } @Composable -private fun ChatMessageRow( +private fun LazyItemScope.renderChatMessageRow( row: MessageListRow.ChatMessage, state: MessageListState, handlers: MessageListHandlers, inSelectionMode: Boolean, - listState: LazyListState, coroutineScope: CoroutineScope, haptics: HapticFeedback, + listState: LazyListState, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, - ourNode: Node, ) { + val ourNode = state.ourNode ?: return val message = row.message val selected by remember(message.uuid, state.selectedIds.value) { @@ -206,19 +226,14 @@ private fun ChatMessageRow( } MessageItem( + modifier = Modifier.animateItem(), node = node, ourNode = ourNode, message = message, selected = selected, - onClick = { - if (inSelectionMode) { - state.selectedIds.value = - if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid - } - }, + onClick = { if (inSelectionMode) state.selectedIds.toggle(message.uuid) }, onLongClick = { - state.selectedIds.value = - if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + state.selectedIds.toggle(message.uuid) haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, onClickChip = handlers.onClickChip, @@ -238,44 +253,6 @@ private fun ChatMessageRow( ) } -@Composable -private fun MessageListContent( - listState: LazyListState, - messageRows: List, - state: MessageListState, - handlers: MessageListHandlers, - inSelectionMode: Boolean, - onShowStatusDialog: (Message) -> Unit, - onShowReactions: (List) -> Unit, - coroutineScope: CoroutineScope, - haptics: HapticFeedback, - modifier: Modifier = Modifier, -) { - LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { - items( - items = messageRows, - key = { row -> - when (row) { - is MessageListRow.ChatMessage -> row.message.uuid - is MessageListRow.UnreadDivider -> row.key - } - }, - ) { row -> - MessageRowContent( - row = row, - state = state, - handlers = handlers, - inSelectionMode = inSelectionMode, - listState = listState, - coroutineScope = coroutineScope, - haptics = haptics, - onShowStatusDialog = onShowStatusDialog, - onShowReactions = onShowReactions, - ) - } - } -} - @Composable private fun MessageStatusDialog( message: Message, @@ -367,7 +344,13 @@ private fun AutoScrollToBottom( ) = with(listState) { val shouldAutoScroll by remember(hasUnreadMessages) { - derivedStateOf { !hasUnreadMessages && firstVisibleItemIndex < itemThreshold } + derivedStateOf { + val isAtBottom = + firstVisibleItemIndex == 0 && + firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE + val isNearBottom = firstVisibleItemIndex <= itemThreshold + isAtBottom || (!hasUnreadMessages && isNearBottom) + } } if (shouldAutoScroll) { LaunchedEffect(list) { @@ -387,7 +370,7 @@ private fun UpdateUnreadCount( ) { LaunchedEffect(messages) { snapshotFlow { listState.firstVisibleItemIndex } - .debounce(timeoutMillis = 500L) + .debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS) .collectLatest { index -> val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal } if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt new file mode 100644 index 0000000000..f9ba166e9f --- /dev/null +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 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.feature.messaging + +/** + * Shared configuration for how unread markers behave in the message thread. + * + * Keeping these in one place makes it easier to reason about how the unread divider and auto-mark-as-read flows work + * across `Message` and `MessageList`. + */ +internal object UnreadUiDefaults { + /** + * The number of most-recent messages we attempt to keep visible when jumping to unread content. + * + * With the list reversed (newest at index 0) this translates to showing up to this many messages *above* the unread + * divider so the user can read into the conversation with enough context. + */ + const val VISIBLE_CONTEXT_COUNT = 5 + + /** + * Acceptable pixel offset from the absolute bottom of the list while still treating the user as "caught up". + * Compose list positioning can drift by a few pixels during fling settles, so this tolerance keeps the auto-scroll + * behavior feeling buttery when new packets arrive. + */ + const val AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE = 8 + + /** + * Delay (in milliseconds) before we persist a new "last read" marker while scrolling. + * + * A longer debounce prevents thrashing the database during quick scrubs yet still feels responsive once the user + * settles on a position. + */ + const val SCROLL_DEBOUNCE_MILLIS = 5_000L +} From 74c3007f1d7d6e546c795a0c57b94440167a57b2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:12:52 -0600 Subject: [PATCH 33/62] feat(nsd): Add support for Android 14+ NSD resolving (#3731) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/repository/network/NsdManager.kt | 123 ++++++++++++------ .../mesh/repository/radio/StreamInterface.kt | 2 +- .../geeksville/mesh/service/MeshService.kt | 9 -- .../main/java/com/geeksville/mesh/ui/Main.kt | 4 +- .../ui/connections/components/BLEDevices.kt | 36 ++++- .../geeksville/mesh/ui/contact/Contacts.kt | 6 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 4 +- .../mesh/ui/sharing/ChannelViewModel.kt | 2 +- 8 files changed, 124 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt index 4c88bd046d..47a47f7b80 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/network/NsdManager.kt @@ -17,57 +17,63 @@ package com.geeksville.mesh.repository.network +import android.annotation.SuppressLint import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo +import android.os.Build +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asExecutor import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.suspendCancellableCoroutine +import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList import kotlin.coroutines.resume @OptIn(ExperimentalCoroutinesApi::class) -internal fun NsdManager.serviceList( - serviceType: String, -): Flow> = discoverServices(serviceType).mapLatest { serviceList -> - serviceList - .mapNotNull { resolveService(it) } -} +internal fun NsdManager.serviceList(serviceType: String): Flow> = + discoverServices(serviceType).mapLatest { serviceList -> serviceList.mapNotNull { resolveService(it) } } private fun NsdManager.discoverServices( serviceType: String, protocolType: Int = NsdManager.PROTOCOL_DNS_SD, ): Flow> = callbackFlow { val serviceList = CopyOnWriteArrayList() - val discoveryListener = object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { - cancel("Start Discovery failed: Error code: $errorCode") - } + val discoveryListener = + object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Start Discovery failed: Error code: $errorCode") + } - override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { - cancel("Stop Discovery failed: Error code: $errorCode") - } + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + cancel("Stop Discovery failed: Error code: $errorCode") + } - override fun onDiscoveryStarted(serviceType: String) { - } + override fun onDiscoveryStarted(serviceType: String) { + Timber.d("NSD Service discovery started") + } - override fun onDiscoveryStopped(serviceType: String) { - close() - } + override fun onDiscoveryStopped(serviceType: String) { + Timber.d("NSD Service discovery stopped") + close() + } - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - serviceList += serviceInfo - trySend(serviceList) - } + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + Timber.d("NSD Service found: $serviceInfo") + serviceList += serviceInfo + trySend(serviceList) + } - override fun onServiceLost(serviceInfo: NsdServiceInfo) { - serviceList.removeAll { it.serviceName == serviceInfo.serviceName } - trySend(serviceList) + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + Timber.d("NSD Service lost: $serviceInfo") + serviceList.removeAll { it.serviceName == serviceInfo.serviceName } + trySend(serviceList) + } } - } trySend(emptyList()) // Emit an initial empty list discoverServices(serviceType, protocolType, discoveryListener) @@ -80,17 +86,60 @@ private fun NsdManager.discoverServices( } } -private suspend fun NsdManager.resolveService( - serviceInfo: NsdServiceInfo, -): NsdServiceInfo? = suspendCancellableCoroutine { continuation -> - val listener = object : NsdManager.ResolveListener { - override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { - continuation.resume(null) - } +@SuppressLint("NewApi") +private suspend fun NsdManager.resolveService(serviceInfo: NsdServiceInfo): NsdServiceInfo? = + suspendCancellableCoroutine { continuation -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val callback = + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + continuation.resume(null) + } + + override fun onServiceUpdated(updatedServiceInfo: NsdServiceInfo) { + if (updatedServiceInfo.hostAddresses.isNotEmpty()) { + continuation.resume(updatedServiceInfo) + try { + unregisterServiceInfoCallback(this) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + } - override fun onServiceResolved(serviceInfo: NsdServiceInfo) { - continuation.resume(serviceInfo) + override fun onServiceLost() { + continuation.resume(null) + try { + unregisterServiceInfoCallback(this) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + + override fun onServiceInfoCallbackUnregistered() { + // No op + } + } + registerServiceInfoCallback(serviceInfo, Dispatchers.Main.asExecutor(), callback) + continuation.invokeOnCancellation { + try { + unregisterServiceInfoCallback(callback) + } catch (e: IllegalArgumentException) { + Timber.w(e, "Already unregistered") + } + } + } else { + val listener = + object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + continuation.resume(null) + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + continuation.resume(serviceInfo) + } + } + @Suppress("DEPRECATION") + resolveService(serviceInfo, listener) } } - resolveService(serviceInfo, listener) -} diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt index 1134c6c02f..a7e712e63c 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt @@ -87,7 +87,7 @@ abstract class StreamInterface(protected val service: RadioInterfaceService) : I /** Print device serial debug output somewhere */ private fun debugOut(b: Byte) { - when (val c = b.toChar()) { + when (val c = b.toInt().toChar()) { '\r' -> {} // ignore '\n' -> { Timber.d("DeviceLog: $debugLineBuf") diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index e26980e92a..6fa5eb2dfa 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -2242,15 +2242,6 @@ class MeshService : Service() { rememberReaction(packet.copy { from = myNodeNum }) } - fun clearDatabases() = serviceScope.handledLaunch { - Timber.d("Clearing nodeDB") - discardNodeDB() - nodeRepository.clearNodeDB() - - Timber.d("Clearing packetDB") - packetRepository.get().clearPacketDB() - } - private fun updateLastAddress(deviceAddr: String?) { val currentAddr = meshPrefs.deviceAddress Timber.d("setDeviceAddress: received request to change to: ${deviceAddr.anonymize}") diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index b6a2196dc2..5ecf182c17 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -292,7 +293,8 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode item( icon = { TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { Text( diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt index 4e82cdd0c2..6d9adf3a7f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt @@ -52,6 +52,7 @@ import com.geeksville.mesh.model.DeviceListEntry import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.service.ConnectionState @@ -98,19 +99,42 @@ fun BLEDevices( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) } else { - listOf(Manifest.permission.ACCESS_FINE_LOCATION) + listOf( + Manifest.permission.BLUETOOTH, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) } } val context = LocalContext.current - val permsMissing = stringResource(Res.string.permission_missing) + val permsMissing = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + stringResource(Res.string.permission_missing_31) + } else { + stringResource(Res.string.permission_missing) + } val coroutineScope = rememberCoroutineScope() + val singlePermissionState = + rememberPermissionState( + permission = Manifest.permission.ACCESS_BACKGROUND_LOCATION, + onPermissionResult = { granted -> + scanModel.refreshPermissions() + scanModel.startScan() + }, + ) + val permissionsState = rememberMultiplePermissionsState( permissions = bluetoothPermissionsList, - onPermissionsResult = { - if (it.values.all { granted -> granted } && bluetoothEnabled) { + onPermissionsResult = { permissions -> + val granted = permissions.values.all { it } + if (permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false)) { + coroutineScope.launch { context.showToast(permsMissing) } + singlePermissionState.launchPermissionRequest() + } + if (granted) { scanModel.refreshPermissions() scanModel.startScan() } else { @@ -121,8 +145,8 @@ fun BLEDevices( val settingsLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { - // Eventually auto scan once bluetooth is available - // checkPermissionsAndScan(permissionsState, scanModel, bluetoothEnabled) + scanModel.refreshPermissions() + scanModel.startScan() } Column( diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 8fc4a5b6b8..6801b46ea8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -155,11 +155,7 @@ fun ContactsScreen( // if it's a node, look up the nodeNum including the ! val nodeKey = contact.contactKey.substring(1) val node = viewModel.getNode(nodeKey) - - if (node != null) { - // navigate to node details. - onNavigateToNodeDetails(node.num) - } + onNavigateToNodeDetails(node.num) } else { // Channels } diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 094a3e4ea6..79e0702004 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -363,12 +363,12 @@ fun ChannelScreen( item { PreferenceFooter( enabled = enabled, - negativeText = Res.string.reset, + negativeText = stringResource(Res.string.reset), onNegativeClicked = { focusManager.clearFocus() showResetDialog = true }, - positiveText = Res.string.scan, + positiveText = stringResource(Res.string.scan), onPositiveClicked = { focusManager.clearFocus() if (cameraPermissionState.status.isGranted) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt index 3822448252..a7a7a76c70 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt @@ -60,7 +60,7 @@ constructor( // managed mode disables all access to configuration val isManaged: Boolean - get() = localConfig.value.device.isManaged || localConfig.value.security.isManaged + get() = localConfig.value.security.isManaged var txEnabled: Boolean get() = localConfig.value.lora.txEnabled From 04b185d6b902da11b0e20f0f5c644fee12601982 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:38:31 -0600 Subject: [PATCH 34/62] New Crowdin updates (#3734) --- .../composeResources/values-be/strings.xml | 1 - .../composeResources/values-bg/strings.xml | 6 +- .../composeResources/values-ca/strings.xml | 3 +- .../composeResources/values-cs/strings.xml | 17 +- .../composeResources/values-de/strings.xml | 6 +- .../composeResources/values-el/strings.xml | 1 + .../composeResources/values-es/strings.xml | 188 +++++++++++++++++- .../composeResources/values-et/strings.xml | 11 +- .../composeResources/values-fi/strings.xml | 11 +- .../composeResources/values-fr/strings.xml | 6 +- .../composeResources/values-ga/strings.xml | 2 + .../composeResources/values-gl/strings.xml | 12 ++ .../composeResources/values-he/strings.xml | 2 + .../composeResources/values-hr/strings.xml | 2 + .../composeResources/values-ht/strings.xml | 2 + .../composeResources/values-hu/strings.xml | 6 +- .../composeResources/values-is/strings.xml | 2 + .../composeResources/values-it/strings.xml | 5 +- .../composeResources/values-ja/strings.xml | 2 + .../composeResources/values-ko/strings.xml | 3 + .../composeResources/values-lt/strings.xml | 2 + .../composeResources/values-nl/strings.xml | 3 + .../composeResources/values-no/strings.xml | 2 + .../composeResources/values-pl/strings.xml | 3 + .../values-pt-rBR/strings.xml | 4 +- .../composeResources/values-pt/strings.xml | 12 +- .../composeResources/values-ro/strings.xml | 2 + .../composeResources/values-ru/strings.xml | 6 +- .../composeResources/values-sk/strings.xml | 2 + .../composeResources/values-sl/strings.xml | 2 + .../composeResources/values-sq/strings.xml | 2 + .../composeResources/values-sr/strings.xml | 2 + .../composeResources/values-srp/strings.xml | 2 + .../composeResources/values-sv/strings.xml | 8 +- .../composeResources/values-tr/strings.xml | 3 + .../composeResources/values-uk/strings.xml | 3 + .../values-zh-rCN/strings.xml | 5 +- .../values-zh-rTW/strings.xml | 7 +- 38 files changed, 332 insertions(+), 26 deletions(-) diff --git a/core/strings/src/commonMain/composeResources/values-be/strings.xml b/core/strings/src/commonMain/composeResources/values-be/strings.xml index 43b6eb60ac..7321b05c7a 100644 --- a/core/strings/src/commonMain/composeResources/values-be/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-be/strings.xml @@ -34,7 +34,6 @@ праз MQTT праз MQTT Абраныя - Ігнараваныя вузлы Нераспазнанае Чакае пацвярджэння У чарзе на адпраўку diff --git a/core/strings/src/commonMain/composeResources/values-bg/strings.xml b/core/strings/src/commonMain/composeResources/values-bg/strings.xml index 6eb0a05762..98af73d6d4 100644 --- a/core/strings/src/commonMain/composeResources/values-bg/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-bg/strings.xml @@ -35,7 +35,6 @@ с MQTT с MQTT Чрез любим - Игнорирани възли Неразпознат Изчакване за потвърждение Наредено на опашка за изпращане @@ -146,6 +145,7 @@ Текущи връзки: Wifi IP: Ethernet IP: + Свързване Няма връзка Свързан е с радио, но рядиото е в режим на заспиване Изисква се актуализация на приложението @@ -255,6 +255,8 @@ Изчистването на SQL кеша е неуспешно, вижте logcat за подробности Мениджър на кеш Свалянето приключи! + Свалянето приключи с %1$d грешки + %1$d плочки посока: %1$d° разстояние: %2$s Редактиране на пътна точка Изтриване на пътна точка? @@ -678,6 +680,7 @@ Конфигуриране на критични предупреждения Meshtastic използва известия, за да ви държи в течение за нови съобщения и други важни събития. Можете да актуализирате разрешенията си за известия по всяко време от настройките. Напред + %1$d възела са на опашка за изтриване: Свързване с устройство Нормален Сателит @@ -707,7 +710,6 @@ Деактивирането на позицията на първичния канал позволява периодично излъчване на позиция на първия вторичен канал с активирана позиция, в противен случай е необходимо ръчно заявяване на позиция. Конфигурация на устройството Изпращане на телеметрия на устройството - Активиране/дезактивиране на модула за телеметрия на устройството за изпращане на метрики към mesh 1 час 8 часа 24 часа diff --git a/core/strings/src/commonMain/composeResources/values-ca/strings.xml b/core/strings/src/commonMain/composeResources/values-ca/strings.xml index 336525bb21..7d9db02c3a 100644 --- a/core/strings/src/commonMain/composeResources/values-ca/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ca/strings.xml @@ -33,7 +33,6 @@ via MQTT via MQTT via Favorits - Nodes Ignorats No reconeguts Esperant confirmació En cua per enviar @@ -176,6 +175,8 @@ Error en la purga de la memòria cau SQL, veure logcat per a detalls Director de la memòria cau Descarrega completa! + Descarrega completa amb %1$d errors + %1$d tessel·les rumb: %1$d° distància: %2$s Editar punt de pas Esborrar punt de pas? diff --git a/core/strings/src/commonMain/composeResources/values-cs/strings.xml b/core/strings/src/commonMain/composeResources/values-cs/strings.xml index d8f278ae7d..f09544a6fd 100644 --- a/core/strings/src/commonMain/composeResources/values-cs/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-cs/strings.xml @@ -35,7 +35,6 @@ přes MQTT přes MQTT Oblíbené - Ignorované uzly Neznámý Čeká na potvrzení Ve frontě k odeslání @@ -81,6 +80,7 @@ Při trojím stisku tlačítka odeslat polohu na primární kanál. Nastavuje blikání LED diody na zařízení. U většiny zařízení lze ovládat jednu ze čtyř LED diod, avšak LED diody nabíječky a GPS nelze ovládat. Časové pásmo pro zobrazování dat na displeji a v záznamech (logu). + Použít časové pásmo telefonu Umožní odesílat informace o sousedních uzlech (NeighborInfo) nejen do MQTT a PhoneAPI, ale také přes LoRa. Nedostupné na kanálech s výchozím klíčem a názvem. Doba, po kterou zůstane displej aktivní po stisku tlačítka nebo po přijetí zprávy. Automaticky přepíná stránky na displeji v daném intervalu. @@ -147,6 +147,7 @@ Port: Připojeno Připojeno k vysílači (%1$s) + Připojování Nepřipojeno Připojené k uspanému vysílači Aplikace je příliš stará @@ -166,6 +167,7 @@ Vymazat protokoly Vymazat Stav doručení zprávy + Nové zprávy Upozornění na přímou zprávu Upozornění na hromadné zprávy Upozornění na varování @@ -250,6 +252,8 @@ Vyčištění mezipaměti SQL selhalo, podrobnosti naleznete v logcat Správce mezipaměti Stahování dokončeno! + Stahování dokončeno s %1$d chybami + %1$d dlaždic směr: %1$d° vzdálenost: %2$s Upravit waypoint Smazat waypoint? @@ -404,6 +408,8 @@ Vstup Nahoru/Dolů/Výběr povolen Odeslat zvonek Zprávy + Limit mezipaměti databáze zařízení + Maximální počet databází zařízení uchovávaných v tomto telefonu Konfigurace detekčního senzoru Detekční senzor povolen Minimální vysílání (sekundy) @@ -555,6 +561,7 @@ Počet záznamů Server Nastavení telemetrie + Interval aktualizace metrik zařízení Modul měření životního prostředí povolen Zobrazení měření životního prostředí povoleno Měření životního prostředí používá Fahrenheit @@ -689,6 +696,8 @@ Zrušit výběr Zpráva Napište zprávu + WiFi zařízení + BLE zařízení Spárovaná zařízení Dostupná zařízení Připojená zařízení @@ -734,6 +743,7 @@ Meshtastic vás pomocí oznámení upozorní na nové zprávy a důležité události. Nastavení oznámení si můžete kdykoli upravit. Další Povolit oprávnění + %1$d uzlů zařazeno k odstranění: Varování: Tímto odstraníte uzly z databází v aplikaci i v zařízení.\nVybrané položky se sčítají (kombinují). Normální Satelitní @@ -760,13 +770,18 @@ Vypnutí odesílání polohy na primárním kanálu umožní pravidelné vysílání polohy na prvním sekundárním kanálu, kde je odesílání polohy povoleno. V opačném případě je nutné polohu vyžádat ručně. Nastavení zařízení "[Vzdálený] %1$s" + Odesílat telemetrii zařízení + Povolit/zakázat modul telemetrie zařízení pro odesílání metrik do mesh sítě. Jedná se o nominální hodnoty. Přetížené mesh sítě se automaticky přizpůsobí na delší intervaly podle počtu online uzlů. Mesh sítě s méně než 10 uzly budou přecházet na kratší intervaly. Vše 1 hodina 8 hodin 24 hodin 48 hodin Filtrovat podle času posledního slyšení: %1$s + %1$d dBm Nastavení systému Žádné statistiky k dispozici Shromažďujeme analytická data, která nám pomáhají vylepšovat aplikaci pro Android (děkujeme). Získáváme anonymizované informace o chování uživatelů, například hlášení o pádech aplikace, používání jednotlivých obrazovek apod. + Přeposláno uzlem: %1$s + Chcete zachovat oblíbené položky? diff --git a/core/strings/src/commonMain/composeResources/values-de/strings.xml b/core/strings/src/commonMain/composeResources/values-de/strings.xml index ca7654cfff..d5863646c9 100644 --- a/core/strings/src/commonMain/composeResources/values-de/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-de/strings.xml @@ -35,7 +35,6 @@ über MQTT über MQTT über Favorit - Ignorierte Knoten Unbekannt Warte auf Bestätigung Zur Sende-Warteschlange hinzugefügt @@ -183,6 +182,7 @@ Aktuelle Verbindungen: WLAN IP: Ethernet IP: + Wird verbunden Nicht verbunden Mit Funkgerät verbunden, aber es ist im Schlafmodus Anwendungsaktualisierung erforderlich @@ -299,6 +299,8 @@ Das Säubern des SQL-Zwischenspeichers ist fehlgeschlagen, siehe Logcat für Details Zwischenspeicher-Verwaltung Herunterladen abgeschlossen! + Herunterladen abgeschlossen mit %1$d Fehlern + %1$d Kacheln Richtung: %1$d° Entfernung: %2$s Wegpunkt bearbeiten Wegpunkt löschen? @@ -849,6 +851,7 @@ Meshtastic nutzt Benachrichtigungen, um Sie über neue Nachrichten und andere wichtige Ereignisse auf dem Laufenden zu halten. Sie können Ihre Benachrichtigungsrechte jederzeit in den Einstellungen aktualisieren. Weiter Berechtigungen erteilen + %1$d Knoten in der Warteschlange zum Löschen: Achtung: Dies entfernt Knoten aus der App und Gerätedatenbank.\nDie Auswahl ist kumulativ. Verbinde mit Gerät Normal @@ -889,7 +892,6 @@ Geräteeinstellungen "[Entfernt] %1$s" Gerätetelemetrie senden - Aktivieren/Deaktivieren Sie das Modul Gerätetelemetrie, um Daten an das Netzwerk zu senden Beliebig 1 Stunde 8 Stunden diff --git a/core/strings/src/commonMain/composeResources/values-el/strings.xml b/core/strings/src/commonMain/composeResources/values-el/strings.xml index 487b2543c4..abce913baa 100644 --- a/core/strings/src/commonMain/composeResources/values-el/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-el/strings.xml @@ -121,6 +121,7 @@ Η προσωρινή μνήμη SQL καθαρίστηκε για %1$s Διαχείριση Προσωρινής Αποθήκευσης Η λήψη ολοκληρώθηκε! + Λήψη ολοκληρώθηκε με %1$d σφάλματα Επεξεργασία σημείου διαδρομής Διαγραφή σημείου πορείας; Νέο σημείο πορείας diff --git a/core/strings/src/commonMain/composeResources/values-es/strings.xml b/core/strings/src/commonMain/composeResources/values-es/strings.xml index a9aae721a5..468fa281e6 100644 --- a/core/strings/src/commonMain/composeResources/values-es/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-es/strings.xml @@ -19,6 +19,7 @@ Meshtastic Filtro quitar filtro de nodo + Filtrar por Incluir desconocidos Ocultar nodos desconectados Mostrar sólo nodos directos @@ -34,7 +35,6 @@ vía MQTT vía MQTT vía Favorita - Nodos ignorados No reconocido Esperando ser reconocido En cola para enviar @@ -80,17 +80,43 @@ Todos Si está en nuestro canal privado o desde otra red con los mismos parámetros lora, retransmite cualquier mensaje observado. Igual al comportamiento que TODOS pero omite la decodificación de paquetes y simplemente los retransmite. Sólo disponible en el rol repetidor. Establecer esto en cualquier otro rol dará como resultado TODOS los comportamientos. + Solo locales Ignora mensajes observados desde mallas foráneas que están abiertas o que no pueden descifrar. Solo retransmite mensajes en los nodos locales principales / canales secundarios. + Solo conocido Ignora los mensajes recibidos de redes externas como LOCAL ONLY, pero ignora también mensajes de nodos que no están ya en la lista de nodos conocidos. Ninguna Solo permitido para los roles SENSOR, TRACKER y TAK_TRACKER, esto inhibirá todas las retransmisiones, no a diferencia del rol de CLIENT_MUTE. Ignora paquetes de puertos no estándar, tales como los TAK, Test de Rango (Rangetest), Contador de paquetes (Pax), etc. Solo retransmite paquetes que vengan de puertos estándar como: Información de Nodo (NodeInfo), Mensajes de texto, Posición, telemetría y Routing. Trate un doble toque en acelerómetros soportados como una pulsación de botón de usuario. + Enviar una posición en el canal primario cuando el botón de usuario se presiona tres veces. Controla el LED parpadeante del dispositivo. Para la mayoría de los dispositivos esto controlará uno de los hasta 4 LEDs, el cargador y el GPS tienen LEDs no controlables. + Zona horaria para fechas en la pantalla del dispositivo y registro. + Utilizar zona horaria del teléfono Si, además de enviarlo a MQTT y a la API del móvil, nuestra información de vecinos debe ser transmitida por LoRa. (No disponible en un canal con clave y nombre por defecto). + Cuánto tiempo permanece encendido la pantalla después de pulsar el botón de usuario o recibir mensajes. + Cambia automáticamente a la siguiente página en la pantalla como un carrusel, basado en el intervalo especificado. + Voltear la pantalla verticalmente. + Unidades mostradas en la pantalla del dispositivo. + Anular detección automática de pantalla OLED. + Encabezado del texto de la pantalla en negrita. + Requiere que haya un acelerómetro en su dispositivo. + La región donde utilizará su radio. + Establece el número máximo de saltos, por defecto 3. Aumentar saltos también incrementa la congestión y debe ser utilizado con cuidado. 0 saltos de difusión no obtendrán ACKs. + La frecuencia de funcionamiento de su nodo se calcula en base a la región, el preajuste del módem y este campo. Cuando es 0, la ranura se calcula automáticamente basándose en el nombre del canal principal y cambiará de la rama pública predeterminada. Cambie a la ranura pública por defecto si se configuran los canales privados primarios y públicos secundarios. + Habilitar WiFi desactivará la conexión bluetooth a la aplicación. + Habilitar Ethernet desactivará la conexión bluetooth a la aplicación. Las conexiones TCP no están disponibles en dispositivos Apple. + Habilitar paquetes de difusión vía UDP en la red local. + El intervalo máximo que puede transcurrir sin que un nodo transmita una posición. + Rapidez de actualización de la posición que se enviará si se cumple la distancia mínima. + Cambio de distancia mínima en metros para considerar una transmisión de posición Smart. Utilizado para crear una clave compartida con un dispositivo remoto. + Clave pública autorizada para enviar mensajes de administración a este nodo. + Dispositivo gestionado por administrador de la malla, el usuario no puede acceder a las configuraciones del dispositivo. Intervalo de transmisión + Intervalo mínimo + Distancia mínima + Dispositivo GPS Posición Fijada Altitud Depuración @@ -101,10 +127,13 @@ Enviar Aún no ha emparejado una radio compatible con Meshtastic con este teléfono. Empareje un dispositivo y configure su nombre de usuario. \n\nEsta aplicación de código abierto es una prueba alfa; si encuentra un problema publiquelo en el foro: https://github.com/orgs/meshtastic/discussions\n\nPara obtener más información visite nuestra página web - www.meshtastic.org. Usted + Permitir analíticas y reporte de errores. Aceptar Cancelar + Descartar Guardar Nueva URL de canal recibida + Meshtastic necesita permisos de ubicación habilitados para encontrar nuevos dispositivos mediante Bluetooth. Puede desactivar cuando no esté en uso. Informar de un fallo Informar de un fallo ¿Está seguro de que quiere informar de un error? Después de informar por favor publique en https://github.com/orgs/meshtastic/discussions para que podamos comparar el informe con lo que encontró. @@ -113,6 +142,7 @@ El emparejamiento ha fallado, por favor seleccione de nuevo El acceso a la localización está desactivado, no se puede proporcionar la posición a la malla. Compartir + Visto nuevo nodo: %1$s Desconectado Dispositivo en reposo Conectado: %1$s Encendido @@ -120,6 +150,8 @@ Puerto: Conectado Conectado a la radio (%1$s) + Conexiones actuales: + Conectando No está conectado Conectado a la radio, pero está en reposo Es necesario actualizar la aplicación @@ -128,8 +160,12 @@ Notificaciones de servicio Acerca de La URL de este canal no es válida y no puede utilizarse + Este contacto no es válido y no se puede agregar Panel de depuración + Payload decodificado: Exportar registros + Exportación cancelada + %1$d logs exportados Filtros Filtros activos Buscar en registros… @@ -153,6 +189,7 @@ Vale ¡Debe establecer una región! No se puede cambiar de canal porque la radio aún no está conectada. Por favor inténtelo de nuevo. + Exportar todos los paquetes Reiniciar Escanear Añadir @@ -196,6 +233,10 @@ Añadir al mensaje Envía instantáneo Restablecer los valores de fábrica + Bluetooth está deshabilitado. Por favor, actívalo en la configuración de tu dispositivo. + Abrir ajustes + Versión del firmware: %1$s + Meshtastic necesita activar los permisos \"Dispositivos cercanos\" para encontrar y conectarse a dispositivos mediante Bluetooth. Puede desactivar cuando no esté en uso. Mensaje Directo Reinicio de NodeDB Envío confirmado @@ -222,6 +263,8 @@ Error en la purga del caché SQL, consulte logcat para obtener más detalles Gestor de Caché ¡Descarga completa! + Descarga completa con %1$d errores + %1$d Fichas rumbo: %1$d° distancia: %2$s Editar punto de referencia ¿Eliminar punto de referencia? @@ -234,6 +277,10 @@ 8 horas 1 semana Siempre + Siempre silenciado + No silenciado + Silenciado por %1d días, %.1f horas + Silenciado por %.1f horas Reemplazar Escanear código QR WiFi Formato de código QR de credencial wifi inválido @@ -254,6 +301,7 @@ IAQ Clave compartida Cifrado de Clave Pública + Los mensajes directos están utilizando la nueva infraestructura de clave pública para el cifrado. Clave pública no coincide La clave pública no coincide con la clave guardada. Puedes eliminar el nodo y dejar que intercambie claves otra vez, esto podría indicar un problema de seguridad más serio. Contacta al usuario a través de otro canal confiable para saber si el cambio la de clave es por un restablecimiento de fábrica u otra acción intencional. Intercambiar información de usuario @@ -298,6 +346,8 @@ Rango de Valores 0 - 500. ¡Carácter Campana de Alerta! ¡Alerta Crítica! Favorito + Añadir a favoritos + Eliminar de favoritos ¿Añadir '%1$s' como un nodo favorito? ¿Eliminar '%1$s' como un nodo favorito? Registro de métricas de energía @@ -318,6 +368,7 @@ Rango de Valores 0 - 500. Configuración UDP Última escucha: %2$s
Última posición: %3$s
Batería: %4$s]]>
Cambiar mi posición + Orientación norte Usuario Canales Dispositivo @@ -357,6 +408,7 @@ Rango de Valores 0 - 500. Baja de Paquetes Permitida Por defecto Posición activada + Ubicación precisa Pin GPIO Tipo Ocultar contraseña @@ -390,14 +442,24 @@ Rango de Valores 0 - 500. Tipo de detección para activar Utilizar el modo de entrada PULL_UP Dispositivo + Rol del dispositivo + Botón GPIO + Zumbador GPIO Modo de retransmisión Intervalo de transmisión de información del nodo + Doble pulsación como botón + Zona horaria + Latido LED Pantalla + Pantalla activa durante La brújula apunta al norte Girar la pantalla 180º Unidades en pantalla + Tipo de OLED Modo de la pantalla + Siempre apuntar al norte Encabezado en negrita + Despertar al tocar o al mover Orientación de la brújula Configuración de las notificaciones externas Notificaciones externas activadas @@ -418,15 +480,20 @@ Rango de Valores 0 - 500. Tono de notificación Utilizar el Buzzer como uno I2S LoRa + Opciones + Avanzado Usar predefinido Ancho de Banda + Factor de dispersión Tasa de codificación Desplazamiento de la Frecuencia (MHz) Región + Número de saltos Transmisión Activa Potencia de transmisión Slot de frecuencia Sobreescribir el Tiempo de Trabajo + Sobreescribir frecuencia Ventilador del Amplificador apagado Ignorar Paquetes MQTT Configuración MQTT @@ -445,10 +512,12 @@ Rango de Valores 0 - 500. Intervalo de refresco (segundos) Transmitir en LoRa Conexión Red + Opciones WiFi Habilitado WiFi del Nodo Activada SSID (Nombre la Red) PSK (Contraseña) + Opciones Ethernet Ethernet del Nodo Activado Servidor NTP Servidor rsyslog @@ -469,6 +538,7 @@ Rango de Valores 0 - 500. Latitud Longitud Altitud (en metros) + Definir desde la ubicación actual del teléfono Periodo entre Actualizaciones de Posición del GPS (en Segundos) Redefinir el Pin de RX de GPS Redefinir el Pin de TX de GPS @@ -476,8 +546,13 @@ Rango de Valores 0 - 500. Marcas de posición Configuración de elecenergía Activar el modo ahorro de energía + Apagar al perder energía Retraso del apagado con batería (segundos) + Sobreescribir multiplicador ADC Sobreescribir relación del multiplicador ADC + Esperar Bluetooth durante + Duración del sueño súper profundo + Duración de sueño ligero Dirección I2C del INA_2xx para la batería Configuración del test de alcance Test de alcance activado @@ -488,6 +563,7 @@ Rango de Valores 0 - 500. Permitir el acceso sin un pin definido Pines disponibles Seguridad + Claves Admin Clave Pública Clave privada Contraseña de administrador @@ -507,12 +583,16 @@ Rango de Valores 0 - 500. Historial máximo devuelto Servidor Configuración de la telemetría + Intervalo actualización de métricas del dispositivo + Intervalo actualización de métricas del entorno Módulo para las medidas del entorno activado Mostrar las medidas del entorno en la Grados Fahrenheit para la temperatura ambiente Módulo para la medición de la calidad del aire activado + Intervalo actualización de métricas calidad del aire Icono de la calidad del aire Módulo de medidas eléctricas activado + Intervalo de actualización de métricas de energía Medidas eléctricas en pantalla Configuración del Usuario Identificación del nodo @@ -540,8 +620,11 @@ Rango de Valores 0 - 500. Número del nodo Identificación del usuario Tiempo encendido + Carga %1$d + Disco libre %1$d Fecha Rumbo + Velocidad Satélites Altitud Frecuencia @@ -556,6 +639,8 @@ Rango de Valores 0 - 500. Dinámico Escanear el código QR Compartir contacto + Notas + Añadir una nota privada… ¿Importar el contacto compartido? No se puede enviar mensajes Sin monitorear o parte de la infraestructura @@ -574,6 +659,9 @@ Rango de Valores 0 - 500. Carga Cadena del usuario Navegar hacia + Conexión + Mapa Mesh + Conversaciones Nodos Ajustes Introduzca su región @@ -586,21 +674,119 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m Estoy de acuerdo. Se recomienda encarecidamente actualizar el software. Para beneficiarse de los parches y de las funciones nuevas, por favor, actualiza el firmware del nodo. \n\nÚltima versión estable:%1$s + Caduca + Fecha + Solo favoritos + Las claves se han visto comprometidas, selecciona OK para generar de nuevo. + Regenerar clave privada + Exportar claves + Exporta claves públicas y privadas a un archivo. Por favor, almacena en algún lugar de forma segura. Reaccionar Desconectar + Buscando dispositivos Bluetooth… + No hay dispositivos Bluetooth emparejados. + No se encontraron dispositivos de red. + No se encontraron dispositivos Serial USB. + Desplazarse hacia abajo Meshtastic + Buscando + Estado de seguridad + Canal desconocido + Advertencia Lux ultravioletas Desconocido + Esta radio es gestionada y sólo puede ser cambiada por un administrador remoto. + Avanzado + Un candado verde significa que el canal está cifrado de forma segura con una clave AES de 128 o 256 bits. + Un candado abierto amarillo significa que el canal no está cifrado de forma segura, no se usa para datos precisos de ubicación, y no usa ninguna clave o una clave conocida de 1 byte. + Canal inseguro, ubicación precisa + Un candado rojo abierto significa que el canal no está cifrado de forma segura, se usa para datos de ubicación precisos y no usa ninguna clave o una clave conocida de 1 byte. + Advertencia: insegura, ubicación precisa & MQTT Uplink + Un candado rojo abierto con una advertencia significa que el canal no está cifrado de forma segura, se utiliza para datos de ubicación precisos que se están subiendo a Internet a través de MQTT, y no usa ninguna clave o una clave conocida de 1 byte. + Mostrar estado actual Descartar + ¿Sguro que desea eliminar este nodo? + Respondiendo a %1$s + Cancelar respuesta + ¿Eliminar mensajes? + Limpiar selección Mensaje + Escribe un mensaje + Dispositivos WiFi + Dispositivos BLE + Dispositivos emparejados + Dispositivos disponibles + Dispositivo conectado Descarga + Instalada actualmente + Última estable + Última alfa + Apoyado por la comunidad Meshtastic + Edición de firmware + Dispositivos de red recientes + Dispositivos de red descubiertos + Empezar + Bienvenido a + Manténgase conectado en cualquier lugar + Crea tus propias redes + Rastrear y comparte ubicaciones + Comparte tu ubicación en tiempo real y mantén tu grupo coordinado con las características integradas del GPS. + Mensajes entrantes + Nuevos nodos + Notificaciones para nodos recién descubiertos. + Batería baja + Configurar permisos de notificación + Ubicación del teléfono + Meshtastic utiliza la ubicación de tu teléfono para habilitar varias características. Puedes actualizar los permisos de ubicación en cualquier momento desde la configuración. + Compartir ubicación + Utilice el GPS del teléfono para enviar ubicaciones a su nodo en lugar de usar un GPS hardware en su nodo. + Filtros de distancia + Filtra la lista de nodos y el mapa de mallas basándose en la proximidad a tu teléfono. + Habilita el punto de posición azul para tu teléfono en el mapa de la malla. + Configurar permisos de ubicación Saltar ajustes + Alertas críticas + Siguiente + Otorgar permisos + Conectándose al dispositivo + Satélite + Terreno + Híbrido + Capas del mapa + No se cargaron capas personalizadas. + Añadir capa + Ocultar capa + Mostrar capa + Eliminar capa + Añadir capa + Nodos en esta ubicación + Tipo de mapa seleccionado + Nombre no puede estar vacío. + El nombre ya existe. + URL no puede estar vacío. + Versión + Características del canal + Compartir ubicación + Transmisión periódica de posición + Los mensajes de la malla se enviarán a la internet pública a través de la puerta de enlace configurada de cualquier nodo. + Deshabilitar la posición en el canal principal permite las emisiones de posición periódica en el primer canal secundario con la posición habilitada, de lo contrario se requiere una solicitud de posición manual. + Configuración del dispositivo + Enviar Telemetría del dispositivo + Activar/Desactivar el módulo de telemetría del dispositivo para enviar métricas a la malla. Estos son valores nominales. Las mallas congestionadas escalarán automáticamente a intervalos más largos basados en el número de nodos en línea. Mallas con menos de 10 nodos escalarán a intervalos más rápidos. + 1 hora 8 Horas 24 Horas 48 Horas + %1$d dBm + Ninguna aplicación disponible para manejar enlace. + Ajustes del sistema + No hay estadísticas disponibles + Se recopilan analíticas de uso para ayudarnos a mejorar la aplicación Android (¡gracias!), recibiremos información anónima sobre el comportamiento del usuario. Esto incluye reportes de fallos, pantallas utilizadas en la aplicación, etc. + Para más información, consulte nuestra política de privacidad. + ¿Conservar favoritos? diff --git a/core/strings/src/commonMain/composeResources/values-et/strings.xml b/core/strings/src/commonMain/composeResources/values-et/strings.xml index 7dfa2f035e..f50a3a15d6 100644 --- a/core/strings/src/commonMain/composeResources/values-et/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-et/strings.xml @@ -21,6 +21,7 @@ eemalda sõlmefilter Filtreeri Kaasa tundmatud + Välista infrastruktuur Peida ühenduseta Kuva ainult otseühendusega Sa vaatad eiratud sõlmi,\nVajuta tagasi minekuks sõlmede nimekirja. @@ -35,7 +36,7 @@ läbi MQTT läbi MQTT läbi Lemmikud - Eiratud sõlmed + Näita ainult ignoreeritud sõlmi Tundmatu Ootab kinnitamist Saatmise järjekorras @@ -183,6 +184,7 @@ Praegused ühendused: Wifi IP-aadress: Etherneti IP-aadress: + Ühendan Ei ole ühendatud Ühendatud raadioga, aga see on unerežiimis Vajalik on rakenduse värskendus @@ -213,6 +215,7 @@ See eemaldab teie seadmest kõik logipaketid ja andmebaasikirjed – see on täielik lähtestamine ja see on püsiv. Kustuta Sõnumi edastamise olek + Uued sõnumid allpool Otsesõnumi teated Ringhäälingusõnumi teated Märguteated @@ -299,6 +302,8 @@ SQL-i vahemälu tühjendamine ebaõnnestus, vaata üksikasju logcat'ist Vahemälu haldamine Allalaadimine on lõppenud! + Allalaadimine lõpetati %1$d veaga + %1$d paani suund: %1$d° kaugus: %2$s Muuda teekonnapunkti Eemalda teekonnapunkt? @@ -849,6 +854,7 @@ Meshtastic kasutab märguandeid, et hoida teid kursis uute sõnumite ja muude oluliste sündmustega. Saate oma märguannete õigusi igal ajal seadetes muuta. Järgmine Anna luba + %1$d eemaldatavat sõlme nimekirjas: Hoiatus: See eemaldab sõlmed rakendusest, kui ka seadmest.\nValikud on lisaks eelnevale. Ühendan seadet Normaalne @@ -889,7 +895,7 @@ Seadme sätted "[Kaugjuhtimine] %1$s" Saada seadme telemeetria - Luba/keela seadme telemeetriamooduli mõõdikute saatmiseks kärgvõrgustiku + Luba/keela seadme telemeetria andmete saatmine kärgvõrku. Need on nominaalväärtused. Ülekoormatud kärgvõrgu puhul skaleeritakse automaatselt pikemad intervallid võrgus olevate sõlmede arvu põhjal. Alla 10 sõlmega kärgvõrgu puhul skaleeritakse kiiremad intervallid. Kõik 1 tund 8 tundi @@ -906,4 +912,5 @@ Tühistatud - 0 Edastab: %1$s Säilita lemmikud? + USB seadmed diff --git a/core/strings/src/commonMain/composeResources/values-fi/strings.xml b/core/strings/src/commonMain/composeResources/values-fi/strings.xml index d090687252..d059af0aeb 100644 --- a/core/strings/src/commonMain/composeResources/values-fi/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-fi/strings.xml @@ -21,6 +21,7 @@ tyhjennä suodatukset Suodata otsikon mukaan Näytä tuntemattomat + Ohita infrastruktuurilaitteet Piilota ei yhteydessä olevat laitteet Näytä vain suorat yhteydet Katselet tällä hetkellä huomioimattomia laitteita,\nPaina palataksesi laitelistaan. @@ -35,7 +36,7 @@ MQTT:n kautta MQTT:n kautta Suosikkien kautta - Huomioimattomat laitteet + Näytä vain huomioimattomat solmut Tuntematon Odottaa vahvistusta Jonossa lähetettäväksi @@ -183,6 +184,7 @@ Aktiiviset yhteydet: WiFi-verkon IP: Ethernet-verkon IP: + Yhdistetään Ei yhdistetty Yhdistetty radioon, mutta se on lepotilassa Sovelluspäivitys vaaditaan @@ -213,6 +215,7 @@ Tämä poistaa kaikki lokipaketit ja tietokantamerkinnät laitteestasi – Kyseessä on täydellinen nollaus, ja se on pysyvä. Tyhjennä Viestin toimitustila + Uudet viestit alla Suorien viestien ilmoitukset Yleislähetysviestien ilmoitukset Hälytysilmoitukset @@ -299,6 +302,8 @@ SQL-välimuistin tyhjennys epäonnistui, katso logcat saadaksesi lisätietoja Välimuistin Hallinta Lataus on valmis! + Lataus valmis %1$d virheellä + %1$d Laattaa suunta: %1$d° etäisyys: %2$s Muokkaa reittipistettä Poista reittipiste? @@ -849,6 +854,7 @@ Meshtastic käyttää ilmoituksia tiedottaakseen uusista viesteistä ja muista tärkeistä tapahtumista. Voit muuttaa ilmoitusasetuksia milloin tahansa. Seuraava Myönnä oikeudet + %1$d laitetta jonossa poistettavaksi: Varoitus: Tämä poistaa laitteet sovelluksen sekä laitteen tietokannoista.\nValinnat lisätään aiempiin. Yhdistetään laitteeseen Normaali @@ -889,7 +895,7 @@ Laitteen asetukset "[Etälaite] %1$s" Lähetä laitteen telemetriatiedot - Ota käyttöön/poista käytöstä telemetriamoduuli laitteen mittaustietojen lähettämiseksi mesh-verkkoon + Ota käyttöön tai poista käytöstä laitteen telemetriamoduuli, joka lähettää mittaustietoja mesh-verkkoon. Arvot ovat nimellisiä. Ruuhkaisissa verkoissa lähetysväliä pidennetään automaattisesti verkossa olevien laitteiden määrän perusteella. Alle 10 laitteen verkoissa lähetysväliä lyhennetään automaattisesti. Milloin tahansa 1 tunti 8 tuntia @@ -906,4 +912,5 @@ Ei asetettu – 0 Välittänyt: %1$s Säilytä suosikit? + USB-laitteet diff --git a/core/strings/src/commonMain/composeResources/values-fr/strings.xml b/core/strings/src/commonMain/composeResources/values-fr/strings.xml index 3b791ec757..8ee08d3875 100644 --- a/core/strings/src/commonMain/composeResources/values-fr/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-fr/strings.xml @@ -35,7 +35,6 @@ via MQTT via MQTT par Favoris - Nœuds ignorés Non reconnu En attente d'accusé de réception En file d'attente pour l'envoi @@ -179,6 +178,7 @@ Port : Connecté Connecté à la radio (%1$s) + Connexion en cours Non connecté Connecté à la radio, mais en mode veille Mise à jour de l’application requise @@ -295,6 +295,8 @@ La purge du cache SQL a échoué, consultez « logcat » pour plus de détails Gestionnaire du cache Téléchargement terminé ! + Téléchargement terminé avec %1$d erreurs + Vignettes de %1$d échelle : %1$d° distance : %2$s Modifier le repère Supprimer le repère ? @@ -826,6 +828,7 @@ Meshtastic utilise les notifications pour vous tenir à jour sur les nouveaux messages et autres événements importants. Vous pouvez mettre à jour vos autorisations de notification à tout moment à partir des paramètres. Suivant Accorder les autorisations + %1$d nœuds en attente de suppression : Attention : Ceci supprime les nœuds des bases de données in-app et on-device.\nLes sélections sont additionnelles. Connexion à l'appareil Normal @@ -866,7 +869,6 @@ Configuration de l'appareil "[Distant] %1$s" Envoyer la télémétrie de l'appareil - Activer/Désactiver le module de télémétrie de l'appareil pour envoyer des métriques au maillage N'importe laquelle 1 Heure 8 Heures diff --git a/core/strings/src/commonMain/composeResources/values-ga/strings.xml b/core/strings/src/commonMain/composeResources/values-ga/strings.xml index 6ee4785955..3c37a953cc 100644 --- a/core/strings/src/commonMain/composeResources/values-ga/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ga/strings.xml @@ -171,6 +171,8 @@ Teip ar ghlanadh Cásla SQL, féach logcat le haghaidh sonraí Bainisteoir Cásla Íoslódáil críochnaithe! + Íoslódáil críochnaithe le %1$d earráidí + %1$d tíleanna comhthéacs: %1$d° achar: %2$s Cuir in eagar an pointe bealach Scrios an pointe bealach? diff --git a/core/strings/src/commonMain/composeResources/values-gl/strings.xml b/core/strings/src/commonMain/composeResources/values-gl/strings.xml index 7a8dee0bfc..5df745a6eb 100644 --- a/core/strings/src/commonMain/composeResources/values-gl/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-gl/strings.xml @@ -19,6 +19,7 @@ Filtro quitar filtro de nodo Incluír descoñecido + Amosar detalles A-Z Canle Distancia @@ -26,7 +27,12 @@ Última escoita vía MQTT vía MQTT + Non recoñecido + Sen resposta + Non autorizado Fallou o envío cifrado + Chave pública descoñecida + Cliente Aplicación conectada ou dispositivo de mensaxería autónomo. Nome de canle @@ -51,6 +57,7 @@ Desconectado Dispositivo durmindo Enderezo IP: + Porto: Conectado á radio (%1$s) Non conectado Conectado á radio, pero está durmindo @@ -61,6 +68,9 @@ Acerca de A ligazón desta canle non é válida e non pode usarse Panel de depuración + Filtros + Engadir filtro + Limpar todos os filtros Limpar Estado de envío de mensaxe Actualización de firmware necesaria. @@ -133,6 +143,8 @@ A purga de Caché SQL fallou, mira logcat para os detalles Xestor de caché Descarga completada! + Descarga completada con %1$d errores + %1$d 'tiles' rumbo: %1$d distancia:%2$s Editar punto de ruta Eliminar punto de ruta? diff --git a/core/strings/src/commonMain/composeResources/values-he/strings.xml b/core/strings/src/commonMain/composeResources/values-he/strings.xml index 618b3cbbf8..1d48511aeb 100644 --- a/core/strings/src/commonMain/composeResources/values-he/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-he/strings.xml @@ -131,6 +131,8 @@ נכשל איפוס מטמון SQL, ראה logcat לפרטים ניהול מטמון ההורדה הושלמה! + ההורדה הושלמה עם %1$d שגיאות + %1$d אזורי מפה כיוון: %1$d° מרחק: %2$s ערוך נקודת ציון מחק נקודת ציון? diff --git a/core/strings/src/commonMain/composeResources/values-hr/strings.xml b/core/strings/src/commonMain/composeResources/values-hr/strings.xml index 0964dc21f1..ef3b483e12 100644 --- a/core/strings/src/commonMain/composeResources/values-hr/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-hr/strings.xml @@ -132,6 +132,8 @@ Čišćenje SQL predmemorije nije uspjelo, pogledajte logcat za detalje Upravitelj predmemorije Preuzimanje je završeno! + Preuzimanje je završeno s %1$d pogrešaka + %1$d dijelova karte smjer: %1$d° udaljenost: %2$s Uredi putnu točku Obriši putnu točku? diff --git a/core/strings/src/commonMain/composeResources/values-ht/strings.xml b/core/strings/src/commonMain/composeResources/values-ht/strings.xml index 24c688a97b..9f76bd2dd8 100644 --- a/core/strings/src/commonMain/composeResources/values-ht/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ht/strings.xml @@ -168,6 +168,8 @@ Echèk efase Kach SQL, tcheke logcat pou detay Manadjè Kach Telechajman konplè! + Telechajman konplè avèk %1$d erè + %1$d tèk ang: %1$d° distans: %2$s Modifye pwen Efase pwen? diff --git a/core/strings/src/commonMain/composeResources/values-hu/strings.xml b/core/strings/src/commonMain/composeResources/values-hu/strings.xml index c2c7291c1d..c80890b4bd 100644 --- a/core/strings/src/commonMain/composeResources/values-hu/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-hu/strings.xml @@ -35,7 +35,6 @@ MQTT-n Keresztül MQTT-n Keresztül Kedvencek szerint - Figyelmen kívül hagyott csomópontok Ismeretlen Visszajelzésre vár Elküldésre vár @@ -180,6 +179,7 @@ Port: Kapcsolódva Kapcsolódva a(z) %1$s rádióhoz + Csatlakozás… Nincs kapcsolat Kapcsolódva a rádióhoz, de az alvó üzemmódban van Az alkalmazás frissítése szükséges @@ -296,6 +296,8 @@ SQL gyorsítótár kiürítése sikertelen, a részleteket lásd a logcat-ben Gyorsítótár kezelő A letöltés befejeződött! + A letöltés %1$d hibával fejeződött be + %1$d csempe irányszög: %1$d° távolság: %2$s Útpont szerkesztés Útpont törlés? @@ -836,6 +838,7 @@ A Meshtastic értesítésekkel tájékoztat az új üzenetekről és más fontos eseményekről. Az értesítési engedélyeket bármikor módosíthatod a beállításokban. Tovább Engedély megadása + %1$d csomópont vár törlésre: Figyelem: Ez eltávolítja a csomópontokat az alkalmazás és az eszköz adatbázisából.\nA kijelölések összeadódnak. Csatlakozás az eszközhöz Normál @@ -876,7 +879,6 @@ Eszközkonfiguráció "[Távoli] %1$s" Eszköztelemetria küldése - A telemetria modul engedélyezése/letiltása a metrikák hálózatra küldéséhez Bármely 1 óra 8 óra diff --git a/core/strings/src/commonMain/composeResources/values-is/strings.xml b/core/strings/src/commonMain/composeResources/values-is/strings.xml index 68977a38e7..223a09b819 100644 --- a/core/strings/src/commonMain/composeResources/values-is/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-is/strings.xml @@ -118,6 +118,8 @@ Hreinsun SQL skyndiminnis mistókts, sjá upplýsingar í logcat Sýsla með skyndiminni Niðurhali lokið! + Niðurhali lauk með %1$d villum + %1$d reitar miðun: %1$d° fjarlægð: %2$s Breyta leiðarpunkti Eyða leiðarpunkti? diff --git a/core/strings/src/commonMain/composeResources/values-it/strings.xml b/core/strings/src/commonMain/composeResources/values-it/strings.xml index acf3c4415a..e73d11c554 100644 --- a/core/strings/src/commonMain/composeResources/values-it/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-it/strings.xml @@ -33,7 +33,6 @@ via MQTT via MQTT via Preferiti - Nodi ignorati Non riconosciuto In attesa di conferma In coda per l'invio @@ -119,6 +118,7 @@ Porta: Connesso Connesso alla radio (%1$s) + Connessione in corso Non connesso Connesso alla radio, ma sta dormendo Aggiornamento dell'applicazione necessario @@ -233,6 +233,8 @@ Eliminazione della cache SQL non riuscita, vedere logcat per i dettagli Gestione della cache Scaricamento completato! + Download completo con %1$d errori + %1$d riquadri della mappa direzione: %1$d° distanza: %2$s Modifica waypoint Elimina waypoint? @@ -737,6 +739,7 @@ Meshtastic utilizza le notifiche per tenerti aggiornato su nuovi messaggi e altri eventi importanti. È possibile aggiornare i permessi di notifica in qualsiasi momento dalle impostazioni. Avanti Concedi permessi + %1$d nodi in coda per l'eliminazione: Attenzione: questo rimuove i nodi dal database dell'app e sul dispositivo. Le selezioni\nsono additive. Connessione al dispositivo in corso… Normale diff --git a/core/strings/src/commonMain/composeResources/values-ja/strings.xml b/core/strings/src/commonMain/composeResources/values-ja/strings.xml index 1457ad8f10..ac74d1bc13 100644 --- a/core/strings/src/commonMain/composeResources/values-ja/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ja/strings.xml @@ -180,6 +180,8 @@ SQL キャッシュの削除に失敗しました。詳細は logcat を参照してください。 キャッシュの管理 ダウンロード完了! + ダウンロード完了、%1$dのエラーがあります。 + %1$d タイル 方位: %1$d°距離: %2$s ウェイポイントを編集 ウェイポイントを削除しますか? diff --git a/core/strings/src/commonMain/composeResources/values-ko/strings.xml b/core/strings/src/commonMain/composeResources/values-ko/strings.xml index ab728d64b5..3d6ec7947f 100644 --- a/core/strings/src/commonMain/composeResources/values-ko/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ko/strings.xml @@ -103,6 +103,7 @@ 포트: 연결됨 (%1$s)에 연결됨 + 연결 중 연결되지 않음 연결되었지만, 해당 장치는 절전모드입니다. 앱 업데이트가 필요합니다. @@ -191,6 +192,8 @@ SQL 캐시 제거 실패, 자세한 내용은 logcat 참조 캐시 관리자 다운로드 완료! + %1$d 에러로 다운로드 완료되지 않았습니다. + %1$d 타일 방위: %1$d° 거리: %2$s 웨이포인트 편집 웨이포인트 삭제? diff --git a/core/strings/src/commonMain/composeResources/values-lt/strings.xml b/core/strings/src/commonMain/composeResources/values-lt/strings.xml index 47cb3180b7..0387c3af2a 100644 --- a/core/strings/src/commonMain/composeResources/values-lt/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-lt/strings.xml @@ -169,6 +169,8 @@ SQL talpyklos išvalymas nepavyko, detales žiūrėkite logcat Talpyklos valdymas Atsiuntimas baigtas! + Atsiuntimas baigtas su %1$d klaidomis + %1$d plytelės kryptis: %1$d° atstumas: %2$s Redaguoti kelio tašką Ištrinti orientyrą? diff --git a/core/strings/src/commonMain/composeResources/values-nl/strings.xml b/core/strings/src/commonMain/composeResources/values-nl/strings.xml index 8bdc8a52c1..5d1cfb90c2 100644 --- a/core/strings/src/commonMain/composeResources/values-nl/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-nl/strings.xml @@ -98,6 +98,7 @@ Poort: Verbonden Verbonden met radio (%1$s) + Bezig met verbinden Niet verbonden Verbonden met radio in slaapstand Applicatie bijwerken vereist @@ -182,6 +183,8 @@ SQL cache verwijderen mislukt, zie logcat voor details Cachemanager Download voltooid! + Download voltooid met %1$d fouten + %1$d tegels richting: %1$d° afstand: %2$s Wijzig waypoint Waypoint verwijderen? diff --git a/core/strings/src/commonMain/composeResources/values-no/strings.xml b/core/strings/src/commonMain/composeResources/values-no/strings.xml index 3a7fb24c3c..f594353ef7 100644 --- a/core/strings/src/commonMain/composeResources/values-no/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-no/strings.xml @@ -174,6 +174,8 @@ Tømming av SQL-mellomlager feilet, se logcat for detaljer Mellomlagerbehandler Nedlastingen er fullført! + Nedlasting fullført med %1$d feil + %1$d fliser retning: %1$d° avstand: %2$s Rediger veipunkt Fjern veipunkt? diff --git a/core/strings/src/commonMain/composeResources/values-pl/strings.xml b/core/strings/src/commonMain/composeResources/values-pl/strings.xml index 521766e402..e1bf277a1c 100644 --- a/core/strings/src/commonMain/composeResources/values-pl/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-pl/strings.xml @@ -105,6 +105,7 @@ Adres IP: Połączony Połączono z urządzeniem (%1$s) + Łączenie Nie połączono Połączono z urządzeniem, ale jest ono w stanie uśpienia Konieczna aktualizacja aplikacji @@ -191,6 +192,8 @@ Usuwanie pamięci podręcznej SQL nie powiodło się, zobacz logcat Zarządzanie pamięcią podręczną Pobieranie ukończone! + Pobieranie zakończone z %1$d błędami + %1$d mapy kierunek: %1$d° odległość: %2$s Edytuj punkt nawigacji Usuń punkt nawigacji? diff --git a/core/strings/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/strings/src/commonMain/composeResources/values-pt-rBR/strings.xml index bf81683daf..d279f98344 100644 --- a/core/strings/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -33,7 +33,6 @@ via MQTT via MQTT via Favorito - Nós Ignorados Desconhecido Esperando para ser reconhecido Programado para envio @@ -213,6 +212,8 @@ Falha na remoção do cache SQL, consulte logcat para obter detalhes Gerenciador de cache Download concluído! + Download concluído com %1$d erros + %1$d blocos direção: %1$d° distância: %2$s Editar ponto de referência Excluir ponto de referência? @@ -695,6 +696,7 @@ Meshtastic usa notificações para mantê-lo atualizado sobre novas mensagens e outros eventos importantes. Você pode atualizar suas permissões de notificação a qualquer momento nas configurações. Avançar Conceder Permissões + %1$d nós na fila para exclusão: Cuidado: Isso irá remover nós dos bancos de dados do aplicativo e do dispositivo.\nSeleções são somadas. Conectando ao dispositivo Normal diff --git a/core/strings/src/commonMain/composeResources/values-pt/strings.xml b/core/strings/src/commonMain/composeResources/values-pt/strings.xml index ae812ca0c1..890fc6fb04 100644 --- a/core/strings/src/commonMain/composeResources/values-pt/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-pt/strings.xml @@ -35,7 +35,6 @@ via MQTT via MQTT via Favorito - Nós ignorados Desconhecido A aguardar confirmação Na fila de envio @@ -95,6 +94,14 @@ Ativar a transmissão de pacotes via UDP através da rede local. O intervalo máximo que pode decorrer sem que um nó transmita a sua posição. + Intervalo de Difusão + Posição Inteligente + Intervalo Mínimo + Distância Mínima + GPS do Dispositivo + Posição fixa + Altitude + Intervalo de Atualização Depuração Nome do Canal Código QR @@ -122,6 +129,7 @@ Porta: Ligado Ligado ao rádio (%1$s) + A ligar Desligado Ligado ao rádio, mas está a dormir A aplicação é muito antiga @@ -206,6 +214,8 @@ Falha na remoção do cache SQL, consulte logcat para obter detalhes Gerenciador de cache Download concluído! + Download concluído com %1$d erros + %1$d blocos direção: %1$d° distância: %2$s Editar ponto de referência Apagar o ponto de referência? diff --git a/core/strings/src/commonMain/composeResources/values-ro/strings.xml b/core/strings/src/commonMain/composeResources/values-ro/strings.xml index 49a2b8880f..dfc11fbb55 100644 --- a/core/strings/src/commonMain/composeResources/values-ro/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ro/strings.xml @@ -121,6 +121,8 @@ Ștergerea cache-ului SQL a eșuat, vedeți logcat pentru detalii Manager cache Descărcare finalizată! + Descărcare finalizată cu %1$d erori + %1$d secțiuni compas: %1$d° distanță: %2$s Editează waypoint Şterge waypointul? diff --git a/core/strings/src/commonMain/composeResources/values-ru/strings.xml b/core/strings/src/commonMain/composeResources/values-ru/strings.xml index 80bf7101e8..000a407a8a 100644 --- a/core/strings/src/commonMain/composeResources/values-ru/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-ru/strings.xml @@ -33,7 +33,6 @@ через MQTT через MQTT по фаворитам - Игнорируемые узлы Нераспознанный Ожидание подтверждения В очереди на отправку @@ -167,6 +166,7 @@ Порт: Подключено Подключен к радиостанции (%1$s) + Подключение Нет соединения Подключен к радиостанции, но она спит Требуется обновление приложения @@ -284,6 +284,8 @@ Ошибка очистки кэша SQL, подробности в logcat Менеджер кэша Скачивание завершено! + Скачивание завершено с %1$d ошибок + %1$d файла курс: %1$d° расстояние: %2$s Редактировать путевую точку Удалить путевую точку? @@ -816,6 +818,7 @@ Meshtastic использует уведомления, чтобы держать вас в курсе новых сообщений и других важных событий. Вы можете обновить разрешения уведомлений в любое время из настроек. Далее Предоставить разрешения + %1$d узлов в очереди для удаления: Осторожно: Это удаляет узлы из базы данных в приложении и устройства.\nВыбор является совокупным Подключение к устройству Обычный @@ -856,7 +859,6 @@ Настройки устройства "[Удалённо] %1$s" Отправлять телеметрию устройства - Включить/отключить телеметрический модуль устройства для отправки метрик в сетку Любой 1 час 8 часов diff --git a/core/strings/src/commonMain/composeResources/values-sk/strings.xml b/core/strings/src/commonMain/composeResources/values-sk/strings.xml index 3666ddb790..e46529af0b 100644 --- a/core/strings/src/commonMain/composeResources/values-sk/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-sk/strings.xml @@ -181,6 +181,8 @@ Vyčistenie SQL Cache zlyhalo, podrobnosti nájdete v logcat Cache Manager Sťahovanie dokončené! + Sťahovanie ukončené s %1$d chybami + %1$d dlaždíc smer: %1$d° vzdialenosť: %2$s Editovať cieľový bod Vymazať cieľový bod? diff --git a/core/strings/src/commonMain/composeResources/values-sl/strings.xml b/core/strings/src/commonMain/composeResources/values-sl/strings.xml index 4b0c5227bf..22c48f13b1 100644 --- a/core/strings/src/commonMain/composeResources/values-sl/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-sl/strings.xml @@ -176,6 +176,8 @@ Čiščenje predpomnilnika SQL Cache ni uspelo, za podrobnosti glejte logcat Upravitelj predpomnilnika Prenos končan! + Prenos končan z %1$d napakami + %1$d plošče lega: %1$d° oddaljenost: %2$s Uredi točko poti Izbriši točko poti? diff --git a/core/strings/src/commonMain/composeResources/values-sq/strings.xml b/core/strings/src/commonMain/composeResources/values-sq/strings.xml index 92ed20433b..b7e0b26a9a 100644 --- a/core/strings/src/commonMain/composeResources/values-sq/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-sq/strings.xml @@ -168,6 +168,8 @@ Pastrimi i Cache SQL ka dështuar, shihni logcat për detaje Menaxheri i Cache Shkarkimi përfundoi! + Shkarkimi përfundoi me %1$d gabime + %1$d pllaka drejtimi: %1$d° distanca: %2$s Redakto pikën e rreshtit Të fshihet pika e rreshtit? diff --git a/core/strings/src/commonMain/composeResources/values-sr/strings.xml b/core/strings/src/commonMain/composeResources/values-sr/strings.xml index 37f4164681..4efef6f0bd 100644 --- a/core/strings/src/commonMain/composeResources/values-sr/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-sr/strings.xml @@ -230,6 +230,8 @@ Пражњење SQL кеша није успело, погледајте logcat за детаље Меначер кеш меморије Преузимање готово! + Преузимање довршено са %1$d грешака + %1$d плочице смер: %1$d° растојање: %2$s Измените тачку путање Обрисати тачку путање? diff --git a/core/strings/src/commonMain/composeResources/values-srp/strings.xml b/core/strings/src/commonMain/composeResources/values-srp/strings.xml index 224d12da15..5f36e55490 100644 --- a/core/strings/src/commonMain/composeResources/values-srp/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-srp/strings.xml @@ -230,6 +230,8 @@ Пражњење SQL кеша није успело, погледајте logcat за детаље Меначер кеш меморије Преузимање готово! + Преузимање довршено са %1$d грешака + %1$d плочице смер: %1$d° растојање: %2$s Измените тачку путање Обрисати тачку путање? diff --git a/core/strings/src/commonMain/composeResources/values-sv/strings.xml b/core/strings/src/commonMain/composeResources/values-sv/strings.xml index f5d491b5c0..d0c2500bfc 100644 --- a/core/strings/src/commonMain/composeResources/values-sv/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-sv/strings.xml @@ -35,7 +35,6 @@ via MQTT via MQTT via Favoriter - Ignorerade noder Okänd Inväntar kvittens Kvittens köad @@ -179,6 +178,7 @@ Aktuella anslutningar: Wifi IP: Ethernet IP: + Ansluter Ej ansluten Ansluten till radioenhet, men den är i sovläge Applikationen måste uppgraderas @@ -295,6 +295,8 @@ SQL-cache rensning misslyckades, se logcat för detaljer Cache-hanterare Nedladdningen slutförd! + Nedladdning slutförd med %1$d fel + %1$d kartdelar bäring: %1$d° distans: %2$s Redigera vägpunkt Radera vägpunkt? @@ -819,6 +821,7 @@ Meshtastic använder aviseringar för att hålla dig uppdaterad om nya meddelanden och andra viktiga händelser. Du kan uppdatera dina aviseringsbehörigheter när som helst från inställningar. Nästa Ge behörigheter + %1$d noder köade för radering: Varning: Detta tar bort noder från både appen och enhetens databaser.\nMarkeringar är inklusive. Ansluter till enhet Normal @@ -858,12 +861,12 @@ Konfiguration av enhet "[Fjärr] %1$s" Skicka enhetstelemetri - Aktivera/inaktivera enhetens telemetrimodul för att skicka mätvärden till nätet Valfri 1 timme 8 timmar 24 timmar 48 timmar + Filtrera på senaste kontakt: %1$s %1$d dBm Saknar applikation för att hantera länken. Systeminställningar @@ -872,4 +875,5 @@ "Analysplattformar: " För mer information, se vår integritetspolicy. Odefinierad - 0 + Vidaresänt av: %1$s diff --git a/core/strings/src/commonMain/composeResources/values-tr/strings.xml b/core/strings/src/commonMain/composeResources/values-tr/strings.xml index 1a1680420e..efd48a1c14 100644 --- a/core/strings/src/commonMain/composeResources/values-tr/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-tr/strings.xml @@ -101,6 +101,7 @@ Bağlantı noktası: Bağlandı (%1$s) telsizine bağlandı + Bağlanıyor Bağlı değil Cihaza bağlandı, ancak uyku durumunda Uygulama güncellemesi gerekli @@ -192,6 +193,8 @@ SQL Önbellek temizleme başarısız, ayrıntılar için logcat' e bakın Önbellek Yöneticisi İndirme tamamlandı! + İndirme %1$d hata ile tamamlandı + %1$d harita parçası yön: %1$d° mesafe: %2$s Yer işareti düzenle Yer işaretini sil? diff --git a/core/strings/src/commonMain/composeResources/values-uk/strings.xml b/core/strings/src/commonMain/composeResources/values-uk/strings.xml index 152f2d2707..caa11b695e 100644 --- a/core/strings/src/commonMain/composeResources/values-uk/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-uk/strings.xml @@ -58,6 +58,7 @@ IP Адреса: Порт: Підключено до радіомодуля (%1$s) + Підключення... Не підключено Підключено до радіомодуля, але він в режимі сну Потрібне оновлення програми @@ -145,6 +146,8 @@ Помилка очищення кешу SQL, перегляньте logcat для деталей Керування кешем Звантаження завершено! + Завантаження завершено з %1$d помилками + %1$d плиток прийом: %1$d° відстань: %2$s Редагувати точку Видалити мітку? diff --git a/core/strings/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/strings/src/commonMain/composeResources/values-zh-rCN/strings.xml index 46348a802c..c05cab47e6 100644 --- a/core/strings/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -33,7 +33,6 @@ 通过 MQTT 通过 MQTT 通过收藏夹 - 忽略的节点 无法识别的 正在等待确认 发送队列中 @@ -127,6 +126,7 @@ 端口: 已连接 已连接至设备 (%1$s) + 正在连接 尚未联机 已连接至设备,但设备正在休眠中 需要更新应用程序 @@ -234,6 +234,8 @@ 清除 SQL 缓存失败,请查看 logcat 纪录 缓存管理员 下载已完成! + 下载完成,但有 %1$d 个错误 + %1$d 图砖 方位:%1$d° 距离:%2$s 编辑航点 删除航点? @@ -724,6 +726,7 @@ 配置关键警报 Meshtastic 使用通知来随时更新新消息和其他重要事件。您可以随时从设置中更新您的通知权限。 下一步 + %1$d 节点待删除: 注意:这将从应用内和设备上的数据库中移除节点。\n选择是附加性的。 正在连接设备 普通 diff --git a/core/strings/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/strings/src/commonMain/composeResources/values-zh-rTW/strings.xml index 5026ccc5c3..5d6cf8b450 100644 --- a/core/strings/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/core/strings/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -35,7 +35,6 @@ 有節點MQTT排序 有節點MQTT排序 通過喜好 - 已忽略節點 無法識別 正在等待確認 發送佇列中 @@ -183,6 +182,7 @@ 目前連線: WIFI IP: 乙太網路 IP: + 正在連線 未連線 已連接至無線電,但它正在休眠中 需要應用程式更新 @@ -298,6 +298,8 @@ SQL快取清除失敗,請查看logcat以獲取詳細資訊。 快取管理 下載已完成! + 下載完成,但有 %1$d 個錯誤 + %1$d 圖磚 方位:%1$d° 距離:%2$s 編輯航點 刪除航點? @@ -845,6 +847,7 @@ Meshtastic 使用通知功能讓您隨時了解新訊息和其他重要事件。您可以隨時在設定中更新通知權限。 繼續 授予權限 + %1$d 個節點已排定移除: 注意:這會將節點從應用程式和裝置資料庫中移除。\n所選的項目將會加入待處理中。 正在連線至裝置 標準 @@ -885,7 +888,6 @@ 裝置設定 "[遠端] %1$s" 傳送裝置遙測資料 - 啟用/禁用裝置遙測模組 所有 1 小時 8 小時 @@ -900,4 +902,5 @@ "分析平台: " 欲了解更多資訊,請查閱我們的隱私權政策。 預設值 - 0 + 經由:%1$s From 3e241221457eac81141313693a78c50727907192 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:42:06 -0600 Subject: [PATCH 35/62] chore(deps): update com.squareup.okhttp3:logging-interceptor to v5.3.2 (#3733) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- 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 83d621a46a..f9e63c8c9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -111,7 +111,7 @@ ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negoti ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktorfit = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } -okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.1" } +okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.2" } # Testing androidx-test-ext-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } From 01c52e4d353ac89dfe469b73992f52f121727a3e Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Wed, 19 Nov 2025 09:54:36 -0800 Subject: [PATCH 36/62] ux polish --- .../org/meshtastic/feature/map/MapView.kt | 21 -- .../feature/map/maplibre/ui/MapLibrePOC.kt | 190 +++++++++++------- 2 files changed, 112 insertions(+), 99 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 58299cb9a7..3d4affa939 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -990,27 +990,6 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: } } - // Add a button to switch back to osmdroid when using MapLibre - // Note: This is placed outside the Crossfade so it's always accessible - if (useMapLibre) { - Box(modifier = Modifier.fillMaxSize()) { - IconButton( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(12.dp), - onClick = { - useMapLibre = false - // Cleanup: osmdroid map will be recreated when switching back - }, - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Switch back to osmdroid", - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } showEditWaypointDialog?.let { waypoint -> EditWaypointDialog( diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 77d307faf8..b9c3aec766 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Explore @@ -48,6 +49,8 @@ import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Navigation +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Remove import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.AlertDialog @@ -158,7 +161,6 @@ import kotlin.math.sin fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDetails: (Int) -> Unit = {}) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - var selectedInfo by remember { mutableStateOf(null) } var selectedNodeNum by remember { mutableStateOf(null) } val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() @@ -536,14 +538,14 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe val loc = map.locationComponent.lastKnownLocation if (loc != null) { val target = LatLng(loc.latitude, loc.longitude) - map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 12.0)) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(target, 10.0)) didInitialCenter = true } else { ourNode?.validPosition?.let { p -> map.animateCamera( CameraUpdateFactory.newLatLngZoom( LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 12.0, + 10.0, ), ) didInitialCenter = true @@ -648,31 +650,24 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe return@addOnMapClickListener true } } - selectedInfo = - f?.let { - val kind = it.getStringProperty("kind") - when (kind) { - "node" -> { - val num = it.getNumberProperty("num")?.toInt() ?: -1 - val n = nodes.firstOrNull { node -> node.num == num } - selectedNodeNum = num - n?.let { node -> - "Node ${node.user.longName.ifBlank { - node.num.toString() - }} (${node.gpsString()})" - } ?: "Node $num" - } - "waypoint" -> { - val id = it.getNumberProperty("id")?.toInt() ?: -1 - // Open edit dialog for waypoint - waypoints.values - .find { pkt -> pkt.data.waypoint?.id == id } - ?.let { pkt -> editingWaypoint = pkt.data.waypoint } - "Waypoint: ${it.getStringProperty("name") ?: id}" - } - else -> null + // Handle node/waypoint selection + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + selectedNodeNum = num } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + waypoints.values + .find { pkt -> pkt.data.waypoint?.id == id } + ?.let { pkt -> editingWaypoint = pkt.data.waypoint } + } + else -> {} } + } true } // Long-press to create waypoint @@ -865,18 +860,6 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe }, ) - selectedInfo?.let { info -> - Surface( - modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth().padding(12.dp), - tonalElevation = 6.dp, - shadowElevation = 6.dp, - ) { - Column(modifier = Modifier.padding(12.dp)) { - Text(text = info, style = MaterialTheme.typography.bodyMedium) - } - } - } - // Role legend (based on roles present in current nodes) val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } if (showLegend && rolesPresent.isNotEmpty()) { @@ -916,33 +899,43 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe var mapFilterExpanded by remember { mutableStateOf(false) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) HorizontalFloatingToolbar( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 72.dp), // Top padding to avoid exit button + modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), // Top padding to avoid exit button expanded = true, content = { - // Compass button (matches Google Maps style - appears first, rotates with map bearing) - val compassBearing = mapRef?.cameraPosition?.bearing?.toFloat() ?: 0f - val compassIcon = if (followBearing) Icons.Filled.Navigation else Icons.Outlined.Navigation - MapButton( - onClick = { - if (isLocationTrackingEnabled) { - followBearing = !followBearing - Timber.tag("MapLibrePOC").d("Follow bearing toggled: %s", followBearing) - } else { - // Enable tracking when compass is clicked - if (hasLocationPermission) { - isLocationTrackingEnabled = true - followBearing = true - Timber.tag("MapLibrePOC").d("Enabled tracking + bearing from compass button") - } else { - Timber.tag("MapLibrePOC").w("Location permission not granted") + // Consolidated GPS button (cycles through: Off -> On -> On with bearing) + if (hasLocationPermission) { + val gpsIcon = when { + isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation + isLocationTrackingEnabled -> Icons.Filled.MyLocation + else -> Icons.Outlined.MyLocation + } + MapButton( + onClick = { + when { + !isLocationTrackingEnabled -> { + // Off -> On + isLocationTrackingEnabled = true + followBearing = false + Timber.tag("MapLibrePOC").d("GPS tracking enabled") + } + isLocationTrackingEnabled && !followBearing -> { + // On -> On with bearing + followBearing = true + Timber.tag("MapLibrePOC").d("GPS tracking with bearing enabled") + } + else -> { + // On with bearing -> Off + isLocationTrackingEnabled = false + followBearing = false + Timber.tag("MapLibrePOC").d("GPS tracking disabled") + } } - } - }, - icon = compassIcon, - contentDescription = null, - iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { !followBearing }, - modifier = Modifier.rotate(-compassBearing), - ) + }, + icon = gpsIcon, + contentDescription = null, + iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, + ) + } Box { MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { @@ -1203,21 +1196,6 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe contentDescription = null, ) - // Location tracking button (matches Google Maps style) - if (hasLocationPermission) { - MapButton( - onClick = { - isLocationTrackingEnabled = !isLocationTrackingEnabled - if (!isLocationTrackingEnabled) { - followBearing = false - } - Timber.tag("MapLibrePOC").d("Location tracking toggled: %s", isLocationTrackingEnabled) - }, - icon = if (isLocationTrackingEnabled) Icons.Filled.LocationDisabled else Icons.Outlined.MyLocation, - contentDescription = null, - ) - } - // Cache management button MapButton( onClick = { showCacheBottomSheet = true }, @@ -1230,6 +1208,62 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe }, ) + // Zoom controls (bottom right) - Google Maps style + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp), + ) { + // Zoom in button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomIn()) + Timber.tag("MapLibrePOC").d("Zoom in") + } + }, + icon = Icons.Outlined.Add, + contentDescription = "Zoom in", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + // Zoom out button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomOut()) + Timber.tag("MapLibrePOC").d("Zoom out") + } + }, + icon = Icons.Outlined.Remove, + contentDescription = "Zoom out", + ) + + Spacer(modifier = Modifier.size(8.dp)) + + // Compass reset button - resets map orientation to north (original top-right compass) + val currentBearing = mapRef?.cameraPosition?.bearing ?: 0.0 + if (kotlin.math.abs(currentBearing) > 0.1) { // Only show if map is rotated + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera( + CameraUpdateFactory.newCameraPosition( + org.maplibre.android.camera.CameraPosition.Builder(map.cameraPosition) + .bearing(0.0) + .build() + ) + ) + Timber.tag("MapLibrePOC").d("Compass reset to north") + } + }, + icon = Icons.Outlined.Navigation, + contentDescription = "Reset to north", + ) + } + } + // Custom tile URL dialog if (showCustomTileDialog) { AlertDialog( @@ -1437,7 +1471,7 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } if (selectedNode != null) { ModalBottomSheet(onDismissRequest = { selectedNodeNum = null }, sheetState = sheetState) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp)) { NodeChip(node = selectedNode) val longName = selectedNode.user.longName if (!longName.isNullOrBlank()) { From b9492cdf33e4bcb611fee498037fa2f55e8e8ad1 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Thu, 20 Nov 2025 11:26:38 -0800 Subject: [PATCH 37/62] utilize maplibre ambient caching api; initial work on node tracks --- .../org/meshtastic/feature/map/MapView.kt | 9 +- .../map/component/TileCacheManagementSheet.kt | 186 ++---- .../feature/map/maplibre/MapLibreConstants.kt | 4 + .../maplibre/core/MapLibreDataTransformers.kt | 39 ++ .../map/maplibre/core/MapLibreLayerManager.kt | 62 ++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 131 ++--- .../utils/MapLibreTileCacheManager.kt | 555 +----------------- 7 files changed, 231 insertions(+), 755 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 3d4affa939..5db1a54fbc 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -380,7 +380,12 @@ private fun PurgeTileSourceDialog(onDismiss: () -> Unit) { @OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist @Suppress("CyclomaticComplexMethod", "LongMethod") @Composable -fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) { +fun MapView( + mapViewModel: MapViewModel = hiltViewModel(), + navigateToNodeDetails: (Int) -> Unit, + focusedNodeNum: Int? = null, + nodeTracks: List? = null, +) { var mapFilterExpanded by remember { mutableStateOf(false) } val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() @@ -790,6 +795,8 @@ fun MapView(mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( mapViewModel = mapViewModel, onNavigateToNodeDetails = navigateToNodeDetails, + focusedNodeNum = focusedNodeNum, + nodeTracks = nodeTracks, ) } else { // osmdroid implementation diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt index 3a2bea2fab..208687937a 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt @@ -45,44 +45,22 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.maplibre.android.geometry.LatLngBounds import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager import timber.log.Timber -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -@Suppress("LongMethod") @Composable @OptIn(ExperimentalMaterial3Api::class) fun TileCacheManagementSheet( cacheManager: MapLibreTileCacheManager, - currentBounds: LatLngBounds?, - currentZoom: Double?, - styleUrl: String?, onDismiss: () -> Unit, ) { - var hotAreas by remember { mutableStateOf>(emptyList()) } - var cacheSizeBytes by remember { mutableStateOf(null) } - var lastUpdateTime by remember { mutableStateOf(null) } - var isCaching by remember { mutableStateOf(false) } - - fun refreshData() { - hotAreas = cacheManager.getHotAreas() - CoroutineScope(Dispatchers.IO).launch { - cacheSizeBytes = cacheManager.getCacheSizeBytes() - } - } - - LaunchedEffect(Unit) { - refreshData() - } + var isClearing by remember { mutableStateOf(false) } LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { item { Text( modifier = Modifier.padding(16.dp), - text = "Tile Cache Management", + text = "Map Cache", style = MaterialTheme.typography.headlineSmall, ) HorizontalDivider() @@ -91,141 +69,38 @@ fun TileCacheManagementSheet( item { Column(modifier = Modifier.padding(16.dp)) { Text( - text = "Cache Statistics", + text = "About Map Caching", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp), ) Text( - text = "Hot Areas: ${hotAreas.size}", + text = "Map tiles are automatically cached by MapLibre as you view the map. " + + "This improves performance and allows limited offline viewing of previously visited areas.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), ) - cacheSizeBytes?.let { size -> - val sizeMb = size / (1024.0 * 1024.0) - Text( - text = "Estimated Cache Size: ${String.format("%.2f", sizeMb)} MB", - style = MaterialTheme.typography.bodyMedium, - ) - } - lastUpdateTime?.let { time -> - val dateFormat = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()) - Text( - text = "Last Update: ${dateFormat.format(Date(time))}", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - HorizontalDivider() - } - - // Cache current area button - item { - val canCache = currentBounds != null && currentZoom != null && styleUrl != null - Button( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - onClick = { - if (canCache) { - isCaching = true - CoroutineScope(Dispatchers.IO).launch { - try { - cacheManager.cacheCurrentArea(currentBounds!!, currentZoom!!, styleUrl!!) - refreshData() - } catch (e: Exception) { - Timber.tag("TileCacheManagementSheet").e(e, "Error caching area: ${e.message}") - } finally { - isCaching = false - } - } - } - }, - enabled = canCache && !isCaching, - ) { - Text( - when { - isCaching -> "Caching..." - styleUrl == null -> "Caching unavailable (custom tiles)" - else -> "Cache This Area Now" - }, - ) - } - } - if (styleUrl == null) { - item { Text( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - text = "Tile caching is only available when using standard map styles, not custom raster tiles.", + text = "The cache is managed automatically and will be cleared if your device runs low on storage.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + HorizontalDivider() } item { - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp), - text = "Cached Areas", - style = MaterialTheme.typography.titleSmall, - ) - } - - if (hotAreas.isEmpty()) { - item { + Column(modifier = Modifier.padding(16.dp)) { Text( - modifier = Modifier.padding(16.dp), - text = "No cached areas yet. Areas you view frequently will be automatically cached.", - style = MaterialTheme.typography.bodyMedium, + text = "Cache Management", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), ) - } - } else { - items(hotAreas, key = { it.id }) { area -> - ListItem( - headlineContent = { - Text( - text = "Area ${area.id.take(8)}", - style = MaterialTheme.typography.bodyLarge, - ) - }, - supportingContent = { - Column { - Text( - text = "Visits: ${area.visitCount} | Time: ${area.totalTimeSec}s", - style = MaterialTheme.typography.bodySmall, - ) - Text( - text = "Zoom: ${String.format("%.1f", area.zoom)} | " + - "Center: ${String.format("%.4f", area.centerLat)}, ${String.format("%.4f", area.centerLon)}", - style = MaterialTheme.typography.bodySmall, - ) - if (area.offlineRegionId != null) { - Text( - text = "✓ Cached (Region ID: ${area.offlineRegionId})", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - text = "⏳ Caching in progress...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - ) - } - } - }, - trailingContent = { - if (area.offlineRegionId != null) { - IconButton( - onClick = { - // TODO: Implement delete for individual region - }, - ) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = "Delete cached area", - ) - } - } - }, + Text( + text = "If you're experiencing issues with outdated map tiles or want to free up storage space, you can clear the cache below.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - HorizontalDivider() } } @@ -233,17 +108,34 @@ fun TileCacheManagementSheet( Button( modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = { - // Clear all cache + isClearing = true CoroutineScope(Dispatchers.IO).launch { - cacheManager.clearCache() - hotAreas = cacheManager.getHotAreas() - cacheSizeBytes = cacheManager.getCacheSizeBytes() + try { + cacheManager.clearCache() + Timber.tag("TileCacheManagementSheet").d("Cache cleared successfully") + } catch (e: Exception) { + Timber.tag("TileCacheManagementSheet").e(e, "Error clearing cache: ${e.message}") + } finally { + withContext(Dispatchers.Main) { + isClearing = false + } + } } }, + enabled = !isClearing, ) { - Text("Clear All Cache") + Text(if (isClearing) "Clearing..." else "Clear Map Cache") } } + + item { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + text = "Note: Clearing the cache will require re-downloading tiles as you view the map again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt index 9b70436f77..30e3e9dd61 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -29,6 +29,8 @@ object MapLibreConstants { const val NODES_SOURCE_ID = "meshtastic-nodes-source" const val NODES_CLUSTER_SOURCE_ID = "meshtastic-nodes-source-clustered" const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" + const val TRACK_LINE_SOURCE_ID = "meshtastic-track-line-source" + const val TRACK_POINTS_SOURCE_ID = "meshtastic-track-points-source" const val OSM_SOURCE_ID = "osm-tiles" // Layer IDs @@ -40,6 +42,8 @@ object MapLibreConstants { const val CLUSTER_COUNT_LAYER_ID = "meshtastic-cluster-count-layer" const val WAYPOINTS_LAYER_ID = "meshtastic-waypoints-layer" const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" + const val TRACK_LINE_LAYER_ID = "meshtastic-track-line-layer" + const val TRACK_POINTS_LAYER_ID = "meshtastic-track-points-layer" const val OSM_LAYER_ID = "osm-layer" // Cluster configuration diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index d33cc7cce8..f3cb832b39 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -21,6 +21,7 @@ import org.maplibre.android.maps.Style import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Feature import org.maplibre.geojson.FeatureCollection +import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D @@ -30,6 +31,7 @@ import org.meshtastic.feature.map.maplibre.utils.roleColorHex import org.meshtastic.feature.map.maplibre.utils.safeSubstring import org.meshtastic.feature.map.maplibre.utils.shortNameFallback import org.meshtastic.feature.map.maplibre.utils.stripEmojisForMapLabel +import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint import timber.log.Timber @@ -155,3 +157,40 @@ fun escapeJson(input: String): String { } return sb.toString() } + +/** Converts a list of positions to a GeoJSON LineString for track rendering */ +fun positionsToLineStringFeature(positions: List): Feature? { + if (positions.size < 2) return null + + val points = positions.map { pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + Point.fromLngLat(lon, lat) + } + + return Feature.fromGeometry(LineString.fromLngLats(points)) +} + +/** Converts a list of positions to a GeoJSON FeatureCollection for track point markers */ +fun positionsToPointFeatures(positions: List): FeatureCollection { + val features = positions.mapIndexed { index, pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + val feature = Feature.fromGeometry(point) + + feature.addStringProperty("kind", "track_point") + feature.addNumberProperty("index", index) + feature.addNumberProperty("time", pos.time) + feature.addNumberProperty("altitude", pos.altitude) + feature.addNumberProperty("groundSpeed", pos.groundSpeed) + feature.addNumberProperty("groundTrack", pos.groundTrack) + feature.addNumberProperty("satsInView", pos.satsInView) + feature.addNumberProperty("latitude", lat) + feature.addNumberProperty("longitude", lon) + + feature + } + + return FeatureCollection.fromFeatures(features) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index 0092cac5ca..509d20b71b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -77,6 +77,10 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_TEXT_LAYER_NOC import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.OSM_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.PRECISION_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID import timber.log.Timber @@ -391,3 +395,61 @@ fun logStyleState(whenTag: String, style: Style) { Timber.tag("MapLibrePOC").w(e, "Failed to log style state") } } + +/** Manages track line and point sources and layers for node track display */ +fun ensureTrackSourcesAndLayers(style: Style, trackColor: String = "#FF5722") { + // Add track line source if it doesn't exist + if (style.getSource(TRACK_LINE_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(TRACK_LINE_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added track line GeoJsonSource") + } + + // Add track points source if it doesn't exist + if (style.getSource(TRACK_POINTS_SOURCE_ID) == null) { + style.addSource(GeoJsonSource(TRACK_POINTS_SOURCE_ID, emptyFeatureCollectionJson())) + Timber.tag("MapLibrePOC").d("Added track points GeoJsonSource") + } + + // Add track line layer if it doesn't exist + if (style.getLayer(TRACK_LINE_LAYER_ID) == null) { + val lineLayer = LineLayer(TRACK_LINE_LAYER_ID, TRACK_LINE_SOURCE_ID) + .withProperties( + lineColor(trackColor), + lineWidth(3f), + lineOpacity(0.8f) + ) + + // Add above OSM layer if it exists + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(lineLayer, OSM_LAYER_ID) + } else { + style.addLayer(lineLayer) + } + Timber.tag("MapLibrePOC").d("Added track line LineLayer") + } + + // Add track points layer if it doesn't exist + if (style.getLayer(TRACK_POINTS_LAYER_ID) == null) { + val pointsLayer = CircleLayer(TRACK_POINTS_LAYER_ID, TRACK_POINTS_SOURCE_ID) + .withProperties( + circleColor(trackColor), + circleRadius(5f), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(2f), + circleOpacity(0.7f) + ) + + // Add above track line layer + style.addLayerAbove(pointsLayer, TRACK_LINE_LAYER_ID) + Timber.tag("MapLibrePOC").d("Added track points CircleLayer") + } +} + +/** Removes track sources and layers from the style */ +fun removeTrackSourcesAndLayers(style: Style) { + style.getLayer(TRACK_POINTS_LAYER_ID)?.let { style.removeLayer(it) } + style.getLayer(TRACK_LINE_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(TRACK_POINTS_SOURCE_ID)?.let { style.removeSource(it) } + style.getSource(TRACK_LINE_SOURCE_ID)?.let { style.removeSource(it) } + Timber.tag("MapLibrePOC").d("Removed track sources and layers") +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index b9c3aec766..36478a1e4c 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -120,16 +120,22 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURC import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle import org.meshtastic.feature.map.maplibre.core.ensureImportedLayerSourceAndLayers import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers +import org.meshtastic.feature.map.maplibre.core.ensureTrackSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.logStyleState import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.positionsToLineStringFeature +import org.meshtastic.feature.map.maplibre.core.positionsToPointFeatures import org.meshtastic.feature.map.maplibre.core.reinitializeStyleAfterSwitch import org.meshtastic.feature.map.maplibre.core.removeImportedLayerSourceAndLayers +import org.meshtastic.feature.map.maplibre.core.removeTrackSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis import org.meshtastic.feature.map.maplibre.core.waypointsToFeatureCollectionFC @@ -158,7 +164,12 @@ import kotlin.math.sin @SuppressLint("MissingPermission") @Composable @OptIn(ExperimentalMaterial3Api::class) -fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDetails: (Int) -> Unit = {}) { +fun MapLibrePOC( + mapViewModel: MapViewModel = hiltViewModel(), + onNavigateToNodeDetails: (Int) -> Unit = {}, + focusedNodeNum: Int? = null, + nodeTracks: List? = null, +) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var selectedNodeNum by remember { mutableStateOf(null) } @@ -198,7 +209,6 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe // Tile cache management - initialize after MapLibre is initialized var tileCacheManager by remember { mutableStateOf(null) } var showCacheBottomSheet by remember { mutableStateOf(false) } - var lastCacheUpdateTime by remember { mutableStateOf(null) } val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() @@ -226,61 +236,6 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe } } - // Periodic cache updates - LaunchedEffect(tileCacheManager) { - tileCacheManager?.let { manager -> - while (true) { - val intervalMs = manager.getUpdateIntervalMs() - kotlinx.coroutines.delay(intervalMs) - try { - manager.updateCachedRegions() - lastCacheUpdateTime = System.currentTimeMillis() - Timber.tag("MapLibrePOC").d("Cache update completed at ${lastCacheUpdateTime}") - } catch (e: Exception) { - Timber.tag("MapLibrePOC").e(e, "Failed to update cached regions") - } - } - } - } - - // Periodic hot area tracking - record viewport every 5 seconds even when camera is idle - LaunchedEffect(tileCacheManager, mapRef) { - if (tileCacheManager == null) { - Timber.tag("MapLibrePOC").d("Periodic hot area tracking: tileCacheManager is null, waiting...") - return@LaunchedEffect - } - if (mapRef == null) { - Timber.tag("MapLibrePOC").d("Periodic hot area tracking: mapRef is null, waiting...") - return@LaunchedEffect - } - - val manager = tileCacheManager!! - val map = mapRef!! - - Timber.tag("MapLibrePOC").d("Starting periodic hot area tracking (every 5 seconds)") - - while (true) { - kotlinx.coroutines.delay(5000) // Check every 5 seconds - try { - val style = map.style - if (style != null) { - val bounds = map.projection.visibleRegion.latLngBounds - val zoom = map.cameraPosition.zoom - // Only cache if using a standard style URL (not custom raster tiles) - val styleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL - Timber.tag("MapLibrePOC").d("Periodic hot area check: zoom=%.2f, bounds=[%.4f,%.4f,%.4f,%.4f], styleUrl=$styleUrl", - zoom, bounds.latitudeNorth, bounds.latitudeSouth, bounds.longitudeEast, bounds.longitudeWest) - if (styleUrl != null) { - manager.recordViewport(bounds, zoom, styleUrl) - } - } else { - Timber.tag("MapLibrePOC").d("Periodic hot area check: style is null, skipping") - } - } catch (e: Exception) { - Timber.tag("MapLibrePOC").e(e, "Failed to record viewport in periodic check") - } - } - } // Helper functions for layer management fun toggleLayerVisibility(layerId: String) { @@ -463,6 +418,45 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe style.setTransition(TransitionOptions(0, 0)) logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) + + // Setup track sources and layers if rendering node tracks + if (nodeTracks != null && focusedNodeNum != null) { + // Get the focused node to use its color + val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } + val trackColor = focusedNode?.let { + String.format("#%06X", 0xFFFFFF and it.colors.second) + } ?: "#FF5722" // Default orange color + + ensureTrackSourcesAndLayers(style, trackColor) + + // Filter tracks by time using lastHeardTrackFilter + val filteredTracks = nodeTracks.filter { + mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > System.currentTimeMillis() / 1000 - mapFilterState.lastHeardTrackFilter.seconds + }.sortedBy { it.time } + + // Update track line + if (filteredTracks.size >= 2) { + positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(lineFeature) + } + } + + // Update track points + if (filteredTracks.isNotEmpty()) { + val pointsFC = positionsToPointFeatures(filteredTracks) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(pointsFC) + } + + Timber.tag("MapLibrePOC") + .d("Track data set: %d positions", filteredTracks.size) + } else { + // Remove track layers if no tracks to display + removeTrackSourcesAndLayers(style) + } + // Push current data immediately after style load try { val density = context.resources.displayMetrics.density @@ -733,21 +727,6 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe Timber.tag("MapLibrePOC") .d("onCameraIdle: rendered features in viewport=%d", rendered.size) } catch (_: Throwable) {} - - // Track viewport for tile caching (hot areas) - tileCacheManager?.let { manager -> - try { - val bounds = map.projection.visibleRegion.latLngBounds - val zoom = map.cameraPosition.zoom - // Only cache if using a standard style URL (not custom raster tiles) - val styleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL - if (styleUrl != null) { - manager.recordViewport(bounds, zoom, styleUrl) - } - } catch (e: Exception) { - Timber.tag("MapLibrePOC").e(e, "Failed to record viewport for tile caching") - } - } } // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { @@ -1399,16 +1378,8 @@ fun MapLibrePOC(mapViewModel: MapViewModel = hiltViewModel(), onNavigateToNodeDe onDismissRequest = { showCacheBottomSheet = false }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { - val currentBounds = mapRef?.projection?.visibleRegion?.latLngBounds - val currentZoom = mapRef?.cameraPosition?.zoom - // Only allow caching if using a standard style URL (not custom raster tiles) - // Custom raster tiles are built programmatically and don't have a style URL - val currentStyleUrl = if (usingCustomTiles) null else MapLibreConstants.STYLE_URL TileCacheManagementSheet( cacheManager = tileCacheManager!!, - currentBounds = currentBounds, - currentZoom = currentZoom, - styleUrl = currentStyleUrl, onDismiss = { showCacheBottomSheet = false }, ) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt index d22ecaf469..fa03d0d87f 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt @@ -18,435 +18,48 @@ package org.meshtastic.feature.map.maplibre.utils import android.content.Context -import android.content.SharedPreferences -import com.google.gson.Gson -import com.google.gson.JsonObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.maplibre.android.geometry.LatLngBounds -import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.offline.OfflineManager import org.maplibre.android.offline.OfflineRegion -import org.maplibre.android.offline.OfflineRegionError -import org.maplibre.android.offline.OfflineRegionStatus -import org.maplibre.android.offline.OfflineTilePyramidRegionDefinition import timber.log.Timber -import java.util.UUID -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min /** - * Manages tile caching for "hot areas" - frequently visited map regions. - * Automatically caches tiles for areas where users spend time viewing the map. + * Simplified tile cache manager for MapLibre. + * Provides basic cache clearing functionality. + * Note: MapLibre automatically caches tiles via HTTP caching as you view the map. */ class MapLibreTileCacheManager(private val context: Context) { // Lazy initialization - only create OfflineManager after MapLibre is initialized private val offlineManager: OfflineManager by lazy { OfflineManager.getInstance(context) } - private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val gson = Gson() - - companion object { - private const val PREFS_NAME = "maplibre_tile_cache" - private const val KEY_HOT_AREAS = "hot_areas" - private const val KEY_UPDATE_INTERVAL_MS = "update_interval_ms" - private const val KEY_MAX_CACHE_SIZE_MB = "max_cache_size_mb" - private const val KEY_HOT_AREA_THRESHOLD_SEC = "hot_area_threshold_sec" - private const val KEY_MIN_ZOOM = "min_zoom" - private const val KEY_MAX_ZOOM = "max_zoom" - - // Default values - private const val DEFAULT_UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000 // 7 days - private const val DEFAULT_MAX_CACHE_SIZE_MB = 500L // 500 MB - private const val DEFAULT_HOT_AREA_THRESHOLD_SEC = 60L // 1 minute - private const val DEFAULT_MIN_ZOOM = 10.0 - private const val DEFAULT_MAX_ZOOM = 16.0 - - private const val JSON_FIELD_REGION_NAME = "name" - private const val JSON_FIELD_REGION_ID = "id" - private const val JSON_FIELD_CENTER_LAT = "centerLat" - private const val JSON_FIELD_CENTER_LON = "centerLon" - private const val JSON_FIELD_ZOOM = "zoom" - private const val JSON_FIELD_LAST_VISIT = "lastVisit" - private const val JSON_FIELD_VISIT_COUNT = "visitCount" - private const val JSON_FIELD_TOTAL_TIME_SEC = "totalTimeSec" - private const val JSON_CHARSET = "UTF-8" - } - - data class HotArea( - val id: String, - val centerLat: Double, - val centerLon: Double, - val zoom: Double, - val bounds: LatLngBounds, - var lastVisit: Long, - var visitCount: Int, - var totalTimeSec: Long, - var offlineRegionId: Long? = null, - ) - - /** - * Records a camera position/viewport as a potential hot area. - * If the user spends enough time in this area, it will be cached. - */ - fun recordViewport( - bounds: LatLngBounds, - zoom: Double, - styleUrl: String, - ) { - val centerLat = (bounds.latitudeNorth + bounds.latitudeSouth) / 2.0 - val centerLon = (bounds.longitudeEast + bounds.longitudeWest) / 2.0 - - Timber.tag("MapLibreTileCacheManager").d("recordViewport called: center=[%.4f,%.4f], zoom=%.2f", centerLat, centerLon, zoom) - - // Find existing hot area within threshold (same general location) - val existingArea = findHotArea(centerLat, centerLon, zoom) - val now = System.currentTimeMillis() - - Timber.tag("MapLibreTileCacheManager").d("findHotArea result: ${if (existingArea != null) "found area ${existingArea.id.take(8)}" else "no match, creating new"}") - - if (existingArea != null) { - // Update existing area - track actual elapsed time since last visit - val timeSinceLastVisit = (now - existingArea.lastVisit) / 1000 // Convert to seconds - existingArea.lastVisit = now - existingArea.visitCount++ - // Add actual elapsed time (capped at 5 seconds per call to avoid huge jumps if app was backgrounded) - existingArea.totalTimeSec += minOf(timeSinceLastVisit, 5) - - // Check if threshold is met and region doesn't exist yet - val thresholdSec = getHotAreaThresholdSec() - if (existingArea.totalTimeSec >= thresholdSec && existingArea.offlineRegionId == null) { - Timber.tag("MapLibreTileCacheManager").d( - "Hot area threshold met (${existingArea.totalTimeSec}s >= ${thresholdSec}s), creating offline region: ${existingArea.id}", - ) - createOfflineRegionForHotArea(existingArea, styleUrl) - } else { - Timber.tag("MapLibreTileCacheManager").d( - "Hot area progress: ${existingArea.totalTimeSec}s / ${thresholdSec}s (area: ${existingArea.id.take(8)})", - ) - } - saveHotAreas() - } else { - // Create new hot area - Timber.tag("MapLibreTileCacheManager").d("Creating new hot area: center=[%.4f,%.4f], zoom=%.2f", centerLat, centerLon, zoom) - val newArea = HotArea( - id = UUID.randomUUID().toString(), - centerLat = centerLat, - centerLon = centerLon, - zoom = zoom, - bounds = bounds, - lastVisit = now, - visitCount = 1, - totalTimeSec = 1, - ) - addHotArea(newArea) - } - } /** - * Manually caches the current viewport immediately, bypassing the hot area threshold. - * Useful for "Cache this area now" functionality. + * Clears the ambient cache (automatic HTTP tile cache). + * MapLibre automatically caches tiles in the "ambient cache" as you view the map. + * This method clears that cache to free up storage space. */ - fun cacheCurrentArea( - bounds: LatLngBounds, - zoom: Double, - styleUrl: String, - ) { - val centerLat = (bounds.latitudeNorth + bounds.latitudeSouth) / 2.0 - val centerLon = (bounds.longitudeEast + bounds.longitudeWest) / 2.0 - - // Check if this area is already cached - val existingArea = findHotArea(centerLat, centerLon, zoom) - val now = System.currentTimeMillis() - - if (existingArea != null && existingArea.offlineRegionId != null) { - // Already cached, just update visit info - existingArea.lastVisit = now - existingArea.visitCount++ - saveHotAreas() - Timber.tag("MapLibreTileCacheManager").d("Area already cached: ${existingArea.id}") - return - } - - // Create or update hot area and immediately cache it - val area = if (existingArea != null) { - existingArea.apply { - lastVisit = now - visitCount++ - totalTimeSec = getHotAreaThresholdSec() // Set to threshold to ensure caching - } - } else { - HotArea( - id = UUID.randomUUID().toString(), - centerLat = centerLat, - centerLon = centerLon, - zoom = zoom, - bounds = bounds, - lastVisit = now, - visitCount = 1, - totalTimeSec = getHotAreaThresholdSec(), // Set to threshold to ensure caching - ).also { addHotArea(it) } - } - - // Immediately create offline region - if (area.offlineRegionId == null) { - Timber.tag("MapLibreTileCacheManager").d("Manually caching area: ${area.id}") - createOfflineRegionForHotArea(area, styleUrl) - } - - saveHotAreas() - } - - /** - * Finds a hot area near the given coordinates and zoom level - */ - private fun findHotArea(lat: Double, lon: Double, zoom: Double): HotArea? { - val areas = loadHotAreas() - val zoomDiffThreshold = 2.0 // Within 2 zoom levels - - Timber.tag("MapLibreTileCacheManager").d("findHotArea: searching ${areas.size} areas for lat=%.4f, lon=%.4f, zoom=%.2f", lat, lon, zoom) - - val result = areas.firstOrNull { area -> - val latDiff = abs(area.centerLat - lat) - val lonDiff = abs(area.centerLon - lon) - val zoomDiff = abs(area.zoom - zoom) - - // Within ~1km and similar zoom level - val matches = latDiff < 0.01 && lonDiff < 0.01 && zoomDiff < zoomDiffThreshold - if (matches) { - Timber.tag("MapLibreTileCacheManager").d(" Match found: area ${area.id.take(8)}, latDiff=%.4f, lonDiff=%.4f, zoomDiff=%.2f", latDiff, lonDiff, zoomDiff) - } - matches - } - - if (result == null && areas.isNotEmpty()) { - Timber.tag("MapLibreTileCacheManager").d(" No match. Closest area: lat=%.4f, lon=%.4f, zoom=%.2f", areas[0].centerLat, areas[0].centerLon, areas[0].zoom) - } - - return result - } - - /** - * Creates an offline region for a hot area - */ - private fun createOfflineRegionForHotArea(area: HotArea, styleUrl: String) { - // Validate style URL - must be a valid HTTP/HTTPS URL ending in .json - if (!styleUrl.startsWith("http://") && !styleUrl.startsWith("https://")) { - Timber.tag("MapLibreTileCacheManager").e("Invalid style URL (must be HTTP/HTTPS): $styleUrl") - return - } - if (!styleUrl.endsWith(".json") && !styleUrl.contains("style.json")) { - Timber.tag("MapLibreTileCacheManager").w("Style URL may not be valid (should end in .json or contain style.json): $styleUrl") - } - - // Validate bounds - val bounds = area.bounds - if (bounds.latitudeNorth <= bounds.latitudeSouth) { - Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: north (%.4f) must be > south (%.4f)", bounds.latitudeNorth, bounds.latitudeSouth) - return - } - if (bounds.longitudeEast <= bounds.longitudeWest) { - Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: east (%.4f) must be > west (%.4f)", bounds.longitudeEast, bounds.longitudeWest) - return - } - if (bounds.latitudeNorth > 90.0 || bounds.latitudeSouth < -90.0) { - Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: latitude out of range [%.4f, %.4f]", bounds.latitudeSouth, bounds.latitudeNorth) - return - } - if (bounds.longitudeEast > 180.0 || bounds.longitudeWest < -180.0) { - Timber.tag("MapLibreTileCacheManager").e("Invalid bounds: longitude out of range [%.4f, %.4f]", bounds.longitudeWest, bounds.longitudeEast) - return - } - - val minZoom = getMinZoom() - val maxZoom = getMaxZoom() - if (minZoom < 0 || maxZoom < minZoom) { - Timber.tag("MapLibreTileCacheManager").e("Invalid zoom range: min=%.1f, max=%.1f", minZoom, maxZoom) - return - } - - val pixelRatio = context.resources.displayMetrics.density - if (pixelRatio <= 0) { - Timber.tag("MapLibreTileCacheManager").e("Invalid pixel ratio: %.2f", pixelRatio) - return - } - - Timber.tag("MapLibreTileCacheManager").d("Creating offline region: styleUrl=$styleUrl, bounds=[%.4f,%.4f,%.4f,%.4f], zoom=[%.1f-%.1f], pixelRatio=%.2f", - bounds.latitudeNorth, bounds.latitudeSouth, - bounds.longitudeEast, bounds.longitudeWest, - minZoom, maxZoom, pixelRatio) - - try { - val definition = OfflineTilePyramidRegionDefinition( - styleUrl, - bounds, - minZoom, - maxZoom, - pixelRatio, - ) - - val metadata = encodeHotAreaMetadata(area) - - offlineManager.createOfflineRegion( - definition, - metadata, - object : OfflineManager.CreateOfflineRegionCallback { - override fun onCreate(offlineRegion: OfflineRegion) { - Timber.tag("MapLibreTileCacheManager").d("Offline region created: ${offlineRegion.id} for hot area: ${area.id}") - area.offlineRegionId = offlineRegion.id - saveHotAreas() - - // Start download - startDownload(offlineRegion) - } - - override fun onError(error: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to create offline region: $error") - } - }, - ) - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Exception creating offline region: ${e.message}") - } - } - - /** - * Starts downloading tiles for an offline region - */ - private fun startDownload(region: OfflineRegion) { - try { - region.setObserver(object : OfflineRegion.OfflineRegionObserver { - override fun onStatusChanged(status: OfflineRegionStatus) { - try { - val percentage = if (status.requiredResourceCount > 0) { - (100.0 * status.completedResourceCount / status.requiredResourceCount).toInt() - } else { - 0 - } - - Timber.tag("MapLibreTileCacheManager").d( - "Offline region ${region.id} progress: $percentage% " + - "(${status.completedResourceCount}/${status.requiredResourceCount})", - ) - - if (status.isComplete) { - Timber.tag("MapLibreTileCacheManager").d("Offline region ${region.id} download complete") - region.setObserver(null) - } - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Error in onStatusChanged: ${e.message}") - // Remove observer on error to prevent further callbacks - try { - region.setObserver(null) - } catch (ex: Exception) { - Timber.tag("MapLibreTileCacheManager").e(ex, "Error removing observer: ${ex.message}") - } - } - } - - override fun onError(error: OfflineRegionError) { - Timber.tag("MapLibreTileCacheManager").e("Offline region ${region.id} error: reason=${error.reason}, message=${error.message}") - // Remove observer on error to prevent further callbacks - try { - region.setObserver(null) - region.setDownloadState(OfflineRegion.STATE_INACTIVE) - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Error handling offline region error: ${e.message}") - } - } - - override fun mapboxTileCountLimitExceeded(limit: Long) { - Timber.tag("MapLibreTileCacheManager").w("Tile count limit exceeded: $limit") - // Remove observer on error to prevent further callbacks - try { - region.setObserver(null) - region.setDownloadState(OfflineRegion.STATE_INACTIVE) - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Error handling tile limit exceeded: ${e.message}") - } - } - }) - - region.setDownloadState(OfflineRegion.STATE_ACTIVE) - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Exception starting download for region ${region.id}: ${e.message}") - } - } - - /** - * Updates all cached regions by invalidating their ambient cache - */ - suspend fun updateCachedRegions() = withContext(Dispatchers.IO) { - Timber.tag("MapLibreTileCacheManager").d("Updating cached regions...") - - offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { - override fun onList(offlineRegions: Array?) { - if (offlineRegions == null || offlineRegions.isEmpty()) { - Timber.tag("MapLibreTileCacheManager").d("No offline regions to update") - return - } - - Timber.tag("MapLibreTileCacheManager").d("Found ${offlineRegions.size} offline regions to update") - - offlineRegions.forEach { region -> - // Invalidate ambient cache to force re-download - offlineManager.invalidateAmbientCache(object : OfflineManager.FileSourceCallback { - override fun onSuccess() { - Timber.tag("MapLibreTileCacheManager").d("Invalidated cache for region ${region.id}") - // Resume download to update tiles - region.setDownloadState(OfflineRegion.STATE_ACTIVE) - } - - override fun onError(message: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to invalidate cache: $message") - } - }) - } - } - - override fun onError(error: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") - } - }) - } + suspend fun clearCache() = withContext(Dispatchers.IO) { + Timber.tag("MapLibreTileCacheManager").d("Clearing ambient cache...") - /** - * Gets the total cache size in bytes - */ - suspend fun getCacheSizeBytes(): Long = withContext(Dispatchers.IO) { - // MapLibre doesn't expose cache size directly, so we estimate based on regions - // This is a rough approximation - var totalSize = 0L - offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { - override fun onList(offlineRegions: Array?) { - if (offlineRegions != null) { - offlineRegions.forEach { region -> - // Estimate ~100KB per region (very rough) - totalSize += 100 * 1024 - } - } + offlineManager.clearAmbientCache(object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully cleared ambient cache") } - override fun onError(error: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to clear ambient cache: $message") } }) - totalSize - } - - /** - * Clears all cached regions - */ - suspend fun clearCache() = withContext(Dispatchers.IO) { - Timber.tag("MapLibreTileCacheManager").d("Clearing cache...") + // Also delete any offline regions if they exist (from the old broken implementation) offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { override fun onList(offlineRegions: Array?) { if (offlineRegions == null || offlineRegions.isEmpty()) { - Timber.tag("MapLibreTileCacheManager").d("No offline regions to clear") + Timber.tag("MapLibreTileCacheManager").d("No offline regions to clean up") return } + Timber.tag("MapLibreTileCacheManager").d("Cleaning up ${offlineRegions.size} offline regions from old implementation") offlineRegions.forEach { region -> region.delete(object : OfflineRegion.OfflineRegionDeleteCallback { override fun onDelete() { @@ -458,9 +71,6 @@ class MapLibreTileCacheManager(private val context: Context) { } }) } - - // Clear hot areas - clearHotAreas() } override fun onError(error: String) { @@ -470,129 +80,20 @@ class MapLibreTileCacheManager(private val context: Context) { } /** - * Gets list of all hot areas + * Sets the maximum size for the ambient cache in bytes. + * Default is typically 50MB. Call this to increase or decrease the cache size. */ - fun getHotAreas(): List { - // Invalidate cache to ensure we get the latest from SharedPreferences - hotAreasCache = null - return loadHotAreas() - } - - // Preferences getters/setters - fun getUpdateIntervalMs(): Long = prefs.getLong(KEY_UPDATE_INTERVAL_MS, DEFAULT_UPDATE_INTERVAL_MS) - fun setUpdateIntervalMs(intervalMs: Long) = prefs.edit().putLong(KEY_UPDATE_INTERVAL_MS, intervalMs).apply() - - fun getMaxCacheSizeMb(): Long = prefs.getLong(KEY_MAX_CACHE_SIZE_MB, DEFAULT_MAX_CACHE_SIZE_MB) - fun setMaxCacheSizeMb(sizeMb: Long) = prefs.edit().putLong(KEY_MAX_CACHE_SIZE_MB, sizeMb).apply() - - fun getHotAreaThresholdSec(): Long = prefs.getLong(KEY_HOT_AREA_THRESHOLD_SEC, DEFAULT_HOT_AREA_THRESHOLD_SEC) - fun setHotAreaThresholdSec(thresholdSec: Long) = prefs.edit().putLong(KEY_HOT_AREA_THRESHOLD_SEC, thresholdSec).apply() - - fun getMinZoom(): Double = prefs.getFloat(KEY_MIN_ZOOM, DEFAULT_MIN_ZOOM.toFloat()).toDouble() - fun setMinZoom(zoom: Double) = prefs.edit().putFloat(KEY_MIN_ZOOM, zoom.toFloat()).apply() - - fun getMaxZoom(): Double = prefs.getFloat(KEY_MAX_ZOOM, DEFAULT_MAX_ZOOM.toFloat()).toDouble() - fun setMaxZoom(zoom: Double) = prefs.edit().putFloat(KEY_MAX_ZOOM, zoom.toFloat()).apply() - - // Hot area persistence - private fun loadHotAreas(): MutableList { - if (hotAreasCache != null) { - return hotAreasCache!! - } - val json = prefs.getString(KEY_HOT_AREAS, "[]") ?: "[]" - return try { - val jsonArray = gson.fromJson(json, Array::class.java) - val areas = jsonArray.map { json -> - HotArea( - id = json.id, - centerLat = json.centerLat, - centerLon = json.centerLon, - zoom = json.zoom, - bounds = LatLngBounds.from( - json.boundsNorth, - json.boundsEast, - json.boundsSouth, - json.boundsWest, - ), - lastVisit = json.lastVisit, - visitCount = json.visitCount, - totalTimeSec = json.totalTimeSec, - offlineRegionId = json.offlineRegionId, - ) - }.toMutableList() - hotAreasCache = areas - Timber.tag("MapLibreTileCacheManager").d("Loaded ${areas.size} hot areas from SharedPreferences") - areas - } catch (e: Exception) { - Timber.tag("MapLibreTileCacheManager").e(e, "Failed to load hot areas") - mutableListOf().also { hotAreasCache = it } - } - } - - // In-memory cache of hot areas to avoid reloading from SharedPreferences every time - private var hotAreasCache: MutableList? = null - - private fun saveHotAreas() { - val areas = hotAreasCache ?: loadHotAreas() - val jsonArray = areas.map { area -> - HotAreaJson( - id = area.id, - centerLat = area.centerLat, - centerLon = area.centerLon, - zoom = area.zoom, - boundsNorth = area.bounds.latitudeNorth, - boundsSouth = area.bounds.latitudeSouth, - boundsEast = area.bounds.longitudeEast, - boundsWest = area.bounds.longitudeWest, - lastVisit = area.lastVisit, - visitCount = area.visitCount, - totalTimeSec = area.totalTimeSec, - offlineRegionId = area.offlineRegionId, - ) - } - val json = gson.toJson(jsonArray) - prefs.edit().putString(KEY_HOT_AREAS, json).apply() - Timber.tag("MapLibreTileCacheManager").d("Saved ${areas.size} hot areas to SharedPreferences") - } - - private fun addHotArea(area: HotArea) { - val areas = hotAreasCache ?: loadHotAreas() - areas.add(area) - hotAreasCache = areas - saveHotAreas() - } - - private fun clearHotAreas() { - hotAreasCache = null - prefs.edit().remove(KEY_HOT_AREAS).apply() - } + fun setMaximumAmbientCacheSize(sizeBytes: Long, callback: OfflineManager.FileSourceCallback? = null) { + Timber.tag("MapLibreTileCacheManager").d("Setting maximum ambient cache size to $sizeBytes bytes") + offlineManager.setMaximumAmbientCacheSize(sizeBytes, callback ?: object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully set maximum ambient cache size") + } - private fun encodeHotAreaMetadata(area: HotArea): ByteArray { - val jsonObject = JsonObject() - jsonObject.addProperty(JSON_FIELD_REGION_NAME, "Hot Area: ${area.id.take(8)}") - jsonObject.addProperty(JSON_FIELD_REGION_ID, area.id) - jsonObject.addProperty(JSON_FIELD_CENTER_LAT, area.centerLat) - jsonObject.addProperty(JSON_FIELD_CENTER_LON, area.centerLon) - jsonObject.addProperty(JSON_FIELD_ZOOM, area.zoom) - jsonObject.addProperty(JSON_FIELD_LAST_VISIT, area.lastVisit) - jsonObject.addProperty(JSON_FIELD_VISIT_COUNT, area.visitCount) - jsonObject.addProperty(JSON_FIELD_TOTAL_TIME_SEC, area.totalTimeSec) - return jsonObject.toString().toByteArray(charset(JSON_CHARSET)) + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to set maximum ambient cache size: $message") + } + }) } - - private data class HotAreaJson( - val id: String, - val centerLat: Double, - val centerLon: Double, - val zoom: Double, - val boundsNorth: Double, - val boundsSouth: Double, - val boundsEast: Double, - val boundsWest: Double, - val lastVisit: Long, - val visitCount: Int, - val totalTimeSec: Long, - val offlineRegionId: Long?, - ) } From dec658415fa847f16e0d766935d7ec82b0be649d Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Thu, 20 Nov 2025 11:34:39 -0800 Subject: [PATCH 38/62] clean up legacy ui bits --- .../feature/map/maplibre/ui/MapLibrePOC.kt | 72 +++++++------------ 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 36478a1e4c..5cdc7bc236 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -41,14 +41,12 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationDisabled import androidx.compose.material.icons.filled.MyLocation -import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Remove import androidx.compose.material.icons.outlined.Storage @@ -1187,58 +1185,42 @@ fun MapLibrePOC( }, ) - // Zoom controls (bottom right) - Google Maps style - Column( + // Zoom controls (bottom right) - wrapped in Surface for consistent background + Surface( modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 16.dp, end = 16.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = 3.dp, ) { - // Zoom in button - MapButton( - onClick = { - mapRef?.let { map -> - map.animateCamera(CameraUpdateFactory.zoomIn()) - Timber.tag("MapLibrePOC").d("Zoom in") - } - }, - icon = Icons.Outlined.Add, - contentDescription = "Zoom in", - ) - - Spacer(modifier = Modifier.size(4.dp)) - - // Zoom out button - MapButton( - onClick = { - mapRef?.let { map -> - map.animateCamera(CameraUpdateFactory.zoomOut()) - Timber.tag("MapLibrePOC").d("Zoom out") - } - }, - icon = Icons.Outlined.Remove, - contentDescription = "Zoom out", - ) - - Spacer(modifier = Modifier.size(8.dp)) - - // Compass reset button - resets map orientation to north (original top-right compass) - val currentBearing = mapRef?.cameraPosition?.bearing ?: 0.0 - if (kotlin.math.abs(currentBearing) > 0.1) { // Only show if map is rotated + Column( + modifier = Modifier.padding(4.dp), + ) { + // Zoom in button MapButton( onClick = { mapRef?.let { map -> - map.animateCamera( - CameraUpdateFactory.newCameraPosition( - org.maplibre.android.camera.CameraPosition.Builder(map.cameraPosition) - .bearing(0.0) - .build() - ) - ) - Timber.tag("MapLibrePOC").d("Compass reset to north") + map.animateCamera(CameraUpdateFactory.zoomIn()) + Timber.tag("MapLibrePOC").d("Zoom in") + } + }, + icon = Icons.Outlined.Add, + contentDescription = "Zoom in", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + // Zoom out button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomOut()) + Timber.tag("MapLibrePOC").d("Zoom out") } }, - icon = Icons.Outlined.Navigation, - contentDescription = "Reset to north", + icon = Icons.Outlined.Remove, + contentDescription = "Zoom out", ) } } From 66102db74f0b444c6f7ce1bfd09dc9678a1438ec Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Thu, 20 Nov 2025 17:55:37 -0800 Subject: [PATCH 39/62] logging, center on click etc --- .../org/meshtastic/feature/map/MapView.kt | 5 + .../feature/map/maplibre/ui/MapLibrePOC.kt | 129 +++++++++++++++++- .../feature/map/node/NodeMapScreen.kt | 68 ++++----- 3 files changed, 165 insertions(+), 37 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 5db1a54fbc..b521eb8099 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -792,6 +792,11 @@ fun MapView( ) { isMapLibre -> if (isMapLibre) { // MapLibre implementation + timber.log.Timber.tag("MapView").d( + "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", + focusedNodeNum ?: "null", + nodeTracks?.size ?: 0 + ) org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( mapViewModel = mapViewModel, onNavigateToNodeDetails = navigateToNodeDetails, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 5cdc7bc236..683a49e0a2 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -171,6 +171,13 @@ fun MapLibrePOC( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var selectedNodeNum by remember { mutableStateOf(null) } + + // Log track parameters on entry + Timber.tag("MapLibrePOC").d( + "MapLibrePOC called - focusedNodeNum=%s, nodeTracks count=%d", + focusedNodeNum ?: "null", + nodeTracks?.size ?: 0 + ) val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() var isLocationTrackingEnabled by remember { mutableStateOf(false) } @@ -418,39 +425,99 @@ fun MapLibrePOC( ensureSourcesAndLayers(style) // Setup track sources and layers if rendering node tracks + Timber.tag("MapLibrePOC").d( + "Track check: nodeTracks=%s (%d positions), focusedNodeNum=%s", + if (nodeTracks != null) "NOT NULL" else "NULL", + nodeTracks?.size ?: 0, + focusedNodeNum ?: "NULL" + ) + if (nodeTracks != null && focusedNodeNum != null) { + Timber.tag("MapLibrePOC").d( + "Loading tracks for node %d, total positions: %d", + focusedNodeNum, + nodeTracks.size + ) + // Get the focused node to use its color val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } + Timber.tag("MapLibrePOC").d( + "Focused node found: %s (searching in %d nodes)", + if (focusedNode != null) "YES" else "NO", + nodes.size + ) + val trackColor = focusedNode?.let { String.format("#%06X", 0xFFFFFF and it.colors.second) } ?: "#FF5722" // Default orange color + Timber.tag("MapLibrePOC").d("Track color: %s", trackColor) + ensureTrackSourcesAndLayers(style, trackColor) + Timber.tag("MapLibrePOC").d("Track sources and layers ensured") // Filter tracks by time using lastHeardTrackFilter + val currentTimeSeconds = System.currentTimeMillis() / 1000 + val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds + Timber.tag("MapLibrePOC").d( + "Filtering tracks - filter: %s (seconds: %d), current time: %d", + mapFilterState.lastHeardTrackFilter, + filterSeconds, + currentTimeSeconds + ) + val filteredTracks = nodeTracks.filter { - mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || - it.time > System.currentTimeMillis() / 1000 - mapFilterState.lastHeardTrackFilter.seconds + val keep = mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + if (!keep) { + Timber.tag("MapLibrePOC").v( + "Filtering out position at time %d (age: %d seconds)", + it.time, + currentTimeSeconds - it.time + ) + } + keep }.sortedBy { it.time } + Timber.tag("MapLibrePOC").d( + "Tracks filtered: %d positions remain (from %d total)", + filteredTracks.size, + nodeTracks.size + ) + // Update track line if (filteredTracks.size >= 2) { + Timber.tag("MapLibrePOC").d("Creating line feature from %d points", filteredTracks.size) positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> + Timber.tag("MapLibrePOC").d("Setting line feature on source") (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(lineFeature) + Timber.tag("MapLibrePOC").d("Track line set successfully") + } ?: run { + Timber.tag("MapLibrePOC").w("Failed to create line feature - positionsToLineStringFeature returned null") } + } else { + Timber.tag("MapLibrePOC").w("Not enough points for track line (need >=2, have %d)", filteredTracks.size) } // Update track points if (filteredTracks.isNotEmpty()) { + Timber.tag("MapLibrePOC").d("Creating point features from %d points", filteredTracks.size) val pointsFC = positionsToPointFeatures(filteredTracks) (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource) ?.setGeoJson(pointsFC) + Timber.tag("MapLibrePOC").d("Track points set successfully") + } else { + Timber.tag("MapLibrePOC").w("No filtered tracks to display as points") } - Timber.tag("MapLibrePOC") - .d("Track data set: %d positions", filteredTracks.size) + Timber.tag("MapLibrePOC").i( + "✓ Track rendering complete: %d positions displayed for node %d", + filteredTracks.size, + focusedNodeNum + ) } else { + Timber.tag("MapLibrePOC").d("No tracks to display - removing track layers") // Remove track layers if no tracks to display removeTrackSourcesAndLayers(style) } @@ -622,6 +689,24 @@ fun MapLibrePOC( } ?: emptyList() val members = nodes.filter { nums.contains(it.num) } if (members.isNotEmpty()) { + // Center camera on cluster with zoom in + val geom = f.geometry() + if (geom is Point) { + val clusterLat = geom.latitude() + val clusterLon = geom.longitude() + val clusterLatLng = LatLng(clusterLat, clusterLon) + map.animateCamera( + CameraUpdateFactory.newLatLngZoom(clusterLatLng, map.cameraPosition.zoom + 1.5), + 300 + ) + Timber.tag("MapLibrePOC").d( + "Centering on cluster at (%.5f, %.5f) with %d members", + clusterLat, + clusterLon, + members.size + ) + } + // Center the radial overlay on the actual cluster point (not the raw click) val clusterCenter = (f.geometry() as? Point)?.let { p -> @@ -649,6 +734,24 @@ fun MapLibrePOC( "node" -> { val num = it.getNumberProperty("num")?.toInt() ?: -1 selectedNodeNum = num + + // Center camera on selected node + val geom = it.geometry() + if (geom is Point) { + val nodeLat = geom.latitude() + val nodeLon = geom.longitude() + val nodeLatLng = LatLng(nodeLat, nodeLon) + map.animateCamera( + CameraUpdateFactory.newLatLng(nodeLatLng), + 300 + ) + Timber.tag("MapLibrePOC").d( + "Centering on node %d at (%.5f, %.5f)", + num, + nodeLat, + nodeLon + ) + } } "waypoint" -> { val id = it.getNumberProperty("id")?.toInt() ?: -1 @@ -656,6 +759,24 @@ fun MapLibrePOC( waypoints.values .find { pkt -> pkt.data.waypoint?.id == id } ?.let { pkt -> editingWaypoint = pkt.data.waypoint } + + // Center camera on waypoint + val geom = it.geometry() + if (geom is Point) { + val wpLat = geom.latitude() + val wpLon = geom.longitude() + val wpLatLng = LatLng(wpLat, wpLon) + map.animateCamera( + CameraUpdateFactory.newLatLng(wpLatLng), + 300 + ) + Timber.tag("MapLibrePOC").d( + "Centering on waypoint %d at (%.5f, %.5f)", + id, + wpLat, + wpLon + ) + } } else -> {} } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index c79e813e78..f0c41dd441 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -17,47 +17,49 @@ package org.meshtastic.feature.map.node -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle -import org.meshtastic.feature.map.addCopyright -import org.meshtastic.feature.map.addPolyline -import org.meshtastic.feature.map.addPositionMarkers -import org.meshtastic.feature.map.addScaleBarOverlay -import org.meshtastic.feature.map.rememberMapViewWithLifecycle -import org.osmdroid.util.BoundingBox -import org.osmdroid.util.GeoPoint - -private const val DEG_D = 1e-7 +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.feature.map.MapView +import timber.log.Timber @Composable fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { - val density = LocalDensity.current - val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() - val geoPoints = positionLogs.map { GeoPoint(it.latitudeI * DEG_D, it.longitudeI * DEG_D) } - val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) } - val mapView = - rememberMapViewWithLifecycle( - applicationId = nodeMapViewModel.applicationId, - box = cameraView, - tileSource = nodeMapViewModel.tileSource, - ) + val node by nodeMapViewModel.node.collectAsStateWithLifecycle() + val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() + val destNum = node?.num - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { mapView }, - update = { map -> - map.overlays.clear() - map.addCopyright() - map.addScaleBarOverlay(density) + Timber.tag("NodeMapScreen").d( + "NodeMapScreen rendering - destNum=%s, positions count=%d", + destNum ?: "null", + positions.size + ) - map.addPolyline(density, geoPoints) {} - map.addPositionMarkers(positionLogs) {} + Scaffold( + topBar = { + MainAppBar( + title = node?.user?.longName ?: "", + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = {}, + onClickChip = {}, + ) }, - ) + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + Timber.tag("NodeMapScreen").d( + "Calling MapView with focusedNodeNum=%s, nodeTracks count=%d", + destNum ?: "null", + positions.size + ) + MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {}) + } + } } From c4590800caa708b404328c9958ee95ef45432a16 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Thu, 20 Nov 2025 18:44:06 -0800 Subject: [PATCH 40/62] center on click, refactor poc main --- .../map/maplibre/ui/MapLibreBottomSheets.kt | 120 +++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 798 ++++++------------ .../feature/map/maplibre/ui/MapLibreUI.kt | 468 ++++++++++ 3 files changed, 827 insertions(+), 559 deletions(-) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt new file mode 100644 index 0000000000..86cc5f8f4a --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip + +/** + * Bottom sheet showing details and actions for a selected node + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NodeDetailsBottomSheet( + node: Node, + lastHeardAgo: String, + coords: String, + distanceKm: String?, + onViewFullNode: () -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp)) { + NodeChip(node = node) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text( + text = longName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) + Text(text = "Coordinates: $coords") + if (distanceKm != null) { + Text(text = "Distance: $distanceKm km") + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + Button(onClick = onViewFullNode) { + Text("View full node") + } + } + } + } +} + +/** + * Bottom sheet showing a list of nodes in a large cluster + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClusterListBottomSheet( + members: List, + onNodeClicked: (Node) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState, +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) + LazyColumn { + items(members) { node -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { + onNodeClicked(node) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + NodeChip( + node = node, + onClick = { onNodeClicked(node) }, + ) + Spacer(modifier = Modifier.width(12.dp)) + val longName = node.user.longName + if (!longName.isNullOrBlank()) { + Text(text = longName, style = MaterialTheme.typography.bodyLarge) + } + } + } + } + } + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 683a49e0a2..c3ee83c4e0 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -689,15 +689,35 @@ fun MapLibrePOC( } ?: emptyList() val members = nodes.filter { nums.contains(it.num) } if (members.isNotEmpty()) { - // Center camera on cluster with zoom in + // Center camera on cluster (without zoom) to keep cluster intact val geom = f.geometry() if (geom is Point) { val clusterLat = geom.latitude() val clusterLon = geom.longitude() val clusterLatLng = LatLng(clusterLat, clusterLon) map.animateCamera( - CameraUpdateFactory.newLatLngZoom(clusterLatLng, map.cameraPosition.zoom + 1.5), - 300 + CameraUpdateFactory.newLatLng(clusterLatLng), + 300, + object : MapLibreMap.CancelableCallback { + override fun onFinish() { + // Calculate screen position AFTER camera animation completes + val clusterCenter = + map.projection.toScreenLocation(LatLng(clusterLat, clusterLon)) + + // Set overlay state after camera animation completes + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + clusterListMembers = members + } else { + // Show radial overlay for small clusters + expandedCluster = + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + } + } + override fun onCancel() { + // Animation was cancelled, don't show overlay + } + } ) Timber.tag("MapLibrePOC").d( "Centering on cluster at (%.5f, %.5f) with %d members", @@ -705,20 +725,18 @@ fun MapLibrePOC( clusterLon, members.size ) - } - - // Center the radial overlay on the actual cluster point (not the raw click) - val clusterCenter = - (f.geometry() as? Point)?.let { p -> - map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) - } ?: screenPoint - if (pointCount > CLUSTER_RADIAL_MAX) { - // Show list for large clusters - clusterListMembers = members } else { - // Show radial overlay for small clusters - expandedCluster = - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + // No geometry, show overlay immediately using current screen position + val clusterCenter = + (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + if (pointCount > CLUSTER_RADIAL_MAX) { + clusterListMembers = members + } else { + expandedCluster = + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + } } } return@addOnMapClickListener true @@ -959,502 +977,205 @@ fun MapLibrePOC( ) // Role legend (based on roles present in current nodes) - val rolesPresent = remember(nodes) { nodes.map { it.user.role }.toSet() } - if (showLegend && rolesPresent.isNotEmpty()) { - Surface( + if (showLegend) { + RoleLegend( + nodes = nodes, modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), - tonalElevation = 4.dp, - shadowElevation = 4.dp, - ) { - Column(modifier = Modifier.padding(8.dp)) { - rolesPresent.take(6).forEach { role -> - val fakeNode = - Node( - num = 0, - user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), - ) - Row( - modifier = Modifier.padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = CircleShape, - color = roleColor(fakeNode), - modifier = Modifier.size(12.dp), - ) {} - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = role.name.lowercase().replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } + ) } // Map controls: horizontal toolbar at the top (matches Google Maps style) - var mapFilterExpanded by remember { mutableStateOf(false) } - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - HorizontalFloatingToolbar( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), // Top padding to avoid exit button - expanded = true, - content = { - // Consolidated GPS button (cycles through: Off -> On -> On with bearing) - if (hasLocationPermission) { - val gpsIcon = when { - isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation - isLocationTrackingEnabled -> Icons.Filled.MyLocation - else -> Icons.Outlined.MyLocation - } - MapButton( - onClick = { - when { - !isLocationTrackingEnabled -> { - // Off -> On - isLocationTrackingEnabled = true - followBearing = false - Timber.tag("MapLibrePOC").d("GPS tracking enabled") - } - isLocationTrackingEnabled && !followBearing -> { - // On -> On with bearing - followBearing = true - Timber.tag("MapLibrePOC").d("GPS tracking with bearing enabled") - } - else -> { - // On with bearing -> Off - isLocationTrackingEnabled = false - followBearing = false - Timber.tag("MapLibrePOC").d("GPS tracking disabled") - } - } - }, - icon = gpsIcon, - contentDescription = null, - iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, - ) - } - Box { - MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) - DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { - DropdownMenuItem( - text = { Text("Only favorites") }, - onClick = { - mapViewModel.toggleOnlyFavorites() - mapFilterExpanded = false - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.onlyFavorites, - onCheckedChange = { - mapViewModel.toggleOnlyFavorites() - // Refresh both sources when filters change - mapRef?.style?.let { st -> - val filtered = - applyFilters( - nodes, - mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), - enabledRoles, - ) - (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), - ) - (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), - ) - } - }, - ) - }, - ) - DropdownMenuItem( - text = { Text("Show precision circle") }, - onClick = { - mapViewModel.toggleShowPrecisionCircleOnMap() - mapFilterExpanded = false - }, - trailingIcon = { - Checkbox( - checked = mapFilterState.showPrecisionCircle, - onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() }, - ) - }, + MapToolbar( + hasLocationPermission = hasLocationPermission, + isLocationTrackingEnabled = isLocationTrackingEnabled, + followBearing = followBearing, + onLocationTrackingChanged = { enabled, follow -> + isLocationTrackingEnabled = enabled + followBearing = follow + }, + mapFilterState = mapFilterState, + onToggleOnlyFavorites = { + mapViewModel.toggleOnlyFavorites() + // Refresh both sources when filters change + mapRef?.style?.let { st -> + val filtered = + applyFilters( + nodes, + mapFilterState.copy(onlyFavorites = !mapFilterState.onlyFavorites), + enabledRoles, + ) + (st.getSource(NODES_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), ) - androidx.compose.material3.Divider() - Text( - text = "Roles", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + (st.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + nodesToFeatureCollectionJsonWithSelection(filtered, emptySet()), ) - val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } - roles.forEach { role -> - val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) - DropdownMenuItem( - text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, - onClick = { - enabledRoles = - if (enabledRoles.isEmpty()) { - setOf(role) - } else if (enabledRoles.contains(role)) { - enabledRoles - role - } else { - enabledRoles + role - } - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - trailingIcon = { - Checkbox( - checked = checked, - onCheckedChange = { - enabledRoles = - if (enabledRoles.isEmpty()) { - setOf(role) - } else if (enabledRoles.contains(role)) { - enabledRoles - role - } else { - enabledRoles + role - } - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - ) - }, - ) + } + }, + onToggleShowPrecisionCircle = { mapViewModel.toggleShowPrecisionCircleOnMap() }, + nodes = nodes, + enabledRoles = enabledRoles, + onRoleToggled = { role -> + enabledRoles = + if (enabledRoles.isEmpty()) { + setOf(role) + } else if (enabledRoles.contains(role)) { + enabledRoles - role + } else { + enabledRoles + role } - androidx.compose.material3.Divider() - DropdownMenuItem( - text = { Text("Enable clustering") }, - onClick = { - clusteringEnabled = !clusteringEnabled - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, - trailingIcon = { - Checkbox( - checked = clusteringEnabled, - onCheckedChange = { - clusteringEnabled = it - mapRef?.style?.let { st -> - mapRef?.let { map -> - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - } - } - }, + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, ) - }, - ) - } - } - // Map style selector (matches Google Maps - Map icon) - Box { - MapButton( - onClick = { mapTypeMenuExpanded = true }, - icon = Icons.Outlined.Map, - contentDescription = null, - ) - DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { - Text( - text = "Map Style", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - baseStyles.forEachIndexed { index, style -> - DropdownMenuItem( - text = { Text(style.label) }, - onClick = { - baseStyleIndex = index - usingCustomTiles = false - mapTypeMenuExpanded = false - val next = baseStyles[baseStyleIndex % baseStyles.size] - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) - map.setStyle(buildMeshtasticStyle(next)) { st -> - Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) - val density = context.resources.displayMetrics.density - clustersShown = - reinitializeStyleAfterSwitch( - context, - map, - st, - waypoints, - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - clusteringEnabled, - clustersShown, - density, - ) - } - } - }, - trailingIcon = { - if (index == baseStyleIndex && !usingCustomTiles) { - Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") - } - }, - ) } - androidx.compose.material3.HorizontalDivider() - DropdownMenuItem( - text = { - Text( - if (customTileUrl.isEmpty()) { - "Custom Tile URL..." - } else { - "Custom: ${customTileUrl.take(30)}..." - }, + } + }, + clusteringEnabled = clusteringEnabled, + onClusteringToggled = { enabled -> + clusteringEnabled = enabled + mapRef?.style?.let { st -> + mapRef?.let { map -> + clustersShown = + setClusterVisibilityHysteresis( + map, + st, + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, ) - }, - onClick = { - mapTypeMenuExpanded = false - customTileUrlInput = customTileUrl - showCustomTileDialog = true - }, - trailingIcon = { - if (usingCustomTiles && customTileUrl.isNotEmpty()) { - Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") - } - }, - ) + } } + }, + baseStyles = baseStyles, + baseStyleIndex = baseStyleIndex, + usingCustomTiles = usingCustomTiles, + onStyleSelected = { index -> + baseStyleIndex = index + usingCustomTiles = false + val next = baseStyles[baseStyleIndex % baseStyles.size] + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.setStyle(buildMeshtasticStyle(next)) { st -> + Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) + } } - - // Map layers button (matches Google Maps - Layers icon) - MapButton( - onClick = { showLayersBottomSheet = true }, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) - - // Cache management button - MapButton( - onClick = { showCacheBottomSheet = true }, - icon = Icons.Outlined.Storage, - contentDescription = null, - ) - - // Legend button - MapButton(onClick = { showLegend = !showLegend }, icon = Icons.Outlined.Info, contentDescription = null) }, + customTileUrl = customTileUrl, + onCustomTileClicked = { + customTileUrlInput = customTileUrl + showCustomTileDialog = true + }, + onShowLayersClicked = { showLayersBottomSheet = true }, + onShowCacheClicked = { showCacheBottomSheet = true }, + onShowLegendToggled = { showLegend = !showLegend }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), ) - // Zoom controls (bottom right) - wrapped in Surface for consistent background - Surface( + // Zoom controls (bottom right) + ZoomControls( + mapRef = mapRef, modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 16.dp, end = 16.dp), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainer, - shadowElevation = 3.dp, - ) { - Column( - modifier = Modifier.padding(4.dp), - ) { - // Zoom in button - MapButton( - onClick = { - mapRef?.let { map -> - map.animateCamera(CameraUpdateFactory.zoomIn()) - Timber.tag("MapLibrePOC").d("Zoom in") - } - }, - icon = Icons.Outlined.Add, - contentDescription = "Zoom in", - ) - - Spacer(modifier = Modifier.size(4.dp)) - - // Zoom out button - MapButton( - onClick = { - mapRef?.let { map -> - map.animateCamera(CameraUpdateFactory.zoomOut()) - Timber.tag("MapLibrePOC").d("Zoom out") - } - }, - icon = Icons.Outlined.Remove, - contentDescription = "Zoom out", - ) - } - } + ) // Custom tile URL dialog if (showCustomTileDialog) { - AlertDialog( - onDismissRequest = { showCustomTileDialog = false }, - title = { Text("Custom Tile URL") }, - text = { - Column { - Text( - text = "Enter tile URL with {z}/{x}/{y} placeholders:", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - Text( - text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 16.dp), - ) - OutlinedTextField( - value = customTileUrlInput, - onValueChange = { customTileUrlInput = it }, - label = { Text("Tile URL") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { - customTileUrl = customTileUrlInput.trim() - if (customTileUrl.isNotEmpty()) { - usingCustomTiles = true - // Apply custom tiles (use first base style as template but we'll override the raster - // source) - mapRef?.let { map -> - Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) - map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> - Timber.tag("MapLibrePOC").d("Custom tiles applied") - val density = context.resources.displayMetrics.density - clustersShown = - reinitializeStyleAfterSwitch( - context, - map, - st, - waypoints, - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - clusteringEnabled, - clustersShown, - density, - ) - } - } + CustomTileDialog( + customTileUrlInput = customTileUrlInput, + onCustomTileUrlInputChanged = { customTileUrlInput = it }, + onApply = { + customTileUrl = customTileUrlInput.trim() + if (customTileUrl.isNotEmpty()) { + usingCustomTiles = true + // Apply custom tiles (use first base style as template but we'll override the raster source) + mapRef?.let { map -> + Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) + map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> + Timber.tag("MapLibrePOC").d("Custom tiles applied") + val density = context.resources.displayMetrics.density + clustersShown = + reinitializeStyleAfterSwitch( + context, + map, + st, + waypoints, + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + clusteringEnabled, + clustersShown, + density, + ) } - showCustomTileDialog = false - }, - ) { - Text("Apply") + } } + showCustomTileDialog = false }, - dismissButton = { TextButton(onClick = { showCustomTileDialog = false }) { Text("Cancel") } }, + onDismiss = { showCustomTileDialog = false }, ) } // Expanded cluster radial overlay expandedCluster?.let { ec -> val d = context.resources.displayMetrics.density - val centerX = (ec.centerPx.x / d).dp - val centerY = (ec.centerPx.y / d).dp - val radiusPx = 72f * d - val itemSize = 40.dp - val n = ec.members.size.coerceAtLeast(1) - ec.members.forEachIndexed { idx, node -> - val theta = (2.0 * Math.PI * idx / n) - val x = (ec.centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() - val y = (ec.centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() - val xDp = (x / d).dp - val yDp = (y / d).dp - val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) - val itemHeight = 36.dp - val itemWidth = (40 + label.length * 10).dp - Surface( - modifier = - Modifier.align(Alignment.TopStart) - .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) - .size(width = itemWidth, height = itemHeight) - .clickable { - selectedNodeNum = node.num - expandedCluster = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - shape = CircleShape, - color = roleColor(node), - shadowElevation = 6.dp, - ) { - Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } - } - } + ClusterRadialOverlay( + centerPx = ec.centerPx, + members = ec.members, + density = d, + onNodeClicked = { node -> + selectedNodeNum = node.num + expandedCluster = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) + } + }, + modifier = Modifier.align(Alignment.TopStart), + ) } // Layer management bottom sheet @@ -1491,88 +1212,47 @@ fun MapLibrePOC( // Bottom sheet with node details and actions val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val selectedNode = selectedNodeNum?.let { num -> nodes.firstOrNull { it.num == num } } + // Cluster list bottom sheet (for large clusters) clusterListMembers?.let { members -> - ModalBottomSheet( - onDismissRequest = { clusterListMembers = null }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - ) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) - LazyColumn { - items(members) { node -> - Row( - modifier = - Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - verticalAlignment = Alignment.CenterVertically, - ) { - NodeChip( - node = node, - onClick = { - selectedNodeNum = node.num - clusterListMembers = null - node.validPosition?.let { p -> - mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), - ) - } - }, - ) - Spacer(modifier = Modifier.width(12.dp)) - val longName = node.user.longName - if (!longName.isNullOrBlank()) { - Text(text = longName, style = MaterialTheme.typography.bodyLarge) - } - } - } + ClusterListBottomSheet( + members = members, + onNodeClicked = { node -> + selectedNodeNum = node.num + clusterListMembers = null + node.validPosition?.let { p -> + mapRef?.animateCamera( + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + 15.0, + ), + ) } - } - } + }, + onDismiss = { clusterListMembers = null }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) } + + // Node details bottom sheet if (selectedNode != null) { - ModalBottomSheet(onDismissRequest = { selectedNodeNum = null }, sheetState = sheetState) { - Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 16.dp, end = 16.dp, bottom = 16.dp)) { - NodeChip(node = selectedNode) - val longName = selectedNode.user.longName - if (!longName.isNullOrBlank()) { - Text( - text = longName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } - val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) - val coords = selectedNode.gpsString() - Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) - Text(text = "Coordinates: $coords") - val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } - if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") - Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { - Button( - onClick = { - onNavigateToNodeDetails(selectedNode.num) - selectedNodeNum = null - }, - ) { - Text("View full node") - } - } - } - } + val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) + val coords = selectedNode.gpsString() + val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } + val distanceKm = km?.let { "%.1f".format(it) } + + NodeDetailsBottomSheet( + node = selectedNode, + lastHeardAgo = lastHeardAgo, + coords = coords, + distanceKm = distanceKm, + onViewFullNode = { + onNavigateToNodeDetails(selectedNode.num) + selectedNodeNum = null + }, + onDismiss = { selectedNodeNum = null }, + sheetState = sheetState, + ) } // Waypoint editing dialog editingWaypoint?.let { waypointToEdit -> diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt new file mode 100644 index 0000000000..e826ff9e41 --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt @@ -0,0 +1,468 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import android.graphics.PointF +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Layers +import androidx.compose.material.icons.outlined.Map +import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalFloatingToolbar +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.sources.GeoJsonSource +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState +import org.meshtastic.feature.map.component.MapButton +import org.meshtastic.feature.map.maplibre.BaseMapStyle +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson +import org.meshtastic.feature.map.maplibre.utils.protoShortName +import org.meshtastic.feature.map.maplibre.utils.roleColor +import org.meshtastic.feature.map.maplibre.utils.shortNameFallback +import org.meshtastic.proto.ConfigProtos +import timber.log.Timber + +/** + * Role legend overlay showing colors for different node roles + */ +@Composable +fun RoleLegend( + nodes: List, + modifier: Modifier = Modifier, +) { + val rolesPresent = nodes.map { it.user.role }.toSet() + + if (rolesPresent.isNotEmpty()) { + Surface( + modifier = modifier, + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + rolesPresent.take(6).forEach { role -> + val fakeNode = + Node( + num = 0, + user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), + ) + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = roleColor(fakeNode), + modifier = Modifier.size(12.dp), + ) {} + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = role.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } +} + +/** + * Map toolbar with GPS, filter, map style, and layers controls + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MapToolbar( + hasLocationPermission: Boolean, + isLocationTrackingEnabled: Boolean, + followBearing: Boolean, + onLocationTrackingChanged: (enabled: Boolean, follow: Boolean) -> Unit, + mapFilterState: MapFilterState, + onToggleOnlyFavorites: () -> Unit, + onToggleShowPrecisionCircle: () -> Unit, + nodes: List, + enabledRoles: Set, + onRoleToggled: (ConfigProtos.Config.DeviceConfig.Role) -> Unit, + clusteringEnabled: Boolean, + onClusteringToggled: (Boolean) -> Unit, + baseStyles: List, + baseStyleIndex: Int, + usingCustomTiles: Boolean, + onStyleSelected: (Int) -> Unit, + customTileUrl: String, + onCustomTileClicked: () -> Unit, + onShowLayersClicked: () -> Unit, + onShowCacheClicked: () -> Unit, + onShowLegendToggled: () -> Unit, + modifier: Modifier = Modifier, +) { + var mapFilterExpanded by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + var mapTypeMenuExpanded by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + + HorizontalFloatingToolbar( + modifier = modifier, + expanded = true, + content = { + // Consolidated GPS button (cycles through: Off -> On -> On with bearing) + if (hasLocationPermission) { + val gpsIcon = when { + isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation + isLocationTrackingEnabled -> Icons.Filled.MyLocation + else -> Icons.Outlined.MyLocation + } + MapButton( + onClick = { + when { + !isLocationTrackingEnabled -> { + // Off -> On + onLocationTrackingChanged(true, false) + Timber.tag("MapLibrePOC").d("GPS tracking enabled") + } + isLocationTrackingEnabled && !followBearing -> { + // On -> On with bearing + onLocationTrackingChanged(true, true) + Timber.tag("MapLibrePOC").d("GPS tracking with bearing enabled") + } + else -> { + // On with bearing -> Off + onLocationTrackingChanged(false, false) + Timber.tag("MapLibrePOC").d("GPS tracking disabled") + } + } + }, + icon = gpsIcon, + contentDescription = null, + iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, + ) + } + + // Filter menu + Box { + MapButton(onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, contentDescription = null) + DropdownMenu(expanded = mapFilterExpanded, onDismissRequest = { mapFilterExpanded = false }) { + DropdownMenuItem( + text = { Text("Only favorites") }, + onClick = { + onToggleOnlyFavorites() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.onlyFavorites, + onCheckedChange = { onToggleOnlyFavorites() }, + ) + }, + ) + DropdownMenuItem( + text = { Text("Show precision circle") }, + onClick = { + onToggleShowPrecisionCircle() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = mapFilterState.showPrecisionCircle, + onCheckedChange = { onToggleShowPrecisionCircle() }, + ) + }, + ) + androidx.compose.material3.HorizontalDivider() + Text( + text = "Roles", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + val roles = nodes.map { it.user.role }.distinct().sortedBy { it.name } + roles.forEach { role -> + val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) + DropdownMenuItem( + text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + onClick = { + onRoleToggled(role) + }, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { onRoleToggled(role) }, + ) + }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { Text("Enable clustering") }, + onClick = { + onClusteringToggled(!clusteringEnabled) + }, + trailingIcon = { + Checkbox( + checked = clusteringEnabled, + onCheckedChange = { onClusteringToggled(it) }, + ) + }, + ) + } + } + + // Map style selector + Box { + MapButton( + onClick = { mapTypeMenuExpanded = true }, + icon = Icons.Outlined.Map, + contentDescription = null, + ) + DropdownMenu(expanded = mapTypeMenuExpanded, onDismissRequest = { mapTypeMenuExpanded = false }) { + Text( + text = "Map Style", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + baseStyles.forEachIndexed { index, style -> + DropdownMenuItem( + text = { Text(style.label) }, + onClick = { + onStyleSelected(index) + mapTypeMenuExpanded = false + }, + trailingIcon = { + if (index == baseStyleIndex && !usingCustomTiles) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + androidx.compose.material3.HorizontalDivider() + DropdownMenuItem( + text = { + Text( + if (customTileUrl.isEmpty()) { + "Custom Tile URL..." + } else { + "Custom: ${customTileUrl.take(30)}..." + }, + ) + }, + onClick = { + mapTypeMenuExpanded = false + onCustomTileClicked() + }, + trailingIcon = { + if (usingCustomTiles && customTileUrl.isNotEmpty()) { + Icon(imageVector = Icons.Outlined.Check, contentDescription = "Selected") + } + }, + ) + } + } + + // Map layers button + MapButton( + onClick = onShowLayersClicked, + icon = Icons.Outlined.Layers, + contentDescription = null, + ) + + // Cache management button + MapButton( + onClick = onShowCacheClicked, + icon = Icons.Outlined.Storage, + contentDescription = null, + ) + + // Legend button + MapButton(onClick = onShowLegendToggled, icon = Icons.Outlined.Info, contentDescription = null) + }, + ) +} + +/** + * Zoom controls (zoom in/out buttons) + */ +@Composable +fun ZoomControls( + mapRef: MapLibreMap?, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = 3.dp, + ) { + Column( + modifier = Modifier.padding(4.dp), + ) { + // Zoom in button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomIn()) + Timber.tag("MapLibrePOC").d("Zoom in") + } + }, + icon = Icons.Outlined.Add, + contentDescription = "Zoom in", + ) + + Spacer(modifier = Modifier.size(4.dp)) + + // Zoom out button + MapButton( + onClick = { + mapRef?.let { map -> + map.animateCamera(CameraUpdateFactory.zoomOut()) + Timber.tag("MapLibrePOC").d("Zoom out") + } + }, + icon = Icons.Outlined.Remove, + contentDescription = "Zoom out", + ) + } + } +} + +/** + * Custom tile URL configuration dialog + */ +@Composable +fun CustomTileDialog( + customTileUrlInput: String, + onCustomTileUrlInputChanged: (String) -> Unit, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Custom Tile URL") }, + text = { + Column { + Text( + text = "Enter tile URL with {z}/{x}/{y} placeholders:", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = "Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + OutlinedTextField( + value = customTileUrlInput, + onValueChange = onCustomTileUrlInputChanged, + label = { Text("Tile URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton(onClick = onApply) { + Text("Apply") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** + * Radial overlay showing cluster members in a circle + */ +@Composable +fun ClusterRadialOverlay( + centerPx: PointF, + members: List, + density: Float, + onNodeClicked: (Node) -> Unit, + modifier: Modifier = Modifier, +) { + val centerX = (centerPx.x / density).dp + val centerY = (centerPx.y / density).dp + val radiusPx = 72f * density + val itemSize = 40.dp + val n = members.size.coerceAtLeast(1) + + members.forEachIndexed { idx, node -> + val theta = (2.0 * Math.PI * idx / n) + val x = (centerPx.x + (radiusPx * kotlin.math.cos(theta))).toFloat() + val y = (centerPx.y + (radiusPx * kotlin.math.sin(theta))).toFloat() + val xDp = (x / density).dp + val yDp = (y / density).dp + val label = (protoShortName(node) ?: shortNameFallback(node)).take(4) + val itemHeight = 36.dp + val itemWidth = (40 + label.length * 10).dp + + Surface( + modifier = modifier + .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) + .size(width = itemWidth, height = itemHeight) + .clickable { onNodeClicked(node) }, + shape = CircleShape, + color = roleColor(node), + shadowElevation = 6.dp, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = label, color = Color.White, maxLines = 1) + } + } + } +} From e209eb5e4770d07a4eeccdb9db7a5d5efc5e8b85 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 13:32:01 -0800 Subject: [PATCH 41/62] initial pass @ heatmap --- .../feature/map/maplibre/MapLibreConstants.kt | 2 + .../maplibre/core/MapLibreDataTransformers.kt | 12 ++ .../map/maplibre/core/MapLibreLayerManager.kt | 98 ++++++++++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 133 ++++++++++---- .../feature/map/maplibre/ui/MapLibreUI.kt | 15 ++ wireless-install.sh | 169 ++++++++++++++++++ 6 files changed, 391 insertions(+), 38 deletions(-) create mode 100755 wireless-install.sh diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt index 30e3e9dd61..504331996b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -31,6 +31,7 @@ object MapLibreConstants { const val WAYPOINTS_SOURCE_ID = "meshtastic-waypoints-source" const val TRACK_LINE_SOURCE_ID = "meshtastic-track-line-source" const val TRACK_POINTS_SOURCE_ID = "meshtastic-track-points-source" + const val HEATMAP_SOURCE_ID = "meshtastic-heatmap-source" const val OSM_SOURCE_ID = "osm-tiles" // Layer IDs @@ -44,6 +45,7 @@ object MapLibreConstants { const val PRECISION_CIRCLE_LAYER_ID = "meshtastic-precision-circle-layer" const val TRACK_LINE_LAYER_ID = "meshtastic-track-line-layer" const val TRACK_POINTS_LAYER_ID = "meshtastic-track-points-layer" + const val HEATMAP_LAYER_ID = "meshtastic-heatmap-layer" const val OSM_LAYER_ID = "osm-layer" // Cluster configuration diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index f3cb832b39..eee966ff4d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -194,3 +194,15 @@ fun positionsToPointFeatures(positions: List): FeatureCollection { return FeatureCollection.fromFeatures(features) } + +/** Converts nodes to simple GeoJSON FeatureCollection for heatmap */ +fun nodesToHeatmapFeatureCollection(nodes: List): FeatureCollection { + val features = nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + Feature.fromGeometry(point) + } + return FeatureCollection.fromFeatures(features) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index 509d20b71b..2a80ce8881 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -38,6 +38,7 @@ import org.maplibre.android.style.expressions.Expression.toString import org.maplibre.android.style.expressions.Expression.zoom import org.maplibre.android.style.layers.CircleLayer import org.maplibre.android.style.layers.FillLayer +import org.maplibre.android.style.layers.HeatmapLayer import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.PropertyFactory.circleColor import org.maplibre.android.style.layers.PropertyFactory.circleOpacity @@ -46,6 +47,11 @@ import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth import org.maplibre.android.style.layers.PropertyFactory.fillColor import org.maplibre.android.style.layers.PropertyFactory.fillOpacity +import org.maplibre.android.style.layers.PropertyFactory.heatmapColor +import org.maplibre.android.style.layers.PropertyFactory.heatmapIntensity +import org.maplibre.android.style.layers.PropertyFactory.heatmapOpacity +import org.maplibre.android.style.layers.PropertyFactory.heatmapRadius +import org.maplibre.android.style.layers.PropertyFactory.heatmapWeight import org.maplibre.android.style.layers.PropertyFactory.lineColor import org.maplibre.android.style.layers.PropertyFactory.lineOpacity import org.maplibre.android.style.layers.PropertyFactory.lineWidth @@ -68,6 +74,8 @@ import org.maplibre.geojson.FeatureCollection import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_COUNT_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID @@ -453,3 +461,93 @@ fun removeTrackSourcesAndLayers(style: Style) { style.getSource(TRACK_LINE_SOURCE_ID)?.let { style.removeSource(it) } Timber.tag("MapLibrePOC").d("Removed track sources and layers") } + +/** Ensures heatmap source and layer exist in the style */ +fun ensureHeatmapSourceAndLayer(style: Style) { + // Add heatmap source if it doesn't exist + if (style.getSource(HEATMAP_SOURCE_ID) == null) { + val emptyFeatureCollection = FeatureCollection.fromFeatures(emptyList()) + val heatmapSource = GeoJsonSource(HEATMAP_SOURCE_ID, emptyFeatureCollection) + style.addSource(heatmapSource) + Timber.tag("MapLibrePOC").d("Added heatmap GeoJsonSource") + } + + // Add heatmap layer if it doesn't exist + if (style.getLayer(HEATMAP_LAYER_ID) == null) { + val heatmapLayer = HeatmapLayer(HEATMAP_LAYER_ID, HEATMAP_SOURCE_ID) + .withProperties( + // Each node contributes equally to the heatmap + heatmapWeight(literal(1.0)), + // Increase the heatmap intensity by zoom level + // Higher intensity = more sensitive to node density + heatmapIntensity( + interpolate( + linear(), zoom(), + stop(0, 0.3), + stop(9, 0.8), + stop(15, 1.5) + ) + ), + // Color ramp for heatmap - requires higher density to reach warmer colors + heatmapColor( + interpolate( + linear(), literal("heatmap-density"), + stop(0.0, toColor(literal("rgba(33,102,172,0)"))), + stop(0.1, toColor(literal("rgb(33,102,172)"))), + stop(0.3, toColor(literal("rgb(103,169,207)"))), + stop(0.5, toColor(literal("rgb(209,229,240)"))), + stop(0.7, toColor(literal("rgb(253,219,199)"))), + stop(0.85, toColor(literal("rgb(239,138,98)"))), + stop(1.0, toColor(literal("rgb(178,24,43)"))) + ) + ), + // Smaller radius = each node influences a smaller area + // More nodes needed in close proximity to create high density + heatmapRadius( + interpolate( + linear(), zoom(), + stop(0, 2.0), + stop(9, 6.0), + stop(15, 10.0) + ) + ), + // Transition from heatmap to circle layer by zoom level + heatmapOpacity( + interpolate( + linear(), zoom(), + stop(7, 1.0), + stop(22, 1.0) + ) + ) + ) + + // Add above OSM layer if it exists, otherwise add at bottom + if (style.getLayer(OSM_LAYER_ID) != null) { + style.addLayerAbove(heatmapLayer, OSM_LAYER_ID) + } else { + style.addLayerAt(heatmapLayer, 0) // Add at bottom + } + Timber.tag("MapLibrePOC").d("Added heatmap HeatmapLayer") + } +} + +/** Removes heatmap source and layer from the style */ +fun removeHeatmapSourceAndLayer(style: Style) { + style.getLayer(HEATMAP_LAYER_ID)?.let { style.removeLayer(it) } + style.getSource(HEATMAP_SOURCE_ID)?.let { style.removeSource(it) } + Timber.tag("MapLibrePOC").d("Removed heatmap source and layer") +} + +/** Toggle visibility of node/cluster/waypoint layers */ +fun setNodeLayersVisibility(style: Style, visible: Boolean) { + val visibilityValue = if (visible) "visible" else "none" + style.getLayer(NODES_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODE_TEXT_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(NODE_TEXT_LAYER_NOCLUSTER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(CLUSTER_COUNT_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(WAYPOINTS_LAYER_ID)?.setProperties(visibility(visibilityValue)) + style.getLayer(PRECISION_CIRCLE_LAYER_ID)?.setProperties(visibility(visibilityValue)) + Timber.tag("MapLibrePOC").d("Set node layers visibility to: $visibilityValue") +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index c3ee83c4e0..0c1d5a0d09 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -96,6 +96,7 @@ import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.layers.PropertyFactory.visibility import org.maplibre.android.style.layers.TransitionOptions import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Point @@ -114,6 +115,8 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYE import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID @@ -124,10 +127,13 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle +import org.meshtastic.feature.map.maplibre.core.ensureHeatmapSourceAndLayer import org.meshtastic.feature.map.maplibre.core.ensureImportedLayerSourceAndLayers import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.ensureTrackSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.logStyleState +import org.meshtastic.feature.map.maplibre.core.nodesToHeatmapFeatureCollection +import org.meshtastic.feature.map.maplibre.core.setNodeLayersVisibility import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection import org.meshtastic.feature.map.maplibre.core.positionsToLineStringFeature import org.meshtastic.feature.map.maplibre.core.positionsToPointFeatures @@ -206,6 +212,9 @@ fun MapLibrePOC( var clustersShown by remember { mutableStateOf(false) } var lastClusterEvalMs by remember { mutableStateOf(0L) } + // Heatmap mode + var heatmapEnabled by remember { mutableStateOf(false) } + // Map layer management var mapLayers by remember { mutableStateOf>(emptyList()) } var showLayersBottomSheet by remember { mutableStateOf(false) } @@ -406,6 +415,46 @@ fun MapLibrePOC( } } + // Heatmap mode management + LaunchedEffect(heatmapEnabled, nodes, mapFilterState, enabledRoles, ourNode, isLocationTrackingEnabled, clusteringEnabled) { + mapRef?.let { map -> + map.style?.let { style -> + if (heatmapEnabled) { + // Filter nodes same way as regular view + val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + + // Update heatmap source with filtered node positions + val heatmapFC = nodesToHeatmapFeatureCollection(filteredNodes) + (style.getSource(HEATMAP_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(heatmapFC) + + // Hide node/cluster/waypoint layers + setNodeLayersVisibility(style, false) + + // Show heatmap layer + style.getLayer(HEATMAP_LAYER_ID)?.setProperties(visibility("visible")) + + Timber.tag("MapLibrePOC").d("Heatmap enabled: %d nodes", filteredNodes.size) + } else { + // Hide heatmap layer + style.getLayer(HEATMAP_LAYER_ID)?.setProperties(visibility("none")) + + // Restore proper clustering visibility based on current state + val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + clustersShown = setClusterVisibilityHysteresis( + map, + style, + filteredNodes, + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle + ) + + Timber.tag("MapLibrePOC").d("Heatmap disabled, clustering=%b, clustersShown=%b", clusteringEnabled, clustersShown) + } + } + } + } + Box(modifier = Modifier.fillMaxSize()) { AndroidView( modifier = Modifier.fillMaxSize(), @@ -423,6 +472,7 @@ fun MapLibrePOC( style.setTransition(TransitionOptions(0, 0)) logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) + ensureHeatmapSourceAndLayer(style) // Setup track sources and layers if rendering node tracks Timber.tag("MapLibrePOC").d( @@ -817,6 +867,8 @@ fun MapLibrePOC( // Update clustering visibility on camera idle (zoom changes) map.addOnCameraIdleListener { val st = map.style ?: return@addOnCameraIdleListener + // Skip node updates when heatmap is enabled + if (heatmapEnabled) return@addOnCameraIdleListener // Debounce to avoid rapid toggling during kinetic flings/tiles loading val now = SystemClock.uptimeMillis() if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener @@ -927,50 +979,53 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").w(e, "Failed to update location component") } } - Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) - val density = context.resources.displayMetrics.density - val bounds2 = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = - nodes.filter { n -> - val p = n.validPosition ?: return@filter false - bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + // Skip node updates when heatmap is enabled + if (!heatmapEnabled) { + Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) + val density = context.resources.displayMetrics.density + val bounds2 = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + nodes.filter { n -> + val p = n.validPosition ?: return@filter false + bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) + chosen } - chosen - } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - waypointsToFeatureCollectionFC(waypoints.values), - ) - val filteredNow = - applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) - safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source - // Apply visibility now - clustersShown = - setClusterVisibilityHysteresis( - map, - style, - applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + val filteredNow = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val jsonNow = nodesToFeatureCollectionJsonWithSelection(filteredNow, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, jsonNow) + safeSetGeoJson(style, NODES_SOURCE_ID, jsonNow) // Also populate non-clustered source + // Apply visibility now + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), clusteringEnabled, clustersShown, mapFilterState.showPrecisionCircle, ) + } logStyleState("update(block)", style) } }, @@ -1105,6 +1160,8 @@ fun MapLibrePOC( onShowLayersClicked = { showLayersBottomSheet = true }, onShowCacheClicked = { showCacheBottomSheet = true }, onShowLegendToggled = { showLegend = !showLegend }, + heatmapEnabled = heatmapEnabled, + onHeatmapToggled = { heatmapEnabled = !heatmapEnabled }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), ) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt index e826ff9e41..0775dcd80e 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt @@ -153,6 +153,8 @@ fun MapToolbar( onShowLayersClicked: () -> Unit, onShowCacheClicked: () -> Unit, onShowLegendToggled: () -> Unit, + heatmapEnabled: Boolean, + onHeatmapToggled: () -> Unit, modifier: Modifier = Modifier, ) { var mapFilterExpanded by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } @@ -260,6 +262,19 @@ fun MapToolbar( ) }, ) + DropdownMenuItem( + text = { Text("Show heatmap") }, + onClick = { + onHeatmapToggled() + mapFilterExpanded = false + }, + trailingIcon = { + Checkbox( + checked = heatmapEnabled, + onCheckedChange = { onHeatmapToggled() }, + ) + }, + ) } } diff --git a/wireless-install.sh b/wireless-install.sh new file mode 100755 index 0000000000..819b70a060 --- /dev/null +++ b/wireless-install.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +# Wireless APK Build, Install & Launch Script for Meshtastic Android +# This script automates building, installing, and launching the fdroidDebug APK wirelessly + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Project root directory +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APK_PATH="${PROJECT_ROOT}/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" +PACKAGE_NAME="com.geeksville.mesh" +MAIN_ACTIVITY="${PACKAGE_NAME}/.MainActivity" + +echo -e "${BLUE}=== Meshtastic Wireless Build, Install & Launch ===${NC}\n" + +# Parse command line arguments +SKIP_BUILD=false +SKIP_LAUNCH=false + +while [[ "$#" -gt 0 ]]; do + case $1 in + --skip-build) SKIP_BUILD=true ;; + --skip-launch) SKIP_LAUNCH=true ;; + --help) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --skip-build Skip building the APK (use existing build)" + echo " --skip-launch Install but don't launch the app" + echo " --help Show this help message" + echo "" + exit 0 + ;; + *) echo "Unknown parameter: $1"; exit 1 ;; + esac + shift +done + +# Build APK +if [ "$SKIP_BUILD" = false ]; then + echo -e "${BLUE}Building fdroidDebug APK...${NC}" + echo -e "${YELLOW}(This may take a minute)${NC}\n" + + if ./gradlew assembleFdroidDebug --console=plain | tail -20; then + echo -e "\n${GREEN}✓ Build successful${NC}\n" + else + echo -e "\n${RED}✗ Build failed${NC}" + exit 1 + fi +else + echo -e "${YELLOW}Skipping build (--skip-build flag)${NC}\n" +fi + +# Check if APK exists +if [ ! -f "$APK_PATH" ]; then + echo -e "${RED}Error: APK not found at ${APK_PATH}${NC}" + echo -e "${YELLOW}Try running without --skip-build${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Found APK: $(basename "$APK_PATH")${NC}" + +# Check for connected devices +DEVICES=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ') + +if [ "$DEVICES" -eq "0" ]; then + echo -e "\n${YELLOW}No devices connected.${NC}" + echo -e "${BLUE}Starting wireless pairing process...${NC}\n" + + echo "On your phone:" + echo "1. Go to Settings → Developer Options → Wireless debugging" + echo "2. Tap 'Pair device with pairing code'" + echo "" + + # Prompt for pairing information + read -p "Enter IP address (e.g., 192.168.8.169): " IP_ADDRESS + read -p "Enter pairing port (e.g., 45621): " PAIRING_PORT + read -p "Enter 6-digit pairing code: " PAIRING_CODE + + echo -e "\n${BLUE}Pairing with device...${NC}" + if adb pair "${IP_ADDRESS}:${PAIRING_PORT}" "${PAIRING_CODE}"; then + echo -e "${GREEN}✓ Successfully paired!${NC}\n" + else + echo -e "${RED}✗ Pairing failed. Please try again.${NC}" + exit 1 + fi + + # Prompt for connection port + echo "On your phone's Wireless debugging screen," + echo "find the 'IP address & Port' at the top (different from pairing port)" + echo "" + read -p "Enter connection port (e.g., 34925): " CONNECTION_PORT + + echo -e "\n${BLUE}Connecting to device...${NC}" + if adb connect "${IP_ADDRESS}:${CONNECTION_PORT}"; then + echo -e "${GREEN}✓ Connected to ${IP_ADDRESS}:${CONNECTION_PORT}${NC}\n" + else + echo -e "${RED}✗ Connection failed. Check the port and try again.${NC}" + exit 1 + fi + + # Wait a moment for connection to stabilize + sleep 1 +elif [ "$DEVICES" -eq "1" ]; then + DEVICE=$(adb devices | grep "device$" | awk '{print $1}') + echo -e "${GREEN}✓ Device already connected: ${DEVICE}${NC}\n" +else + echo -e "${YELLOW}Multiple devices connected. Using first available device.${NC}\n" + DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') + echo -e "${GREEN}Selected device: ${DEVICE}${NC}\n" +fi + +# Verify connection +DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') +if [ -z "$DEVICE" ]; then + echo -e "${RED}Error: No device connected after pairing process.${NC}" + exit 1 +fi + +# Install APK +echo -e "${BLUE}Installing APK to device ${DEVICE}...${NC}" +echo -e "${YELLOW}(This may take a minute over wireless connection)${NC}\n" + +if adb -s "$DEVICE" install -r -t "$APK_PATH"; then + echo -e "\n${GREEN}╔════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ APK installed successfully! ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════╝${NC}\n" +else + echo -e "\n${RED}✗ Installation failed.${NC}" + exit 1 +fi + +# Launch app +if [ "$SKIP_LAUNCH" = false ]; then + echo -e "${BLUE}Launching Meshtastic app...${NC}\n" + + # Stop app if already running + adb -s "$DEVICE" shell am force-stop "$PACKAGE_NAME" 2>/dev/null || true + + # Launch main activity + if adb -s "$DEVICE" shell am start -n "$MAIN_ACTIVITY" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER; then + echo -e "\n${GREEN}✓ App launched successfully!${NC}\n" + + echo -e "${BLUE}Test the new heatmap feature:${NC}" + echo "1. Navigate to the map view" + echo "2. Tap the filter icon (tune icon)" + echo "3. Check 'Show heatmap'" + echo "" + else + echo -e "\n${YELLOW}⚠ Could not launch app automatically. Please open it manually.${NC}\n" + fi +else + echo -e "${YELLOW}Skipping app launch (--skip-launch flag)${NC}\n" + echo -e "${BLUE}Test the new heatmap feature:${NC}" + echo "1. Open Meshtastic app on your phone" + echo "2. Navigate to the map view" + echo "3. Tap the filter icon (tune icon)" + echo "4. Check 'Show heatmap'" + echo "" +fi + +echo -e "${GREEN}Device: ${DEVICE}${NC}" From 094ac175d0696ac40e27c23eaac998668efe3e40 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:02:59 -0800 Subject: [PATCH 42/62] merge main --- build-and-install-android.sh | 162 +++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100755 build-and-install-android.sh diff --git a/build-and-install-android.sh b/build-and-install-android.sh new file mode 100755 index 0000000000..1a2c8bcbfa --- /dev/null +++ b/build-and-install-android.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# Wireless APK Build, Install & Launch Script for Meshtastic Android +# This script automates building, installing, and launching the fdroidDebug APK wirelessly + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Project root directory +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APK_PATH="${PROJECT_ROOT}/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" +PACKAGE_NAME="com.geeksville.mesh.fdroid.debug" +MAIN_ACTIVITY="${PACKAGE_NAME}/.MainActivity" + +echo -e "${BLUE}=== Meshtastic Wireless Build, Install & Launch ===${NC}\n" + +# Parse command line arguments +SKIP_BUILD=false +SKIP_LAUNCH=false + +while [[ "$#" -gt 0 ]]; do + case $1 in + --skip-build) SKIP_BUILD=true ;; + --skip-launch) SKIP_LAUNCH=true ;; + --help) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --skip-build Skip building the APK (use existing build)" + echo " --skip-launch Install but don't launch the app" + echo " --help Show this help message" + echo "" + exit 0 + ;; + *) echo "Unknown parameter: $1"; exit 1 ;; + esac + shift +done + +# Build APK +if [ "$SKIP_BUILD" = false ]; then + echo -e "${BLUE}Building fdroidDebug APK...${NC}" + echo -e "${YELLOW}(This may take a minute)${NC}\n" + + if ./gradlew assembleFdroidDebug --console=plain | tail -20; then + echo -e "\n${GREEN}✓ Build successful${NC}\n" + else + echo -e "\n${RED}✗ Build failed${NC}" + exit 1 + fi +else + echo -e "${YELLOW}Skipping build (--skip-build flag)${NC}\n" +fi + +# Check if APK exists +if [ ! -f "$APK_PATH" ]; then + echo -e "${RED}Error: APK not found at ${APK_PATH}${NC}" + echo -e "${YELLOW}Try running without --skip-build${NC}" + exit 1 +fi + +echo -e "${GREEN}✓ Found APK: $(basename "$APK_PATH")${NC}" + +# Check for connected devices +DEVICES=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ') + +if [ "$DEVICES" -eq "0" ]; then + echo -e "\n${YELLOW}No devices connected.${NC}" + echo -e "${BLUE}Starting wireless pairing process...${NC}\n" + + echo "On your phone:" + echo "1. Go to Settings → Developer Options → Wireless debugging" + echo "2. Tap 'Pair device with pairing code'" + echo "" + + # Prompt for pairing information + read -p "Enter IP address (e.g., 192.168.8.169): " IP_ADDRESS + read -p "Enter pairing port (e.g., 45621): " PAIRING_PORT + read -p "Enter 6-digit pairing code: " PAIRING_CODE + + echo -e "\n${BLUE}Pairing with device...${NC}" + if adb pair "${IP_ADDRESS}:${PAIRING_PORT}" "${PAIRING_CODE}"; then + echo -e "${GREEN}✓ Successfully paired!${NC}\n" + else + echo -e "${RED}✗ Pairing failed. Please try again.${NC}" + exit 1 + fi + + # Prompt for connection port + echo "On your phone's Wireless debugging screen," + echo "find the 'IP address & Port' at the top (different from pairing port)" + echo "" + read -p "Enter connection port (e.g., 34925): " CONNECTION_PORT + + echo -e "\n${BLUE}Connecting to device...${NC}" + if adb connect "${IP_ADDRESS}:${CONNECTION_PORT}"; then + echo -e "${GREEN}✓ Connected to ${IP_ADDRESS}:${CONNECTION_PORT}${NC}\n" + else + echo -e "${RED}✗ Connection failed. Check the port and try again.${NC}" + exit 1 + fi + + # Wait a moment for connection to stabilize + sleep 1 +elif [ "$DEVICES" -eq "1" ]; then + DEVICE=$(adb devices | grep "device$" | awk '{print $1}') + echo -e "${GREEN}✓ Device already connected: ${DEVICE}${NC}\n" +else + echo -e "${YELLOW}Multiple devices connected. Using first available device.${NC}\n" + DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') + echo -e "${GREEN}Selected device: ${DEVICE}${NC}\n" +fi + +# Verify connection +DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') +if [ -z "$DEVICE" ]; then + echo -e "${RED}Error: No device connected after pairing process.${NC}" + exit 1 +fi + +# Install APK +echo -e "${BLUE}Installing APK to device ${DEVICE}...${NC}" +echo -e "${YELLOW}(This may take a minute over wireless connection)${NC}\n" + +if adb -s "$DEVICE" install -r -t "$APK_PATH"; then + echo -e "\n${GREEN}╔════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✓ APK installed successfully! ║${NC}" + echo -e "${GREEN}╚════════════════════════════════════════╝${NC}\n" +else + echo -e "\n${RED}✗ Installation failed.${NC}" + exit 1 +fi + +# Launch app +if [ "$SKIP_LAUNCH" = false ]; then + echo -e "${BLUE}Launching Meshtastic app...${NC}\n" + + # Stop app if already running + adb -s "$DEVICE" shell am force-stop "$PACKAGE_NAME" 2>/dev/null || true + + # Launch main activity + if adb -s "$DEVICE" shell am start -n "$MAIN_ACTIVITY" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER; then + echo -e "\n${GREEN}✓ App launched successfully!${NC}\n" + + echo -e "${BLUE}Test the new heatmap feature:${NC}" + echo "1. Navigate to the map view" + echo "2. Tap the filter icon (tune icon)" + echo "3. Check 'Show heatmap'" + echo "" + else + echo -e "\n${YELLOW}⚠ Could not launch app automatically. Please open it manually.${NC}\n" + fi +fi + +echo -e "${GREEN}Device: ${DEVICE}${NC}" + From 641328ce77e5e2cd0c831303f97ff5a2acf8d279 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:03:18 -0800 Subject: [PATCH 43/62] merge main --- build-and-install-android.sh | 162 ----------------------------------- 1 file changed, 162 deletions(-) delete mode 100755 build-and-install-android.sh diff --git a/build-and-install-android.sh b/build-and-install-android.sh deleted file mode 100755 index 1a2c8bcbfa..0000000000 --- a/build-and-install-android.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash - -# Wireless APK Build, Install & Launch Script for Meshtastic Android -# This script automates building, installing, and launching the fdroidDebug APK wirelessly - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Project root directory -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APK_PATH="${PROJECT_ROOT}/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" -PACKAGE_NAME="com.geeksville.mesh.fdroid.debug" -MAIN_ACTIVITY="${PACKAGE_NAME}/.MainActivity" - -echo -e "${BLUE}=== Meshtastic Wireless Build, Install & Launch ===${NC}\n" - -# Parse command line arguments -SKIP_BUILD=false -SKIP_LAUNCH=false - -while [[ "$#" -gt 0 ]]; do - case $1 in - --skip-build) SKIP_BUILD=true ;; - --skip-launch) SKIP_LAUNCH=true ;; - --help) - echo "Usage: $0 [options]" - echo "" - echo "Options:" - echo " --skip-build Skip building the APK (use existing build)" - echo " --skip-launch Install but don't launch the app" - echo " --help Show this help message" - echo "" - exit 0 - ;; - *) echo "Unknown parameter: $1"; exit 1 ;; - esac - shift -done - -# Build APK -if [ "$SKIP_BUILD" = false ]; then - echo -e "${BLUE}Building fdroidDebug APK...${NC}" - echo -e "${YELLOW}(This may take a minute)${NC}\n" - - if ./gradlew assembleFdroidDebug --console=plain | tail -20; then - echo -e "\n${GREEN}✓ Build successful${NC}\n" - else - echo -e "\n${RED}✗ Build failed${NC}" - exit 1 - fi -else - echo -e "${YELLOW}Skipping build (--skip-build flag)${NC}\n" -fi - -# Check if APK exists -if [ ! -f "$APK_PATH" ]; then - echo -e "${RED}Error: APK not found at ${APK_PATH}${NC}" - echo -e "${YELLOW}Try running without --skip-build${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Found APK: $(basename "$APK_PATH")${NC}" - -# Check for connected devices -DEVICES=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ') - -if [ "$DEVICES" -eq "0" ]; then - echo -e "\n${YELLOW}No devices connected.${NC}" - echo -e "${BLUE}Starting wireless pairing process...${NC}\n" - - echo "On your phone:" - echo "1. Go to Settings → Developer Options → Wireless debugging" - echo "2. Tap 'Pair device with pairing code'" - echo "" - - # Prompt for pairing information - read -p "Enter IP address (e.g., 192.168.8.169): " IP_ADDRESS - read -p "Enter pairing port (e.g., 45621): " PAIRING_PORT - read -p "Enter 6-digit pairing code: " PAIRING_CODE - - echo -e "\n${BLUE}Pairing with device...${NC}" - if adb pair "${IP_ADDRESS}:${PAIRING_PORT}" "${PAIRING_CODE}"; then - echo -e "${GREEN}✓ Successfully paired!${NC}\n" - else - echo -e "${RED}✗ Pairing failed. Please try again.${NC}" - exit 1 - fi - - # Prompt for connection port - echo "On your phone's Wireless debugging screen," - echo "find the 'IP address & Port' at the top (different from pairing port)" - echo "" - read -p "Enter connection port (e.g., 34925): " CONNECTION_PORT - - echo -e "\n${BLUE}Connecting to device...${NC}" - if adb connect "${IP_ADDRESS}:${CONNECTION_PORT}"; then - echo -e "${GREEN}✓ Connected to ${IP_ADDRESS}:${CONNECTION_PORT}${NC}\n" - else - echo -e "${RED}✗ Connection failed. Check the port and try again.${NC}" - exit 1 - fi - - # Wait a moment for connection to stabilize - sleep 1 -elif [ "$DEVICES" -eq "1" ]; then - DEVICE=$(adb devices | grep "device$" | awk '{print $1}') - echo -e "${GREEN}✓ Device already connected: ${DEVICE}${NC}\n" -else - echo -e "${YELLOW}Multiple devices connected. Using first available device.${NC}\n" - DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') - echo -e "${GREEN}Selected device: ${DEVICE}${NC}\n" -fi - -# Verify connection -DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') -if [ -z "$DEVICE" ]; then - echo -e "${RED}Error: No device connected after pairing process.${NC}" - exit 1 -fi - -# Install APK -echo -e "${BLUE}Installing APK to device ${DEVICE}...${NC}" -echo -e "${YELLOW}(This may take a minute over wireless connection)${NC}\n" - -if adb -s "$DEVICE" install -r -t "$APK_PATH"; then - echo -e "\n${GREEN}╔════════════════════════════════════════╗${NC}" - echo -e "${GREEN}║ ✓ APK installed successfully! ║${NC}" - echo -e "${GREEN}╚════════════════════════════════════════╝${NC}\n" -else - echo -e "\n${RED}✗ Installation failed.${NC}" - exit 1 -fi - -# Launch app -if [ "$SKIP_LAUNCH" = false ]; then - echo -e "${BLUE}Launching Meshtastic app...${NC}\n" - - # Stop app if already running - adb -s "$DEVICE" shell am force-stop "$PACKAGE_NAME" 2>/dev/null || true - - # Launch main activity - if adb -s "$DEVICE" shell am start -n "$MAIN_ACTIVITY" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER; then - echo -e "\n${GREEN}✓ App launched successfully!${NC}\n" - - echo -e "${BLUE}Test the new heatmap feature:${NC}" - echo "1. Navigate to the map view" - echo "2. Tap the filter icon (tune icon)" - echo "3. Check 'Show heatmap'" - echo "" - else - echo -e "\n${YELLOW}⚠ Could not launch app automatically. Please open it manually.${NC}\n" - fi -fi - -echo -e "${GREEN}Device: ${DEVICE}${NC}" - From d884ee0a4c022675e0beeb48d687d02bb0a1f4f7 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:06:23 -0800 Subject: [PATCH 44/62] Remove maplibre-native-integration.md design doc --- docs/design/maplibre-native-integration.md | 278 --------------------- 1 file changed, 278 deletions(-) delete mode 100644 docs/design/maplibre-native-integration.md diff --git a/docs/design/maplibre-native-integration.md b/docs/design/maplibre-native-integration.md deleted file mode 100644 index 69dd79d9ce..0000000000 --- a/docs/design/maplibre-native-integration.md +++ /dev/null @@ -1,278 +0,0 @@ -# MapLibre Native integration for F-Droid flavor (replace osmdroid) - -## Overview - -The F-Droid variant currently uses osmdroid, which is archived and no longer actively maintained. This document proposes migrating the F-Droid flavor to MapLibre Native for a modern, actively maintained, fully open-source mapping stack compatible with F-Droid constraints. - -Reference: MapLibre Native (BSD-2-Clause) — `org.maplibre.gl:android-sdk` [GitHub repository and README](https://github.com/maplibre/maplibre-native). The README shows Android setup and basic usage, e.g.: - -```gradle -implementation 'org.maplibre.gl:android-sdk:12.1.0' // use latest stable from releases -``` - -Releases page (latest): see Android release notes (e.g., android-v12.1.0) in the repository releases feed. - - -## Goals - -- Replace osmdroid in the `fdroid` flavor with MapLibre Native. -- Maintain core map features: live node markers, waypoints, tracks (polylines), bounding-box selection, map layers, and user location. -- Stay F-Droid compatible: no proprietary SDKs, no analytics, and tile sources/styles that don’t require proprietary keys. -- Provide a path for offline usage (caching and/or offline regions) comparable to current expectations. -- Keep the Google flavor unchanged (continues using Google Maps). - - -## Non-goals - -- Changing the Google flavor map implementation. -- Shipping proprietary tile styles or API-key–gated providers by default. -- Reworking unrelated UI/UX; only adapt what’s necessary for MapLibre. - - -## Current state (F-Droid flavor) - -The `fdroid` flavor is implemented with osmdroid components and includes custom overlays, caching, clustering, and tile source utilities. Key entry points and features include (non-exhaustive): - -- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt`: Composable map screen hosting an `AndroidView` of `org.osmdroid.views.MapView`. -- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewWithLifecycle.kt`: Lifecycle-aware creation and configuration of osmdroid `MapView`, including zoom bounds, DPI scaling, scroll limits, and user-agent config. -- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt`: Node-focused map screen using fdroid map utilities. -- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewExtensions.kt`: Helpers to add copyright, overlays (markers, polylines, scale bar, gridlines). -- `feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/model/*`: Tile source abstractions and WMS helpers (e.g., NOAA WMS), custom tile source with auth, and marker classes. -- `feature/map/src/fdroid/java/org/meshtastic/feature/map/cluster/*`: Custom clustering (RadiusMarkerClusterer, MarkerClusterer, etc.). -- Caching/downloading logic via osmdroid `CacheManager`, `SqliteArchiveTileWriter`, and related helpers. - -Build dependencies: - -- `fdroidImplementation(libs.osmdroid.android)` and `fdroidImplementation(libs.osmdroid.geopackage)` in `app/build.gradle.kts`. - - -## Proposed architecture (F-Droid flavor on MapLibre Native) - -At a high level, we replace the fdroid/osmdroid source set with a MapLibre-based implementation using `org.maplibre.android.maps.MapView` hosted in `AndroidView` for Compose interop. Feature parity is achieved by translating current overlays/utilities to MapLibre’s source/layer model. - -### Parity with Google Maps flavor (feature-by-feature) - -This section captures the Google flavor’s current capabilities and how we’ll provide equivalent behavior with MapLibre Native for the F-Droid flavor. - -- Map types (Normal/Satellite/Terrain/Hybrid) - - Google: `MapTypeDropdown` switches `MapType` (Normal, Satellite, Terrain, Hybrid). - - MapLibre: Provide a “Style chooser” mapped to a set of styles (e.g., vector “basic/streets”, “terrain” style when available, “satellite”, and “hybrid” if a provider is configured). Implementation: swap style URLs at runtime. Note: Satellite/Hybrid require non-proprietary providers; we will ship only F-Droid-compliant defaults and allow users to add custom styles. - -- Custom raster tile overlays (user-defined URL template) - - Google: `TileOverlay` with user-managed providers and a manager sheet to add/edit/remove templates. - - MapLibre: Add `RasterSource` + `RasterLayer` using `{z}/{x}/{y}` URL templates. Keep the same management UI: add/edit/remove templates, persist selection, z-order above/below base style as applicable. - -- Custom map layers: KML and GeoJSON import - - Google: Imports KML (`KmlLayer`) and GeoJSON (`GeoJsonLayer`) with visibility toggles and persistence. - - MapLibre: Native `GeoJsonSource` for GeoJSON; for KML, implement conversion to GeoJSON at import time (preferred) or a KML renderer. Keep the same UI: file picker, layer list with visibility toggles, and persistence. Large layers should be loaded off the main thread. - -- Clustering of node markers - - Google: Clustering via utility logic and a dialog to display items within a cluster. - - MapLibre: Enable clustering on a `GeoJsonSource` (cluster=true). Use styled `SymbolLayer` for clusters and single points. On cluster tap, either zoom into the cluster or surface a dialog with the items (obtain children via query on `cluster_id` using runtime API or by maintaining an index in the view model). Match the existing UX where feasible. - -- Marker info and selection UI - - Google: `MarkerInfoWindowComposable` for node/waypoint info; cluster dialog for multiple items. - - MapLibre: Use map click callbacks to detect feature selection (via queryRenderedFeatures) and show Compose-based bottom sheets or dialogs for details. Highlight the selected feature via data-driven styling (e.g., different icon or halo) to mimic info-window emphasis. - -- Tracks and polylines - - Google: `Polyline` for node tracks. - - MapLibre: `GeoJsonSource` + `LineLayer` for tracks. Style width/color dynamically (e.g., by selection or theme). Maintain performance by updating sources incrementally. - -- Location indicator and follow/bearing modes - - Google: Map UI shows device location, with toggles for follow and bearing. - - MapLibre: Use the built-in location component to display position and bearing. Implement follow mode (camera tracking current location) and toggle bearing-follow (bearing locked to phone heading) via camera updates. Respect permissions as in the current flow. - -- Scale bar - - Google: `ScaleBar` widget (compose). - - MapLibre: Implement a Compose overlay scale bar using map camera state and projection (compute meters-per-pixel at latitude; snap to nice distances). Show/hide per the current controls. - -- Camera, gestures, and UI controls - - Google: `MapProperties`/`MapUiSettings` for gestures, compass, traffic, etc. - - MapLibre: Use MapLibre’s `UiSettings` and camera APIs to align gesture enablement. Provide a compass toggle if needed (Compose overlay or style-embedded widget). - -- Map filter menu and other HUD controls - - Google: Compose-driven “filter” and “map type” menus; custom layer manager; custom tile provider manager. - - MapLibre: Reuse the same Compose controls; wire actions to MapLibre implementations (style swap, raster source add/remove, layer visibility toggles). - -- Persistence and state - - Keep the same persistence strategy used by the Google flavor for selected map type/style, custom tile providers, and imported layers (URIs, visibility). Ensure parity in initial load behavior and error handling. - -Gaps and proposed handling: -- Satellite/Hybrid availability depends on F-Droid-compliant providers; we will ship only compliant defaults and rely on user-provided styles for others. -- KML requires conversion or a dedicated renderer; we will implement KML→GeoJSON conversion at import time for parity with visibility toggles and persistence. - -### Core components - -- Lifecycle-aware `MapView` wrapper (Compose): Use `AndroidView` to create and manage `org.maplibre.android.maps.MapView` with lifecycle forwarding (onStart/onStop/etc.), mirroring the current `MapViewWithLifecycle.kt` responsibilities. -- Style and sources: - - Default dev style: `https://demotiles.maplibre.org/style.json` for initial bring-up (public demo). This avoids proprietary keys and is fine for development. Later, we can switch to a more appropriate default for production. - - Raster tile support: Add `RasterSource` for user-provided raster tile URL templates (equivalent to existing “custom tile provider URL” functionality). - - Vector data for app overlays: Use `GeoJsonSource` for nodes, waypoints, and tracks, with appropriate `SymbolLayer` and `LineLayer` styling. Polygons (e.g., bounding box) via `FillLayer` or line+fill combo. -- Clustering: - - Use `GeoJsonSource` with clustering enabled for node markers (MapLibre supports clustering at the source level). Configure cluster radius and properties to emulate current behavior. -- Location indicator: - - Use the built-in location component in MapLibre Native to show the device location and bearing (when permitted). -- Gestures and camera: - - MapLibre’s `UiSettings` and camera APIs mirror Mapbox GL Native; expose zoom, bearing, tilt as needed to match osmdroid behavior. -- Permissions: - - Retain existing Compose permission handling; wire to enable/disable location component. - - -## Offline and caching - -MapLibre Native offers mechanisms similar to Mapbox GL Native for tile caching and offline regions. We will: - -1. Start with default HTTP caching (on-device tile cache) to improve repeated region performance. -2. Evaluate and, if feasible, implement offline regions for vector tiles (download defined bounding boxes and zoom ranges). This would replace osmdroid’s `CacheManager` flow and UI. If vector offline proves complex, a phase may introduce raster MBTiles as an interim solution (note: MapLibre Native does not directly read MBTiles; an import/conversion path or custom source is needed if we choose that route). -3. Preserve current UX affordances: bounding-box selection for offline region definition, progress UI, cache size/budget, purge actions. - -Open question: confirm current MapLibre Native offline APIs and recommended approach on Android at the chosen SDK version. If upstream guidance prefers vector style packs or a particular cache API, we’ll align to that. - - -## Tile sources, styles, and F-Droid compliance - -- Default dev style: MapLibre demo style (`demotiles.maplibre.org`) for development/testing only. -- Production defaults must: - - Avoid proprietary SDKs and keys. - - Respect provider TOS (e.g., OSM or self-hosted tiles). Consider self-hosted vector tiles (OpenMapTiles) or community-friendly providers with clear terms for mobile clients. -- Custom tile provider URL: - - Support raster custom URLs via `RasterSource` in the style at runtime. - - For vector custom sources, we’ll likely require a user-supplied style JSON URL (vector styles are described by style JSONs that reference vector sources); we can enable “Custom style URL” input for advanced users. - - -## Build and dependencies - -- Add MapLibre Native to the `fdroid` configuration in `app/build.gradle.kts`: - - `fdroidImplementation("org.maplibre.gl:android-sdk:")` -- Remove: - - `fdroidImplementation(libs.osmdroid.android)` - - `fdroidImplementation(libs.osmdroid.geopackage)` (and related exclusions) -- Native ABIs: - - App already configures `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`; MapLibre Native provides native libs accordingly, so the existing NDK filters should be compatible. -- Min/target SDK: - - MapLibre Native minSdk is compatible (App targets API 26+; verify exact minSdk for selected MapLibre version). - - -## Migration plan (phased) - -Phase 1: Bring-up and parity core -- Create `fdroid`-only MapLibre `MapView` composable using `AndroidView` and lifecycle wiring. -- Initialize `MapLibre.getInstance(context)` and load a simple style. -- Render nodes and waypoints using `GeoJsonSource` + `SymbolLayer`. -- Render tracks using `GeoJsonSource` + `LineLayer`. -- Replace location overlay with MapLibre location component. -- Replace map gestures and scale bar equivalents (either via style or simple Compose overlay). - -Phase 2: Clustering and UI polish -- Implement clustering with `GeoJsonSource` clustering features. -- Style cluster circles/labels; match existing look/feel as feasible. -- Restore “map filter” UX and marker selection/infowindows (Compose side panels/dialogs). - -Phase 3: Offline and cache UX -- Implement region selection overlay as style layers (polygon/line) and coordinate it with cache/offline manager. -- Add offline region creation, progress, and management (estimates, purge, etc.). - -Phase 4: Cleanup -- Remove osmdroid-specific code: tile source models, WMS helpers (unless replaced with MapLibre raster sources), custom cluster Java classes, cache manager extensions. - - -## Risks and mitigations - -- Native size increase: MapLibre includes native libs; monitor APK size impact per ABI and leverage existing ABI filters. -- GPU driver quirks: MapLibre uses OpenGL/Metal (platform dependent); test across representative devices/ABIs. -- Offline complexity: Vector offline requires careful style/source handling; mitigate via phased rollout and clear user-facing expectations. -- Tile provider TOS: Ensure defaults are compliant; prefer self-hosted or community-safe options. -- Performance: Reassess clustering and update strategies (debounce updates, differential GeoJSON updates) to keep frame times smooth. - - -## Testing strategy - -- Device matrix across `armeabi-v7a`, `arm64-v8a`, `x86`, `x86_64`. -- Regression tests for: - - Marker rendering and selection. - - Tracks and waypoints visibility and styles. - - Location component and permissions. - - Clustering behavior at varying zooms. - - Offline region create/purge flows (Phase 3). -- Manual checks for F-Droid build and install flow (“no Google” hygiene already present in the build). - - -## Timeline (estimate) - -- Phase 1: 1–2 weeks (core rendering, location, parity for main screen) -- Phase 2: 1 week (clustering + polish) -- Phase 3: 2–3 weeks (offline regions + UX) -- Phase 4: 0.5–1 week (cleanup, remove osmdroid code) - - -## Open questions - -- Preferred default production style: community vector tiles vs. raster OSM tiles? -- Confirm MapLibre Native offline APIs and best-practice for Android in the selected version. -- Retain any WMS layers? If needed, evaluate WMS via raster tile intermediary or custom source pipeline. - - -## References - -- MapLibre Native repository and README (Android usage and examples): https://github.com/maplibre/maplibre-native - - Shows `MapView` usage, style loading, and dependency coordinates in the README. - - Recent Android releases (e.g., android-v12.1.0) are available in the releases section. - -## Proof of Concept (POC) scope and steps - -Objective: Stand up MapLibre Native in the F-Droid flavor alongside the existing osmdroid implementation without removing osmdroid yet. Demonstrate base map rendering, device location, and rendering of core app entities (nodes, waypoints, tracks) using MapLibre sources/layers. Keep the change additive and easy to revert. - -Scope (must-have): -- Add MapLibre dependency to `fdroid` configuration. -- Introduce a new, isolated Composable (e.g., `MapLibreMapView`) using `AndroidView` to host `org.maplibre.android.maps.MapView`. -- Initialize MapLibre and load a dev-safe style (e.g., `https://demotiles.maplibre.org/style.json`). -- Render nodes and waypoints using `GeoJsonSource` + `SymbolLayer` with a simple, built-in marker image. -- Render tracks as polylines using `GeoJsonSource` + `LineLayer`. -- Enable the MapLibre location component (when permissions are granted). -- Provide a temporary developer entry point to reach the POC screen (e.g., a debug-only navigation route or an in-app dev menu item for `fdroidDebug` builds). - -Scope (nice-to-have, if time allows): -- Simple style switcher between 2–3 known-good styles (dev/demo only). -- Add a “Custom raster URL” input to attach a `RasterSource` + `RasterLayer` using `{z}/{x}/{y}` templates. -- Basic cluster styling using a clustered `GeoJsonSource` (no cluster detail dialog yet). - -Out of scope for POC: -- Offline region downloads and cache management UI. -- KML import and full custom layer manager (GeoJSON-only import is acceptable if trivial). -- Complete parity polish and advanced gestures/controls. - -Implementation steps: -1) Build configuration - - Add `fdroidImplementation("org.maplibre.gl:android-sdk:")`. - - Keep osmdroid dependencies during POC; we will not remove them yet. - -2) New POC Composable and lifecycle - - Create `MapLibreMapView` in the `fdroid` source set. - - Use `AndroidView` to host `MapView`; forward lifecycle events (onStart/onStop/etc.). - - Call `MapLibre.getInstance(context)` and set the style URI once the map is ready. - -3) Data plumbing - - From the existing `MapViewModel`, derive FeatureCollections for nodes, waypoints, and tracks. - - Create `GeoJsonSource` entries for each category; update them on state changes. - - Add a default marker image to the style and wire a `SymbolLayer` for nodes/waypoints; add a `LineLayer` for tracks. - -4) Location component - - Enable MapLibre’s location component when location permission is granted. - - Add a simple “my location” action (centers the camera on the device location). - -5) Temporary navigation - - Add a debug-only nav destination or dev menu entry to open the POC screen without impacting existing map flows. - -6) Developer QA checklist - - Build `assembleFdroidDebug` and open the POC map. - - Verify: base map loads; device location shows when permitted; nodes/waypoints/track render. - - Verify: panning/zooming works smoothly on at least one ARM64 device and one emulator. - -Acceptance criteria: -- On `fdroidDebug`, a developer can open the POC screen and see: - - A MapLibre-based map with a working style. - - Device location indicator (when permission is granted). - - Visible nodes, waypoints, and at least one track drawn via MapLibre layers. -- No regressions to existing osmdroid-based map screens. - -Estimated effort: 1–2 days \ No newline at end of file From 00faafa0bb4a613eec22435691b7217eb83b31a2 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:06:37 -0800 Subject: [PATCH 45/62] Remove wireless-install.sh personal script --- wireless-install.sh | 169 -------------------------------------------- 1 file changed, 169 deletions(-) delete mode 100755 wireless-install.sh diff --git a/wireless-install.sh b/wireless-install.sh deleted file mode 100755 index 819b70a060..0000000000 --- a/wireless-install.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/bin/bash - -# Wireless APK Build, Install & Launch Script for Meshtastic Android -# This script automates building, installing, and launching the fdroidDebug APK wirelessly - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Project root directory -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APK_PATH="${PROJECT_ROOT}/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk" -PACKAGE_NAME="com.geeksville.mesh" -MAIN_ACTIVITY="${PACKAGE_NAME}/.MainActivity" - -echo -e "${BLUE}=== Meshtastic Wireless Build, Install & Launch ===${NC}\n" - -# Parse command line arguments -SKIP_BUILD=false -SKIP_LAUNCH=false - -while [[ "$#" -gt 0 ]]; do - case $1 in - --skip-build) SKIP_BUILD=true ;; - --skip-launch) SKIP_LAUNCH=true ;; - --help) - echo "Usage: $0 [options]" - echo "" - echo "Options:" - echo " --skip-build Skip building the APK (use existing build)" - echo " --skip-launch Install but don't launch the app" - echo " --help Show this help message" - echo "" - exit 0 - ;; - *) echo "Unknown parameter: $1"; exit 1 ;; - esac - shift -done - -# Build APK -if [ "$SKIP_BUILD" = false ]; then - echo -e "${BLUE}Building fdroidDebug APK...${NC}" - echo -e "${YELLOW}(This may take a minute)${NC}\n" - - if ./gradlew assembleFdroidDebug --console=plain | tail -20; then - echo -e "\n${GREEN}✓ Build successful${NC}\n" - else - echo -e "\n${RED}✗ Build failed${NC}" - exit 1 - fi -else - echo -e "${YELLOW}Skipping build (--skip-build flag)${NC}\n" -fi - -# Check if APK exists -if [ ! -f "$APK_PATH" ]; then - echo -e "${RED}Error: APK not found at ${APK_PATH}${NC}" - echo -e "${YELLOW}Try running without --skip-build${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ Found APK: $(basename "$APK_PATH")${NC}" - -# Check for connected devices -DEVICES=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ') - -if [ "$DEVICES" -eq "0" ]; then - echo -e "\n${YELLOW}No devices connected.${NC}" - echo -e "${BLUE}Starting wireless pairing process...${NC}\n" - - echo "On your phone:" - echo "1. Go to Settings → Developer Options → Wireless debugging" - echo "2. Tap 'Pair device with pairing code'" - echo "" - - # Prompt for pairing information - read -p "Enter IP address (e.g., 192.168.8.169): " IP_ADDRESS - read -p "Enter pairing port (e.g., 45621): " PAIRING_PORT - read -p "Enter 6-digit pairing code: " PAIRING_CODE - - echo -e "\n${BLUE}Pairing with device...${NC}" - if adb pair "${IP_ADDRESS}:${PAIRING_PORT}" "${PAIRING_CODE}"; then - echo -e "${GREEN}✓ Successfully paired!${NC}\n" - else - echo -e "${RED}✗ Pairing failed. Please try again.${NC}" - exit 1 - fi - - # Prompt for connection port - echo "On your phone's Wireless debugging screen," - echo "find the 'IP address & Port' at the top (different from pairing port)" - echo "" - read -p "Enter connection port (e.g., 34925): " CONNECTION_PORT - - echo -e "\n${BLUE}Connecting to device...${NC}" - if adb connect "${IP_ADDRESS}:${CONNECTION_PORT}"; then - echo -e "${GREEN}✓ Connected to ${IP_ADDRESS}:${CONNECTION_PORT}${NC}\n" - else - echo -e "${RED}✗ Connection failed. Check the port and try again.${NC}" - exit 1 - fi - - # Wait a moment for connection to stabilize - sleep 1 -elif [ "$DEVICES" -eq "1" ]; then - DEVICE=$(adb devices | grep "device$" | awk '{print $1}') - echo -e "${GREEN}✓ Device already connected: ${DEVICE}${NC}\n" -else - echo -e "${YELLOW}Multiple devices connected. Using first available device.${NC}\n" - DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') - echo -e "${GREEN}Selected device: ${DEVICE}${NC}\n" -fi - -# Verify connection -DEVICE=$(adb devices | grep "device$" | head -1 | awk '{print $1}') -if [ -z "$DEVICE" ]; then - echo -e "${RED}Error: No device connected after pairing process.${NC}" - exit 1 -fi - -# Install APK -echo -e "${BLUE}Installing APK to device ${DEVICE}...${NC}" -echo -e "${YELLOW}(This may take a minute over wireless connection)${NC}\n" - -if adb -s "$DEVICE" install -r -t "$APK_PATH"; then - echo -e "\n${GREEN}╔════════════════════════════════════════╗${NC}" - echo -e "${GREEN}║ ✓ APK installed successfully! ║${NC}" - echo -e "${GREEN}╚════════════════════════════════════════╝${NC}\n" -else - echo -e "\n${RED}✗ Installation failed.${NC}" - exit 1 -fi - -# Launch app -if [ "$SKIP_LAUNCH" = false ]; then - echo -e "${BLUE}Launching Meshtastic app...${NC}\n" - - # Stop app if already running - adb -s "$DEVICE" shell am force-stop "$PACKAGE_NAME" 2>/dev/null || true - - # Launch main activity - if adb -s "$DEVICE" shell am start -n "$MAIN_ACTIVITY" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER; then - echo -e "\n${GREEN}✓ App launched successfully!${NC}\n" - - echo -e "${BLUE}Test the new heatmap feature:${NC}" - echo "1. Navigate to the map view" - echo "2. Tap the filter icon (tune icon)" - echo "3. Check 'Show heatmap'" - echo "" - else - echo -e "\n${YELLOW}⚠ Could not launch app automatically. Please open it manually.${NC}\n" - fi -else - echo -e "${YELLOW}Skipping app launch (--skip-launch flag)${NC}\n" - echo -e "${BLUE}Test the new heatmap feature:${NC}" - echo "1. Open Meshtastic app on your phone" - echo "2. Navigate to the map view" - echo "3. Tap the filter icon (tune icon)" - echo "4. Check 'Show heatmap'" - echo "" -fi - -echo -e "${GREEN}Device: ${DEVICE}${NC}" From 5dd08052d3a5504fedcd5a4d564ee5a96f30f2b9 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:45:27 -0800 Subject: [PATCH 46/62] Use MapLibre for node track visualization instead of osmdroid --- .../feature/map/node/NodeMapScreen.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index f0c41dd441..702a1feace 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -23,13 +23,19 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.MapView +import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.maplibre.ui.MapLibrePOC import timber.log.Timber @Composable -fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) { +fun NodeMapScreen( + nodeMapViewModel: NodeMapViewModel, + onNavigateUp: () -> Unit, + mapViewModel: BaseMapViewModel = hiltViewModel() +) { val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() val destNum = node?.num @@ -55,11 +61,16 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { Timber.tag("NodeMapScreen").d( - "Calling MapView with focusedNodeNum=%s, nodeTracks count=%d", + "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", destNum ?: "null", positions.size ) - MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {}) + MapLibrePOC( + mapViewModel = mapViewModel, + onNavigateToNodeDetails = {}, + focusedNodeNum = destNum, + nodeTracks = positions, + ) } } } From ec9efccf89c17208a382f94c75f0125b961d5a54 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:51:24 -0800 Subject: [PATCH 47/62] Make MapLibre the default map implementation --- .../src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index b521eb8099..da32484ec7 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -406,7 +406,7 @@ fun MapView( var showPurgeTileSourceDialog by remember { mutableStateOf(false) } var showMapStyleDialog by remember { mutableStateOf(false) } // Map engine selection: false = osmdroid, true = MapLibre - var useMapLibre by remember { mutableStateOf(false) } + var useMapLibre by remember { mutableStateOf(true) } val scope = rememberCoroutineScope() val context = LocalContext.current @@ -844,9 +844,9 @@ fun MapView( contentDescription = Res.string.map_style_selection, ) MapButton( - onClick = { useMapLibre = true }, + onClick = { useMapLibre = false }, icon = Icons.Outlined.Layers, - contentDescription = "Switch to MapLibre", + contentDescription = "Switch to osmdroid", ) Box(modifier = Modifier) { MapButton( From 1a698973bb7e578f171c118eaa42a9cc99453e3c Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Fri, 21 Nov 2025 19:56:05 -0800 Subject: [PATCH 48/62] Fix type mismatch: use MapViewModel instead of BaseMapViewModel --- .../kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index 702a1feace..38d1f39cde 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.feature.map.BaseMapViewModel +import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.map.maplibre.ui.MapLibrePOC import timber.log.Timber @@ -34,7 +34,7 @@ import timber.log.Timber fun NodeMapScreen( nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit, - mapViewModel: BaseMapViewModel = hiltViewModel() + mapViewModel: MapViewModel = hiltViewModel() ) { val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() From a51c1cff016d48b010c8fd0a1d63ee882643589c Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 07:48:30 -0800 Subject: [PATCH 49/62] node tracks - clusters and nodes now hidden --- .../feature/map/maplibre/ui/MapLibrePOC.kt | 237 ++++++++++++++---- 1 file changed, 185 insertions(+), 52 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 0c1d5a0d09..1980a33dcf 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -193,8 +193,12 @@ fun MapLibrePOC( var expandedCluster by remember { mutableStateOf(null) } var clusterListMembers by remember { mutableStateOf?>(null) } var mapRef by remember { mutableStateOf(null) } + var styleReady by remember { mutableStateOf(false) } var mapViewRef by remember { mutableStateOf(null) } var didInitialCenter by remember { mutableStateOf(false) } + // Track whether we're currently showing tracks (for callback checks) + val showingTracksRef = remember { mutableStateOf(false) } + showingTracksRef.value = nodeTracks != null && focusedNodeNum != null var showLegend by remember { mutableStateOf(false) } var enabledRoles by remember { mutableStateOf>(emptySet()) } var clusteringEnabled by remember { mutableStateOf(true) } @@ -416,7 +420,10 @@ fun MapLibrePOC( } // Heatmap mode management - LaunchedEffect(heatmapEnabled, nodes, mapFilterState, enabledRoles, ourNode, isLocationTrackingEnabled, clusteringEnabled) { + LaunchedEffect(heatmapEnabled, nodes, mapFilterState, enabledRoles, ourNode, isLocationTrackingEnabled, clusteringEnabled, nodeTracks, focusedNodeNum) { + // Don't manage heatmap/clustering when showing tracks + if (nodeTracks != null && focusedNodeNum != null) return@LaunchedEffect + mapRef?.let { map -> map.style?.let { style -> if (heatmapEnabled) { @@ -455,6 +462,97 @@ fun MapLibrePOC( } } + // Handle node tracks rendering when nodeTracks or focusedNodeNum changes + LaunchedEffect(nodeTracks, focusedNodeNum, mapFilterState.lastHeardTrackFilter, styleReady) { + if (!styleReady) { + Timber.tag("MapLibrePOC").d("LaunchedEffect: Waiting for style to be ready") + return@LaunchedEffect + } + + val map = mapRef ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: mapRef is null") + return@LaunchedEffect + } + val style = map.style ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: Style not ready yet") + return@LaunchedEffect + } + + map.let { map -> + style.let { style -> + if (nodeTracks != null && focusedNodeNum != null) { + Timber.tag("MapLibrePOC").d( + "LaunchedEffect: Rendering tracks for node %d, total positions: %d", + focusedNodeNum, + nodeTracks.size + ) + + // Ensure track sources and layers exist + ensureTrackSourcesAndLayers(style) + + // Get the focused node to use its color + val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } + + // Apply time filter + val currentTimeSeconds = System.currentTimeMillis() / 1000 + val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds + val filteredTracks = nodeTracks.filter { + mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + } + + Timber.tag("MapLibrePOC").d( + "LaunchedEffect: Tracks filtered: %d positions remain (from %d total)", + filteredTracks.size, + nodeTracks.size + ) + + // Update track line + if (filteredTracks.size >= 2) { + positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(lineFeature) + Timber.tag("MapLibrePOC").d("LaunchedEffect: Track line updated") + } + } + + // Update track points + if (filteredTracks.isNotEmpty()) { + val pointsFC = positionsToPointFeatures(filteredTracks) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource) + ?.setGeoJson(pointsFC) + Timber.tag("MapLibrePOC").d("LaunchedEffect: Track points updated") + + // Center camera on the tracks + val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() + filteredTracks.forEach { position -> + trackBounds.include( + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D + ) + ) + } + val padding = 100 // pixels + map.animateCamera( + CameraUpdateFactory.newLatLngBounds( + trackBounds.build(), + padding + ) + ) + Timber.tag("MapLibrePOC").d( + "LaunchedEffect: Camera centered on %d track positions", + filteredTracks.size + ) + } + } else { + Timber.tag("MapLibrePOC").d("LaunchedEffect: No tracks to display - removing track layers") + removeTrackSourcesAndLayers(style) + } + } + } + } + Box(modifier = Modifier.fillMaxSize()) { AndroidView( modifier = Modifier.fillMaxSize(), @@ -470,6 +568,7 @@ fun MapLibrePOC( map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> Timber.tag("MapLibrePOC").d("Style loaded (base=%s)", baseStyle.label) style.setTransition(TransitionOptions(0, 0)) + styleReady = true logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) ensureHeatmapSourceAndLayer(style) @@ -566,6 +665,30 @@ fun MapLibrePOC( filteredTracks.size, focusedNodeNum ) + + // Center camera on the tracks + if (filteredTracks.isNotEmpty()) { + val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() + filteredTracks.forEach { position -> + trackBounds.include( + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D + ) + ) + } + val padding = 100 // pixels + map.animateCamera( + CameraUpdateFactory.newLatLngBounds( + trackBounds.build(), + padding + ) + ) + Timber.tag("MapLibrePOC").d( + "Camera centered on %d track positions", + filteredTracks.size + ) + } } else { Timber.tag("MapLibrePOC").d("No tracks to display - removing track layers") // Remove track layers if no tracks to display @@ -574,10 +697,49 @@ fun MapLibrePOC( // Push current data immediately after style load try { - val density = context.resources.displayMetrics.density - val bounds = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = + // Only set node data if we're not showing tracks + if (nodeTracks == null || focusedNodeNum == null) { + val density = context.resources.displayMetrics.density + val bounds = map.projection.visibleRegion.latLngBounds + val labelSet = run { + val visible = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + .filter { n -> + val p = n.validPosition ?: return@filter false + bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + } + val sorted = + visible.sortedWith( + compareByDescending { it.isFavorite } + .thenByDescending { it.lastHeard }, + ) + val cell = (80f * density).toInt().coerceAtLeast(48) + val occupied = HashSet() + val chosen = LinkedHashSet() + for (n in sorted) { + val p = n.validPosition ?: continue + val pt = + map.projection.toScreenLocation( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + ) + val cx = (pt.x / cell).toInt() + val cy = (pt.y / cell).toInt() + val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) + if (occupied.add(key)) chosen.add(n.num) + } + chosen + } + (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + waypointsToFeatureCollectionFC(waypoints.values), + ) + // Set clustered source only (like MapLibre example) + val filteredNodes = applyFilters( nodes, mapFilterState, @@ -585,55 +747,21 @@ fun MapLibrePOC( ourNode?.num, isLocationTrackingEnabled, ) - .filter { n -> - val p = n.validPosition ?: return@filter false - bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + Timber.tag("MapLibrePOC") + .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source + Timber.tag("MapLibrePOC") + .d( + "Initial data set after style load. nodes=%d waypoints=%d", + nodes.size, + waypoints.size, ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - ) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen + logStyleState("after-style-load(post-sources)", style) + } else { + Timber.tag("MapLibrePOC").d("Skipping node data setup - showing tracks instead") } - (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( - waypointsToFeatureCollectionFC(waypoints.values), - ) - // Set clustered source only (like MapLibre example) - val filteredNodes = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) - Timber.tag("MapLibrePOC") - .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) - safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) - safeSetGeoJson(style, NODES_SOURCE_ID, json) // Also populate non-clustered source - Timber.tag("MapLibrePOC") - .d( - "Initial data set after style load. nodes=%d waypoints=%d", - nodes.size, - waypoints.size, - ) - logStyleState("after-style-load(post-sources)", style) } catch (t: Throwable) { Timber.tag("MapLibrePOC").e(t, "Failed to set initial data after style load") } @@ -869,6 +997,11 @@ fun MapLibrePOC( val st = map.style ?: return@addOnCameraIdleListener // Skip node updates when heatmap is enabled if (heatmapEnabled) return@addOnCameraIdleListener + // Skip node updates when showing tracks + if (showingTracksRef.value) { + Timber.tag("MapLibrePOC").d("onCameraIdle: Skipping node updates - showing tracks") + return@addOnCameraIdleListener + } // Debounce to avoid rapid toggling during kinetic flings/tiles loading val now = SystemClock.uptimeMillis() if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener From 4dba8680168f4c91eee892bc8eee3462c31c3c7f Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 07:59:47 -0800 Subject: [PATCH 50/62] fix InvalidLatLngBoundsException --- .../feature/map/maplibre/ui/MapLibrePOC.kt | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 1980a33dcf..85eac6e901 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -524,26 +524,38 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC").d("LaunchedEffect: Track points updated") // Center camera on the tracks - val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() - filteredTracks.forEach { position -> - trackBounds.include( - org.maplibre.android.geometry.LatLng( - position.latitudeI * DEG_D, - position.longitudeI * DEG_D + if (filteredTracks.size == 1) { + // Single position - just center on it with a fixed zoom + val position = filteredTracks.first() + val latLng = org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D + ) + map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 12.0)) + Timber.tag("MapLibrePOC").d("LaunchedEffect: Camera centered on single track position") + } else { + // Multiple positions - fit bounds + val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() + filteredTracks.forEach { position -> + trackBounds.include( + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D + ) + ) + } + val padding = 100 // pixels + map.animateCamera( + CameraUpdateFactory.newLatLngBounds( + trackBounds.build(), + padding ) ) - } - val padding = 100 // pixels - map.animateCamera( - CameraUpdateFactory.newLatLngBounds( - trackBounds.build(), - padding + Timber.tag("MapLibrePOC").d( + "LaunchedEffect: Camera centered on %d track positions", + filteredTracks.size ) - ) - Timber.tag("MapLibrePOC").d( - "LaunchedEffect: Camera centered on %d track positions", - filteredTracks.size - ) + } } } else { Timber.tag("MapLibrePOC").d("LaunchedEffect: No tracks to display - removing track layers") From 8ecec7fbd650fff4bbb361b274fcee1a3af9543d Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 08:16:38 -0800 Subject: [PATCH 51/62] fix kmz upload --- .../feature/map/maplibre/utils/MapLibreLayerUtils.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt index f3aa8aeef7..f153f84aa9 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -154,7 +154,8 @@ private fun extractKmlFromKmz(inputStream: InputStream): String? { while (entry != null) { val fileName = entry.name.lowercase() if (fileName.endsWith(".kml")) { - val kmlContent = zipInputStream.bufferedReader().use { it.readText() } + // Read content without closing the stream + val kmlContent = zipInputStream.bufferedReader().readText() zipInputStream.closeEntry() Timber.tag("MapLibreLayerUtils").d("Extracted KML from KMZ: ${entry.name}") return kmlContent From 883bde09237890bb908658abf84f25bae39ad0aa Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 08:24:27 -0800 Subject: [PATCH 52/62] debug logging --- .../map/maplibre/core/MapLibreLayerManager.kt | 18 ++++++++++++++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 24 +++++++++++++++++++ .../map/maplibre/utils/MapLibreLayerUtils.kt | 15 ++++++++++-- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index 2a80ce8881..b912e3e4eb 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -251,23 +251,34 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S val lineLayerId = "imported-layer-lines-$layerId" val fillLayerId = "imported-layer-fills-$layerId" + Timber.tag("MapLibreLayerManager").d( + "ensureImportedLayerSourceAndLayers: layerId=%s, hasGeoJson=%s, isVisible=%s", + layerId, geoJson != null, isVisible + ) + try { // Add or update source val existingSource = style.getSource(sourceId) if (existingSource == null) { // Create new source if (geoJson != null) { + Timber.tag("MapLibreLayerManager").d("Creating new GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) style.addSource(GeoJsonSource(sourceId, geoJson)) } else { + Timber.tag("MapLibreLayerManager").d("Creating empty GeoJSON source: %s", sourceId) style.addSource(GeoJsonSource(sourceId, FeatureCollection.fromFeatures(emptyList()))) } } else if (geoJson != null && existingSource is GeoJsonSource) { // Update existing source + Timber.tag("MapLibreLayerManager").d("Updating existing GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) existingSource.setGeoJson(geoJson) + } else { + Timber.tag("MapLibreLayerManager").d("Source already exists: %s", sourceId) } // Add point layer (CircleLayer for points) if (style.getLayer(pointLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating point layer: %s", pointLayerId) val pointLayer = CircleLayer(pointLayerId, sourceId) pointLayer.setProperties( circleColor("#3388ff"), @@ -279,11 +290,13 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(pointLayer, OSM_LAYER_ID) } else { + Timber.tag("MapLibreLayerManager").d("Updating point layer visibility: %s -> %s", pointLayerId, if (isVisible) "visible" else "none") style.getLayer(pointLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } // Add line layer (LineLayer for LineStrings) if (style.getLayer(lineLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating line layer: %s", lineLayerId) val lineLayer = LineLayer(lineLayerId, sourceId) lineLayer.setProperties( lineColor("#3388ff"), @@ -293,11 +306,13 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(lineLayer, OSM_LAYER_ID) } else { + Timber.tag("MapLibreLayerManager").d("Updating line layer visibility: %s -> %s", lineLayerId, if (isVisible) "visible" else "none") style.getLayer(lineLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } // Add fill layer (FillLayer for Polygons) if (style.getLayer(fillLayerId) == null) { + Timber.tag("MapLibreLayerManager").d("Creating fill layer: %s", fillLayerId) val fillLayer = FillLayer(fillLayerId, sourceId) fillLayer.setProperties( fillColor("#3388ff"), @@ -306,8 +321,11 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(fillLayer, OSM_LAYER_ID) } else { + Timber.tag("MapLibreLayerManager").d("Updating fill layer visibility: %s -> %s", fillLayerId, if (isVisible) "visible" else "none") style.getLayer(fillLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } + + Timber.tag("MapLibreLayerManager").d("Successfully ensured layers for: %s", layerId) } catch (e: Exception) { Timber.tag("MapLibreLayerManager").e(e, "Error ensuring imported layer source and layers for $layerId") } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 85eac6e901..000afd612d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -282,11 +282,13 @@ fun MapLibrePOC( if (result.resultCode == android.app.Activity.RESULT_OK) { result.data?.data?.let { uri -> val fileName = uri.getFileName(context) + Timber.tag("MapLibrePOC").d("File picker result: uri=%s, fileName=%s", uri, fileName) coroutineScope.launch { val layerName = fileName?.substringBeforeLast('.') ?: "Layer ${mapLayers.size + 1}" val extension = fileName?.substringAfterLast('.', "")?.lowercase() ?: context.contentResolver.getType(uri)?.split('/')?.last() + Timber.tag("MapLibrePOC").d("Layer upload: name=%s, extension=%s", layerName, extension) val kmlExtensions = listOf("kml", "kmz", "vnd.google-earth.kml+xml", "vnd.google-earth.kmz") val geoJsonExtensions = listOf("geojson", "json") val layerType = @@ -296,15 +298,24 @@ fun MapLibrePOC( else -> null } if (layerType != null) { + Timber.tag("MapLibrePOC").d("Detected layer type: %s", layerType) val finalFileName = fileName ?: "layer_${java.util.UUID.randomUUID()}.$extension" val localFileUri = copyFileToInternalStorage(context, uri, finalFileName) if (localFileUri != null) { + Timber.tag("MapLibrePOC").d("File copied to internal storage: %s", localFileUri) val newItem = MapLayerItem(name = layerName, uri = localFileUri, layerType = layerType) mapLayers = mapLayers + newItem + Timber.tag("MapLibrePOC").d("Layer added to list. Total layers: %d", mapLayers.size) + } else { + Timber.tag("MapLibrePOC").e("Failed to copy file to internal storage") } + } else { + Timber.tag("MapLibrePOC").w("Unsupported file type: extension=%s", extension) } } } + } else { + Timber.tag("MapLibrePOC").d("File picker cancelled or failed: resultCode=%d", result.resultCode) } } @@ -329,20 +340,33 @@ fun MapLibrePOC( LaunchedEffect(mapLayers, mapRef) { mapRef?.let { map -> map.style?.let { style -> + Timber.tag("MapLibrePOC").d("Layer rendering LaunchedEffect triggered. Layers count: %d", mapLayers.size) coroutineScope.launch { // Load GeoJSON for layers that don't have it cached mapLayers.forEach { layer -> if (!layerGeoJsonCache.containsKey(layer.id)) { + Timber.tag("MapLibrePOC").d("Loading GeoJSON for layer: id=%s, name=%s, type=%s", layer.id, layer.name, layer.layerType) val geoJson = loadLayerGeoJson(context, layer) if (geoJson != null) { + val featureCount = try { + val jsonObj = org.json.JSONObject(geoJson) + jsonObj.optJSONArray("features")?.length() ?: 0 + } catch (e: Exception) { 0 } + Timber.tag("MapLibrePOC").d("GeoJSON loaded for layer %s: %d features, %d bytes", layer.name, featureCount, geoJson.length) layerGeoJsonCache = layerGeoJsonCache + (layer.id to geoJson) + } else { + Timber.tag("MapLibrePOC").e("Failed to load GeoJSON for layer: %s", layer.name) } + } else { + Timber.tag("MapLibrePOC").d("Using cached GeoJSON for layer: %s", layer.name) } } // Ensure all layers are rendered mapLayers.forEach { layer -> val geoJson = layerGeoJsonCache[layer.id] + Timber.tag("MapLibrePOC").d("Rendering layer: id=%s, name=%s, visible=%s, hasGeoJson=%s", + layer.id, layer.name, layer.isVisible, geoJson != null) ensureImportedLayerSourceAndLayers(style, layer.id, geoJson, layer.isVisible) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt index f153f84aa9..e14346bbdd 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -270,13 +270,24 @@ private fun parseCoordinates(coordStr: String): List = coordStr.split(" " /** Loads GeoJSON from a layer item (converting KML if needed) */ suspend fun loadLayerGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { - when (layerItem.layerType) { - LayerType.KML -> convertKmlToGeoJson(context, layerItem) + Timber.tag("MapLibreLayerUtils").d("loadLayerGeoJson: name=%s, type=%s, uri=%s", layerItem.name, layerItem.layerType, layerItem.uri) + val result = when (layerItem.layerType) { + LayerType.KML -> { + Timber.tag("MapLibreLayerUtils").d("Converting KML to GeoJSON for: %s", layerItem.name) + convertKmlToGeoJson(context, layerItem) + } LayerType.GEOJSON -> { + Timber.tag("MapLibreLayerUtils").d("Loading GeoJSON directly for: %s", layerItem.name) val uri = layerItem.uri ?: return@withContext null getInputStreamFromUri(context, uri)?.use { stream -> stream.bufferedReader().use { it.readText() } } } } + if (result != null) { + Timber.tag("MapLibreLayerUtils").d("Successfully loaded GeoJSON for %s: %d bytes", layerItem.name, result.length) + } else { + Timber.tag("MapLibreLayerUtils").e("Failed to load GeoJSON for: %s", layerItem.name) + } + result } /** Extension function to get file name from URI */ From da061084cfbf95c65170cbb1b4d2b0b093d32d79 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 08:49:02 -0800 Subject: [PATCH 53/62] spotless --- .../org/meshtastic/feature/map/MapView.kt | 81 +-- .../map/component/TileCacheManagementSheet.kt | 29 +- .../maplibre/core/MapLibreDataTransformers.kt | 59 +-- .../map/maplibre/core/MapLibreLayerManager.kt | 124 +++-- .../map/maplibre/ui/MapLibreBottomSheets.kt | 26 +- .../map/maplibre/ui/MapLibreControlButtons.kt | 6 - .../feature/map/maplibre/ui/MapLibrePOC.kt | 471 +++++++++--------- .../feature/map/maplibre/ui/MapLibreUI.kt | 129 ++--- .../map/maplibre/utils/MapLibreLayerUtils.kt | 75 ++- .../utils/MapLibreTileCacheManager.kt | 101 ++-- .../feature/map/node/NodeMapScreen.kt | 16 +- 11 files changed, 508 insertions(+), 609 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index da32484ec7..2f8168d45b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -19,6 +19,8 @@ package org.meshtastic.feature.map import android.Manifest // Added for Accompanist import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,10 +34,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Lens import androidx.compose.material.icons.filled.LocationDisabled import androidx.compose.material.icons.filled.PinDrop @@ -52,7 +51,6 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -76,7 +74,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi // Added for Accompanist @@ -427,8 +424,7 @@ fun MapView( Timber.d("mapStyleId from prefs: $id") return CustomTileSource.getTileSource(id).also { zoomLevelMax = it.maximumZoomLevel.toDouble() - showDownloadButton = - if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false + showDownloadButton = if (it is OnlineTileSourceBase) it.tileSourcePolicy.acceptsBulkDownload() else false } } @@ -464,18 +460,12 @@ fun MapView( MyLocationNewOverlay(this).apply { enableMyLocation() enableFollowLocation() - getBitmapFromVectorDrawable( - context, - org.meshtastic.core.ui.R.drawable.ic_map_location_dot_24 - ) + getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_location_dot_24) ?.let { setPersonIcon(it) setPersonAnchor(0.5f, 0.5f) } - getBitmapFromVectorDrawable( - context, - org.meshtastic.core.ui.R.drawable.ic_map_navigation_24 - )?.let { + getBitmapFromVectorDrawable(context, org.meshtastic.core.ui.R.drawable.ic_map_navigation_24)?.let { setDirectionIcon(it) setDirectionAnchor(0.5f, 0.5f) } @@ -503,18 +493,14 @@ fun MapView( val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val markerIcon = remember { - AppCompatResources.getDrawable( - context, - org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24 - ) + AppCompatResources.getDrawable(context, org.meshtastic.core.ui.R.drawable.ic_baseline_location_on_24) } fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = mapViewModel.ourNodeInfo.value val displayUnits = mapViewModel.config.display.units - val mapFilterStateValue = - mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly + val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly return nodesWithPosition.mapNotNull { node -> if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) { return@mapNotNull null @@ -644,8 +630,7 @@ fun MapView( MarkerWithLabel(this, label, emoji).apply { id = "${pt.id}" title = "${pt.name} (${getUsername(waypoint.data.from)}$lock)" - snippet = - "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr" + snippet = "[$time] ${pt.description} " + stringResource(Res.string.expires) + ": $expireTimeStr" position = GeoPoint(pt.latitudeI * 1e-7, pt.longitudeI * 1e-7) setVisible(false) // This seems to be always false, was this intended? setOnLongClickListener { @@ -697,13 +682,7 @@ fun MapView( // Only update osmdroid markers when using osmdroid if (!useMapLibre && map != null) { - with(map) { - UpdateMarkers( - onNodesChanged(nodes), - onWaypointChanged(waypoints.values), - nodeClusterer - ) - } + with(map) { UpdateMarkers(onNodesChanged(nodes), onWaypointChanged(waypoints.values), nodeClusterer) } } fun MapView.generateBoxOverlay() { @@ -713,18 +692,13 @@ fun MapView( downloadRegionBoundingBox = boundingBox.zoomIn(zoomFactor) val polygon = Polygon().apply { - points = Polygon.pointsAsRect(downloadRegionBoundingBox) - .map { GeoPoint(it.latitude, it.longitude) } + points = Polygon.pointsAsRect(downloadRegionBoundingBox).map { GeoPoint(it.latitude, it.longitude) } } overlays.add(polygon) invalidate() val tileCount: Int = CacheManager(this) - .possibleTilesInArea( - downloadRegionBoundingBox, - zoomLevelMin.toInt(), - zoomLevelMax.toInt() - ) + .possibleTilesInArea(downloadRegionBoundingBox, zoomLevelMin.toInt(), zoomLevelMax.toInt()) cacheEstimate = com.meshtastic.core.strings.getString(Res.string.map_cache_tiles, tileCount) } @@ -792,11 +766,12 @@ fun MapView( ) { isMapLibre -> if (isMapLibre) { // MapLibre implementation - timber.log.Timber.tag("MapView").d( - "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", - focusedNodeNum ?: "null", - nodeTracks?.size ?: 0 - ) + timber.log.Timber.tag("MapView") + .d( + "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", + focusedNodeNum ?: "null", + nodeTracks?.size ?: 0, + ) org.meshtastic.feature.map.maplibre.ui.MapLibrePOC( mapViewModel = mapViewModel, onNavigateToNodeDetails = navigateToNodeDetails, @@ -834,8 +809,7 @@ fun MapView( ) } else { Column( - modifier = Modifier.padding(top = 16.dp, end = 16.dp) - .align(Alignment.TopEnd), + modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd), verticalArrangement = Arrangement.spacedBy(8.dp), ) { MapButton( @@ -939,11 +913,11 @@ fun MapView( if (hasGps) { MapButton( icon = - if (myLocationOverlay == null) { - Icons.Outlined.MyLocation - } else { - Icons.Default.LocationDisabled - }, + if (myLocationOverlay == null) { + Icons.Outlined.MyLocation + } else { + Icons.Default.LocationDisabled + }, contentDescription = stringResource(Res.string.toggle_my_position), onClick = { if (locationPermissionsState.allPermissionsGranted) { @@ -1002,7 +976,6 @@ fun MapView( } } - showEditWaypointDialog?.let { waypoint -> EditWaypointDialog( waypoint = waypoint, @@ -1011,12 +984,10 @@ fun MapView( showEditWaypointDialog = null mapViewModel.sendWaypoint( waypoint.copy { - if (id == 0) id = - mapViewModel.generatePacketId() ?: return@EditWaypointDialog + if (id == 0) id = mapViewModel.generatePacketId() ?: return@EditWaypointDialog if (name == "") name = "Dropped Pin" if (expire == 0) expire = Int.MAX_VALUE - lockedTo = - if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 + lockedTo = if (waypoint.lockedTo != 0) mapViewModel.myNodeNum ?: 0 else 0 if (waypoint.icon == 0) icon = 128205 }, ) @@ -1033,4 +1004,4 @@ fun MapView( ) } } -} \ No newline at end of file +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt index 208687937a..b465e32724 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/TileCacheManagementSheet.kt @@ -22,19 +22,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,19 +43,12 @@ import timber.log.Timber @Composable @OptIn(ExperimentalMaterial3Api::class) -fun TileCacheManagementSheet( - cacheManager: MapLibreTileCacheManager, - onDismiss: () -> Unit, -) { +fun TileCacheManagementSheet(cacheManager: MapLibreTileCacheManager, onDismiss: () -> Unit) { var isClearing by remember { mutableStateOf(false) } LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { item { - Text( - modifier = Modifier.padding(16.dp), - text = "Map Cache", - style = MaterialTheme.typography.headlineSmall, - ) + Text(modifier = Modifier.padding(16.dp), text = "Map Cache", style = MaterialTheme.typography.headlineSmall) HorizontalDivider() } @@ -74,7 +60,8 @@ fun TileCacheManagementSheet( modifier = Modifier.padding(bottom = 8.dp), ) Text( - text = "Map tiles are automatically cached by MapLibre as you view the map. " + + text = + "Map tiles are automatically cached by MapLibre as you view the map. " + "This improves performance and allows limited offline viewing of previously visited areas.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -97,7 +84,8 @@ fun TileCacheManagementSheet( modifier = Modifier.padding(bottom = 8.dp), ) Text( - text = "If you're experiencing issues with outdated map tiles or want to free up storage space, you can clear the cache below.", + text = + "If you're experiencing issues with outdated map tiles or want to free up storage space, you can clear the cache below.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -116,9 +104,7 @@ fun TileCacheManagementSheet( } catch (e: Exception) { Timber.tag("TileCacheManagementSheet").e(e, "Error clearing cache: ${e.message}") } finally { - withContext(Dispatchers.Main) { - isClearing = false - } + withContext(Dispatchers.Main) { isClearing = false } } } }, @@ -138,4 +124,3 @@ fun TileCacheManagementSheet( } } } - diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index eee966ff4d..884b253354 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -162,47 +162,50 @@ fun escapeJson(input: String): String { fun positionsToLineStringFeature(positions: List): Feature? { if (positions.size < 2) return null - val points = positions.map { pos -> - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - Point.fromLngLat(lon, lat) - } + val points = + positions.map { pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + Point.fromLngLat(lon, lat) + } return Feature.fromGeometry(LineString.fromLngLats(points)) } /** Converts a list of positions to a GeoJSON FeatureCollection for track point markers */ fun positionsToPointFeatures(positions: List): FeatureCollection { - val features = positions.mapIndexed { index, pos -> - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - val point = Point.fromLngLat(lon, lat) - val feature = Feature.fromGeometry(point) + val features = + positions.mapIndexed { index, pos -> + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + val feature = Feature.fromGeometry(point) - feature.addStringProperty("kind", "track_point") - feature.addNumberProperty("index", index) - feature.addNumberProperty("time", pos.time) - feature.addNumberProperty("altitude", pos.altitude) - feature.addNumberProperty("groundSpeed", pos.groundSpeed) - feature.addNumberProperty("groundTrack", pos.groundTrack) - feature.addNumberProperty("satsInView", pos.satsInView) - feature.addNumberProperty("latitude", lat) - feature.addNumberProperty("longitude", lon) + feature.addStringProperty("kind", "track_point") + feature.addNumberProperty("index", index) + feature.addNumberProperty("time", pos.time) + feature.addNumberProperty("altitude", pos.altitude) + feature.addNumberProperty("groundSpeed", pos.groundSpeed) + feature.addNumberProperty("groundTrack", pos.groundTrack) + feature.addNumberProperty("satsInView", pos.satsInView) + feature.addNumberProperty("latitude", lat) + feature.addNumberProperty("longitude", lon) - feature - } + feature + } return FeatureCollection.fromFeatures(features) } /** Converts nodes to simple GeoJSON FeatureCollection for heatmap */ fun nodesToHeatmapFeatureCollection(nodes: List): FeatureCollection { - val features = nodes.mapNotNull { node -> - val pos = node.validPosition ?: return@mapNotNull null - val lat = pos.latitudeI * DEG_D - val lon = pos.longitudeI * DEG_D - val point = Point.fromLngLat(lon, lat) - Feature.fromGeometry(point) - } + val features = + nodes.mapNotNull { node -> + val pos = node.validPosition ?: return@mapNotNull null + val lat = pos.latitudeI * DEG_D + val lon = pos.longitudeI * DEG_D + val point = Point.fromLngLat(lon, lat) + Feature.fromGeometry(point) + } return FeatureCollection.fromFeatures(features) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index b912e3e4eb..ea32146c92 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -251,10 +251,13 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S val lineLayerId = "imported-layer-lines-$layerId" val fillLayerId = "imported-layer-fills-$layerId" - Timber.tag("MapLibreLayerManager").d( - "ensureImportedLayerSourceAndLayers: layerId=%s, hasGeoJson=%s, isVisible=%s", - layerId, geoJson != null, isVisible - ) + Timber.tag("MapLibreLayerManager") + .d( + "ensureImportedLayerSourceAndLayers: layerId=%s, hasGeoJson=%s, isVisible=%s", + layerId, + geoJson != null, + isVisible, + ) try { // Add or update source @@ -262,7 +265,8 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S if (existingSource == null) { // Create new source if (geoJson != null) { - Timber.tag("MapLibreLayerManager").d("Creating new GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) + Timber.tag("MapLibreLayerManager") + .d("Creating new GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) style.addSource(GeoJsonSource(sourceId, geoJson)) } else { Timber.tag("MapLibreLayerManager").d("Creating empty GeoJSON source: %s", sourceId) @@ -270,7 +274,8 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S } } else if (geoJson != null && existingSource is GeoJsonSource) { // Update existing source - Timber.tag("MapLibreLayerManager").d("Updating existing GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) + Timber.tag("MapLibreLayerManager") + .d("Updating existing GeoJSON source: %s (%d bytes)", sourceId, geoJson.length) existingSource.setGeoJson(geoJson) } else { Timber.tag("MapLibreLayerManager").d("Source already exists: %s", sourceId) @@ -290,7 +295,8 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(pointLayer, OSM_LAYER_ID) } else { - Timber.tag("MapLibreLayerManager").d("Updating point layer visibility: %s -> %s", pointLayerId, if (isVisible) "visible" else "none") + Timber.tag("MapLibreLayerManager") + .d("Updating point layer visibility: %s -> %s", pointLayerId, if (isVisible) "visible" else "none") style.getLayer(pointLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } @@ -306,7 +312,8 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(lineLayer, OSM_LAYER_ID) } else { - Timber.tag("MapLibreLayerManager").d("Updating line layer visibility: %s -> %s", lineLayerId, if (isVisible) "visible" else "none") + Timber.tag("MapLibreLayerManager") + .d("Updating line layer visibility: %s -> %s", lineLayerId, if (isVisible) "visible" else "none") style.getLayer(lineLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } @@ -321,7 +328,8 @@ fun ensureImportedLayerSourceAndLayers(style: Style, layerId: String, geoJson: S ) style.addLayerAbove(fillLayer, OSM_LAYER_ID) } else { - Timber.tag("MapLibreLayerManager").d("Updating fill layer visibility: %s -> %s", fillLayerId, if (isVisible) "visible" else "none") + Timber.tag("MapLibreLayerManager") + .d("Updating fill layer visibility: %s -> %s", fillLayerId, if (isVisible) "visible" else "none") style.getLayer(fillLayerId)?.setProperties(visibility(if (isVisible) "visible" else "none")) } @@ -438,12 +446,9 @@ fun ensureTrackSourcesAndLayers(style: Style, trackColor: String = "#FF5722") { // Add track line layer if it doesn't exist if (style.getLayer(TRACK_LINE_LAYER_ID) == null) { - val lineLayer = LineLayer(TRACK_LINE_LAYER_ID, TRACK_LINE_SOURCE_ID) - .withProperties( - lineColor(trackColor), - lineWidth(3f), - lineOpacity(0.8f) - ) + val lineLayer = + LineLayer(TRACK_LINE_LAYER_ID, TRACK_LINE_SOURCE_ID) + .withProperties(lineColor(trackColor), lineWidth(3f), lineOpacity(0.8f)) // Add above OSM layer if it exists if (style.getLayer(OSM_LAYER_ID) != null) { @@ -456,14 +461,15 @@ fun ensureTrackSourcesAndLayers(style: Style, trackColor: String = "#FF5722") { // Add track points layer if it doesn't exist if (style.getLayer(TRACK_POINTS_LAYER_ID) == null) { - val pointsLayer = CircleLayer(TRACK_POINTS_LAYER_ID, TRACK_POINTS_SOURCE_ID) - .withProperties( - circleColor(trackColor), - circleRadius(5f), - circleStrokeColor("#FFFFFF"), - circleStrokeWidth(2f), - circleOpacity(0.7f) - ) + val pointsLayer = + CircleLayer(TRACK_POINTS_LAYER_ID, TRACK_POINTS_SOURCE_ID) + .withProperties( + circleColor(trackColor), + circleRadius(5f), + circleStrokeColor("#FFFFFF"), + circleStrokeWidth(2f), + circleOpacity(0.7f), + ) // Add above track line layer style.addLayerAbove(pointsLayer, TRACK_LINE_LAYER_ID) @@ -492,52 +498,34 @@ fun ensureHeatmapSourceAndLayer(style: Style) { // Add heatmap layer if it doesn't exist if (style.getLayer(HEATMAP_LAYER_ID) == null) { - val heatmapLayer = HeatmapLayer(HEATMAP_LAYER_ID, HEATMAP_SOURCE_ID) - .withProperties( - // Each node contributes equally to the heatmap - heatmapWeight(literal(1.0)), - // Increase the heatmap intensity by zoom level - // Higher intensity = more sensitive to node density - heatmapIntensity( - interpolate( - linear(), zoom(), - stop(0, 0.3), - stop(9, 0.8), - stop(15, 1.5) - ) - ), - // Color ramp for heatmap - requires higher density to reach warmer colors - heatmapColor( - interpolate( - linear(), literal("heatmap-density"), - stop(0.0, toColor(literal("rgba(33,102,172,0)"))), - stop(0.1, toColor(literal("rgb(33,102,172)"))), - stop(0.3, toColor(literal("rgb(103,169,207)"))), - stop(0.5, toColor(literal("rgb(209,229,240)"))), - stop(0.7, toColor(literal("rgb(253,219,199)"))), - stop(0.85, toColor(literal("rgb(239,138,98)"))), - stop(1.0, toColor(literal("rgb(178,24,43)"))) - ) - ), - // Smaller radius = each node influences a smaller area - // More nodes needed in close proximity to create high density - heatmapRadius( - interpolate( - linear(), zoom(), - stop(0, 2.0), - stop(9, 6.0), - stop(15, 10.0) - ) - ), - // Transition from heatmap to circle layer by zoom level - heatmapOpacity( - interpolate( - linear(), zoom(), - stop(7, 1.0), - stop(22, 1.0) - ) + val heatmapLayer = + HeatmapLayer(HEATMAP_LAYER_ID, HEATMAP_SOURCE_ID) + .withProperties( + // Each node contributes equally to the heatmap + heatmapWeight(literal(1.0)), + // Increase the heatmap intensity by zoom level + // Higher intensity = more sensitive to node density + heatmapIntensity(interpolate(linear(), zoom(), stop(0, 0.3), stop(9, 0.8), stop(15, 1.5))), + // Color ramp for heatmap - requires higher density to reach warmer colors + heatmapColor( + interpolate( + linear(), + literal("heatmap-density"), + stop(0.0, toColor(literal("rgba(33,102,172,0)"))), + stop(0.1, toColor(literal("rgb(33,102,172)"))), + stop(0.3, toColor(literal("rgb(103,169,207)"))), + stop(0.5, toColor(literal("rgb(209,229,240)"))), + stop(0.7, toColor(literal("rgb(253,219,199)"))), + stop(0.85, toColor(literal("rgb(239,138,98)"))), + stop(1.0, toColor(literal("rgb(178,24,43)"))), + ), + ), + // Smaller radius = each node influences a smaller area + // More nodes needed in close proximity to create high density + heatmapRadius(interpolate(linear(), zoom(), stop(0, 2.0), stop(9, 6.0), stop(15, 10.0))), + // Transition from heatmap to circle layer by zoom level + heatmapOpacity(interpolate(linear(), zoom(), stop(7, 1.0), stop(22, 1.0))), ) - ) // Add above OSM layer if it exists, otherwise add at bottom if (style.getLayer(OSM_LAYER_ID) != null) { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt index 86cc5f8f4a..70c75dc280 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreBottomSheets.kt @@ -39,9 +39,7 @@ import androidx.compose.ui.unit.dp import org.meshtastic.core.database.model.Node import org.meshtastic.core.ui.component.NodeChip -/** - * Bottom sheet showing details and actions for a selected node - */ +/** Bottom sheet showing details and actions for a selected node */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun NodeDetailsBottomSheet( @@ -70,17 +68,13 @@ fun NodeDetailsBottomSheet( Text(text = "Distance: $distanceKm km") } Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { - Button(onClick = onViewFullNode) { - Text("View full node") - } + Button(onClick = onViewFullNode) { Text("View full node") } } } } } -/** - * Bottom sheet showing a list of nodes in a large cluster - */ +/** Bottom sheet showing a list of nodes in a large cluster */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ClusterListBottomSheet( @@ -89,24 +83,16 @@ fun ClusterListBottomSheet( onDismiss: () -> Unit, sheetState: SheetState, ) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text(text = "Cluster items (${members.size})", style = MaterialTheme.typography.titleMedium) LazyColumn { items(members) { node -> Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { - onNodeClicked(node) - }, + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).clickable { onNodeClicked(node) }, verticalAlignment = Alignment.CenterVertically, ) { - NodeChip( - node = node, - onClick = { onNodeClicked(node) }, - ) + NodeChip(node = node, onClick = { onNodeClicked(node) }) Spacer(modifier = Modifier.width(12.dp)) val longName = node.user.longName if (!longName.isNullOrBlank()) { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt index d82cf114f3..af267f678c 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreControlButtons.kt @@ -25,28 +25,22 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.LocationDisabled import androidx.compose.material.icons.filled.Navigation import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation import androidx.compose.material.icons.outlined.Navigation -import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.style.sources.GeoJsonSource diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 000afd612d..7aa7cc6e28 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -24,50 +24,14 @@ import android.graphics.RectF import android.os.SystemClock import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.LocationDisabled -import androidx.compose.material.icons.filled.MyLocation -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Explore -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Map -import androidx.compose.material.icons.outlined.MyLocation -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.Remove -import androidx.compose.material.icons.outlined.Storage -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.HorizontalFloatingToolbar -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.draw.rotate -import org.meshtastic.core.ui.theme.StatusColors.StatusRed import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -79,7 +43,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -101,16 +64,13 @@ import org.maplibre.android.style.layers.TransitionOptions import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Point import org.meshtastic.core.database.model.Node -import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.feature.map.LayerType import org.meshtastic.feature.map.MapLayerItem import org.meshtastic.feature.map.MapViewModel import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.EditWaypointDialog -import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.component.TileCacheManagementSheet import org.meshtastic.feature.map.maplibre.BaseMapStyle -import org.meshtastic.feature.map.maplibre.MapLibreConstants import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX @@ -132,9 +92,8 @@ import org.meshtastic.feature.map.maplibre.core.ensureImportedLayerSourceAndLaye import org.meshtastic.feature.map.maplibre.core.ensureSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.ensureTrackSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.logStyleState -import org.meshtastic.feature.map.maplibre.core.nodesToHeatmapFeatureCollection -import org.meshtastic.feature.map.maplibre.core.setNodeLayersVisibility import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.nodesToHeatmapFeatureCollection import org.meshtastic.feature.map.maplibre.core.positionsToLineStringFeature import org.meshtastic.feature.map.maplibre.core.positionsToPointFeatures import org.meshtastic.feature.map.maplibre.core.reinitializeStyleAfterSwitch @@ -142,7 +101,9 @@ import org.meshtastic.feature.map.maplibre.core.removeImportedLayerSourceAndLaye import org.meshtastic.feature.map.maplibre.core.removeTrackSourcesAndLayers import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis +import org.meshtastic.feature.map.maplibre.core.setNodeLayersVisibility import org.meshtastic.feature.map.maplibre.core.waypointsToFeatureCollectionFC +import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager import org.meshtastic.feature.map.maplibre.utils.applyFilters import org.meshtastic.feature.map.maplibre.utils.copyFileToInternalStorage import org.meshtastic.feature.map.maplibre.utils.deleteFileFromInternalStorage @@ -152,18 +113,12 @@ import org.meshtastic.feature.map.maplibre.utils.getFileName import org.meshtastic.feature.map.maplibre.utils.hasAnyLocationPermission import org.meshtastic.feature.map.maplibre.utils.loadLayerGeoJson import org.meshtastic.feature.map.maplibre.utils.loadPersistedLayers -import org.meshtastic.feature.map.maplibre.utils.MapLibreTileCacheManager -import org.meshtastic.feature.map.maplibre.utils.protoShortName -import org.meshtastic.feature.map.maplibre.utils.roleColor import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport -import org.meshtastic.feature.map.maplibre.utils.shortNameFallback import org.meshtastic.proto.ConfigProtos import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint import timber.log.Timber -import kotlin.math.cos -import kotlin.math.sin @SuppressLint("MissingPermission") @Composable @@ -179,11 +134,12 @@ fun MapLibrePOC( var selectedNodeNum by remember { mutableStateOf(null) } // Log track parameters on entry - Timber.tag("MapLibrePOC").d( - "MapLibrePOC called - focusedNodeNum=%s, nodeTracks count=%d", - focusedNodeNum ?: "null", - nodeTracks?.size ?: 0 - ) + Timber.tag("MapLibrePOC") + .d( + "MapLibrePOC called - focusedNodeNum=%s, nodeTracks count=%d", + focusedNodeNum ?: "null", + nodeTracks?.size ?: 0, + ) val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() var isLocationTrackingEnabled by remember { mutableStateOf(false) } @@ -254,7 +210,6 @@ fun MapLibrePOC( } } - // Helper functions for layer management fun toggleLayerVisibility(layerId: String) { mapLayers = @@ -340,19 +295,35 @@ fun MapLibrePOC( LaunchedEffect(mapLayers, mapRef) { mapRef?.let { map -> map.style?.let { style -> - Timber.tag("MapLibrePOC").d("Layer rendering LaunchedEffect triggered. Layers count: %d", mapLayers.size) + Timber.tag("MapLibrePOC") + .d("Layer rendering LaunchedEffect triggered. Layers count: %d", mapLayers.size) coroutineScope.launch { // Load GeoJSON for layers that don't have it cached mapLayers.forEach { layer -> if (!layerGeoJsonCache.containsKey(layer.id)) { - Timber.tag("MapLibrePOC").d("Loading GeoJSON for layer: id=%s, name=%s, type=%s", layer.id, layer.name, layer.layerType) + Timber.tag("MapLibrePOC") + .d( + "Loading GeoJSON for layer: id=%s, name=%s, type=%s", + layer.id, + layer.name, + layer.layerType, + ) val geoJson = loadLayerGeoJson(context, layer) if (geoJson != null) { - val featureCount = try { - val jsonObj = org.json.JSONObject(geoJson) - jsonObj.optJSONArray("features")?.length() ?: 0 - } catch (e: Exception) { 0 } - Timber.tag("MapLibrePOC").d("GeoJSON loaded for layer %s: %d features, %d bytes", layer.name, featureCount, geoJson.length) + val featureCount = + try { + val jsonObj = org.json.JSONObject(geoJson) + jsonObj.optJSONArray("features")?.length() ?: 0 + } catch (e: Exception) { + 0 + } + Timber.tag("MapLibrePOC") + .d( + "GeoJSON loaded for layer %s: %d features, %d bytes", + layer.name, + featureCount, + geoJson.length, + ) layerGeoJsonCache = layerGeoJsonCache + (layer.id to geoJson) } else { Timber.tag("MapLibrePOC").e("Failed to load GeoJSON for layer: %s", layer.name) @@ -365,8 +336,14 @@ fun MapLibrePOC( // Ensure all layers are rendered mapLayers.forEach { layer -> val geoJson = layerGeoJsonCache[layer.id] - Timber.tag("MapLibrePOC").d("Rendering layer: id=%s, name=%s, visible=%s, hasGeoJson=%s", - layer.id, layer.name, layer.isVisible, geoJson != null) + Timber.tag("MapLibrePOC") + .d( + "Rendering layer: id=%s, name=%s, visible=%s, hasGeoJson=%s", + layer.id, + layer.name, + layer.isVisible, + geoJson != null, + ) ensureImportedLayerSourceAndLayers(style, layer.id, geoJson, layer.isVisible) } @@ -444,7 +421,17 @@ fun MapLibrePOC( } // Heatmap mode management - LaunchedEffect(heatmapEnabled, nodes, mapFilterState, enabledRoles, ourNode, isLocationTrackingEnabled, clusteringEnabled, nodeTracks, focusedNodeNum) { + LaunchedEffect( + heatmapEnabled, + nodes, + mapFilterState, + enabledRoles, + ourNode, + isLocationTrackingEnabled, + clusteringEnabled, + nodeTracks, + focusedNodeNum, + ) { // Don't manage heatmap/clustering when showing tracks if (nodeTracks != null && focusedNodeNum != null) return@LaunchedEffect @@ -452,7 +439,8 @@ fun MapLibrePOC( map.style?.let { style -> if (heatmapEnabled) { // Filter nodes same way as regular view - val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + val filteredNodes = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) // Update heatmap source with filtered node positions val heatmapFC = nodesToHeatmapFeatureCollection(filteredNodes) @@ -470,17 +458,20 @@ fun MapLibrePOC( style.getLayer(HEATMAP_LAYER_ID)?.setProperties(visibility("none")) // Restore proper clustering visibility based on current state - val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - clustersShown = setClusterVisibilityHysteresis( - map, - style, - filteredNodes, - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle - ) + val filteredNodes = + applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + clustersShown = + setClusterVisibilityHysteresis( + map, + style, + filteredNodes, + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) - Timber.tag("MapLibrePOC").d("Heatmap disabled, clustering=%b, clustersShown=%b", clusteringEnabled, clustersShown) + Timber.tag("MapLibrePOC") + .d("Heatmap disabled, clustering=%b, clustersShown=%b", clusteringEnabled, clustersShown) } } } @@ -493,23 +484,28 @@ fun MapLibrePOC( return@LaunchedEffect } - val map = mapRef ?: run { - Timber.tag("MapLibrePOC").w("LaunchedEffect: mapRef is null") - return@LaunchedEffect - } - val style = map.style ?: run { - Timber.tag("MapLibrePOC").w("LaunchedEffect: Style not ready yet") - return@LaunchedEffect - } + val map = + mapRef + ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: mapRef is null") + return@LaunchedEffect + } + val style = + map.style + ?: run { + Timber.tag("MapLibrePOC").w("LaunchedEffect: Style not ready yet") + return@LaunchedEffect + } map.let { map -> style.let { style -> if (nodeTracks != null && focusedNodeNum != null) { - Timber.tag("MapLibrePOC").d( - "LaunchedEffect: Rendering tracks for node %d, total positions: %d", - focusedNodeNum, - nodeTracks.size - ) + Timber.tag("MapLibrePOC") + .d( + "LaunchedEffect: Rendering tracks for node %d, total positions: %d", + focusedNodeNum, + nodeTracks.size, + ) // Ensure track sources and layers exist ensureTrackSourcesAndLayers(style) @@ -520,22 +516,23 @@ fun MapLibrePOC( // Apply time filter val currentTimeSeconds = System.currentTimeMillis() / 1000 val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds - val filteredTracks = nodeTracks.filter { - mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || - it.time > currentTimeSeconds - filterSeconds - } + val filteredTracks = + nodeTracks.filter { + mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + } - Timber.tag("MapLibrePOC").d( - "LaunchedEffect: Tracks filtered: %d positions remain (from %d total)", - filteredTracks.size, - nodeTracks.size - ) + Timber.tag("MapLibrePOC") + .d( + "LaunchedEffect: Tracks filtered: %d positions remain (from %d total)", + filteredTracks.size, + nodeTracks.size, + ) // Update track line if (filteredTracks.size >= 2) { positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> - (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(lineFeature) + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(lineFeature) Timber.tag("MapLibrePOC").d("LaunchedEffect: Track line updated") } } @@ -543,18 +540,18 @@ fun MapLibrePOC( // Update track points if (filteredTracks.isNotEmpty()) { val pointsFC = positionsToPointFeatures(filteredTracks) - (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(pointsFC) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(pointsFC) Timber.tag("MapLibrePOC").d("LaunchedEffect: Track points updated") // Center camera on the tracks if (filteredTracks.size == 1) { // Single position - just center on it with a fixed zoom val position = filteredTracks.first() - val latLng = org.maplibre.android.geometry.LatLng( - position.latitudeI * DEG_D, - position.longitudeI * DEG_D - ) + val latLng = + org.maplibre.android.geometry.LatLng( + position.latitudeI * DEG_D, + position.longitudeI * DEG_D, + ) map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 12.0)) Timber.tag("MapLibrePOC").d("LaunchedEffect: Camera centered on single track position") } else { @@ -564,21 +561,14 @@ fun MapLibrePOC( trackBounds.include( org.maplibre.android.geometry.LatLng( position.latitudeI * DEG_D, - position.longitudeI * DEG_D - ) + position.longitudeI * DEG_D, + ), ) } val padding = 100 // pixels - map.animateCamera( - CameraUpdateFactory.newLatLngBounds( - trackBounds.build(), - padding - ) - ) - Timber.tag("MapLibrePOC").d( - "LaunchedEffect: Camera centered on %d track positions", - filteredTracks.size - ) + map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), padding)) + Timber.tag("MapLibrePOC") + .d("LaunchedEffect: Camera centered on %d track positions", filteredTracks.size) } } } else { @@ -610,31 +600,34 @@ fun MapLibrePOC( ensureHeatmapSourceAndLayer(style) // Setup track sources and layers if rendering node tracks - Timber.tag("MapLibrePOC").d( - "Track check: nodeTracks=%s (%d positions), focusedNodeNum=%s", - if (nodeTracks != null) "NOT NULL" else "NULL", - nodeTracks?.size ?: 0, - focusedNodeNum ?: "NULL" - ) + Timber.tag("MapLibrePOC") + .d( + "Track check: nodeTracks=%s (%d positions), focusedNodeNum=%s", + if (nodeTracks != null) "NOT NULL" else "NULL", + nodeTracks?.size ?: 0, + focusedNodeNum ?: "NULL", + ) if (nodeTracks != null && focusedNodeNum != null) { - Timber.tag("MapLibrePOC").d( - "Loading tracks for node %d, total positions: %d", - focusedNodeNum, - nodeTracks.size - ) + Timber.tag("MapLibrePOC") + .d( + "Loading tracks for node %d, total positions: %d", + focusedNodeNum, + nodeTracks.size, + ) // Get the focused node to use its color val focusedNode = nodes.firstOrNull { it.num == focusedNodeNum } - Timber.tag("MapLibrePOC").d( - "Focused node found: %s (searching in %d nodes)", - if (focusedNode != null) "YES" else "NO", - nodes.size - ) + Timber.tag("MapLibrePOC") + .d( + "Focused node found: %s (searching in %d nodes)", + if (focusedNode != null) "YES" else "NO", + nodes.size, + ) - val trackColor = focusedNode?.let { - String.format("#%06X", 0xFFFFFF and it.colors.second) - } ?: "#FF5722" // Default orange color + val trackColor = + focusedNode?.let { String.format("#%06X", 0xFFFFFF and it.colors.second) } + ?: "#FF5722" // Default orange color Timber.tag("MapLibrePOC").d("Track color: %s", trackColor) @@ -644,63 +637,79 @@ fun MapLibrePOC( // Filter tracks by time using lastHeardTrackFilter val currentTimeSeconds = System.currentTimeMillis() / 1000 val filterSeconds = mapFilterState.lastHeardTrackFilter.seconds - Timber.tag("MapLibrePOC").d( - "Filtering tracks - filter: %s (seconds: %d), current time: %d", - mapFilterState.lastHeardTrackFilter, - filterSeconds, - currentTimeSeconds - ) + Timber.tag("MapLibrePOC") + .d( + "Filtering tracks - filter: %s (seconds: %d), current time: %d", + mapFilterState.lastHeardTrackFilter, + filterSeconds, + currentTimeSeconds, + ) - val filteredTracks = nodeTracks.filter { - val keep = mapFilterState.lastHeardTrackFilter == org.meshtastic.feature.map.LastHeardFilter.Any || - it.time > currentTimeSeconds - filterSeconds - if (!keep) { - Timber.tag("MapLibrePOC").v( - "Filtering out position at time %d (age: %d seconds)", - it.time, - currentTimeSeconds - it.time - ) - } - keep - }.sortedBy { it.time } + val filteredTracks = + nodeTracks + .filter { + val keep = + mapFilterState.lastHeardTrackFilter == + org.meshtastic.feature.map.LastHeardFilter.Any || + it.time > currentTimeSeconds - filterSeconds + if (!keep) { + Timber.tag("MapLibrePOC") + .v( + "Filtering out position at time %d (age: %d seconds)", + it.time, + currentTimeSeconds - it.time, + ) + } + keep + } + .sortedBy { it.time } - Timber.tag("MapLibrePOC").d( - "Tracks filtered: %d positions remain (from %d total)", - filteredTracks.size, - nodeTracks.size - ) + Timber.tag("MapLibrePOC") + .d( + "Tracks filtered: %d positions remain (from %d total)", + filteredTracks.size, + nodeTracks.size, + ) // Update track line if (filteredTracks.size >= 2) { - Timber.tag("MapLibrePOC").d("Creating line feature from %d points", filteredTracks.size) + Timber.tag("MapLibrePOC") + .d("Creating line feature from %d points", filteredTracks.size) positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> Timber.tag("MapLibrePOC").d("Setting line feature on source") - (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(lineFeature) + (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( + lineFeature, + ) Timber.tag("MapLibrePOC").d("Track line set successfully") - } ?: run { - Timber.tag("MapLibrePOC").w("Failed to create line feature - positionsToLineStringFeature returned null") } + ?: run { + Timber.tag("MapLibrePOC") + .w( + "Failed to create line feature - positionsToLineStringFeature returned null", + ) + } } else { - Timber.tag("MapLibrePOC").w("Not enough points for track line (need >=2, have %d)", filteredTracks.size) + Timber.tag("MapLibrePOC") + .w("Not enough points for track line (need >=2, have %d)", filteredTracks.size) } // Update track points if (filteredTracks.isNotEmpty()) { - Timber.tag("MapLibrePOC").d("Creating point features from %d points", filteredTracks.size) + Timber.tag("MapLibrePOC") + .d("Creating point features from %d points", filteredTracks.size) val pointsFC = positionsToPointFeatures(filteredTracks) - (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource) - ?.setGeoJson(pointsFC) + (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(pointsFC) Timber.tag("MapLibrePOC").d("Track points set successfully") } else { Timber.tag("MapLibrePOC").w("No filtered tracks to display as points") } - Timber.tag("MapLibrePOC").i( - "✓ Track rendering complete: %d positions displayed for node %d", - filteredTracks.size, - focusedNodeNum - ) + Timber.tag("MapLibrePOC") + .i( + "✓ Track rendering complete: %d positions displayed for node %d", + filteredTracks.size, + focusedNodeNum, + ) // Center camera on the tracks if (filteredTracks.isNotEmpty()) { @@ -709,21 +718,14 @@ fun MapLibrePOC( trackBounds.include( org.maplibre.android.geometry.LatLng( position.latitudeI * DEG_D, - position.longitudeI * DEG_D - ) + position.longitudeI * DEG_D, + ), ) } val padding = 100 // pixels - map.animateCamera( - CameraUpdateFactory.newLatLngBounds( - trackBounds.build(), - padding - ) - ) - Timber.tag("MapLibrePOC").d( - "Camera centered on %d track positions", - filteredTracks.size - ) + map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), padding)) + Timber.tag("MapLibrePOC") + .d("Camera centered on %d track positions", filteredTracks.size) } } else { Timber.tag("MapLibrePOC").d("No tracks to display - removing track layers") @@ -914,9 +916,12 @@ fun MapLibrePOC( 300, object : MapLibreMap.CancelableCallback { override fun onFinish() { - // Calculate screen position AFTER camera animation completes + // Calculate screen position AFTER camera animation + // completes val clusterCenter = - map.projection.toScreenLocation(LatLng(clusterLat, clusterLon)) + map.projection.toScreenLocation( + LatLng(clusterLat, clusterLon), + ) // Set overlay state after camera animation completes if (pointCount > CLUSTER_RADIAL_MAX) { @@ -925,25 +930,32 @@ fun MapLibrePOC( } else { // Show radial overlay for small clusters expandedCluster = - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + ExpandedCluster( + clusterCenter, + members.take(CLUSTER_RADIAL_MAX), + ) } } + override fun onCancel() { // Animation was cancelled, don't show overlay } - } - ) - Timber.tag("MapLibrePOC").d( - "Centering on cluster at (%.5f, %.5f) with %d members", - clusterLat, - clusterLon, - members.size + }, ) + Timber.tag("MapLibrePOC") + .d( + "Centering on cluster at (%.5f, %.5f) with %d members", + clusterLat, + clusterLon, + members.size, + ) } else { // No geometry, show overlay immediately using current screen position val clusterCenter = (f.geometry() as? Point)?.let { p -> - map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + map.projection.toScreenLocation( + LatLng(p.latitude(), p.longitude()), + ) } ?: screenPoint if (pointCount > CLUSTER_RADIAL_MAX) { clusterListMembers = members @@ -973,16 +985,9 @@ fun MapLibrePOC( val nodeLat = geom.latitude() val nodeLon = geom.longitude() val nodeLatLng = LatLng(nodeLat, nodeLon) - map.animateCamera( - CameraUpdateFactory.newLatLng(nodeLatLng), - 300 - ) - Timber.tag("MapLibrePOC").d( - "Centering on node %d at (%.5f, %.5f)", - num, - nodeLat, - nodeLon - ) + map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) + Timber.tag("MapLibrePOC") + .d("Centering on node %d at (%.5f, %.5f)", num, nodeLat, nodeLon) } } "waypoint" -> { @@ -998,16 +1003,9 @@ fun MapLibrePOC( val wpLat = geom.latitude() val wpLon = geom.longitude() val wpLatLng = LatLng(wpLat, wpLon) - map.animateCamera( - CameraUpdateFactory.newLatLng(wpLatLng), - 300 - ) - Timber.tag("MapLibrePOC").d( - "Centering on waypoint %d at (%.5f, %.5f)", - id, - wpLat, - wpLon - ) + map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) + Timber.tag("MapLibrePOC") + .d("Centering on waypoint %d at (%.5f, %.5f)", id, wpLat, wpLon) } } else -> {} @@ -1150,7 +1148,8 @@ fun MapLibrePOC( } // Skip node updates when heatmap is enabled if (!heatmapEnabled) { - Timber.tag("MapLibrePOC").d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) + Timber.tag("MapLibrePOC") + .d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) val density = context.resources.displayMetrics.density val bounds2 = map.projection.visibleRegion.latLngBounds val labelSet = run { @@ -1168,7 +1167,8 @@ fun MapLibrePOC( val chosen = LinkedHashSet() for (n in sorted) { val p = n.validPosition ?: continue - val pt = map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) + val pt = + map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) val cx = (pt.x / cell).toInt() val cy = (pt.y / cell).toInt() val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) @@ -1189,11 +1189,17 @@ fun MapLibrePOC( setClusterVisibilityHysteresis( map, style, - applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled), - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ), + clusteringEnabled, + clustersShown, + mapFilterState.showPrecisionCircle, + ) } logStyleState("update(block)", style) } @@ -1202,10 +1208,7 @@ fun MapLibrePOC( // Role legend (based on roles present in current nodes) if (showLegend) { - RoleLegend( - nodes = nodes, - modifier = Modifier.align(Alignment.BottomStart).padding(12.dp), - ) + RoleLegend(nodes = nodes, modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) } // Map controls: horizontal toolbar at the top (matches Google Maps style) @@ -1337,9 +1340,7 @@ fun MapLibrePOC( // Zoom controls (bottom right) ZoomControls( mapRef = mapRef, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 16.dp, end = 16.dp), + modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 16.dp, end = 16.dp), ) // Custom tile URL dialog @@ -1393,10 +1394,7 @@ fun MapLibrePOC( expandedCluster = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), + CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), 15.0), ) } }, @@ -1448,10 +1446,7 @@ fun MapLibrePOC( clusterListMembers = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - 15.0, - ), + CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), 15.0), ) } }, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt index 0775dcd80e..b0b02f82d3 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt @@ -63,57 +63,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.style.sources.GeoJsonSource import org.meshtastic.core.database.model.Node import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.maplibre.BaseMapStyle -import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID -import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection -import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson import org.meshtastic.feature.map.maplibre.utils.protoShortName import org.meshtastic.feature.map.maplibre.utils.roleColor import org.meshtastic.feature.map.maplibre.utils.shortNameFallback import org.meshtastic.proto.ConfigProtos import timber.log.Timber -/** - * Role legend overlay showing colors for different node roles - */ +/** Role legend overlay showing colors for different node roles */ @Composable -fun RoleLegend( - nodes: List, - modifier: Modifier = Modifier, -) { +fun RoleLegend(nodes: List, modifier: Modifier = Modifier) { val rolesPresent = nodes.map { it.user.role }.toSet() if (rolesPresent.isNotEmpty()) { - Surface( - modifier = modifier, - tonalElevation = 4.dp, - shadowElevation = 4.dp, - ) { + Surface(modifier = modifier, tonalElevation = 4.dp, shadowElevation = 4.dp) { Column(modifier = Modifier.padding(8.dp)) { rolesPresent.take(6).forEach { role -> val fakeNode = - Node( - num = 0, - user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build(), - ) - Row( - modifier = Modifier.padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = CircleShape, - color = roleColor(fakeNode), - modifier = Modifier.size(12.dp), - ) {} + Node(num = 0, user = org.meshtastic.proto.MeshProtos.User.newBuilder().setRole(role).build()) + Row(modifier = Modifier.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = CircleShape, color = roleColor(fakeNode), modifier = Modifier.size(12.dp)) {} Spacer(modifier = Modifier.width(8.dp)) Text( text = role.name.lowercase().replaceFirstChar { it.uppercase() }, @@ -126,9 +100,7 @@ fun RoleLegend( } } -/** - * Map toolbar with GPS, filter, map style, and layers controls - */ +/** Map toolbar with GPS, filter, map style, and layers controls */ @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun MapToolbar( @@ -166,11 +138,12 @@ fun MapToolbar( content = { // Consolidated GPS button (cycles through: Off -> On -> On with bearing) if (hasLocationPermission) { - val gpsIcon = when { - isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation - isLocationTrackingEnabled -> Icons.Filled.MyLocation - else -> Icons.Outlined.MyLocation - } + val gpsIcon = + when { + isLocationTrackingEnabled && followBearing -> Icons.Filled.MyLocation + isLocationTrackingEnabled -> Icons.Filled.MyLocation + else -> Icons.Outlined.MyLocation + } MapButton( onClick = { when { @@ -193,7 +166,8 @@ fun MapToolbar( }, icon = gpsIcon, contentDescription = null, - iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, + iconTint = + MaterialTheme.colorScheme.StatusRed.takeIf { isLocationTrackingEnabled && !followBearing }, ) } @@ -238,28 +212,16 @@ fun MapToolbar( val checked = if (enabledRoles.isEmpty()) true else enabledRoles.contains(role) DropdownMenuItem( text = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, - onClick = { - onRoleToggled(role) - }, - trailingIcon = { - Checkbox( - checked = checked, - onCheckedChange = { onRoleToggled(role) }, - ) - }, + onClick = { onRoleToggled(role) }, + trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onRoleToggled(role) }) }, ) } androidx.compose.material3.HorizontalDivider() DropdownMenuItem( text = { Text("Enable clustering") }, - onClick = { - onClusteringToggled(!clusteringEnabled) - }, + onClick = { onClusteringToggled(!clusteringEnabled) }, trailingIcon = { - Checkbox( - checked = clusteringEnabled, - onCheckedChange = { onClusteringToggled(it) }, - ) + Checkbox(checked = clusteringEnabled, onCheckedChange = { onClusteringToggled(it) }) }, ) DropdownMenuItem( @@ -268,12 +230,7 @@ fun MapToolbar( onHeatmapToggled() mapFilterExpanded = false }, - trailingIcon = { - Checkbox( - checked = heatmapEnabled, - onCheckedChange = { onHeatmapToggled() }, - ) - }, + trailingIcon = { Checkbox(checked = heatmapEnabled, onCheckedChange = { onHeatmapToggled() }) }, ) } } @@ -330,18 +287,10 @@ fun MapToolbar( } // Map layers button - MapButton( - onClick = onShowLayersClicked, - icon = Icons.Outlined.Layers, - contentDescription = null, - ) + MapButton(onClick = onShowLayersClicked, icon = Icons.Outlined.Layers, contentDescription = null) // Cache management button - MapButton( - onClick = onShowCacheClicked, - icon = Icons.Outlined.Storage, - contentDescription = null, - ) + MapButton(onClick = onShowCacheClicked, icon = Icons.Outlined.Storage, contentDescription = null) // Legend button MapButton(onClick = onShowLegendToggled, icon = Icons.Outlined.Info, contentDescription = null) @@ -349,23 +298,16 @@ fun MapToolbar( ) } -/** - * Zoom controls (zoom in/out buttons) - */ +/** Zoom controls (zoom in/out buttons) */ @Composable -fun ZoomControls( - mapRef: MapLibreMap?, - modifier: Modifier = Modifier, -) { +fun ZoomControls(mapRef: MapLibreMap?, modifier: Modifier = Modifier) { Surface( modifier = modifier, shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainer, shadowElevation = 3.dp, ) { - Column( - modifier = Modifier.padding(4.dp), - ) { + Column(modifier = Modifier.padding(4.dp)) { // Zoom in button MapButton( onClick = { @@ -395,9 +337,7 @@ fun ZoomControls( } } -/** - * Custom tile URL configuration dialog - */ +/** Custom tile URL configuration dialog */ @Composable fun CustomTileDialog( customTileUrlInput: String, @@ -430,18 +370,12 @@ fun CustomTileDialog( ) } }, - confirmButton = { - TextButton(onClick = onApply) { - Text("Apply") - } - }, + confirmButton = { TextButton(onClick = onApply) { Text("Apply") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, ) } -/** - * Radial overlay showing cluster members in a circle - */ +/** Radial overlay showing cluster members in a circle */ @Composable fun ClusterRadialOverlay( centerPx: PointF, @@ -467,7 +401,8 @@ fun ClusterRadialOverlay( val itemWidth = (40 + label.length * 10).dp Surface( - modifier = modifier + modifier = + modifier .offset(x = xDp - itemWidth / 2, y = yDp - itemHeight / 2) .size(width = itemWidth, height = itemHeight) .clickable { onNodeClicked(node) }, @@ -475,9 +410,7 @@ fun ClusterRadialOverlay( color = roleColor(node), shadowElevation = 6.dp, ) { - Box(contentAlignment = Alignment.Center) { - Text(text = label, color = Color.White, maxLines = 1) - } + Box(contentAlignment = Alignment.Center) { Text(text = label, color = Color.White, maxLines = 1) } } } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt index e14346bbdd..09add323bc 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -120,14 +120,20 @@ suspend fun getInputStreamFromUri(context: Context, uri: Uri): InputStream? = wi suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { try { val uri = layerItem.uri ?: return@withContext null + Timber.tag("MapLibreLayerUtils").d("convertKmlToGeoJson: uri=%s", uri) val inputStream = getInputStreamFromUri(context, uri) ?: return@withContext null inputStream.use { stream -> // Handle KMZ (ZIP) files + val isKmz = layerItem.layerType == LayerType.KML && uri.toString().endsWith(".kmz", ignoreCase = true) + Timber.tag("MapLibreLayerUtils").d("File type: isKmz=%s", isKmz) + val content = - if (layerItem.layerType == LayerType.KML && uri.toString().endsWith(".kmz", ignoreCase = true)) { + if (isKmz) { + Timber.tag("MapLibreLayerUtils").d("Extracting KML from KMZ...") extractKmlFromKmz(stream) } else { + Timber.tag("MapLibreLayerUtils").d("Reading KML directly...") stream.bufferedReader().use { it.readText() } } @@ -136,7 +142,10 @@ suspend fun convertKmlToGeoJson(context: Context, layerItem: MapLayerItem): Stri return@withContext null } - parseKmlToGeoJson(content) + Timber.tag("MapLibreLayerUtils").d("KML content extracted: %d bytes", content.length) + val result = parseKmlToGeoJson(content) + Timber.tag("MapLibreLayerUtils").d("KML parsed to GeoJSON: %d bytes", result.length) + result } } catch (e: Exception) { Timber.tag("MapLibreLayerUtils").e(e, "Error converting KML to GeoJSON") @@ -149,7 +158,7 @@ private fun extractKmlFromKmz(inputStream: InputStream): String? { return try { val zipInputStream = ZipInputStream(inputStream) var entry = zipInputStream.nextEntry - + // Look for KML file in the ZIP (usually named "doc.kml" or similar) while (entry != null) { val fileName = entry.name.lowercase() @@ -163,7 +172,7 @@ private fun extractKmlFromKmz(inputStream: InputStream): String? { zipInputStream.closeEntry() entry = zipInputStream.nextEntry } - + Timber.tag("MapLibreLayerUtils").w("No KML file found in KMZ archive") null } catch (e: Exception) { @@ -175,15 +184,41 @@ private fun extractKmlFromKmz(inputStream: InputStream): String? { /** Parses KML XML and converts to GeoJSON */ private fun parseKmlToGeoJson(kmlContent: String): String { try { + Timber.tag("MapLibreLayerUtils").d("parseKmlToGeoJson: Parsing %d bytes of KML", kmlContent.length) + + // Log first 500 chars of KML to see structure + val preview = kmlContent.take(500) + Timber.tag("MapLibreLayerUtils").d("KML preview: %s", preview) + val factory = DocumentBuilderFactory.newInstance() factory.isNamespaceAware = true val builder = factory.newDocumentBuilder() val doc = builder.parse(kmlContent.byteInputStream()) + // Log root element + val root = doc.documentElement + Timber.tag("MapLibreLayerUtils").d("Root element: tagName=%s, namespaceURI=%s", root.tagName, root.namespaceURI) + val features = mutableListOf() // Parse Placemarks (points, lines, polygons) val placemarks = doc.getElementsByTagName("Placemark") + Timber.tag("MapLibreLayerUtils").d("Found %d Placemark elements", placemarks.length) + + // Also try with namespace + val placemarks2 = doc.getElementsByTagNameNS("*", "Placemark") + Timber.tag("MapLibreLayerUtils").d("Found %d Placemark elements (with NS wildcard)", placemarks2.length) + + // Check for GroundOverlays (raster images) + val groundOverlays = doc.getElementsByTagName("GroundOverlay") + if (groundOverlays.length > 0) { + Timber.tag("MapLibreLayerUtils") + .w( + "Found %d GroundOverlay elements (raster tiles). These are not supported - only vector features (Placemarks) can be displayed.", + groundOverlays.length, + ) + } + for (i in 0 until placemarks.length) { val placemark = placemarks.item(i) as? Element ?: continue val name = placemark.getElementsByTagName("name").item(0)?.textContent ?: "" @@ -244,8 +279,11 @@ private fun parseKmlToGeoJson(kmlContent: String): String { } } + Timber.tag("MapLibreLayerUtils").d("Parsed %d features from KML", features.size) val featureCollection = FeatureCollection.fromFeatures(features) - return featureCollection.toJson() + val json = featureCollection.toJson() + Timber.tag("MapLibreLayerUtils").d("Generated GeoJSON: %d bytes", json.length) + return json } catch (e: Exception) { Timber.tag("MapLibreLayerUtils").e(e, "Error parsing KML") return """{"type":"FeatureCollection","features":[]}""" @@ -270,20 +308,23 @@ private fun parseCoordinates(coordStr: String): List = coordStr.split(" " /** Loads GeoJSON from a layer item (converting KML if needed) */ suspend fun loadLayerGeoJson(context: Context, layerItem: MapLayerItem): String? = withContext(Dispatchers.IO) { - Timber.tag("MapLibreLayerUtils").d("loadLayerGeoJson: name=%s, type=%s, uri=%s", layerItem.name, layerItem.layerType, layerItem.uri) - val result = when (layerItem.layerType) { - LayerType.KML -> { - Timber.tag("MapLibreLayerUtils").d("Converting KML to GeoJSON for: %s", layerItem.name) - convertKmlToGeoJson(context, layerItem) - } - LayerType.GEOJSON -> { - Timber.tag("MapLibreLayerUtils").d("Loading GeoJSON directly for: %s", layerItem.name) - val uri = layerItem.uri ?: return@withContext null - getInputStreamFromUri(context, uri)?.use { stream -> stream.bufferedReader().use { it.readText() } } + Timber.tag("MapLibreLayerUtils") + .d("loadLayerGeoJson: name=%s, type=%s, uri=%s", layerItem.name, layerItem.layerType, layerItem.uri) + val result = + when (layerItem.layerType) { + LayerType.KML -> { + Timber.tag("MapLibreLayerUtils").d("Converting KML to GeoJSON for: %s", layerItem.name) + convertKmlToGeoJson(context, layerItem) + } + LayerType.GEOJSON -> { + Timber.tag("MapLibreLayerUtils").d("Loading GeoJSON directly for: %s", layerItem.name) + val uri = layerItem.uri ?: return@withContext null + getInputStreamFromUri(context, uri)?.use { stream -> stream.bufferedReader().use { it.readText() } } + } } - } if (result != null) { - Timber.tag("MapLibreLayerUtils").d("Successfully loaded GeoJSON for %s: %d bytes", layerItem.name, result.length) + Timber.tag("MapLibreLayerUtils") + .d("Successfully loaded GeoJSON for %s: %d bytes", layerItem.name, result.length) } else { Timber.tag("MapLibreLayerUtils").e("Failed to load GeoJSON for: %s", layerItem.name) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt index fa03d0d87f..234fc0580d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreTileCacheManager.kt @@ -25,75 +25,84 @@ import org.maplibre.android.offline.OfflineRegion import timber.log.Timber /** - * Simplified tile cache manager for MapLibre. - * Provides basic cache clearing functionality. - * Note: MapLibre automatically caches tiles via HTTP caching as you view the map. + * Simplified tile cache manager for MapLibre. Provides basic cache clearing functionality. Note: MapLibre automatically + * caches tiles via HTTP caching as you view the map. */ class MapLibreTileCacheManager(private val context: Context) { // Lazy initialization - only create OfflineManager after MapLibre is initialized private val offlineManager: OfflineManager by lazy { OfflineManager.getInstance(context) } /** - * Clears the ambient cache (automatic HTTP tile cache). - * MapLibre automatically caches tiles in the "ambient cache" as you view the map. - * This method clears that cache to free up storage space. + * Clears the ambient cache (automatic HTTP tile cache). MapLibre automatically caches tiles in the "ambient cache" + * as you view the map. This method clears that cache to free up storage space. */ suspend fun clearCache() = withContext(Dispatchers.IO) { Timber.tag("MapLibreTileCacheManager").d("Clearing ambient cache...") - offlineManager.clearAmbientCache(object : OfflineManager.FileSourceCallback { - override fun onSuccess() { - Timber.tag("MapLibreTileCacheManager").d("Successfully cleared ambient cache") - } + offlineManager.clearAmbientCache( + object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully cleared ambient cache") + } - override fun onError(message: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to clear ambient cache: $message") - } - }) + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to clear ambient cache: $message") + } + }, + ) // Also delete any offline regions if they exist (from the old broken implementation) - offlineManager.listOfflineRegions(object : OfflineManager.ListOfflineRegionsCallback { - override fun onList(offlineRegions: Array?) { - if (offlineRegions == null || offlineRegions.isEmpty()) { - Timber.tag("MapLibreTileCacheManager").d("No offline regions to clean up") - return - } + offlineManager.listOfflineRegions( + object : OfflineManager.ListOfflineRegionsCallback { + override fun onList(offlineRegions: Array?) { + if (offlineRegions == null || offlineRegions.isEmpty()) { + Timber.tag("MapLibreTileCacheManager").d("No offline regions to clean up") + return + } - Timber.tag("MapLibreTileCacheManager").d("Cleaning up ${offlineRegions.size} offline regions from old implementation") - offlineRegions.forEach { region -> - region.delete(object : OfflineRegion.OfflineRegionDeleteCallback { - override fun onDelete() { - Timber.tag("MapLibreTileCacheManager").d("Deleted offline region ${region.id}") - } + Timber.tag("MapLibreTileCacheManager") + .d("Cleaning up ${offlineRegions.size} offline regions from old implementation") + offlineRegions.forEach { region -> + region.delete( + object : OfflineRegion.OfflineRegionDeleteCallback { + override fun onDelete() { + Timber.tag("MapLibreTileCacheManager").d("Deleted offline region ${region.id}") + } - override fun onError(error: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to delete region ${region.id}: $error") - } - }) + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager") + .e("Failed to delete region ${region.id}: $error") + } + }, + ) + } } - } - override fun onError(error: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") - } - }) + override fun onError(error: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to list offline regions: $error") + } + }, + ) } /** - * Sets the maximum size for the ambient cache in bytes. - * Default is typically 50MB. Call this to increase or decrease the cache size. + * Sets the maximum size for the ambient cache in bytes. Default is typically 50MB. Call this to increase or + * decrease the cache size. */ fun setMaximumAmbientCacheSize(sizeBytes: Long, callback: OfflineManager.FileSourceCallback? = null) { Timber.tag("MapLibreTileCacheManager").d("Setting maximum ambient cache size to $sizeBytes bytes") - offlineManager.setMaximumAmbientCacheSize(sizeBytes, callback ?: object : OfflineManager.FileSourceCallback { - override fun onSuccess() { - Timber.tag("MapLibreTileCacheManager").d("Successfully set maximum ambient cache size") - } + offlineManager.setMaximumAmbientCacheSize( + sizeBytes, + callback + ?: object : OfflineManager.FileSourceCallback { + override fun onSuccess() { + Timber.tag("MapLibreTileCacheManager").d("Successfully set maximum ambient cache size") + } - override fun onError(message: String) { - Timber.tag("MapLibreTileCacheManager").e("Failed to set maximum ambient cache size: $message") - } - }) + override fun onError(message: String) { + Timber.tag("MapLibreTileCacheManager").e("Failed to set maximum ambient cache size: $message") + } + }, + ) } } - diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt index 38d1f39cde..cb9e994f32 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/node/NodeMapScreen.kt @@ -34,17 +34,14 @@ import timber.log.Timber fun NodeMapScreen( nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit, - mapViewModel: MapViewModel = hiltViewModel() + mapViewModel: MapViewModel = hiltViewModel(), ) { val node by nodeMapViewModel.node.collectAsStateWithLifecycle() val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle() val destNum = node?.num - Timber.tag("NodeMapScreen").d( - "NodeMapScreen rendering - destNum=%s, positions count=%d", - destNum ?: "null", - positions.size - ) + Timber.tag("NodeMapScreen") + .d("NodeMapScreen rendering - destNum=%s, positions count=%d", destNum ?: "null", positions.size) Scaffold( topBar = { @@ -60,11 +57,8 @@ fun NodeMapScreen( }, ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { - Timber.tag("NodeMapScreen").d( - "Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", - destNum ?: "null", - positions.size - ) + Timber.tag("NodeMapScreen") + .d("Calling MapLibrePOC with focusedNodeNum=%s, nodeTracks count=%d", destNum ?: "null", positions.size) MapLibrePOC( mapViewModel = mapViewModel, onNavigateToNodeDetails = {}, From ac47683e66c6ea4eafb00161e7b4cc8424a7aa8e Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 12:40:14 -0800 Subject: [PATCH 54/62] first pass - janitorial work --- .../map/maplibre/ui/MapLibreNodeDetails.kt | 77 ----------- .../feature/map/maplibre/ui/MapLibrePOC.kt | 122 ++++-------------- .../map/maplibre/utils/MapLibreHelpers.kt | 3 - 3 files changed, 24 insertions(+), 178 deletions(-) delete mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt deleted file mode 100644 index 48e4356794..0000000000 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreNodeDetails.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2025 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.feature.map.maplibre.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.ui.component.NodeChip -import org.meshtastic.feature.map.maplibre.utils.distanceKmBetween -import org.meshtastic.feature.map.maplibre.utils.formatSecondsAgo - -/** Bottom sheet showing selected node details */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NodeDetailsBottomSheet( - selectedNode: Node, - ourNode: Node?, - onNavigateToNodeDetails: (Int) -> Unit, - onDismiss: () -> Unit, - sheetState: SheetState, -) { - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - NodeChip(node = selectedNode) - val longName = selectedNode.user.longName - if (!longName.isNullOrBlank()) { - Text( - text = longName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } - val lastHeardAgo = formatSecondsAgo(selectedNode.lastHeard) - val coords = selectedNode.gpsString() - Text(text = "Last heard: $lastHeardAgo", modifier = Modifier.padding(top = 8.dp)) - Text(text = "Coordinates: $coords") - val km = ourNode?.let { me -> distanceKmBetween(me, selectedNode) } - if (km != null) Text(text = "Distance: ${"%.1f".format(km)} km") - Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { - Button( - onClick = { - onNavigateToNodeDetails(selectedNode.num) - onDismiss() - }, - ) { - Text("View full node") - } - } - } - } -} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 7aa7cc6e28..4a64539f41 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -120,6 +120,13 @@ import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint import timber.log.Timber +// UI Constants +private const val CAMERA_PADDING_PX = 100 +private const val DEFAULT_ZOOM_LEVEL = 15.0 +private const val SINGLE_TRACK_ZOOM_LEVEL = 12.0 +private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L +private const val HIT_BOX_RADIUS_DP = 24f + @SuppressLint("MissingPermission") @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -133,13 +140,6 @@ fun MapLibrePOC( val lifecycleOwner = LocalLifecycleOwner.current var selectedNodeNum by remember { mutableStateOf(null) } - // Log track parameters on entry - Timber.tag("MapLibrePOC") - .d( - "MapLibrePOC called - focusedNodeNum=%s, nodeTracks count=%d", - focusedNodeNum ?: "null", - nodeTracks?.size ?: 0, - ) val ourNode by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle() val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle() var isLocationTrackingEnabled by remember { mutableStateOf(false) } @@ -479,10 +479,7 @@ fun MapLibrePOC( // Handle node tracks rendering when nodeTracks or focusedNodeNum changes LaunchedEffect(nodeTracks, focusedNodeNum, mapFilterState.lastHeardTrackFilter, styleReady) { - if (!styleReady) { - Timber.tag("MapLibrePOC").d("LaunchedEffect: Waiting for style to be ready") - return@LaunchedEffect - } + if (!styleReady) return@LaunchedEffect val map = mapRef @@ -500,13 +497,6 @@ fun MapLibrePOC( map.let { map -> style.let { style -> if (nodeTracks != null && focusedNodeNum != null) { - Timber.tag("MapLibrePOC") - .d( - "LaunchedEffect: Rendering tracks for node %d, total positions: %d", - focusedNodeNum, - nodeTracks.size, - ) - // Ensure track sources and layers exist ensureTrackSourcesAndLayers(style) @@ -522,18 +512,10 @@ fun MapLibrePOC( it.time > currentTimeSeconds - filterSeconds } - Timber.tag("MapLibrePOC") - .d( - "LaunchedEffect: Tracks filtered: %d positions remain (from %d total)", - filteredTracks.size, - nodeTracks.size, - ) - // Update track line if (filteredTracks.size >= 2) { positionsToLineStringFeature(filteredTracks)?.let { lineFeature -> (style.getSource(TRACK_LINE_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(lineFeature) - Timber.tag("MapLibrePOC").d("LaunchedEffect: Track line updated") } } @@ -541,7 +523,6 @@ fun MapLibrePOC( if (filteredTracks.isNotEmpty()) { val pointsFC = positionsToPointFeatures(filteredTracks) (style.getSource(TRACK_POINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson(pointsFC) - Timber.tag("MapLibrePOC").d("LaunchedEffect: Track points updated") // Center camera on the tracks if (filteredTracks.size == 1) { @@ -552,8 +533,7 @@ fun MapLibrePOC( position.latitudeI * DEG_D, position.longitudeI * DEG_D, ) - map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, 12.0)) - Timber.tag("MapLibrePOC").d("LaunchedEffect: Camera centered on single track position") + map.animateCamera(CameraUpdateFactory.newLatLngZoom(latLng, SINGLE_TRACK_ZOOM_LEVEL)) } else { // Multiple positions - fit bounds val trackBounds = org.maplibre.android.geometry.LatLngBounds.Builder() @@ -565,14 +545,10 @@ fun MapLibrePOC( ), ) } - val padding = 100 // pixels - map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), padding)) - Timber.tag("MapLibrePOC") - .d("LaunchedEffect: Camera centered on %d track positions", filteredTracks.size) + map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), CAMERA_PADDING_PX)) } } } else { - Timber.tag("MapLibrePOC").d("LaunchedEffect: No tracks to display - removing track layers") removeTrackSourcesAndLayers(style) } } @@ -738,41 +714,15 @@ fun MapLibrePOC( // Only set node data if we're not showing tracks if (nodeTracks == null || focusedNodeNum == null) { val density = context.resources.displayMetrics.density - val bounds = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - .filter { n -> - val p = n.validPosition ?: return@filter false - bounds.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite } - .thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation( - LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), - ) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } + val filteredNodes = + applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + val labelSet = selectLabelsForViewport(map, filteredNodes, density) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( waypointsToFeatureCollectionFC(waypoints.values), ) @@ -864,7 +814,7 @@ fun MapLibrePOC( clusterListMembers = null val screenPoint = map.projection.toScreenLocation(latLng) // Use a small hitbox to improve taps on small circles - val r = (24 * context.resources.displayMetrics.density) + val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) val rect = android.graphics.RectF( (screenPoint.x - r).toFloat(), @@ -1038,7 +988,7 @@ fun MapLibrePOC( } // Debounce to avoid rapid toggling during kinetic flings/tiles loading val now = SystemClock.uptimeMillis() - if (now - lastClusterEvalMs < 300) return@addOnCameraIdleListener + if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return@addOnCameraIdleListener lastClusterEvalMs = now val filtered = applyFilters( @@ -1151,31 +1101,7 @@ fun MapLibrePOC( Timber.tag("MapLibrePOC") .d("Updating sources. nodes=%d, waypoints=%d", nodes.size, waypoints.size) val density = context.resources.displayMetrics.density - val bounds2 = map.projection.visibleRegion.latLngBounds - val labelSet = run { - val visible = - nodes.filter { n -> - val p = n.validPosition ?: return@filter false - bounds2.contains(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - } - val sorted = - visible.sortedWith( - compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }, - ) - val cell = (80f * density).toInt().coerceAtLeast(48) - val occupied = HashSet() - val chosen = LinkedHashSet() - for (n in sorted) { - val p = n.validPosition ?: continue - val pt = - map.projection.toScreenLocation(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D)) - val cx = (pt.x / cell).toInt() - val cy = (pt.y / cell).toInt() - val key = (cx.toLong() shl 32) or (cy.toLong() and 0xffffffff) - if (occupied.add(key)) chosen.add(n.num) - } - chosen - } + val labelSet = selectLabelsForViewport(map, nodes, density) (style.getSource(WAYPOINTS_SOURCE_ID) as? GeoJsonSource)?.setGeoJson( waypointsToFeatureCollectionFC(waypoints.values), ) @@ -1394,7 +1320,7 @@ fun MapLibrePOC( expandedCluster = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), 15.0), + CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), DEFAULT_ZOOM_LEVEL), ) } }, @@ -1446,7 +1372,7 @@ fun MapLibrePOC( clusterListMembers = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), 15.0), + CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), DEFAULT_ZOOM_LEVEL), ) } }, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt index af2fb93690..7a953bf508 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt @@ -40,9 +40,6 @@ fun hasAnyLocationPermission(context: Context): Boolean { return fine || coarse } -/** Get short name from node (deprecated, kept for compatibility) */ -fun shortName(node: Node): String = shortNameFallback(node) - /** Get protocol-defined short name if present */ fun protoShortName(node: Node): String? { val s = node.user.shortName From e843a33b4d9a30ba553b15e784686f75d07ca932 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 12:49:17 -0800 Subject: [PATCH 55/62] second pass - janitorial work --- .../map/maplibre/ui/CameraIdleHandler.kt | 142 ++++++++++ .../map/maplibre/ui/MapClickHandlers.kt | 231 +++++++++++++++++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 245 +++--------------- 3 files changed, 403 insertions(+), 215 deletions(-) create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt create mode 100644 feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt new file mode 100644 index 0000000000..202ed2b13c --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import android.content.Context +import android.os.SystemClock +import android.view.View +import androidx.compose.runtime.MutableState +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.meshtastic.data.entities.Node +import org.meshtastic.feature.map.MapFilterState +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.core.logStyleState +import org.meshtastic.feature.map.maplibre.core.nodesToFeatureCollectionJsonWithSelection +import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson +import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis +import org.meshtastic.feature.map.maplibre.utils.applyFilters +import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import timber.log.Timber + +private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L + +/** + * Creates a camera idle listener that manages cluster visibility and label selection. + * + * This handler: + * - Debounces rapid camera movements + * - Filters nodes based on current map state + * - Toggles cluster visibility based on zoom level + * - Selects which nodes should display labels + * - Updates both clustered and non-clustered data sources + * + * @param map The MapLibre map instance + * @param context Android context for density calculations + * @param mapViewRef Reference to the MapView for viewport calculations + * @param nodes All available nodes + * @param mapFilterState Current map filter settings + * @param enabledRoles Set of enabled roles for filtering + * @param ourNode The current device's node info + * @param isLocationTrackingEnabled Whether location tracking is active + * @param heatmapEnabled Whether heatmap mode is active + * @param showingTracksRef Whether track visualization is active + * @param clusteringEnabled Whether clustering is enabled + * @param clustersShownState Mutable state tracking whether clusters are currently shown + */ +fun createCameraIdleListener( + map: MapLibreMap, + context: Context, + mapViewRef: View?, + nodes: List, + mapFilterState: MapFilterState, + enabledRoles: Set, + ourNode: Node?, + isLocationTrackingEnabled: Boolean, + heatmapEnabled: Boolean, + showingTracksRef: MutableState, + clusteringEnabled: Boolean, + clustersShownState: MutableState, +): MapLibreMap.OnCameraIdleListener { + var lastClusterEvalMs = 0L + + return MapLibreMap.OnCameraIdleListener { + val st = map.style ?: return@OnCameraIdleListener + + // Skip node updates when heatmap is enabled + if (heatmapEnabled) return@OnCameraIdleListener + + // Skip node updates when showing tracks + if (showingTracksRef.value) { + Timber.tag("CameraIdleHandler").d("Skipping node updates - showing tracks") + return@OnCameraIdleListener + } + + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return@OnCameraIdleListener + lastClusterEvalMs = now + + val filtered = applyFilters( + nodes, + mapFilterState, + enabledRoles, + ourNode?.num, + isLocationTrackingEnabled, + ) + + Timber.tag("CameraIdleHandler").d("Filtered nodes=%d (of %d)", filtered.size, nodes.size) + + clustersShownState.value = setClusterVisibilityHysteresis( + map, + st, + filtered, + clusteringEnabled, + clustersShownState.value, + mapFilterState.showPrecisionCircle, + ) + + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + + Timber.tag("CameraIdleHandler").d( + "Updating sources. labelSet=%d (nums=%s) jsonBytes=%d", + labelSet.size, + labelSet.take(5).joinToString(","), + jsonIdle.length, + ) + + // Update both clustered and non-clustered sources + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) + + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) + Timber.tag("CameraIdleHandler").d("Rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) {} + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt new file mode 100644 index 0000000000..d3cbd7b9ee --- /dev/null +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2025 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.feature.map.maplibre.ui + +import android.content.Context +import android.graphics.PointF +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.geojson.Point +import org.meshtastic.data.entities.Node +import org.meshtastic.data.entities.Packet +import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID +import org.meshtastic.proto.MeshProtos.Waypoint +import timber.log.Timber + +data class ExpandedCluster(val centerPx: PointF, val members: List) + +// Constants +private const val HIT_BOX_RADIUS_DP = 24f +private const val CLUSTER_LIST_FETCH_MAX = 200 +private const val CLUSTER_RADIAL_MAX = 10 + +/** + * Creates a map click listener that handles cluster expansion, node selection, and waypoint editing. + * + * @param map The MapLibre map instance + * @param context Android context for density calculations + * @param nodes All available nodes + * @param waypoints All available waypoints + * @param onExpandedClusterChange Callback when cluster overlay should be shown/hidden + * @param onClusterListMembersChange Callback when cluster list bottom sheet should be shown/hidden + * @param onSelectedNodeChange Callback when a node is selected + * @param onWaypointEditRequest Callback when a waypoint should be edited + */ +fun createMapClickListener( + map: MapLibreMap, + context: Context, + nodes: List, + waypoints: Map, + onExpandedClusterChange: (ExpandedCluster?) -> Unit, + onClusterListMembersChange: (List?) -> Unit, + onSelectedNodeChange: (Int?) -> Unit, + onWaypointEditRequest: (Waypoint?) -> Unit, +): MapLibreMap.OnMapClickListener { + return MapLibreMap.OnMapClickListener { latLng -> + // Any tap on the map clears overlays unless replaced below + onExpandedClusterChange(null) + onClusterListMembersChange(null) + + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) + val rect = android.graphics.RectF( + (screenPoint.x - r).toFloat(), + (screenPoint.y - r).toFloat(), + (screenPoint.x + r).toFloat(), + (screenPoint.y + r).toFloat(), + ) + + val features = map.queryRenderedFeatures( + rect, + CLUSTER_CIRCLE_LAYER_ID, + NODES_LAYER_ID, + NODES_LAYER_NOCLUSTER_ID, + WAYPOINTS_LAYER_ID, + ) + + Timber.tag("MapClickHandlers").d( + "Map click at (%.5f, %.5f) -> %d features", + latLng.latitude, + latLng.longitude, + features.size, + ) + + val f = features.firstOrNull() + + // If cluster tapped, expand using true cluster leaves from the source + if (f != null && f.hasProperty("point_count")) { + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = fc.features()?.mapNotNull { feat -> + try { + feat.getNumberProperty("num")?.toInt() + } catch (_: Throwable) { + null + } + } ?: emptyList() + + val members = nodes.filter { nums.contains(it.num) } + + if (members.isNotEmpty()) { + // Center camera on cluster (without zoom) to keep cluster intact + val geom = f.geometry() + if (geom is Point) { + val clusterLat = geom.latitude() + val clusterLon = geom.longitude() + val clusterLatLng = LatLng(clusterLat, clusterLon) + + map.animateCamera( + CameraUpdateFactory.newLatLng(clusterLatLng), + 300, + object : MapLibreMap.CancelableCallback { + override fun onFinish() { + // Calculate screen position AFTER camera animation completes + val clusterCenter = map.projection.toScreenLocation( + LatLng(clusterLat, clusterLon), + ) + + // Set overlay state after camera animation completes + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + onClusterListMembersChange(members) + } else { + // Show radial overlay for small clusters + onExpandedClusterChange( + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + ) + } + } + + override fun onCancel() { + // Animation was cancelled, don't show overlay + } + }, + ) + + Timber.tag("MapClickHandlers").d( + "Centering on cluster at (%.5f, %.5f) with %d members", + clusterLat, + clusterLon, + members.size, + ) + } else { + // No geometry, show overlay immediately using current screen position + val clusterCenter = (f.geometry() as? Point)?.let { p -> + map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) + } ?: screenPoint + + if (pointCount > CLUSTER_RADIAL_MAX) { + onClusterListMembersChange(members) + } else { + onExpandedClusterChange( + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) + ) + } + } + } + return@OnMapClickListener true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return@OnMapClickListener true + } + } + + // Handle node/waypoint selection + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + onSelectedNodeChange(num) + + // Center camera on selected node + val geom = it.geometry() + if (geom is Point) { + val nodeLat = geom.latitude() + val nodeLon = geom.longitude() + val nodeLatLng = LatLng(nodeLat, nodeLon) + map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) + Timber.tag("MapClickHandlers").d( + "Centering on node %d at (%.5f, %.5f)", + num, + nodeLat, + nodeLon, + ) + } + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + val waypoint = waypoints.values + .find { pkt -> pkt.data.waypoint?.id == id } + ?.data?.waypoint + onWaypointEditRequest(waypoint) + + // Center camera on waypoint + val geom = it.geometry() + if (geom is Point) { + val wpLat = geom.latitude() + val wpLon = geom.longitude() + val wpLatLng = LatLng(wpLat, wpLon) + map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) + Timber.tag("MapClickHandlers").d( + "Centering on waypoint %d at (%.5f, %.5f)", + id, + wpLat, + wpLon, + ) + } + } + else -> {} + } + } + true + } +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 4a64539f41..e75f3c55c6 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -145,7 +145,6 @@ fun MapLibrePOC( var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followBearing by remember { mutableStateOf(false) } var hasLocationPermission by remember { mutableStateOf(false) } - data class ExpandedCluster(val centerPx: android.graphics.PointF, val members: List) var expandedCluster by remember { mutableStateOf(null) } var clusterListMembers by remember { mutableStateOf?>(null) } var mapRef by remember { mutableStateOf(null) } @@ -169,8 +168,8 @@ fun MapLibrePOC( var baseStyleIndex by remember { mutableStateOf(0) } val baseStyle = baseStyles[baseStyleIndex % baseStyles.size] // Remember last applied cluster visibility to reduce flashing - var clustersShown by remember { mutableStateOf(false) } - var lastClusterEvalMs by remember { mutableStateOf(0L) } + val clustersShownState = remember { mutableStateOf(false) } + var clustersShown by clustersShownState // Heatmap mode var heatmapEnabled by remember { mutableStateOf(false) } @@ -808,161 +807,18 @@ fun MapLibrePOC( } } catch (_: Throwable) {} } - map.addOnMapClickListener { latLng -> - // Any tap on the map clears overlays unless replaced below - expandedCluster = null - clusterListMembers = null - val screenPoint = map.projection.toScreenLocation(latLng) - // Use a small hitbox to improve taps on small circles - val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) - val rect = - android.graphics.RectF( - (screenPoint.x - r).toFloat(), - (screenPoint.y - r).toFloat(), - (screenPoint.x + r).toFloat(), - (screenPoint.y + r).toFloat(), - ) - val features = - map.queryRenderedFeatures( - rect, - CLUSTER_CIRCLE_LAYER_ID, - NODES_LAYER_ID, - NODES_LAYER_NOCLUSTER_ID, - WAYPOINTS_LAYER_ID, - ) - Timber.tag("MapLibrePOC") - .d( - "Map click at (%.5f, %.5f) -> %d features", - latLng.latitude, - latLng.longitude, - features.size, - ) - val f = features.firstOrNull() - // If cluster tapped, expand using true cluster leaves from the source - if (f != null && f.hasProperty("point_count")) { - val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 - val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) - val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - if (src != null) { - val fc = src.getClusterLeaves(f, limit, 0L) - val nums = - fc.features()?.mapNotNull { feat -> - try { - feat.getNumberProperty("num")?.toInt() - } catch (_: Throwable) { - null - } - } ?: emptyList() - val members = nodes.filter { nums.contains(it.num) } - if (members.isNotEmpty()) { - // Center camera on cluster (without zoom) to keep cluster intact - val geom = f.geometry() - if (geom is Point) { - val clusterLat = geom.latitude() - val clusterLon = geom.longitude() - val clusterLatLng = LatLng(clusterLat, clusterLon) - map.animateCamera( - CameraUpdateFactory.newLatLng(clusterLatLng), - 300, - object : MapLibreMap.CancelableCallback { - override fun onFinish() { - // Calculate screen position AFTER camera animation - // completes - val clusterCenter = - map.projection.toScreenLocation( - LatLng(clusterLat, clusterLon), - ) - - // Set overlay state after camera animation completes - if (pointCount > CLUSTER_RADIAL_MAX) { - // Show list for large clusters - clusterListMembers = members - } else { - // Show radial overlay for small clusters - expandedCluster = - ExpandedCluster( - clusterCenter, - members.take(CLUSTER_RADIAL_MAX), - ) - } - } - - override fun onCancel() { - // Animation was cancelled, don't show overlay - } - }, - ) - Timber.tag("MapLibrePOC") - .d( - "Centering on cluster at (%.5f, %.5f) with %d members", - clusterLat, - clusterLon, - members.size, - ) - } else { - // No geometry, show overlay immediately using current screen position - val clusterCenter = - (f.geometry() as? Point)?.let { p -> - map.projection.toScreenLocation( - LatLng(p.latitude(), p.longitude()), - ) - } ?: screenPoint - if (pointCount > CLUSTER_RADIAL_MAX) { - clusterListMembers = members - } else { - expandedCluster = - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) - } - } - } - return@addOnMapClickListener true - } else { - map.animateCamera(CameraUpdateFactory.zoomIn()) - return@addOnMapClickListener true - } - } - // Handle node/waypoint selection - f?.let { - val kind = it.getStringProperty("kind") - when (kind) { - "node" -> { - val num = it.getNumberProperty("num")?.toInt() ?: -1 - selectedNodeNum = num - - // Center camera on selected node - val geom = it.geometry() - if (geom is Point) { - val nodeLat = geom.latitude() - val nodeLon = geom.longitude() - val nodeLatLng = LatLng(nodeLat, nodeLon) - map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) - Timber.tag("MapLibrePOC") - .d("Centering on node %d at (%.5f, %.5f)", num, nodeLat, nodeLon) - } - } - "waypoint" -> { - val id = it.getNumberProperty("id")?.toInt() ?: -1 - // Open edit dialog for waypoint - waypoints.values - .find { pkt -> pkt.data.waypoint?.id == id } - ?.let { pkt -> editingWaypoint = pkt.data.waypoint } - - // Center camera on waypoint - val geom = it.geometry() - if (geom is Point) { - val wpLat = geom.latitude() - val wpLon = geom.longitude() - val wpLatLng = LatLng(wpLat, wpLon) - map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) - Timber.tag("MapLibrePOC") - .d("Centering on waypoint %d at (%.5f, %.5f)", id, wpLat, wpLon) - } - } - else -> {} - } - } - true - } + map.addOnMapClickListener( + createMapClickListener( + map = map, + context = context, + nodes = nodes, + waypoints = waypoints, + onExpandedClusterChange = { expandedCluster = it }, + onClusterListMembersChange = { clusterListMembers = it }, + onSelectedNodeChange = { selectedNodeNum = it }, + onWaypointEditRequest = { editingWaypoint = it }, + ) + ) // Long-press to create waypoint map.addOnMapLongClickListener { latLng -> if (isConnected) { @@ -977,63 +833,22 @@ fun MapLibrePOC( true } // Update clustering visibility on camera idle (zoom changes) - map.addOnCameraIdleListener { - val st = map.style ?: return@addOnCameraIdleListener - // Skip node updates when heatmap is enabled - if (heatmapEnabled) return@addOnCameraIdleListener - // Skip node updates when showing tracks - if (showingTracksRef.value) { - Timber.tag("MapLibrePOC").d("onCameraIdle: Skipping node updates - showing tracks") - return@addOnCameraIdleListener - } - // Debounce to avoid rapid toggling during kinetic flings/tiles loading - val now = SystemClock.uptimeMillis() - if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return@addOnCameraIdleListener - lastClusterEvalMs = now - val filtered = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) - Timber.tag("MapLibrePOC") - .d("onCameraIdle: filtered nodes=%d (of %d)", filtered.size, nodes.size) - clustersShown = - setClusterVisibilityHysteresis( - map, - st, - filtered, - clusteringEnabled, - clustersShown, - mapFilterState.showPrecisionCircle, - ) - // Compute which nodes get labels in viewport and update source - val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - Timber.tag("MapLibrePOC") - .d( - "onCameraIdle: updating sources. labelSet=%d (nums=%s) jsonBytes=%d", - labelSet.size, - labelSet.take(5).joinToString(","), - jsonIdle.length, - ) - // Update both clustered and non-clustered sources - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) - safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) - logStyleState("onCameraIdle(post-update)", st) - try { - val w = mapViewRef?.width ?: 0 - val h = mapViewRef?.height ?: 0 - val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) - val rendered = - map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) - Timber.tag("MapLibrePOC") - .d("onCameraIdle: rendered features in viewport=%d", rendered.size) - } catch (_: Throwable) {} - } + map.addOnCameraIdleListener( + createCameraIdleListener( + map = map, + context = context, + mapViewRef = mapViewRef, + nodes = nodes, + mapFilterState = mapFilterState, + enabledRoles = enabledRoles, + ourNode = ourNode, + isLocationTrackingEnabled = isLocationTrackingEnabled, + heatmapEnabled = heatmapEnabled, + showingTracksRef = showingTracksRef, + clusteringEnabled = clusteringEnabled, + clustersShownState = clustersShownState, + ) + ) // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { if (expandedCluster != null || clusterListMembers != null) { From 7231f7d632abc6f60350c7942bb468501396aa6c Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 15:07:38 -0800 Subject: [PATCH 56/62] refactors and appease copilot --- .../feature/map/component/MapButton.kt | 4 +- .../map/maplibre/ui/CameraIdleHandler.kt | 92 +++---- .../map/maplibre/ui/MapClickHandlers.kt | 247 ++++++++---------- .../feature/map/maplibre/ui/MapLibrePOC.kt | 45 ++-- .../map/maplibre/utils/MapLibreLayerUtils.kt | 8 +- .../meshtastic/feature/map/MapLayerItem.kt | 2 +- 6 files changed, 185 insertions(+), 213 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt index 33d26133ed..f8dd74f712 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/MapButton.kt @@ -22,7 +22,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Layers import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -63,7 +63,7 @@ fun MapButton( imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp), - tint = iconTint ?: IconButtonDefaults.filledIconButtonColors().contentColor, + tint = iconTint ?: LocalContentColor.current, ) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt index 202ed2b13c..024b1f3ba8 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/CameraIdleHandler.kt @@ -22,9 +22,8 @@ import android.os.SystemClock import android.view.View import androidx.compose.runtime.MutableState import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import org.meshtastic.data.entities.Node -import org.meshtastic.feature.map.MapFilterState +import org.meshtastic.core.database.model.Node +import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID @@ -35,12 +34,15 @@ import org.meshtastic.feature.map.maplibre.core.safeSetGeoJson import org.meshtastic.feature.map.maplibre.core.setClusterVisibilityHysteresis import org.meshtastic.feature.map.maplibre.utils.applyFilters import org.meshtastic.feature.map.maplibre.utils.selectLabelsForViewport +import org.meshtastic.proto.ConfigProtos import timber.log.Timber private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L +private var lastClusterEvalMs = 0L + /** - * Creates a camera idle listener that manages cluster visibility and label selection. + * Handles camera idle events to manage cluster visibility and label selection. * * This handler: * - Debounces rapid camera movements @@ -62,50 +64,42 @@ private const val CLUSTER_EVAL_DEBOUNCE_MS = 300L * @param clusteringEnabled Whether clustering is enabled * @param clustersShownState Mutable state tracking whether clusters are currently shown */ -fun createCameraIdleListener( +fun handleCameraIdle( map: MapLibreMap, context: Context, mapViewRef: View?, nodes: List, - mapFilterState: MapFilterState, - enabledRoles: Set, + mapFilterState: BaseMapViewModel.MapFilterState, + enabledRoles: Set, ourNode: Node?, isLocationTrackingEnabled: Boolean, heatmapEnabled: Boolean, showingTracksRef: MutableState, clusteringEnabled: Boolean, clustersShownState: MutableState, -): MapLibreMap.OnCameraIdleListener { - var lastClusterEvalMs = 0L +) { + val st = map.style ?: return - return MapLibreMap.OnCameraIdleListener { - val st = map.style ?: return@OnCameraIdleListener + // Skip node updates when heatmap is enabled + if (heatmapEnabled) return - // Skip node updates when heatmap is enabled - if (heatmapEnabled) return@OnCameraIdleListener + // Skip node updates when showing tracks + if (showingTracksRef.value) { + Timber.tag("CameraIdleHandler").d("Skipping node updates - showing tracks") + return + } - // Skip node updates when showing tracks - if (showingTracksRef.value) { - Timber.tag("CameraIdleHandler").d("Skipping node updates - showing tracks") - return@OnCameraIdleListener - } + // Debounce to avoid rapid toggling during kinetic flings/tiles loading + val now = SystemClock.uptimeMillis() + if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return + lastClusterEvalMs = now - // Debounce to avoid rapid toggling during kinetic flings/tiles loading - val now = SystemClock.uptimeMillis() - if (now - lastClusterEvalMs < CLUSTER_EVAL_DEBOUNCE_MS) return@OnCameraIdleListener - lastClusterEvalMs = now + val filtered = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) - val filtered = applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) + Timber.tag("CameraIdleHandler").d("Filtered nodes=%d (of %d)", filtered.size, nodes.size) - Timber.tag("CameraIdleHandler").d("Filtered nodes=%d (of %d)", filtered.size, nodes.size) - - clustersShownState.value = setClusterVisibilityHysteresis( + clustersShownState.value = + setClusterVisibilityHysteresis( map, st, filtered, @@ -114,29 +108,29 @@ fun createCameraIdleListener( mapFilterState.showPrecisionCircle, ) - // Compute which nodes get labels in viewport and update source - val density = context.resources.displayMetrics.density - val labelSet = selectLabelsForViewport(map, filtered, density) - val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) + // Compute which nodes get labels in viewport and update source + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filtered, density) + val jsonIdle = nodesToFeatureCollectionJsonWithSelection(filtered, labelSet) - Timber.tag("CameraIdleHandler").d( + Timber.tag("CameraIdleHandler") + .d( "Updating sources. labelSet=%d (nums=%s) jsonBytes=%d", labelSet.size, labelSet.take(5).joinToString(","), jsonIdle.length, ) - // Update both clustered and non-clustered sources - safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) - safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) - logStyleState("onCameraIdle(post-update)", st) + // Update both clustered and non-clustered sources + safeSetGeoJson(st, NODES_CLUSTER_SOURCE_ID, jsonIdle) + safeSetGeoJson(st, NODES_SOURCE_ID, jsonIdle) + logStyleState("onCameraIdle(post-update)", st) - try { - val w = mapViewRef?.width ?: 0 - val h = mapViewRef?.height ?: 0 - val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) - val rendered = map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) - Timber.tag("CameraIdleHandler").d("Rendered features in viewport=%d", rendered.size) - } catch (_: Throwable) {} - } + try { + val w = mapViewRef?.width ?: 0 + val h = mapViewRef?.height ?: 0 + val bbox = android.graphics.RectF(0f, 0f, w.toFloat(), h.toFloat()) + val rendered = map.queryRenderedFeatures(bbox, NODES_LAYER_ID, CLUSTER_CIRCLE_LAYER_ID) + Timber.tag("CameraIdleHandler").d("Rendered features in viewport=%d", rendered.size) + } catch (_: Throwable) {} } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt index d3cbd7b9ee..9a31628d8a 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt @@ -24,8 +24,8 @@ import org.maplibre.android.geometry.LatLng import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Point -import org.meshtastic.data.entities.Node -import org.meshtastic.data.entities.Packet +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID @@ -42,43 +42,47 @@ private const val CLUSTER_LIST_FETCH_MAX = 200 private const val CLUSTER_RADIAL_MAX = 10 /** - * Creates a map click listener that handles cluster expansion, node selection, and waypoint editing. + * Handles map click events for cluster expansion, node selection, and waypoint editing. * * @param map The MapLibre map instance * @param context Android context for density calculations + * @param latLng The clicked location * @param nodes All available nodes * @param waypoints All available waypoints * @param onExpandedClusterChange Callback when cluster overlay should be shown/hidden * @param onClusterListMembersChange Callback when cluster list bottom sheet should be shown/hidden * @param onSelectedNodeChange Callback when a node is selected * @param onWaypointEditRequest Callback when a waypoint should be edited + * @return true if the click was handled */ -fun createMapClickListener( +fun handleMapClick( map: MapLibreMap, context: Context, + latLng: LatLng, nodes: List, - waypoints: Map, + waypoints: Map, onExpandedClusterChange: (ExpandedCluster?) -> Unit, onClusterListMembersChange: (List?) -> Unit, onSelectedNodeChange: (Int?) -> Unit, onWaypointEditRequest: (Waypoint?) -> Unit, -): MapLibreMap.OnMapClickListener { - return MapLibreMap.OnMapClickListener { latLng -> - // Any tap on the map clears overlays unless replaced below - onExpandedClusterChange(null) - onClusterListMembersChange(null) - - val screenPoint = map.projection.toScreenLocation(latLng) - // Use a small hitbox to improve taps on small circles - val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) - val rect = android.graphics.RectF( +): Boolean { + // Any tap on the map clears overlays unless replaced below + onExpandedClusterChange(null) + onClusterListMembersChange(null) + + val screenPoint = map.projection.toScreenLocation(latLng) + // Use a small hitbox to improve taps on small circles + val r = (HIT_BOX_RADIUS_DP * context.resources.displayMetrics.density) + val rect = + android.graphics.RectF( (screenPoint.x - r).toFloat(), (screenPoint.y - r).toFloat(), (screenPoint.x + r).toFloat(), (screenPoint.y + r).toFloat(), ) - val features = map.queryRenderedFeatures( + val features = + map.queryRenderedFeatures( rect, CLUSTER_CIRCLE_LAYER_ID, NODES_LAYER_ID, @@ -86,24 +90,21 @@ fun createMapClickListener( WAYPOINTS_LAYER_ID, ) - Timber.tag("MapClickHandlers").d( - "Map click at (%.5f, %.5f) -> %d features", - latLng.latitude, - latLng.longitude, - features.size, - ) + Timber.tag("MapClickHandlers") + .d("Map click at (%.5f, %.5f) -> %d features", latLng.latitude, latLng.longitude, features.size) - val f = features.firstOrNull() + val f = features.firstOrNull() - // If cluster tapped, expand using true cluster leaves from the source - if (f != null && f.hasProperty("point_count")) { - val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 - val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX, pointCount.toLong()) - val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) + // If cluster tapped, expand using true cluster leaves from the source + if (f != null && f.hasProperty("point_count")) { + val pointCount = f.getNumberProperty("point_count")?.toInt() ?: 0 + val limit = kotlin.math.min(CLUSTER_LIST_FETCH_MAX.toLong(), pointCount.toLong()) + val src = (map.style?.getSource(NODES_CLUSTER_SOURCE_ID) as? GeoJsonSource) - if (src != null) { - val fc = src.getClusterLeaves(f, limit, 0L) - val nums = fc.features()?.mapNotNull { feat -> + if (src != null) { + val fc = src.getClusterLeaves(f, limit, 0L) + val nums = + fc.features()?.mapNotNull { feat -> try { feat.getNumberProperty("num")?.toInt() } catch (_: Throwable) { @@ -111,121 +112,101 @@ fun createMapClickListener( } } ?: emptyList() - val members = nodes.filter { nums.contains(it.num) } - - if (members.isNotEmpty()) { - // Center camera on cluster (without zoom) to keep cluster intact - val geom = f.geometry() - if (geom is Point) { - val clusterLat = geom.latitude() - val clusterLon = geom.longitude() - val clusterLatLng = LatLng(clusterLat, clusterLon) - - map.animateCamera( - CameraUpdateFactory.newLatLng(clusterLatLng), - 300, - object : MapLibreMap.CancelableCallback { - override fun onFinish() { - // Calculate screen position AFTER camera animation completes - val clusterCenter = map.projection.toScreenLocation( - LatLng(clusterLat, clusterLon), + val members = nodes.filter { nums.contains(it.num) } + + if (members.isNotEmpty()) { + // Center camera on cluster (without zoom) to keep cluster intact + val geom = f.geometry() + if (geom is Point) { + val clusterLat = geom.latitude() + val clusterLon = geom.longitude() + val clusterLatLng = LatLng(clusterLat, clusterLon) + + map.animateCamera( + CameraUpdateFactory.newLatLng(clusterLatLng), + 300, + object : MapLibreMap.CancelableCallback { + override fun onFinish() { + // Calculate screen position AFTER camera animation completes + val clusterCenter = map.projection.toScreenLocation(LatLng(clusterLat, clusterLon)) + + // Set overlay state after camera animation completes + if (pointCount > CLUSTER_RADIAL_MAX) { + // Show list for large clusters + onClusterListMembersChange(members) + } else { + // Show radial overlay for small clusters + onExpandedClusterChange( + ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)), ) - - // Set overlay state after camera animation completes - if (pointCount > CLUSTER_RADIAL_MAX) { - // Show list for large clusters - onClusterListMembersChange(members) - } else { - // Show radial overlay for small clusters - onExpandedClusterChange( - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) - ) - } } - - override fun onCancel() { - // Animation was cancelled, don't show overlay - } - }, - ) - - Timber.tag("MapClickHandlers").d( - "Centering on cluster at (%.5f, %.5f) with %d members", - clusterLat, - clusterLon, - members.size, - ) - } else { - // No geometry, show overlay immediately using current screen position - val clusterCenter = (f.geometry() as? Point)?.let { p -> + } + + override fun onCancel() { + // Animation was cancelled, don't show overlay + } + }, + ) + + Timber.tag("MapClickHandlers") + .d("Centering on cluster at (%.5f, %.5f) with %d members", clusterLat, clusterLon, members.size) + } else { + // No geometry, show overlay immediately using current screen position + val clusterCenter = + (f.geometry() as? Point)?.let { p -> map.projection.toScreenLocation(LatLng(p.latitude(), p.longitude())) } ?: screenPoint - if (pointCount > CLUSTER_RADIAL_MAX) { - onClusterListMembersChange(members) - } else { - onExpandedClusterChange( - ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX)) - ) - } + if (pointCount > CLUSTER_RADIAL_MAX) { + onClusterListMembersChange(members) + } else { + onExpandedClusterChange(ExpandedCluster(clusterCenter, members.take(CLUSTER_RADIAL_MAX))) } } - return@OnMapClickListener true - } else { - map.animateCamera(CameraUpdateFactory.zoomIn()) - return@OnMapClickListener true } + return true + } else { + map.animateCamera(CameraUpdateFactory.zoomIn()) + return true } + } - // Handle node/waypoint selection - f?.let { - val kind = it.getStringProperty("kind") - when (kind) { - "node" -> { - val num = it.getNumberProperty("num")?.toInt() ?: -1 - onSelectedNodeChange(num) - - // Center camera on selected node - val geom = it.geometry() - if (geom is Point) { - val nodeLat = geom.latitude() - val nodeLon = geom.longitude() - val nodeLatLng = LatLng(nodeLat, nodeLon) - map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) - Timber.tag("MapClickHandlers").d( - "Centering on node %d at (%.5f, %.5f)", - num, - nodeLat, - nodeLon, - ) - } + // Handle node/waypoint selection + f?.let { + val kind = it.getStringProperty("kind") + when (kind) { + "node" -> { + val num = it.getNumberProperty("num")?.toInt() ?: -1 + onSelectedNodeChange(num) + + // Center camera on selected node + val geom = it.geometry() + if (geom is Point) { + val nodeLat = geom.latitude() + val nodeLon = geom.longitude() + val nodeLatLng = LatLng(nodeLat, nodeLon) + map.animateCamera(CameraUpdateFactory.newLatLng(nodeLatLng), 300) + Timber.tag("MapClickHandlers").d("Centering on node %d at (%.5f, %.5f)", num, nodeLat, nodeLon) } - "waypoint" -> { - val id = it.getNumberProperty("id")?.toInt() ?: -1 - // Open edit dialog for waypoint - val waypoint = waypoints.values - .find { pkt -> pkt.data.waypoint?.id == id } - ?.data?.waypoint - onWaypointEditRequest(waypoint) - - // Center camera on waypoint - val geom = it.geometry() - if (geom is Point) { - val wpLat = geom.latitude() - val wpLon = geom.longitude() - val wpLatLng = LatLng(wpLat, wpLon) - map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) - Timber.tag("MapClickHandlers").d( - "Centering on waypoint %d at (%.5f, %.5f)", - id, - wpLat, - wpLon, - ) - } + } + "waypoint" -> { + val id = it.getNumberProperty("id")?.toInt() ?: -1 + // Open edit dialog for waypoint + val waypoint = waypoints.values.find { pkt -> pkt.data.waypoint?.id == id }?.data?.waypoint + onWaypointEditRequest(waypoint) + + // Center camera on waypoint + val geom = it.geometry() + if (geom is Point) { + val wpLat = geom.latitude() + val wpLon = geom.longitude() + val wpLatLng = LatLng(wpLat, wpLon) + map.animateCamera(CameraUpdateFactory.newLatLng(wpLatLng), 300) + Timber.tag("MapClickHandlers").d("Centering on waypoint %d at (%.5f, %.5f)", id, wpLat, wpLon) } - else -> {} } + else -> {} } - true } + return true } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index e75f3c55c6..a615ca688d 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -20,15 +20,12 @@ package org.meshtastic.feature.map.maplibre.ui // Import modularized MapLibre components import android.annotation.SuppressLint -import android.graphics.RectF -import android.os.SystemClock import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material.icons.outlined.Map import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet @@ -62,7 +59,6 @@ import org.maplibre.android.style.expressions.Expression.get import org.maplibre.android.style.layers.PropertyFactory.visibility import org.maplibre.android.style.layers.TransitionOptions import org.maplibre.android.style.sources.GeoJsonSource -import org.maplibre.geojson.Point import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.LayerType import org.meshtastic.feature.map.MapLayerItem @@ -71,19 +67,13 @@ import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.TileCacheManagementSheet import org.meshtastic.feature.map.maplibre.BaseMapStyle -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_LIST_FETCH_MAX -import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_RADIAL_MAX import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID -import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.core.activateLocationComponentForStyle import org.meshtastic.feature.map.maplibre.core.buildMeshtasticStyle @@ -544,7 +534,9 @@ fun MapLibrePOC( ), ) } - map.animateCamera(CameraUpdateFactory.newLatLngBounds(trackBounds.build(), CAMERA_PADDING_PX)) + map.animateCamera( + CameraUpdateFactory.newLatLngBounds(trackBounds.build(), CAMERA_PADDING_PX), + ) } } } else { @@ -726,14 +718,6 @@ fun MapLibrePOC( waypointsToFeatureCollectionFC(waypoints.values), ) // Set clustered source only (like MapLibre example) - val filteredNodes = - applyFilters( - nodes, - mapFilterState, - enabledRoles, - ourNode?.num, - isLocationTrackingEnabled, - ) val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) Timber.tag("MapLibrePOC") .d("Setting nodes sources: %d nodes, jsonBytes=%d", nodes.size, json.length) @@ -807,10 +791,11 @@ fun MapLibrePOC( } } catch (_: Throwable) {} } - map.addOnMapClickListener( - createMapClickListener( + map.addOnMapClickListener { latLng -> + handleMapClick( map = map, context = context, + latLng = latLng, nodes = nodes, waypoints = waypoints, onExpandedClusterChange = { expandedCluster = it }, @@ -818,7 +803,7 @@ fun MapLibrePOC( onSelectedNodeChange = { selectedNodeNum = it }, onWaypointEditRequest = { editingWaypoint = it }, ) - ) + } // Long-press to create waypoint map.addOnMapLongClickListener { latLng -> if (isConnected) { @@ -833,8 +818,8 @@ fun MapLibrePOC( true } // Update clustering visibility on camera idle (zoom changes) - map.addOnCameraIdleListener( - createCameraIdleListener( + map.addOnCameraIdleListener { + handleCameraIdle( map = map, context = context, mapViewRef = mapViewRef, @@ -848,7 +833,7 @@ fun MapLibrePOC( clusteringEnabled = clusteringEnabled, clustersShownState = clustersShownState, ) - ) + } // Hide expanded cluster overlay whenever camera moves to avoid stale screen positions map.addOnCameraMoveListener { if (expandedCluster != null || clusterListMembers != null) { @@ -1135,7 +1120,10 @@ fun MapLibrePOC( expandedCluster = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), DEFAULT_ZOOM_LEVEL), + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + DEFAULT_ZOOM_LEVEL, + ), ) } }, @@ -1187,7 +1175,10 @@ fun MapLibrePOC( clusterListMembers = null node.validPosition?.let { p -> mapRef?.animateCamera( - CameraUpdateFactory.newLatLngZoom(LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), DEFAULT_ZOOM_LEVEL), + CameraUpdateFactory.newLatLngZoom( + LatLng(p.latitudeI * DEG_D, p.longitudeI * DEG_D), + DEFAULT_ZOOM_LEVEL, + ), ) } }, diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt index 09add323bc..b355d15698 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreLayerUtils.kt @@ -105,7 +105,13 @@ suspend fun deleteFileFromInternalStorage(uri: Uri) = withContext(Dispatchers.IO } } -/** Gets InputStream from URI */ +/** + * Gets InputStream from URI. + * + * **Important:** Caller is responsible for closing the returned stream using `.use {}` or try-with-resources. + * + * @Suppress("Recycle") is used because this function intentionally returns an unclosed stream for the caller to manage. + */ @Suppress("Recycle") suspend fun getInputStreamFromUri(context: Context, uri: Uri): InputStream? = withContext(Dispatchers.IO) { try { diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt index 23388e3510..af78266059 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MapLayerItem.kt @@ -29,6 +29,6 @@ data class MapLayerItem( val id: String = UUID.randomUUID().toString(), val name: String, val uri: Uri? = null, - var isVisible: Boolean = true, + val isVisible: Boolean = true, val layerType: LayerType, ) From b2522add9419e2a4373b09dec39346b64c6907bf Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Sat, 22 Nov 2025 22:49:43 -0800 Subject: [PATCH 57/62] better waypoint --- .../maplibre/core/MapLibreDataTransformers.kt | 17 ++++++++++++++++- .../map/maplibre/core/MapLibreLayerManager.kt | 11 +++++++---- .../map/maplibre/core/MapLibreStyleBuilder.kt | 7 ++++++- .../feature/map/maplibre/ui/MapLibrePOC.kt | 8 ++++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index 884b253354..93f5d41b7a 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -117,9 +117,24 @@ fun waypointsToFeatureCollectionFC( f.addStringProperty("kind", "waypoint") f.addNumberProperty("id", w.id) f.addStringProperty("name", w.name ?: "Waypoint ${w.id}") - f.addNumberProperty("icon", w.icon) + // Convert icon codepoint to emoji string, use 📍 (0x1F4CD) as default + val iconEmoji = + if (w.icon == 0) { + String(Character.toChars(0x1F4CD)) // 📍 Round Pushpin + } else { + String(Character.toChars(w.icon)) + } + f.addStringProperty("icon", iconEmoji) + timber.log.Timber.tag("MapLibrePOC").d( + "Waypoint feature: lat=%.5f, lon=%.5f, icon=%s, name=%s", + lat, + lon, + iconEmoji, + w.name, + ) } } + timber.log.Timber.tag("MapLibrePOC").d("Created waypoints FC: %d features", features.size) return FeatureCollection.fromFeatures(features) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt index ea32146c92..50f34bc1bb 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreLayerManager.kt @@ -42,6 +42,7 @@ import org.maplibre.android.style.layers.HeatmapLayer import org.maplibre.android.style.layers.LineLayer import org.maplibre.android.style.layers.PropertyFactory.circleColor import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circlePitchAlignment import org.maplibre.android.style.layers.PropertyFactory.circleRadius import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth @@ -218,15 +219,17 @@ fun ensureSourcesAndLayers(style: Style) { Timber.tag("MapLibrePOC").d("Added node text SymbolLayer") } - // Waypoints layer + // Waypoints layer - small precise marker with red ring and white center if (style.getLayer(WAYPOINTS_LAYER_ID) == null) { val layer = CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) .withProperties( - circleColor("#FF5722"), - circleRadius(8f), - circleStrokeColor("#FFFFFF"), + circleColor("#FFFFFF"), // White center for precision + circleRadius(4f), // Small for precision + circleStrokeColor("#FF3B30"), // Red ring circleStrokeWidth(2f), + circleOpacity(1.0f), + circlePitchAlignment("map"), // Keep aligned to map ) if (style.getLayer(OSM_LAYER_ID) != null) style.addLayerAbove(layer, OSM_LAYER_ID) else style.addLayer(layer) Timber.tag("MapLibrePOC").d("Added waypoints CircleLayer") diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt index 933aeae432..597f0b92aa 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt @@ -203,7 +203,12 @@ fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Sty ) .withLayer( CircleLayer(WAYPOINTS_LAYER_ID, WAYPOINTS_SOURCE_ID) - .withProperties(circleColor("#2E7D32"), circleRadius(5f)), + .withProperties( + circleColor("#FFFFFF"), // White center for precision + circleRadius(4f), // Small for precision + circleStrokeColor("#FF3B30"), // Red ring + circleStrokeWidth(2f), + ), ) return builder } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index a615ca688d..b35fb6f146 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -449,6 +449,14 @@ fun MapLibrePOC( // Restore proper clustering visibility based on current state val filteredNodes = applyFilters(nodes, mapFilterState, enabledRoles, ourNode?.num, isLocationTrackingEnabled) + + // Update node sources with current data + val density = context.resources.displayMetrics.density + val labelSet = selectLabelsForViewport(map, filteredNodes, density) + val json = nodesToFeatureCollectionJsonWithSelection(filteredNodes, labelSet) + safeSetGeoJson(style, NODES_CLUSTER_SOURCE_ID, json) + safeSetGeoJson(style, NODES_SOURCE_ID, json) + clustersShown = setClusterVisibilityHysteresis( map, From 5d96d0670e0284dfb75ea22bad18b15608fd19fe Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Mon, 24 Nov 2025 14:11:35 -0800 Subject: [PATCH 58/62] better waypoint --- .../map/maplibre/core/MapLibreDataTransformers.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index 93f5d41b7a..245a8bc664 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -125,13 +125,8 @@ fun waypointsToFeatureCollectionFC( String(Character.toChars(w.icon)) } f.addStringProperty("icon", iconEmoji) - timber.log.Timber.tag("MapLibrePOC").d( - "Waypoint feature: lat=%.5f, lon=%.5f, icon=%s, name=%s", - lat, - lon, - iconEmoji, - w.name, - ) + timber.log.Timber.tag("MapLibrePOC") + .d("Waypoint feature: lat=%.5f, lon=%.5f, icon=%s, name=%s", lat, lon, iconEmoji, w.name) } } timber.log.Timber.tag("MapLibrePOC").d("Created waypoints FC: %d features", features.size) From b19240a24723d2e9f323cc09c4d488adbbe6caee Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Mon, 1 Dec 2025 21:30:02 -0800 Subject: [PATCH 59/62] fix: min/max zoom bounds --- .../feature/map/maplibre/MapLibreConstants.kt | 29 +++++++++++++++---- .../map/maplibre/core/MapLibreStyleBuilder.kt | 4 +-- .../feature/map/maplibre/ui/MapLibrePOC.kt | 11 ++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt index 504331996b..2bcf0e5e84 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -54,13 +54,30 @@ object MapLibreConstants { } /** Base map style options (raster tiles; key-free) */ -enum class BaseMapStyle(val label: String, val urlTemplate: String) { - OSM_STANDARD("OSM", "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"), - CARTO_LIGHT("Light", "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"), - CARTO_DARK("Dark", "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"), +enum class BaseMapStyle(val label: String, val urlTemplate: String, val minZoom: Float, val maxZoom: Float) { + OSM_STANDARD( + label = "OSM", + urlTemplate = "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 19f, + ), + CARTO_LIGHT( + label = "Light", + urlTemplate = "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 20f, + ), + CARTO_DARK( + label = "Dark", + urlTemplate = "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + minZoom = 0f, + maxZoom = 20f, + ), ESRI_SATELLITE( - "Satellite", - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + label = "Satellite", + urlTemplate = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + minZoom = 1f, + maxZoom = 19f, ), } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt index 597f0b92aa..f81d471d76 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreStyleBuilder.kt @@ -84,8 +84,8 @@ fun buildMeshtasticStyle(base: BaseMapStyle, customTileUrl: String? = null): Sty RasterSource( OSM_SOURCE_ID, TileSet("osm", tileUrl).apply { - minZoom = 0f - maxZoom = 22f + minZoom = base.minZoom + maxZoom = base.maxZoom }, 128, ), diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index b35fb6f146..1ec269693f 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -564,6 +564,7 @@ fun MapLibrePOC( mapViewRef = this getMapAsync { map -> mapRef = map + map.applyZoomPreferences(baseStyle) Timber.tag("MapLibrePOC").d("getMapAsync() map acquired, setting style...") // Set initial base raster style using MapLibre test-app pattern map.setStyle(buildMeshtasticStyle(baseStyle)) { style -> @@ -1037,6 +1038,7 @@ fun MapLibrePOC( val next = baseStyles[baseStyleIndex % baseStyles.size] mapRef?.let { map -> Timber.tag("MapLibrePOC").d("Switching base style to: %s", next.label) + map.applyZoomPreferences(next) map.setStyle(buildMeshtasticStyle(next)) { st -> Timber.tag("MapLibrePOC").d("Base map switched to: %s", next.label) val density = context.resources.displayMetrics.density @@ -1089,7 +1091,9 @@ fun MapLibrePOC( // Apply custom tiles (use first base style as template but we'll override the raster source) mapRef?.let { map -> Timber.tag("MapLibrePOC").d("Switching to custom tiles: %s", customTileUrl) - map.setStyle(buildMeshtasticStyle(baseStyles[0], customTileUrl)) { st -> + val templateStyle = baseStyles[baseStyleIndex % baseStyles.size] + map.applyZoomPreferences(templateStyle) + map.setStyle(buildMeshtasticStyle(templateStyle, customTileUrl)) { st -> Timber.tag("MapLibrePOC").d("Custom tiles applied") val density = context.resources.displayMetrics.density clustersShown = @@ -1260,3 +1264,8 @@ fun MapLibrePOC( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } + +private fun MapLibreMap.applyZoomPreferences(style: BaseMapStyle) { + setMinZoomPreference(style.minZoom.toDouble()) + setMaxZoomPreference(style.maxZoom.toDouble()) +} From 1638b4e2343b8310199a6d8fbe95f767cf48ab7b Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Mon, 1 Dec 2025 21:39:42 -0800 Subject: [PATCH 60/62] feat: cluster tap zoom --- .../map/maplibre/ui/MapClickHandlers.kt | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt index 9a31628d8a..d9be7e2193 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapClickHandlers.kt @@ -21,18 +21,22 @@ import android.content.Context import android.graphics.PointF import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng +import org.maplibre.android.geometry.LatLngBounds import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.style.sources.GeoJsonSource import org.maplibre.geojson.Point import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.CLUSTER_CIRCLE_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_LAYER_ID import org.meshtastic.proto.MeshProtos.Waypoint import timber.log.Timber +import kotlin.math.max +import kotlin.math.min data class ExpandedCluster(val centerPx: PointF, val members: List) @@ -40,6 +44,9 @@ data class ExpandedCluster(val centerPx: PointF, val members: List) private const val HIT_BOX_RADIUS_DP = 24f private const val CLUSTER_LIST_FETCH_MAX = 200 private const val CLUSTER_RADIAL_MAX = 10 +private const val CLUSTER_OVERLAY_ZOOM_THRESHOLD = 13.5 +private const val MIN_CLUSTER_SPAN_DEGREES = 0.002 +private const val CLUSTER_BOUNDS_PADDING_PX = 96 /** * Handles map click events for cluster expansion, node selection, and waypoint editing. @@ -115,6 +122,24 @@ fun handleMapClick( val members = nodes.filter { nums.contains(it.num) } if (members.isNotEmpty()) { + val clusterBounds = buildClusterBounds(members) + val zoom = map.cameraPosition?.zoom ?: 0.0 + + if (shouldZoomToBounds(zoom, clusterBounds, members.size)) { + clusterBounds?.let { bounds -> + map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds.bounds, CLUSTER_BOUNDS_PADDING_PX)) + Timber.tag("MapClickHandlers") + .d( + "Zooming to cluster bounds (zoom=%.2f, latSpan=%.4f, lonSpan=%.4f, members=%d)", + zoom, + bounds.latSpan, + bounds.lonSpan, + members.size, + ) + return true + } + } + // Center camera on cluster (without zoom) to keep cluster intact val geom = f.geometry() if (geom is Point) { @@ -210,3 +235,40 @@ fun handleMapClick( } return true } + +private data class ClusterBounds(val bounds: LatLngBounds, val latSpan: Double, val lonSpan: Double) + +private fun buildClusterBounds(members: List): ClusterBounds? { + val builder = LatLngBounds.Builder() + var included = false + var minLat = Double.MAX_VALUE + var maxLat = -Double.MAX_VALUE + var minLon = Double.MAX_VALUE + var maxLon = -Double.MAX_VALUE + + members.forEach { node -> + node.validPosition?.let { vp -> + val lat = vp.latitudeI * DEG_D + val lon = vp.longitudeI * DEG_D + builder.include(LatLng(lat, lon)) + minLat = min(minLat, lat) + maxLat = max(maxLat, lat) + minLon = min(minLon, lon) + maxLon = max(maxLon, lon) + included = true + } + } + + return if (included) { + ClusterBounds(bounds = builder.build(), latSpan = maxLat - minLat, lonSpan = maxLon - minLon) + } else { + null + } +} + +private fun shouldZoomToBounds(zoom: Double, clusterBounds: ClusterBounds?, memberCount: Int): Boolean { + if (clusterBounds == null || memberCount <= 1) return false + val hasMeaningfulSpread = + clusterBounds.latSpan > MIN_CLUSTER_SPAN_DEGREES || clusterBounds.lonSpan > MIN_CLUSTER_SPAN_DEGREES + return zoom < CLUSTER_OVERLAY_ZOOM_THRESHOLD && hasMeaningfulSpread +} From d10d32545140a12a0360961d91844cea22b92b71 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 2 Dec 2025 17:44:04 -0800 Subject: [PATCH 61/62] feat: nodeColor/roleColor toggle, defaulting to nodeColor --- .../composeResources/values/strings.xml | 6 +++ .../map/component/CustomMapLayersSheet.kt | 30 ++++++++++++++ .../feature/map/maplibre/MapLibreConstants.kt | 2 + .../maplibre/core/MapLibreDataTransformers.kt | 8 +++- .../map/maplibre/core/MapLibreStyleBuilder.kt | 7 +++- .../map/maplibre/ui/MapLibreControlButtons.kt | 23 +++++++++++ .../feature/map/maplibre/ui/MapLibrePOC.kt | 41 +++++++++++++++++-- .../feature/map/maplibre/ui/MapLibreUI.kt | 24 +++++++++-- .../map/maplibre/utils/MapLibreHelpers.kt | 3 ++ .../map/component/CustomMapLayersSheet.kt | 30 ++++++++++++++ .../meshtastic/feature/map/MarkerColorMode.kt | 30 ++++++++++++++ 11 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 03d63617ad..98e9a2d90d 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -911,6 +911,12 @@ Add Layer Hide Layer Show Layer + Show role legend + Display a role-to-color legend overlay on the map. + Marker colors + Switch between role colors and node-specific colors. + Role colors + Node colors Remove Layer Add Layer Nodes at this location diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt index 637a1e6536..a9ea7cc489 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -44,10 +44,15 @@ import org.meshtastic.core.strings.add_layer import org.meshtastic.core.strings.hide_layer import org.meshtastic.core.strings.manage_map_layers import org.meshtastic.core.strings.map_layer_formats +import org.meshtastic.core.strings.marker_color_mode_description +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.strings.marker_color_mode_title import org.meshtastic.core.strings.no_map_layers_loaded import org.meshtastic.core.strings.remove_layer import org.meshtastic.core.strings.show_layer import org.meshtastic.feature.map.MapLayerItem +import org.meshtastic.feature.map.MarkerColorMode @Suppress("LongMethod") @Composable @@ -57,6 +62,8 @@ fun CustomMapLayersSheet( onToggleVisibility: (String) -> Unit, onRemoveLayer: (String) -> Unit, onAddLayerClicked: () -> Unit, + markerColorMode: MarkerColorMode? = null, + onMarkerColorModeChange: ((MarkerColorMode) -> Unit)? = null, ) { LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { item { @@ -74,6 +81,29 @@ fun CustomMapLayersSheet( style = MaterialTheme.typography.bodySmall, ) } + if (markerColorMode != null && onMarkerColorModeChange != null) { + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.marker_color_mode_title)) }, + supportingContent = { Text(stringResource(Res.string.marker_color_mode_description)) }, + trailingContent = { + Button(onClick = { onMarkerColorModeChange(markerColorMode.toggle()) }) { + Text( + text = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + } + }, + ) + HorizontalDivider() + } + } if (mapLayers.isEmpty()) { item { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt index 2bcf0e5e84..08aaddf6ef 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/MapLibreConstants.kt @@ -33,6 +33,8 @@ object MapLibreConstants { const val TRACK_POINTS_SOURCE_ID = "meshtastic-track-points-source" const val HEATMAP_SOURCE_ID = "meshtastic-heatmap-source" const val OSM_SOURCE_ID = "osm-tiles" + const val NODE_COLOR_PROPERTY = "nodeColor" + const val ROLE_COLOR_PROPERTY = "roleColor" // Layer IDs const val NODES_LAYER_ID = "meshtastic-nodes-layer" // From clustered source, filtered diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt index 245a8bc664..df735d1cbb 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/core/MapLibreDataTransformers.kt @@ -26,6 +26,7 @@ import org.maplibre.geojson.Point import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D import org.meshtastic.feature.map.maplibre.getPrecisionMeters +import org.meshtastic.feature.map.maplibre.utils.nodeColorHex import org.meshtastic.feature.map.maplibre.utils.protoShortName import org.meshtastic.feature.map.maplibre.utils.roleColorHex import org.meshtastic.feature.map.maplibre.utils.safeSubstring @@ -53,10 +54,11 @@ fun nodesToFeatureCollectionJsonWithSelection(nodes: List, labelNums: Set< val shortEsc = escapeJson(shortForMap) val show = if (labelNums.contains(node.num)) 1 else 0 val role = node.user.role.name - val color = roleColorHex(node) + val roleColor = roleColorHex(node) + val nodeColor = nodeColorHex(node) val longEsc = escapeJson(node.user.longName ?: "") val precisionMeters = getPrecisionMeters(pos.precisionBits) ?: 0.0 - """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","color":"$color","showLabel":$show,"precisionMeters":$precisionMeters}}""" + """{"type":"Feature","geometry":{"type":"Point","coordinates":[$lon,$lat]},"properties":{"kind":"node","num":${node.num},"name":"$longEsc","short":"$shortEsc","role":"$role","roleColor":"$roleColor","nodeColor":"$nodeColor","color":"$roleColor","showLabel":$show,"precisionMeters":$precisionMeters}}""" } return """{"type":"FeatureCollection","features":[${features.joinToString(",")}]}""" } @@ -91,6 +93,8 @@ fun nodesToFeatureCollectionWithSelectionFC(nodes: List, labelNums: Set Unit, onStyleClick: () -> Unit, onLayersClick: () -> Unit = {}, + markerColorMode: MarkerColorMode, + onMarkerColorToggle: () -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier, horizontalAlignment = Alignment.End) { @@ -117,6 +125,21 @@ fun MapLibreControlButtons( Spacer(modifier = Modifier.size(8.dp)) + MapButton( + onClick = onMarkerColorToggle, + icon = Icons.Outlined.Palette, + contentDescription = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + + Spacer(modifier = Modifier.size(8.dp)) + MapButton(onClick = onLegendClick, icon = Icons.Outlined.Info, contentDescription = null) } } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt index 1ec269693f..03af609f28 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibrePOC.kt @@ -55,7 +55,12 @@ import org.maplibre.android.geometry.LatLng import org.maplibre.android.location.modes.RenderMode import org.maplibre.android.maps.MapLibreMap import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.style.expressions.Expression.coalesce import org.maplibre.android.style.expressions.Expression.get +import org.maplibre.android.style.expressions.Expression.literal +import org.maplibre.android.style.expressions.Expression.toColor +import org.maplibre.android.style.layers.PropertyFactory.circleColor import org.maplibre.android.style.layers.PropertyFactory.visibility import org.maplibre.android.style.layers.TransitionOptions import org.maplibre.android.style.sources.GeoJsonSource @@ -63,6 +68,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.feature.map.LayerType import org.meshtastic.feature.map.MapLayerItem import org.meshtastic.feature.map.MapViewModel +import org.meshtastic.feature.map.MarkerColorMode import org.meshtastic.feature.map.component.CustomMapLayersSheet import org.meshtastic.feature.map.component.EditWaypointDialog import org.meshtastic.feature.map.component.TileCacheManagementSheet @@ -71,7 +77,11 @@ import org.meshtastic.feature.map.maplibre.MapLibreConstants.DEG_D import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_LAYER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.HEATMAP_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_CLUSTER_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_LAYER_NOCLUSTER_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODES_SOURCE_ID +import org.meshtastic.feature.map.maplibre.MapLibreConstants.NODE_COLOR_PROPERTY +import org.meshtastic.feature.map.maplibre.MapLibreConstants.ROLE_COLOR_PROPERTY import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_LINE_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.TRACK_POINTS_SOURCE_ID import org.meshtastic.feature.map.maplibre.MapLibreConstants.WAYPOINTS_SOURCE_ID @@ -144,7 +154,7 @@ fun MapLibrePOC( // Track whether we're currently showing tracks (for callback checks) val showingTracksRef = remember { mutableStateOf(false) } showingTracksRef.value = nodeTracks != null && focusedNodeNum != null - var showLegend by remember { mutableStateOf(false) } + var markerColorMode by remember { mutableStateOf(MarkerColorMode.NODE) } var enabledRoles by remember { mutableStateOf>(emptySet()) } var clusteringEnabled by remember { mutableStateOf(true) } var editingWaypoint by remember { mutableStateOf(null) } @@ -173,6 +183,12 @@ fun MapLibrePOC( var tileCacheManager by remember { mutableStateOf(null) } var showCacheBottomSheet by remember { mutableStateOf(false) } + LaunchedEffect(markerColorMode, styleReady, mapRef) { + val style = mapRef?.style ?: return@LaunchedEffect + if (!styleReady) return@LaunchedEffect + applyMarkerColorMode(style, markerColorMode) + } + val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle() val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle() @@ -574,6 +590,7 @@ fun MapLibrePOC( logStyleState("after-style-load(pre-ensure)", style) ensureSourcesAndLayers(style) ensureHeatmapSourceAndLayer(style) + applyMarkerColorMode(style, markerColorMode) // Setup track sources and layers if rendering node tracks Timber.tag("MapLibrePOC") @@ -942,7 +959,7 @@ fun MapLibrePOC( ) // Role legend (based on roles present in current nodes) - if (showLegend) { + if (markerColorMode == MarkerColorMode.ROLE) { RoleLegend(nodes = nodes, modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) } @@ -1057,6 +1074,7 @@ fun MapLibrePOC( clustersShown, density, ) + applyMarkerColorMode(st, markerColorMode) } } }, @@ -1067,7 +1085,8 @@ fun MapLibrePOC( }, onShowLayersClicked = { showLayersBottomSheet = true }, onShowCacheClicked = { showCacheBottomSheet = true }, - onShowLegendToggled = { showLegend = !showLegend }, + markerColorMode = markerColorMode, + onMarkerColorModeToggle = { markerColorMode = markerColorMode.toggle() }, heatmapEnabled = heatmapEnabled, onHeatmapToggled = { heatmapEnabled = !heatmapEnabled }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 16.dp), @@ -1111,6 +1130,7 @@ fun MapLibrePOC( clustersShown, density, ) + applyMarkerColorMode(st, markerColorMode) } } } @@ -1157,6 +1177,8 @@ fun MapLibrePOC( showLayersBottomSheet = false openFilePicker() }, + markerColorMode = markerColorMode, + onMarkerColorModeChange = { mode -> markerColorMode = mode }, ) } } @@ -1269,3 +1291,16 @@ private fun MapLibreMap.applyZoomPreferences(style: BaseMapStyle) { setMinZoomPreference(style.minZoom.toDouble()) setMaxZoomPreference(style.maxZoom.toDouble()) } + +private fun applyMarkerColorMode(style: Style, mode: MarkerColorMode) { + val expression = + when (mode) { + MarkerColorMode.ROLE -> markerColorExpression(ROLE_COLOR_PROPERTY) + MarkerColorMode.NODE -> markerColorExpression(NODE_COLOR_PROPERTY) + } + style.getLayer(NODES_LAYER_ID)?.setProperties(circleColor(expression)) + style.getLayer(NODES_LAYER_NOCLUSTER_ID)?.setProperties(circleColor(expression)) +} + +private fun markerColorExpression(primaryProperty: String) = + coalesce(toColor(get(primaryProperty)), toColor(get(ROLE_COLOR_PROPERTY)), toColor(literal("#2E7D32"))) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt index b0b02f82d3..983149d1e9 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/ui/MapLibreUI.kt @@ -33,10 +33,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Check -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.Map import androidx.compose.material.icons.outlined.MyLocation +import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Remove import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Tune @@ -62,11 +62,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.maps.MapLibreMap import org.meshtastic.core.database.model.Node +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState +import org.meshtastic.feature.map.MarkerColorMode import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.maplibre.BaseMapStyle import org.meshtastic.feature.map.maplibre.utils.protoShortName @@ -124,7 +129,8 @@ fun MapToolbar( onCustomTileClicked: () -> Unit, onShowLayersClicked: () -> Unit, onShowCacheClicked: () -> Unit, - onShowLegendToggled: () -> Unit, + markerColorMode: MarkerColorMode, + onMarkerColorModeToggle: () -> Unit, heatmapEnabled: Boolean, onHeatmapToggled: () -> Unit, modifier: Modifier = Modifier, @@ -292,8 +298,18 @@ fun MapToolbar( // Cache management button MapButton(onClick = onShowCacheClicked, icon = Icons.Outlined.Storage, contentDescription = null) - // Legend button - MapButton(onClick = onShowLegendToggled, icon = Icons.Outlined.Info, contentDescription = null) + MapButton( + onClick = onMarkerColorModeToggle, + icon = Icons.Outlined.Palette, + contentDescription = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) }, ) } diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt index 7a953bf508..3bf5a1654b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/maplibre/utils/MapLibreHelpers.kt @@ -198,6 +198,9 @@ fun roleColorHex(node: Node): String = when (node.user.role) { /** Get Color object for a node based on its role */ fun roleColor(node: Node): Color = Color(android.graphics.Color.parseColor(roleColorHex(node))) +/** Get hex color for a node based on its unique node color */ +fun nodeColorHex(node: Node): String = String.format("#%06X", 0xFFFFFF and node.colors.second) + /** Apply filters to node list */ fun applyFilters( all: List, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt index 637a1e6536..a9ea7cc489 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt @@ -44,10 +44,15 @@ import org.meshtastic.core.strings.add_layer import org.meshtastic.core.strings.hide_layer import org.meshtastic.core.strings.manage_map_layers import org.meshtastic.core.strings.map_layer_formats +import org.meshtastic.core.strings.marker_color_mode_description +import org.meshtastic.core.strings.marker_color_mode_node +import org.meshtastic.core.strings.marker_color_mode_role +import org.meshtastic.core.strings.marker_color_mode_title import org.meshtastic.core.strings.no_map_layers_loaded import org.meshtastic.core.strings.remove_layer import org.meshtastic.core.strings.show_layer import org.meshtastic.feature.map.MapLayerItem +import org.meshtastic.feature.map.MarkerColorMode @Suppress("LongMethod") @Composable @@ -57,6 +62,8 @@ fun CustomMapLayersSheet( onToggleVisibility: (String) -> Unit, onRemoveLayer: (String) -> Unit, onAddLayerClicked: () -> Unit, + markerColorMode: MarkerColorMode? = null, + onMarkerColorModeChange: ((MarkerColorMode) -> Unit)? = null, ) { LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) { item { @@ -74,6 +81,29 @@ fun CustomMapLayersSheet( style = MaterialTheme.typography.bodySmall, ) } + if (markerColorMode != null && onMarkerColorModeChange != null) { + item { + ListItem( + headlineContent = { Text(stringResource(Res.string.marker_color_mode_title)) }, + supportingContent = { Text(stringResource(Res.string.marker_color_mode_description)) }, + trailingContent = { + Button(onClick = { onMarkerColorModeChange(markerColorMode.toggle()) }) { + Text( + text = + stringResource( + if (markerColorMode == MarkerColorMode.ROLE) { + Res.string.marker_color_mode_role + } else { + Res.string.marker_color_mode_node + }, + ), + ) + } + }, + ) + HorizontalDivider() + } + } if (mapLayers.isEmpty()) { item { diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt new file mode 100644 index 0000000000..bd66a2e92d --- /dev/null +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/MarkerColorMode.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 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.feature.map + +/** Determines which color family node markers should use. */ +enum class MarkerColorMode { + ROLE, + NODE, + ; + + fun toggle(): MarkerColorMode = when (this) { + ROLE -> NODE + NODE -> ROLE + } +} From 55de4bb2c3823b0d49b82f27c5f2f0fb2b1af686 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy Date: Tue, 2 Dec 2025 19:45:21 -0800 Subject: [PATCH 62/62] feat: lifecycle mgmt --- .../mesh/navigation/MapNavigation.kt | 8 +- .../mesh/navigation/NodesNavigation.kt | 11 ++- .../mesh/ui/node/AdaptiveNodeListScreen.kt | 17 +++- .../core/prefs/di/MapLibreModule.kt | 52 ++++++++++++ .../core/prefs/map/MapLibrePrefs.kt | 58 +++++++++++++ .../meshtastic/feature/map/MapViewModel.kt | 82 +++++++++++++++++++ 6 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt create mode 100644 core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt diff --git a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt index 5de1c69330..d485282c1a 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/MapNavigation.kt @@ -35,7 +35,13 @@ fun NavGraphBuilder.mapGraph(navController: NavHostController) { restoreState = true } }, - navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, + navigateToNodeDetails = { + navController.navigate(NodesRoutes.NodeDetailGraph(it)) { + // Don't save the state of the map when navigating to node details + // This ensures pressing back from node detail returns to map, not node list + launchSingleTop = false + } + }, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index 85e0043ef4..a28262f062 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -97,13 +97,20 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo ), ), ) { backStackEntry -> - val args = backStackEntry.toRoute() + // Get destNum from the parent graph (NodeDetailGraph) if available, + // otherwise from the route itself (for deep links) + val parentGraphEntry = + remember(backStackEntry) { navController.getBackStackEntry() } + val graphArgs = parentGraphEntry.toRoute() + val routeArgs = backStackEntry.toRoute() + val nodeId = graphArgs.destNum ?: routeArgs.destNum + // When navigating directly to NodeDetail (e.g. from Map or deep link), // we use the Adaptive screen initialized with the specific node ID. AdaptiveNodeListScreen( navController = navController, scrollToTopEvents = scrollToTopEvents, - initialNodeId = args.destNum, + initialNodeId = nodeId, onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) }, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt index f4db4c3119..75aa0000d7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/node/AdaptiveNodeListScreen.kt @@ -67,8 +67,23 @@ fun AdaptiveNodeListScreen( val scope = rememberCoroutineScope() val backNavigationBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange + // Handle back navigation from detail pane BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { - scope.launch { navigator.navigateBack(backNavigationBehavior) } + if (initialNodeId != null) { + // If opened with initialNodeId (from Map/Connections), always go back to previous screen + navController.navigateUp() + } else { + // Normal navigation within scaffold (when opened from Nodes tab) + scope.launch { navigator.navigateBack(backNavigationBehavior) } + } + } + + // Handle back from list pane when opened with initialNodeId + // This handles the case where user is on list pane after scaffold navigation on tablet + BackHandler( + enabled = initialNodeId != null && navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.List, + ) { + navController.navigateUp() } LaunchedEffect(initialNodeId) { diff --git a/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt new file mode 100644 index 0000000000..9414453d82 --- /dev/null +++ b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/di/MapLibreModule.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 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.core.prefs.di + +import android.content.Context +import android.content.SharedPreferences +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.meshtastic.core.prefs.map.MapLibrePrefs +import org.meshtastic.core.prefs.map.MapLibrePrefsImpl +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class MapLibreSharedPreferences + +@Module +@InstallIn(SingletonComponent::class) +object MapLibreProvidesModule { + @Provides + @Singleton + @MapLibreSharedPreferences + fun provideMapLibreSharedPreferences(@ApplicationContext context: Context): SharedPreferences = + context.getSharedPreferences("maplibre_prefs", Context.MODE_PRIVATE) +} + +@Module +@InstallIn(SingletonComponent::class) +abstract class MapLibreBindsModule { + @Binds @Singleton + abstract fun bindMapLibrePrefs(mapLibrePrefsImpl: MapLibrePrefsImpl): MapLibrePrefs +} diff --git a/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt new file mode 100644 index 0000000000..a62d442346 --- /dev/null +++ b/core/prefs/src/fdroid/kotlin/org/meshtastic/core/prefs/map/MapLibrePrefs.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 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.core.prefs.map + +import android.content.SharedPreferences +import org.meshtastic.core.prefs.DoublePrefDelegate +import org.meshtastic.core.prefs.NullableStringPrefDelegate +import org.meshtastic.core.prefs.PrefDelegate +import org.meshtastic.core.prefs.di.MapLibreSharedPreferences +import javax.inject.Inject +import javax.inject.Singleton + +/** Interface for prefs specific to MapLibre. For general map prefs, see MapPrefs. */ +interface MapLibrePrefs { + var cameraTargetLat: Double + var cameraTargetLng: Double + var cameraZoom: Double + var cameraBearing: Double + var cameraTilt: Double + var shouldRestoreCameraPosition: Boolean + var markerColorMode: String + var clusteringEnabled: Boolean + var heatmapEnabled: Boolean + var baseStyleIndex: Int + var customTileUrl: String? + var usingCustomTiles: Boolean +} + +@Singleton +class MapLibrePrefsImpl @Inject constructor(@MapLibreSharedPreferences prefs: SharedPreferences) : MapLibrePrefs { + override var cameraTargetLat: Double by DoublePrefDelegate(prefs, "camera_target_lat", 0.0) + override var cameraTargetLng: Double by DoublePrefDelegate(prefs, "camera_target_lng", 0.0) + override var cameraZoom: Double by DoublePrefDelegate(prefs, "camera_zoom", 15.0) + override var cameraBearing: Double by DoublePrefDelegate(prefs, "camera_bearing", 0.0) + override var cameraTilt: Double by DoublePrefDelegate(prefs, "camera_tilt", 0.0) + override var shouldRestoreCameraPosition: Boolean by PrefDelegate(prefs, "should_restore_camera_position", false) + override var markerColorMode: String by PrefDelegate(prefs, "marker_color_mode", "NODE") + override var clusteringEnabled: Boolean by PrefDelegate(prefs, "clustering_enabled", true) + override var heatmapEnabled: Boolean by PrefDelegate(prefs, "heatmap_enabled", false) + override var baseStyleIndex: Int by PrefDelegate(prefs, "base_style_index", 0) + override var customTileUrl: String? by NullableStringPrefDelegate(prefs, "custom_tile_url", null) + override var usingCustomTiles: Boolean by PrefDelegate(prefs, "using_custom_tiles", false) +} diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 93e5c03bf9..3401b80d82 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -39,6 +39,7 @@ constructor( serviceRepository: ServiceRepository, radioConfigRepository: RadioConfigRepository, buildConfigProvider: BuildConfigProvider, + private val mapLibrePrefs: org.meshtastic.core.prefs.map.MapLibrePrefs, ) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, serviceRepository) { var mapStyleId: Int @@ -56,4 +57,85 @@ constructor( val applicationId = buildConfigProvider.applicationId fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + + // MapLibre camera position + fun saveCameraPosition(latitude: Double, longitude: Double, zoom: Double, bearing: Double, tilt: Double) { + mapLibrePrefs.cameraTargetLat = latitude + mapLibrePrefs.cameraTargetLng = longitude + mapLibrePrefs.cameraZoom = zoom + mapLibrePrefs.cameraBearing = bearing + mapLibrePrefs.cameraTilt = tilt + // Set flag to restore position on next map open (e.g., returning from node details) + mapLibrePrefs.shouldRestoreCameraPosition = true + } + + fun getCameraPosition(): CameraState? { + // Only restore if flag is set (user is returning from node details) + if (!mapLibrePrefs.shouldRestoreCameraPosition) { + return null + } + + val lat = mapLibrePrefs.cameraTargetLat + val lng = mapLibrePrefs.cameraTargetLng + + // Clear the flag so position is only restored once (on return from node details) + mapLibrePrefs.shouldRestoreCameraPosition = false + + return if (lat != 0.0 || lng != 0.0) { + CameraState( + latitude = lat, + longitude = lng, + zoom = mapLibrePrefs.cameraZoom, + bearing = mapLibrePrefs.cameraBearing, + tilt = mapLibrePrefs.cameraTilt, + ) + } else { + null + } + } + + // Map settings + var markerColorMode: String + get() = mapLibrePrefs.markerColorMode + set(value) { + mapLibrePrefs.markerColorMode = value + } + + var clusteringEnabled: Boolean + get() = mapLibrePrefs.clusteringEnabled + set(value) { + mapLibrePrefs.clusteringEnabled = value + } + + var heatmapEnabled: Boolean + get() = mapLibrePrefs.heatmapEnabled + set(value) { + mapLibrePrefs.heatmapEnabled = value + } + + var baseStyleIndex: Int + get() = mapLibrePrefs.baseStyleIndex + set(value) { + mapLibrePrefs.baseStyleIndex = value + } + + var customTileUrl: String? + get() = mapLibrePrefs.customTileUrl + set(value) { + mapLibrePrefs.customTileUrl = value + } + + var usingCustomTiles: Boolean + get() = mapLibrePrefs.usingCustomTiles + set(value) { + mapLibrePrefs.usingCustomTiles = value + } } + +data class CameraState( + val latitude: Double, + val longitude: Double, + val zoom: Double, + val bearing: Double, + val tilt: Double, +)