Loading cardinal-android/app/build.gradle.kts +1 −0 Original line number Diff line number Diff line Loading @@ -108,6 +108,7 @@ android { } buildFeatures { compose = true buildConfig = true } // Define a single UniFFI binding generation task outside of applicationVariants.all to avoid duplication Loading cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +19 −18 Original line number Diff line number Diff line Loading @@ -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+) Loading @@ -154,6 +150,24 @@ 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") lifecycleScope.launch { permissionRequestManager.onPermissionGranted(PermissionRequest.NotificationPermission) } } else { Log.d(TAG, "Location permission denied") lifecycleScope.launch { permissionRequestManager.onPermissionDenied(PermissionRequest.NotificationPermission) } } } private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { // We've bound to LocalMapServerService, cast the IBinder and get LocalMapServerService instance Loading Loading @@ -289,19 +303,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() Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +112 −22 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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. Loading Loading @@ -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) { Loading @@ -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?, Loading @@ -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 } } Loading @@ -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 } /** Loading cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +0 −11 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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) Loading Loading
cardinal-android/app/build.gradle.kts +1 −0 Original line number Diff line number Diff line Loading @@ -108,6 +108,7 @@ android { } buildFeatures { compose = true buildConfig = true } // Define a single UniFFI binding generation task outside of applicationVariants.all to avoid duplication Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +19 −18 Original line number Diff line number Diff line Loading @@ -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+) Loading @@ -154,6 +150,24 @@ 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") lifecycleScope.launch { permissionRequestManager.onPermissionGranted(PermissionRequest.NotificationPermission) } } else { Log.d(TAG, "Location permission denied") lifecycleScope.launch { permissionRequestManager.onPermissionDenied(PermissionRequest.NotificationPermission) } } } private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { // We've bound to LocalMapServerService, cast the IBinder and get LocalMapServerService instance Loading Loading @@ -289,19 +303,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() Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +112 −22 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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. Loading Loading @@ -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) { Loading @@ -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?, Loading @@ -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 } } Loading @@ -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 } /** Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +0 −11 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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) Loading