diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt index 72f4f09c83daecb5c6b53b5afb15f9209f328cf7..2f616944481457e7893e16824c39c710b8f75844 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LocationRepository.kt @@ -46,7 +46,8 @@ import javax.inject.Singleton @Singleton class LocationRepository @Inject constructor( @param:ApplicationContext private val context: Context, - private val appPreferenceRepository: AppPreferenceRepository + private val appPreferenceRepository: AppPreferenceRepository, + private val orientationRepository: OrientationRepository ) { private companion object { @@ -98,6 +99,8 @@ class LocationRepository @Inject constructor( // Try to get last known location from any provider val lastKnownLocation = getLastKnownLocation(locationManager) + lastKnownLocation?.let { orientationRepository.setDeclination(it) } + // If we have a recent last known location, use it lastKnownLocation?.let { location -> if (isLocationRecent(location, currentTime)) { @@ -127,7 +130,7 @@ class LocationRepository @Inject constructor( } /** - * Starts continuous location updates. + * Starts continuous location updates and orientation tracking. * This should be called from the UI when the map is ready. */ @SuppressLint("MissingPermission") @@ -137,6 +140,10 @@ class LocationRepository @Inject constructor( return } + // Start orientation tracking + orientationRepository.start() + Log.d("LocationRepository", "Orientation sensor started") + try { val locationManager = getLocationManager(context) @@ -151,6 +158,8 @@ class LocationRepository @Inject constructor( override fun onLocationChanged(location: Location) { // Update the location flow with the new location _locationFlow.value = location + orientationRepository.setDeclination(location) + synchronized(locationLock) { lastRequestedLocation = location } @@ -192,9 +201,13 @@ class LocationRepository @Inject constructor( } /** - * Stops location updates. + * Stops location updates and orientation tracking. */ fun stopLocationUpdates(context: Context) { + // Stop orientation tracking + orientationRepository.stop() + Log.d("LocationRepository", "Orientation sensor stopped") + locationListener?.let { listener -> try { val locationManager = getLocationManager(context) @@ -803,4 +816,20 @@ class LocationRepository @Inject constructor( createMyLocationPlace(LatLng(location.latitude, location.longitude)) } } + + /** + * Resets the orientation filter. + * Useful after device calibration or when the heading seems incorrect. + */ + fun resetOrientationFilter() { + orientationRepository.reset() + Log.d("LocationRepository", "Orientation filter reset") + } + + /** + * Check if orientation sensors are available on this device. + */ + fun areOrientationSensorsAvailable(): Boolean { + return orientationRepository.areSensorsAvailable() + } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/OrientationRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/OrientationRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..e2b03058f7b5552084cad490c0e3dd871d1a3450 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/OrientationRepository.kt @@ -0,0 +1,267 @@ +/* + * Cardinal Maps + * Copyright (C) 2025 Cardinal Maps Authors + * + * 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 earth.maps.cardinal.data + +import android.content.Context +import android.hardware.GeomagneticField +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +/** + * Repository for handling device orientation using sensor data. + * Uses ROTATION_VECTOR sensor which provides fused sensor data for better accuracy, + * especially on devices with noisy magnetometers. + */ +@Singleton +class OrientationRepository @Inject constructor( + @param:ApplicationContext private val context: Context +) { + private companion object { + private const val TAG = "OrientationRepository" + + // Sensor update rate - SENSOR_DELAY_UI is ~60ms, good balance between accuracy and battery + private const val SENSOR_DELAY = SensorManager.SENSOR_DELAY_UI + + // Low-pass filter alpha value (0.0-1.0) + // Lower values = more smoothing but more lag + // Higher values = less smoothing but more responsive + private const val SMOOTHING_FACTOR = 0.15f + + // Threshold in degrees to ignore small changes (reduces jitter) + private const val CHANGE_THRESHOLD_DEGREES = 2.0f + } + + private val sensorManager: SensorManager = + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + + private val rotationVectorSensor: Sensor? = + sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + + // Current azimuth in degrees (0-360, where 0/360 is North, 90 is East, etc.) + private val _azimuth = MutableStateFlow(0f) + val azimuth: StateFlow = _azimuth.asStateFlow() + + // Indicates if the sensor is currently active + private val _isActive = MutableStateFlow(false) + val isActive: StateFlow = _isActive.asStateFlow() + + // Smoothed azimuth value for filtering out noise + private var smoothedAzimuth: Float = 0f + + // Rotation matrix and orientation arrays for sensor calculations + private val rotationMatrix = FloatArray(9) + private val orientationAngles = FloatArray(3) + + private var declination = 0f + + private val sensorEventListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + event?.let { handleSensorEvent(it) } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + // Log accuracy changes for debugging + when (accuracy) { + SensorManager.SENSOR_STATUS_UNRELIABLE -> + Log.d(TAG, "Sensor accuracy: UNRELIABLE") + + SensorManager.SENSOR_STATUS_ACCURACY_LOW -> + Log.d(TAG, "Sensor accuracy: LOW") + + SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> + Log.d(TAG, "Sensor accuracy: MEDIUM") + + SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> + Log.d(TAG, "Sensor accuracy: HIGH") + } + } + } + + /** + * Starts listening to orientation sensor updates. + */ + fun start() { + if (_isActive.value) { + Log.d(TAG, "Orientation sensor already active") + return + } + + if (!areSensorsAvailable()) { + Log.w(TAG, "ROTATION_VECTOR sensor not available on this device") + return + } + + val registered = sensorManager.registerListener( + sensorEventListener, + rotationVectorSensor, + SENSOR_DELAY + ) + + if (registered) { + _isActive.value = true + Log.d(TAG, "Orientation sensor started") + } else { + Log.e(TAG, "Failed to register orientation sensor listener") + } + } + + /** + * Stops listening to orientation sensor updates. + */ + fun stop() { + if (!_isActive.value) { + return + } + + sensorManager.unregisterListener(sensorEventListener) + _isActive.value = false + Log.d(TAG, "Orientation sensor stopped") + } + + fun setDeclination(location: Location) { + declination = GeomagneticField( + location.latitude.toFloat(), + location.longitude.toFloat(), + 0.0f, + System.currentTimeMillis() + ).declination + } + + /** + * Resets the orientation filter. + * Useful after device calibration or when heading seems incorrect. + */ + fun reset() { + smoothedAzimuth = _azimuth.value + Log.d(TAG, "Orientation filter reset to current azimuth: $smoothedAzimuth") + } + + /** + * Checks if orientation sensors are available on this device. + */ + fun areSensorsAvailable(): Boolean { + return rotationVectorSensor != null + } + + /** + * Handles sensor events and updates the azimuth value. + */ + private fun handleSensorEvent(event: SensorEvent) { + if (event.sensor.type != Sensor.TYPE_ROTATION_VECTOR) { + return + } + + // Convert rotation vector to rotation matrix + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + + // Get orientation angles from rotation matrix + // orientationAngles[0] = azimuth (rotation around Z axis) + // orientationAngles[1] = pitch (rotation around X axis) + // orientationAngles[2] = roll (rotation around Y axis) + SensorManager.getOrientation(rotationMatrix, orientationAngles) + + // Convert azimuth from radians to degrees + // Result is in range [-180, 180], convert to [0, 360] + var azimuthDegrees = Math.toDegrees(orientationAngles[0].toDouble()).toFloat() + if (azimuthDegrees < 0) { + azimuthDegrees += 360f + } + + // Apply declination correction to convert from magnetic north to true north + // Formula: True Heading = Magnetic Heading + Declination + azimuthDegrees += declination + + // Normalize to [0, 360] after adding declination + if (azimuthDegrees < 0f) { + azimuthDegrees += 360f + } else if (azimuthDegrees >= 360f) { + azimuthDegrees -= 360f + } + + // Apply low-pass filter for smoothing + smoothedAzimuth = applyLowPassFilter(azimuthDegrees, smoothedAzimuth) + + // Only update if change is significant (reduces jitter) + if (shouldUpdateAzimuth(smoothedAzimuth, _azimuth.value)) { + _azimuth.value = smoothedAzimuth + } + } + + /** + * Applies a low-pass filter to smooth out sensor noise. + * + * @param newValue The new sensor reading + * @param oldValue The previous filtered value + * @return The filtered value + */ + private fun applyLowPassFilter(newValue: Float, oldValue: Float): Float { + // Handle wrap-around at 0/360 degrees + var delta = newValue - oldValue + + // If the difference is greater than 180 degrees, we've wrapped around + if (delta > 180f) { + delta -= 360f + } else if (delta < -180f) { + delta += 360f + } + + // Apply the filter + var result = oldValue + (delta * SMOOTHING_FACTOR) + + // Normalize to [0, 360] + if (result < 0f) { + result += 360f + } else if (result >= 360f) { + result -= 360f + } + + return result + } + + /** + * Determines if the azimuth should be updated based on the change threshold. + * + * @param newValue The new azimuth value + * @param currentValue The current azimuth value + * @return true if the change exceeds the threshold + */ + private fun shouldUpdateAzimuth(newValue: Float, currentValue: Float): Boolean { + val delta = abs(newValue - currentValue) + + // Handle wrap-around case + val normalizedDelta = if (delta > 180f) { + 360f - delta + } else { + delta + } + + return normalizedDelta >= CHANGE_THRESHOLD_DEGREES + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt index 89855cb01b755b35ce31cecff363a21b4b87cbc2..51c22c419713111eb0442cb48be88933456155b8 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt @@ -164,8 +164,9 @@ fun MapView( ClickResult.Consume }) { val location by mapViewModel.locationFlow.collectAsState() + val sensorHeading by mapViewModel.heading.collectAsState() val savedPlaces by mapViewModel.savedPlacesFlow.collectAsState(FeatureCollection()) - location?.let { LocationPuck(it) } + location?.let { LocationPuck(it, sensorHeading) } FavoritesLayer(savedPlaces, isSystemInDarkTheme()) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt index e40ce5b0a1e124204f3f27c43e74d740432f35e1..23f06b186840978caa810430bedb146a90b1f3e1 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.LocationRepository +import earth.maps.cardinal.data.OrientationRepository import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.ViewportPreferences import earth.maps.cardinal.data.ViewportRepository @@ -64,6 +65,7 @@ class MapViewModel @Inject constructor( private val viewportPreferences: ViewportPreferences, private val viewportRepository: ViewportRepository, private val locationRepository: LocationRepository, + private val orientationRepository: OrientationRepository, private val offlineGeocodingService: OfflineGeocodingService, private val placeDao: SavedPlaceDao, ) : ViewModel() { @@ -87,6 +89,8 @@ class MapViewModel @Inject constructor( val locationFlow: StateFlow = locationRepository.locationFlow + val heading: StateFlow = orientationRepository.azimuth + // Location listener for continuous updates private var locationListener: LocationListener? = null @@ -309,4 +313,4 @@ class MapViewModel @Inject constructor( ) ) } -} \ No newline at end of file +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt index a1acab0d01946fef339aa9b9d754839d88419652..7fad7f93adf1eed37162c61efff640501878e961 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/map/LocationPuck.kt @@ -20,69 +20,57 @@ package earth.maps.cardinal.ui.map import android.location.Location import android.util.Log -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import earth.maps.cardinal.ui.theme.onPuckColorDark -import earth.maps.cardinal.ui.theme.onPuckColorLight -import earth.maps.cardinal.ui.theme.puckColorDarkHighContrast -import earth.maps.cardinal.ui.theme.puckColorLightHighContrast +import androidx.compose.ui.res.painterResource +import earth.maps.cardinal.R.drawable import io.github.dellisd.spatialk.geojson.Point import io.github.dellisd.spatialk.geojson.Position import org.maplibre.compose.expressions.dsl.const -import org.maplibre.compose.expressions.dsl.offset -import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.Source import org.maplibre.compose.sources.rememberGeoJsonSource @Composable -fun LocationPuckLayers(idPrefix: String, locationSource: Source) { - val puckColor = if (isSystemInDarkTheme()) { - puckColorDarkHighContrast +fun LocationPuckLayers(idPrefix: String, locationSource: Source, headingDegrees: Float?) { + val puckDrawable = if (headingDegrees == null) { + painterResource(drawable.location_puck) } else { - puckColorLightHighContrast + painterResource(drawable.location_puck_with_arrow) } - val puckShadowColor = if (isSystemInDarkTheme()) { - onPuckColorDark - } else { - onPuckColorLight - } - - - CircleLayer( - id = "${idPrefix}-shadow", + SymbolLayer( + id = "${idPrefix}-puck", source = locationSource, - radius = const(13.dp), - color = const(puckShadowColor), - blur = const(1f), - translate = offset(0.dp, 1.dp), - ) - CircleLayer( - id = "${idPrefix}-circle", - source = locationSource, - radius = const(7.dp), - color = const(puckColor), - strokeColor = const(Color.White), - strokeWidth = const(3.dp), + iconImage = image(puckDrawable), + iconRotate = const(headingDegrees ?: 0f), ) } @Composable -fun LocationPuck(location: Location) { +fun LocationPuck(location: Location, sensorHeading: Float? = null) { Log.d("Location", "$location") val locationSource = rememberGeoJsonSource( data = GeoJsonData.Features( Point( - Position( + coordinates = Position( location.longitude, location.latitude - ) + ), ) ) ) - LocationPuckLayers(idPrefix = "user-location", locationSource = locationSource) + + // Prefer sensor-based heading over GPS bearing + // Sensor heading is more accurate and updates faster, especially when stationary + val headingDegrees = sensorHeading + ?: if (location.hasBearing()) location.bearing else null + + LocationPuckLayers( + idPrefix = "user-location", + locationSource = locationSource, + headingDegrees = headingDegrees + ) } diff --git a/cardinal-android/app/src/main/res/drawable/location_puck.xml b/cardinal-android/app/src/main/res/drawable/location_puck.xml new file mode 100644 index 0000000000000000000000000000000000000000..ee226c75409c7778e11141f6027e4181edcd2b94 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/location_puck.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/cardinal-android/app/src/main/res/drawable/location_puck_with_arrow.xml b/cardinal-android/app/src/main/res/drawable/location_puck_with_arrow.xml new file mode 100644 index 0000000000000000000000000000000000000000..6ef20c4c620ac83cb8979a593c1d988bb60d5467 --- /dev/null +++ b/cardinal-android/app/src/main/res/drawable/location_puck_with_arrow.xml @@ -0,0 +1,37 @@ + + + + + + + + +