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

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

Merge branch 'ellenhp/compass' into 'main'

Add a compass indicator

Closes #1

See merge request e/os/cardinal!3
parents e56ba81e 5fa7d398
Loading
Loading
Loading
Loading
+32 −3
Original line number Diff line number Diff line
@@ -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()
    }
}
+267 −0
Original line number Diff line number Diff line
/*
 *     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 <https://www.gnu.org/licenses/>.
 */

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<Float> = _azimuth.asStateFlow()

    // Indicates if the sensor is currently active
    private val _isActive = MutableStateFlow(false)
    val isActive: StateFlow<Boolean> = _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
    }
}
+2 −1
Original line number Diff line number Diff line
@@ -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())

+5 −1
Original line number Diff line number Diff line
@@ -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<Location?> = locationRepository.locationFlow

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

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

+26 −38
Original line number Diff line number Diff line
@@ -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
                ),
            )
        )
    )

    // 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
    )
    LocationPuckLayers(idPrefix = "user-location", locationSource = locationSource)
}
Loading