From b60d67297de7c37035698f323ec9c630267c1002 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:39:15 -0600 Subject: [PATCH] fix(map): location perms and button visibility, breadcrumb taps (#4651) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/feature/map/MapView.kt | 111 ++++++++++++++---- .../org/meshtastic/feature/map/MapView.kt | 111 ++++++++++-------- .../map/component/MapControlsOverlay.kt | 23 ++-- 3 files changed, 160 insertions(+), 85 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 7b968b53c..4130e57f3 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 @@ -66,6 +66,7 @@ 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.graphics.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -83,12 +84,13 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.gpsDisabled -import org.meshtastic.core.common.hasGps import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.util.toString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.calculating import org.meshtastic.core.resources.cancel @@ -98,7 +100,10 @@ import org.meshtastic.core.resources.delete_for_everyone import org.meshtastic.core.resources.delete_for_me import org.meshtastic.core.resources.expires import org.meshtastic.core.resources.getString +import org.meshtastic.core.resources.heading +import org.meshtastic.core.resources.latitude import org.meshtastic.core.resources.location_disabled +import org.meshtastic.core.resources.longitude import org.meshtastic.core.resources.map_cache_info import org.meshtastic.core.resources.map_cache_manager import org.meshtastic.core.resources.map_cache_size @@ -116,6 +121,7 @@ import org.meshtastic.core.resources.map_style_selection import org.meshtastic.core.resources.map_subDescription import org.meshtastic.core.resources.map_tile_source import org.meshtastic.core.resources.only_favorites +import org.meshtastic.core.resources.position import org.meshtastic.core.resources.show_precision_circle import org.meshtastic.core.resources.show_waypoints import org.meshtastic.core.resources.toggle_my_position @@ -134,6 +140,7 @@ import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.model.CustomTileSource import org.meshtastic.feature.map.model.MarkerWithLabel import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable @@ -166,12 +173,26 @@ import kotlin.math.sin private fun MapView.updateMarkers( nodeMarkers: List, waypointMarkers: List, + trackMarkers: List, + trackPolylines: List, nodeClusterer: RadiusMarkerClusterer, ) { - Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" } - overlays.removeAll { it is MarkerWithLabel } - // overlays.addAll(nodeMarkers + waypointMarkers) + Logger.d { + "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks" + } + + val trackOverlayIds = (trackMarkers + trackPolylines).toSet() + + overlays.removeAll { overlay -> + overlay is MarkerWithLabel || + (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) || + (overlay is Polyline && overlay !in trackOverlayIds) + } + overlays.addAll(waypointMarkers) + overlays.addAll(trackPolylines) + overlays.addAll(trackMarkers) + nodeClusterer.items.clear() nodeClusterer.items.addAll(nodeMarkers) nodeClusterer.invalidate() @@ -246,8 +267,6 @@ fun MapView( val haptic = LocalHapticFeedback.current fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress) - val hasGps = remember { context.hasGps() } - // Accompanist permissions state for location val locationPermissionsState = rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) @@ -671,6 +690,51 @@ fun MapView( } } + fun MapView.onTracksChanged(nodeTracks: List?, focusedNodeNum: Int?): Pair, List> { + if (nodeTracks == null || focusedNodeNum == null) return emptyList() to emptyList() + + val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter + val timeFilteredPositions = + nodeTracks.filter { + lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds + } + val sortedPositions = timeFilteredPositions.sortedBy { it.time } + + val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList() to emptyList() + val color = focusedNode.colors.second + + val trackPolylines = mutableListOf() + if (sortedPositions.size > 1) { + val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) + segments.forEachIndexed { index, segmentPoints -> + val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) + val polyline = + Polyline().apply { + setPoints( + segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) }, + ) + outlinePaint.color = Color(color).copy(alpha = alpha).toArgb() + outlinePaint.strokeWidth = 8f + } + trackPolylines.add(polyline) + } + } + + val trackMarkers = + sortedPositions.mapIndexedNotNull { index, position -> + if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null + + Marker(this).apply { + this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7) + icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + title = getString(Res.string.position) + snippet = formatAgo(position.time) + } + } + return trackMarkers to trackPolylines + } + Scaffold( floatingActionButton = { DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true } @@ -687,10 +751,13 @@ fun MapView( modifier = Modifier.fillMaxSize(), update = { mapView -> mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints) + val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum) with(mapView) { updateMarkers( onNodesChanged(nodesForMarkers), onWaypointChanged(waypoints.values, selectedWaypointId), + trackMarkers, + trackPolylines, nodeClusterer, ) } @@ -723,7 +790,7 @@ fun MapView( MapButton( onClick = { mapFilterExpanded = true }, icon = Icons.Outlined.Tune, - contentDescription = Res.string.map_filter, + contentDescription = stringResource(Res.string.map_filter), ) DropdownMenu( expanded = mapFilterExpanded, @@ -808,22 +875,20 @@ fun MapView( ) } } - if (hasGps) { - MapButton( - icon = - if (myLocationOverlay == null) { - Icons.Outlined.MyLocation - } else { - Icons.Rounded.LocationDisabled - }, - contentDescription = stringResource(Res.string.toggle_my_position), - ) { - if (locationPermissionsState.allPermissionsGranted) { - map.toggleMyLocation() - } else { - triggerLocationToggleAfterPermission = true - locationPermissionsState.launchMultiplePermissionRequest() - } + MapButton( + icon = + if (myLocationOverlay == null) { + Icons.Outlined.MyLocation + } else { + Icons.Rounded.LocationDisabled + }, + contentDescription = stringResource(Res.string.toggle_my_position), + ) { + if (locationPermissionsState.allPermissionsGranted) { + map.toggleMyLocation() + } else { + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() } } } 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 03081c3ab..82bfe9f85 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 @@ -18,6 +18,7 @@ package org.meshtastic.feature.map +import android.Manifest import android.app.Activity import android.content.Intent import android.graphics.Canvas @@ -62,6 +63,8 @@ import androidx.core.graphics.createBitmap import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import co.touchlab.kermit.Logger +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult @@ -133,7 +136,12 @@ private const val TRACEROUTE_OFFSET_METERS = 100.0 private const val TRACEROUTE_BOUNDS_PADDING_PX = 120 @Suppress("CyclomaticComplexMethod", "LongMethod") -@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn( + MapsComposeExperimentalApi::class, + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalPermissionsApi::class, +) @Composable fun MapView( mapViewModel: MapViewModel = hiltViewModel(), @@ -147,14 +155,24 @@ fun MapView( val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle() - var hasLocationPermission by remember { mutableStateOf(false) } val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle() + // Location permissions state + val locationPermissionsState = + rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION)) + var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) } + // Location tracking state var isLocationTrackingEnabled by remember { mutableStateOf(false) } var followPhoneBearing by remember { mutableStateOf(false) } - LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted } + // Effect to toggle location tracking after permission is granted + LaunchedEffect(locationPermissionsState.allPermissionsGranted) { + if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) { + isLocationTrackingEnabled = true + triggerLocationToggleAfterPermission = false + } + } val filePickerLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> @@ -226,8 +244,8 @@ fun MapView( } // Start/stop location tracking based on state - LaunchedEffect(isLocationTrackingEnabled, hasLocationPermission) { - if (isLocationTrackingEnabled && hasLocationPermission) { + LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) { + if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) { val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L) .setMinUpdateIntervalMillis(2000L) @@ -431,7 +449,11 @@ fun MapView( zoomGesturesEnabled = true, ), properties = - MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission), + MapProperties( + mapType = effectiveGoogleMapType, + isMyLocationEnabled = + isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted, + ), onMapLongClick = { latLng -> if (isConnected) { val newWaypoint = @@ -457,7 +479,7 @@ fun MapView( jointType = JointType.ROUND, color = TracerouteColors.OutgoingRoute, width = 9f, - zIndex = 1.5f, + zIndex = 3.0f, ) } if (tracerouteReturnPoints.size >= 2) { @@ -466,7 +488,7 @@ fun MapView( jointType = JointType.ROUND, color = TracerouteColors.ReturnRoute, width = 7f, - zIndex = 1.4f, + zIndex = 2.5f, ) } @@ -482,26 +504,33 @@ fun MapView( .find { it.num == focusedNodeNum } ?.let { focusedNode -> sortedPositions.forEachIndexed { index, position -> - val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) - val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) - val color = Color(focusedNode.colors.second).copy(alpha = alpha) - if (index == sortedPositions.lastIndex) { - MarkerComposable(state = markerState, zIndex = 1f) { NodeChip(node = focusedNode) } - } else { - MarkerInfoWindowComposable( - state = markerState, - title = stringResource(Res.string.position), - snippet = formatAgo(position.time), - zIndex = alpha, - infoContent = { - PositionInfoWindowContent(position = position, displayUnits = displayUnits) - }, - ) { - Icon( - imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin, - contentDescription = stringResource(Res.string.track_point), - tint = color, - ) + key(position.time) { + val markerState = rememberUpdatedMarkerState(position = position.toLatLng()) + val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) + val color = Color(focusedNode.colors.second).copy(alpha = alpha) + if (index == sortedPositions.lastIndex) { + MarkerComposable(state = markerState, zIndex = 4f) { + NodeChip(node = focusedNode) + } + } else { + MarkerInfoWindowComposable( + state = markerState, + title = stringResource(Res.string.position), + snippet = formatAgo(position.time), + zIndex = 1f + alpha, + infoContent = { + PositionInfoWindowContent( + position = position, + displayUnits = displayUnits, + ) + }, + ) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin, + contentDescription = stringResource(Res.string.track_point), + tint = color, + ) + } } } } @@ -515,6 +544,7 @@ fun MapView( jointType = JointType.ROUND, color = Color(focusedNode.colors.second).copy(alpha = alpha), width = 8f, + zIndex = 0.6f, ) } } @@ -550,25 +580,6 @@ fun MapView( ) } - if (tracerouteForwardPoints.size >= 2) { - Polyline( - points = tracerouteForwardOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.OutgoingRoute, - width = 9f, - zIndex = 2f, - ) - } - if (tracerouteReturnPoints.size >= 2) { - Polyline( - points = tracerouteReturnOffsetPoints, - jointType = JointType.ROUND, - color = TracerouteColors.ReturnRoute, - width = 7f, - zIndex = 1.5f, - ) - } - WaypointMarkers( displayableWaypoints = displayableWaypoints, mapFilterState = mapFilterState, @@ -655,14 +666,16 @@ fun MapView( showCustomTileManagerSheet = true }, isNodeMap = focusedNodeNum != null, - hasLocationPermission = hasLocationPermission, isLocationTrackingEnabled = isLocationTrackingEnabled, onToggleLocationTracking = { - if (hasLocationPermission) { + if (locationPermissionsState.allPermissionsGranted) { isLocationTrackingEnabled = !isLocationTrackingEnabled if (!isLocationTrackingEnabled) { followPhoneBearing = false } + } else { + triggerLocationToggleAfterPermission = true + locationPermissionsState.launchMultiplePermissionRequest() } }, bearing = cameraPositionState.position.bearing, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index c2bccf97d..7ad618683 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -56,7 +56,6 @@ fun MapControlsOverlay( onManageCustomTileProvidersClicked: () -> Unit, // New parameter isNodeMap: Boolean, // Location tracking parameters - hasLocationPermission: Boolean = false, isLocationTrackingEnabled: Boolean = false, onToggleLocationTracking: () -> Unit = {}, bearing: Float = 0f, @@ -117,18 +116,16 @@ fun MapControlsOverlay( ) // Location tracking button - if (hasLocationPermission) { - MapButton( - icon = - if (isLocationTrackingEnabled) { - Icons.Rounded.LocationDisabled - } else { - Icons.Outlined.MyLocation - }, - contentDescription = stringResource(Res.string.toggle_my_position), - onClick = onToggleLocationTracking, - ) - } + MapButton( + icon = + if (isLocationTrackingEnabled) { + Icons.Rounded.LocationDisabled + } else { + Icons.Outlined.MyLocation + }, + contentDescription = stringResource(Res.string.toggle_my_position), + onClick = onToggleLocationTracking, + ) }, ) }