Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c374523b authored by Ellen Poe's avatar Ellen Poe
Browse files

Merge branch 'ellenhp/nav_permission_crash_fix' into 'main'

e/OS specific location fixes

Closes #13

See merge request e/os/cardinal!9
parents f017f5a6 fd685020
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -112,6 +112,7 @@ android {
    }
    buildFeatures {
        compose = true
        buildConfig = true
    }

    // Define a single UniFFI binding generation task outside of applicationVariants.all to avoid duplication
+13 −18
Original line number Diff line number Diff line
@@ -129,11 +129,7 @@ class MainActivity : ComponentActivity() {
    }

    private fun requestLocationPermission() {
        requestPermissions(
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION
            ), LOCATION_PERMISSION_REQUEST_CODE
        )
        locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
    }

    // Permission request launcher for notification permission (Android 13+)
@@ -154,6 +150,18 @@ class MainActivity : ComponentActivity() {
        }
    }

    // Permission request launcher for location permission (Android 13+)
    private val locationPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        hasLocationPermission = isGranted
        if (isGranted) {
            Log.d(TAG, "Location permission granted")
        } else {
            Log.d(TAG, "Location permission denied")
        }
    }

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // We've bound to LocalMapServerService, cast the IBinder and get LocalMapServerService instance
@@ -289,19 +297,6 @@ class MainActivity : ComponentActivity() {
        ferrostarWrapperRepository.androidTtsObserver.stopAndClearQueue()
    }

    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<out String?>, grantResults: IntArray, deviceId: Int
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId)
        when (requestCode) {
            LOCATION_PERMISSION_REQUEST_CODE -> {
                // Check if all permissions were granted
                hasLocationPermission =
                    grantResults.isNotEmpty() && grantResults.all { it == android.content.pm.PackageManager.PERMISSION_GRANTED }
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        ferrostarWrapperRepository.androidTtsObserver.shutdown()
+112 −22
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -55,6 +56,8 @@ class LocationRepository @Inject constructor(
        private const val LOCATION_REQUEST_TIMEOUT_MS = 10000L // 10 seconds
        private const val CONTINUOUS_LOCATION_UPDATE_INTERVAL_MS = 5000L // 5 seconds
        private const val CONTINUOUS_LOCATION_UPDATE_DISTANCE_M = 5f // 5 meters
        private const val FUSED_PROVIDER_TIMEOUT_MS =
            5000L // 5 seconds - discard GPS if fused hasn't updated
    }

    // Location caching with thread safety
@@ -69,8 +72,12 @@ class LocationRepository @Inject constructor(
    private val _locationFlow: MutableStateFlow<Location?> = MutableStateFlow(null)
    val locationFlow: StateFlow<Location?> = _locationFlow.asStateFlow()

    // Location listener for continuous updates
    private var locationListener: LocationListener? = null
    // Location listeners for continuous updates
    private var gpsLocationListener: LocationListener? = null
    private var fusedLocationListener: LocationListener? = null

    // Track last fused provider update time
    private var lastFusedLocationTime: Long = 0L

    /**
     * Gets the current location, either from cache or by requesting a fresh one.
@@ -132,6 +139,10 @@ class LocationRepository @Inject constructor(
    /**
     * Starts continuous location updates and orientation tracking.
     * This should be called from the UI when the map is ready.
     *
     * Subscribes to both GPS and fused providers to handle unreliable fused location
     * on some microG devices. GPS updates are discarded unless fused hasn't provided
     * a location for 5 seconds.
     */
    @SuppressLint("MissingPermission")
    fun startContinuousLocationUpdates(context: Context) {
@@ -146,37 +157,95 @@ class LocationRepository @Inject constructor(

        try {
            val locationManager = getLocationManager(context)
            val availableProviders = locationManager.getProviders(true)

            @Suppress("DEPRECATION") val bestProvider = locationManager.getBestProvider(
                android.location.Criteria().apply {
                    accuracy = android.location.Criteria.ACCURACY_FINE
                }, true
            // Subscribe to fused provider if available
            if (Build.VERSION.SDK_INT >= 31 && availableProviders.contains(LocationManager.FUSED_PROVIDER)) {
                fusedLocationListener = object : LocationListener {
                    override fun onLocationChanged(location: Location) {
                        Log.d("LocationRepository", "Fused location update received")
                        lastFusedLocationTime = System.currentTimeMillis()

                        // Always use fused provider updates
                        _locationFlow.value = location
                        orientationRepository.setDeclination(location)

                        synchronized(locationLock) {
                            lastRequestedLocation = location
                        }
                    }

                    override fun onProviderDisabled(provider: String) {
                        Log.d("LocationRepository", "Fused provider disabled")
                        try {
                            locationManager.removeUpdates(this)
                        } catch (_: Exception) {
                            // Ignore exceptions during cleanup
                        }
                        fusedLocationListener = null
                    }

                    override fun onProviderEnabled(provider: String) {
                        Log.d("LocationRepository", "Fused provider enabled")
                    }

                    override fun onStatusChanged(
                        provider: String?,
                        status: Int,
                        extras: Bundle?
                    ) {
                    }
                }

                locationManager.requestLocationUpdates(
                    LocationManager.FUSED_PROVIDER,
                    CONTINUOUS_LOCATION_UPDATE_INTERVAL_MS,
                    CONTINUOUS_LOCATION_UPDATE_DISTANCE_M,
                    fusedLocationListener!!
                )
                Log.d("LocationRepository", "Subscribed to fused provider")
            }

            bestProvider?.let { provider ->
                locationListener = object : LocationListener {
            // Subscribe to GPS provider if available
            if (availableProviders.contains(LocationManager.GPS_PROVIDER)) {
                gpsLocationListener = object : LocationListener {
                    override fun onLocationChanged(location: Location) {
                        // Update the location flow with the new location
                        val currentTime = System.currentTimeMillis()
                        val timeSinceLastFused = currentTime - lastFusedLocationTime

                        // Only use GPS if fused hasn't updated in 5 seconds
                        if (timeSinceLastFused >= FUSED_PROVIDER_TIMEOUT_MS) {
                            Log.d(
                                "LocationRepository",
                                "Using GPS location (fused timeout: ${timeSinceLastFused}ms)"
                            )
                            _locationFlow.value = location
                            orientationRepository.setDeclination(location)

                            synchronized(locationLock) {
                                lastRequestedLocation = location
                            }
                        } else {
                            Log.d(
                                "LocationRepository",
                                "Discarding GPS update (fused active: ${timeSinceLastFused}ms ago)"
                            )
                        }
                    }

                    override fun onProviderDisabled(provider: String) {
                        // Stop location updates if provider is disabled
                        Log.d("LocationRepository", "GPS provider disabled")
                        try {
                            locationManager.removeUpdates(this)
                        } catch (_: Exception) {
                            // Ignore exceptions during cleanup
                        }
                        locationListener = null
                        _locationFlow.value = null
                        gpsLocationListener = null
                    }

                    override fun onProviderEnabled(provider: String) {}
                    override fun onProviderEnabled(provider: String) {
                        Log.d("LocationRepository", "GPS provider enabled")
                    }

                    override fun onStatusChanged(
                        provider: String?,
@@ -187,15 +256,17 @@ class LocationRepository @Inject constructor(
                }

                locationManager.requestLocationUpdates(
                    provider,
                    LocationManager.GPS_PROVIDER,
                    CONTINUOUS_LOCATION_UPDATE_INTERVAL_MS,
                    CONTINUOUS_LOCATION_UPDATE_DISTANCE_M,
                    locationListener!!
                    gpsLocationListener!!
                )
                Log.d("LocationRepository", "Subscribed to GPS provider")
            }
        } catch (_: Exception) {
            // Handle exceptions during setup
            locationListener = null
            gpsLocationListener = null
            fusedLocationListener = null
            _locationFlow.value = null
        }
    }
@@ -208,15 +279,34 @@ class LocationRepository @Inject constructor(
        orientationRepository.stop()
        Log.d("LocationRepository", "Orientation sensor stopped")

        locationListener?.let { listener ->
        val locationManager = try {
            getLocationManager(context)
        } catch (_: Exception) {
            null
        }

        // Remove GPS listener
        gpsLocationListener?.let { listener ->
            try {
                val locationManager = getLocationManager(context)
                locationManager.removeUpdates(listener)
                locationManager?.removeUpdates(listener)
            } catch (_: Exception) {
                // Ignore exceptions during cleanup
            }
            locationListener = null
            gpsLocationListener = null
        }

        // Remove fused listener
        fusedLocationListener?.let { listener ->
            try {
                locationManager?.removeUpdates(listener)
            } catch (_: Exception) {
                // Ignore exceptions during cleanup
            }
            fusedLocationListener = null
        }

        // Reset fused location timestamp
        lastFusedLocationTime = 0L
    }

    /**
+0 −11
Original line number Diff line number Diff line
@@ -20,7 +20,6 @@ package earth.maps.cardinal.ui.core

import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.util.Log
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.Dp
@@ -72,15 +71,8 @@ class MapViewModel @Inject constructor(

    private companion object {
        const val TAG = "MapViewModel"
        private const val LOCATION_REQUEST_TIMEOUT_MS = 10000L // 10 seconds
        private const val CONTINUOUS_LOCATION_UPDATE_INTERVAL_MS = 5000L // 5 seconds
        private const val CONTINUOUS_LOCATION_UPDATE_DISTANCE_M = 5f // 5 meters
    }

    // Location caching with thread safety
    private var lastRequestedLocation: Location? = null
    private val locationLock = Any()

    // State flows for UI components - delegate to repository
    val isLocating: StateFlow<Boolean> = locationRepository.isLocating

@@ -91,9 +83,6 @@ class MapViewModel @Inject constructor(

    val heading: StateFlow<Float?> = orientationRepository.azimuth

    // Location listener for continuous updates
    private var locationListener: LocationListener? = null

    // Permission tracking
    private val previousPermissionState = AtomicBoolean(false)

+63 −61
Original line number Diff line number Diff line
@@ -113,6 +113,7 @@ fun DirectionsScreen(

    // Track pending location request for auto-retry after permission grant
    var pendingLocationRequest by remember { mutableStateOf<FieldFocusState?>(null) }
    var pendingNavigationStart by remember { mutableStateOf(false) }

    // Get route result from ViewModel
    val routeState = viewModel.routeState
@@ -130,6 +131,18 @@ fun DirectionsScreen(
        appPreferences = appPreferences,
        onCompletion = {
            fieldFocusState = FieldFocusState.NONE
        })

    AutoRetryNavigationStart(
        viewModel = viewModel,
        navController = navController,
        hasLocationPermission = hasLocationPermission,
        hasNotificationPermission = hasNotificationPermission,
        pendingNavigation = pendingNavigationStart,
        onRequestLocationPermission = onRequestLocationPermission,
        onRequestNotificationPermission = onRequestNotificationPermission,
        onPendingNavigationChange = {
            pendingNavigationStart = it
        }
    )

@@ -167,9 +180,9 @@ fun DirectionsScreen(
                routeState = routeState,
                availableProfiles = availableProfiles,
                appPreferences = appPreferences,
                hasNotificationPermission = hasNotificationPermission,
                onRequestNotificationPermission = onRequestNotificationPermission
            )
                onPendingNavigationChange = {
                    pendingNavigationStart = it
                })
        }
    }
}
@@ -208,6 +221,39 @@ private fun AutoRetryMyLocation(
    }
}

@Composable
private fun AutoRetryNavigationStart(
    viewModel: DirectionsViewModel,
    navController: NavController,
    hasLocationPermission: Boolean,
    hasNotificationPermission: Boolean,
    pendingNavigation: Boolean,
    onRequestLocationPermission: () -> Unit,
    onRequestNotificationPermission: () -> Unit,
    onPendingNavigationChange: (Boolean) -> Unit,
) {
    var showNotificationDialog by remember { mutableStateOf(false) }
    NotificationRequestDialog(
        showDialog = showNotificationDialog,
        onDismiss = {
            showNotificationDialog = false
            onPendingNavigationChange(false)
        },
        onConfirm = {
            showNotificationDialog = false
            onRequestNotificationPermission()
        })
    LaunchedEffect(hasNotificationPermission, hasLocationPermission, pendingNavigation) {
        if (pendingNavigation && hasNotificationPermission && hasLocationPermission) {
            viewModel.startNavigation(navController)
        } else if (pendingNavigation && !hasNotificationPermission) {
            showNotificationDialog = true
        } else if (pendingNavigation) {
            onRequestLocationPermission()
        }
    }
}

@Composable
private fun DirectionsScreenFullUI(
    viewModel: DirectionsViewModel,
@@ -219,8 +265,7 @@ private fun DirectionsScreenFullUI(
    routeState: RouteState,
    availableProfiles: List<RoutingProfile>,
    appPreferences: AppPreferenceRepository,
    hasNotificationPermission: Boolean,
    onRequestNotificationPermission: () -> Unit
    onPendingNavigationChange: (Boolean) -> Unit,
) {
    val density = androidx.compose.ui.platform.LocalDensity.current

@@ -331,8 +376,7 @@ private fun DirectionsScreenFullUI(
        routeState = routeState,
        navController = navController,
        appPreferences = appPreferences,
        hasNotificationPermission = hasNotificationPermission,
        onRequestNotificationPermission = onRequestNotificationPermission
        onPendingNavigationChange = onPendingNavigationChange,
    )
}

@@ -342,8 +386,7 @@ private fun DirectionsRouteResults(
    routeState: RouteState,
    navController: NavController,
    appPreferences: AppPreferenceRepository,
    hasNotificationPermission: Boolean,
    onRequestNotificationPermission: () -> Unit
    onPendingNavigationChange: (Boolean) -> Unit,
) {
    val planState = viewModel.planState
    if (viewModel.selectedRoutingMode == RoutingMode.PUBLIC_TRANSPORT) {
@@ -357,10 +400,8 @@ private fun DirectionsRouteResults(
        NonTransitRouteResults(
            routeState = routeState,
            viewModel = viewModel,
            navController = navController,
            appPreferences = appPreferences,
            hasNotificationPermission = hasNotificationPermission,
            onRequestNotificationPermission = onRequestNotificationPermission
            onPendingNavigationChange = onPendingNavigationChange,
        )
    }
}
@@ -418,10 +459,8 @@ private fun TransitRouteResults(
private fun NonTransitRouteResults(
    routeState: RouteState,
    viewModel: DirectionsViewModel,
    navController: NavController,
    appPreferences: AppPreferenceRepository,
    hasNotificationPermission: Boolean,
    onRequestNotificationPermission: () -> Unit
    onPendingNavigationChange: (Boolean) -> Unit,
) {
    when {
        routeState.isLoading -> {
@@ -448,12 +487,10 @@ private fun NonTransitRouteResults(
                ferrostarRoute = routeState.route,
                viewModel = viewModel,
                modifier = Modifier.fillMaxWidth(),
                navController = navController,
                distanceUnit = appPreferences.distanceUnit.collectAsState().value,
                availableProfiles = viewModel.getAvailableProfilesForCurrentMode()
                    .collectAsState(initial = emptyList()).value,
                hasNotificationPermission = hasNotificationPermission,
                onRequestNotificationPermission = onRequestNotificationPermission
                onPendingNavigationChange = onPendingNavigationChange,
            )
        }

@@ -581,14 +618,10 @@ private fun QuickSuggestionsContent(
            onRequestLocationPermission = onRequestLocationPermission,
            coroutineScope = coroutineScope,
            onFieldFocusStateChange = onFieldFocusStateChange,
        ),
        savedPlaces = savedPlaces,
        onSavedPlaceSelected = { place ->
        ), savedPlaces = savedPlaces, onSavedPlaceSelected = { place ->
            updatePlaceForField(viewModel, fieldFocusState, place)
            onFieldFocusStateChange(FieldFocusState.NONE)
        },
        isGettingLocation = viewModel.isGettingLocation,
        modifier = Modifier.fillMaxWidth()
        }, isGettingLocation = viewModel.isGettingLocation, modifier = Modifier.fillMaxWidth()
    )
}

@@ -610,9 +643,7 @@ private fun SearchResultsContent(
}

private fun updatePlaceForField(
    viewModel: DirectionsViewModel,
    fieldFocusState: FieldFocusState,
    place: Place
    viewModel: DirectionsViewModel, fieldFocusState: FieldFocusState, place: Place
) {
    if (fieldFocusState == FieldFocusState.FROM) {
        viewModel.updateFromPlace(place)
@@ -884,24 +915,13 @@ private fun FerrostarRouteResults(
    ferrostarRoute: Route,
    viewModel: DirectionsViewModel,
    modifier: Modifier = Modifier,
    navController: NavController,
    distanceUnit: Int,
    availableProfiles: List<RoutingProfile>,
    hasNotificationPermission: Boolean,
    onRequestNotificationPermission: () -> Unit
    onPendingNavigationChange: (Boolean) -> Unit,
) {
    var showProfileDialog by remember { mutableStateOf(false) }
    var showNotificationDialog by remember { mutableStateOf(false) }
    var pendingNavigation by remember { mutableStateOf(false) }
    val selectedProfile = viewModel.selectedRoutingProfile

    LaunchedEffect(hasNotificationPermission, pendingNavigation) {
        if (pendingNavigation && hasNotificationPermission) {
            pendingNavigation = false
            viewModel.startNavigation(navController)
        }
    }

    LazyColumn(modifier = modifier) {
        item {
            Card(
@@ -953,11 +973,7 @@ private fun FerrostarRouteResults(

                    Button(
                        onClick = {
                            if (hasNotificationPermission) {
                                viewModel.startNavigation(navController)
                            } else {
                                showNotificationDialog = true
                            }
                            onPendingNavigationChange(true)
                        }, modifier = Modifier.fillMaxWidth(), enabled = true
                    ) {
                        Text(stringResource(string.start_navigation))
@@ -1022,18 +1038,7 @@ private fun FerrostarRouteResults(
        onProfileSelected = { profile ->
            viewModel.selectRoutingProfile(profile)
            showProfileDialog = false
        }
    )

    NotificationRequestDialog(
        showDialog = showNotificationDialog,
        onDismiss = { showNotificationDialog = false },
        onConfirm = {
            showNotificationDialog = false
            pendingNavigation = true
            onRequestNotificationPermission()
        }
    )
        })
}

@Composable
@@ -1052,8 +1057,7 @@ private fun ProfileSelectionDialog(
                Column {
                    // Default option
                    TextButton(
                        onClick = { onProfileSelected(null) },
                        modifier = Modifier.fillMaxWidth()
                        onClick = { onProfileSelected(null) }, modifier = Modifier.fillMaxWidth()
                    ) {
                        Text(
                            text = stringResource(string.default_profile),
@@ -1085,9 +1089,7 @@ private fun ProfileSelectionDialog(

@Composable
private fun NotificationRequestDialog(
    showDialog: Boolean,
    onDismiss: () -> Unit,
    onConfirm: () -> Unit
    showDialog: Boolean, onDismiss: () -> Unit, onConfirm: () -> Unit
) {
    if (showDialog) {
        AlertDialog(