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 @@
+
+
+
+
+
+
+
+
+